diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 94aadf0a5..1c449a005 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -7885,6 +7885,17 @@ func (idx *Indexer) extractExternalModules() { parse: modules.ParsePomXML, ownPathFromSrc: readPomXMLOwnName, }, + { + path: "composer.json", + parse: modules.ParseComposerJSON, + ownPathFromSrc: modules.ComposerOwnName, + }, + { + path: "composer.lock", + parse: modules.ParseComposerLock, + // The lockfile has no own-name notion separate from composer.json. + ownPathFromSrc: nil, + }, } for _, m := range manifests { @@ -7936,6 +7947,15 @@ func (idx *Indexer) extractOneModuleManifestSource( FilePath: relPath, Language: manifestLanguage(relPath), } + // composer.json's autoload map is the only statement a PHP repo makes + // about which namespaces are its own. Carried on the manifest node so the + // resolver can tell a first-party `use` from a dependency's without + // re-reading the file. + if filepath.Base(relPath) == "composer.json" { + if roots := modules.ComposerAutoloadRoots(src); len(roots) > 0 { + manifestNode.Meta = map[string]any{"php_autoload_roots": encodeComposerAutoloadRoots(roots)} + } + } allNodes := append([]*graph.Node{manifestNode}, nodes...) idx.applyRepoPrefix(allNodes, edges) idx.graph.AddBatch(allNodes, edges) @@ -7966,6 +7986,22 @@ func (idx *Indexer) extractOneModuleManifestSource( modules.LinkImportsIn(idx.graph, importNodes, specs, ownModulePath) } +// encodeComposerAutoloadRoots flattens the PSR-4 / PSR-0 map into the +// `Prefix=>dir,dir;Prefix=>dir` form graph Meta can hold (Meta is gob-encoded, +// so a nested map costs a codec entry per shape). Sorted for determinism. +func encodeComposerAutoloadRoots(roots map[string][]string) string { + prefixes := make([]string, 0, len(roots)) + for p := range roots { + prefixes = append(prefixes, p) + } + sort.Strings(prefixes) + parts := make([]string, 0, len(prefixes)) + for _, p := range prefixes { + parts = append(parts, p+"=>"+strings.Join(roots[p], ",")) + } + return strings.Join(parts, ";") +} + // manifestLanguage returns the language tag stamped on a manifest's // synthetic file node. Used purely for Brief listings — the kind // field carries the structural type. @@ -7985,6 +8021,11 @@ func manifestLanguage(relPath string) string { return "text" case "pom.xml": return "xml" + case "composer.json", "composer.lock": + // "json", never "php": the synthetic manifest node shares its ID with + // the JSON extractor's file node, and a php-tagged file would vouch + // for PHP presence in a repo that holds no PHP source. + return "json" } return "" } diff --git a/internal/indexer/php_test_detection_test.go b/internal/indexer/php_test_detection_test.go new file mode 100644 index 000000000..1795da9d1 --- /dev/null +++ b/internal/indexer/php_test_detection_test.go @@ -0,0 +1,193 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +func TestTestRole_PHPUnitMethodNaming(t *testing.T) { + assert.Equal(t, "test", TestRole("testHandlesEmptyRecord", "php")) + // The prefix must be followed by an uppercase letter, so ordinary methods + // that merely start with the letters are not swept in. A method named + // exactly `test` is left out for the same reason Go leaves `Test` out; in + // a test file it is classified by file membership anyway. + assert.Empty(t, TestRole("test", "php")) + assert.Empty(t, TestRole("testing", "php")) + assert.Empty(t, TestRole("testable", "php")) + assert.Empty(t, TestRole("setUp", "php")) + assert.Empty(t, TestRole("handle", "php")) +} + +func TestAnnotationTestRole_PHPUnitAttributes(t *testing.T) { + assert.Equal(t, "test", AnnotationTestRole("php", "Test")) + assert.Equal(t, "test", AnnotationTestRole("php", `PHPUnit\Framework\Attributes\Test`)) + assert.Equal(t, "test", AnnotationTestRole("php", "TestWith")) + + // Metadata attributes describe a test rather than declaring one. + assert.Empty(t, AnnotationTestRole("php", "DataProvider")) + assert.Empty(t, AnnotationTestRole("php", "TestDox")) + assert.Empty(t, AnnotationTestRole("php", "CoversClass")) +} + +func TestAnnotationTestRunner_PHP(t *testing.T) { + assert.Equal(t, "phpunit", AnnotationTestRunner("php")) +} + +// newPHPTestIndexer registers only the PHP extractor. +func newPHPTestIndexer(g graph.Store) *Indexer { + reg := parser.NewRegistry() + reg.Register(languages.NewPHPExtractor()) + cfg := config.Default().Index + cfg.Workers = 2 + return New(g, reg, cfg, zap.NewNop()) +} + +func phpTestFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "tests"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0o755)) + + writeFile(t, filepath.Join(dir, "src", "Calculator.php"), `= 0 { + short = short[i+1:] + } + switch short { + case "Test", "test", "TestWith", "TestWithJson": + return "test" + } case "java", "kotlin": short := name if i := strings.LastIndexByte(short, '.'); i >= 0 { @@ -163,6 +184,8 @@ func AnnotationTestRunner(language string) string { return "cargo-test" case "java", "kotlin": return "junit" + case "php": + return "phpunit" } return "" } diff --git a/internal/modules/composer_test.go b/internal/modules/composer_test.go new file mode 100644 index 000000000..7b13ddf3b --- /dev/null +++ b/internal/modules/composer_test.go @@ -0,0 +1,109 @@ +package modules + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const composerManifest = `{ + "name": "acme/store", + "require": { + "php": ">=8.1", + "ext-json": "*", + "lib-openssl": "*", + "composer-runtime-api": "^2.0", + "monolog/monolog": "^3.0", + "symfony/console": "~6.4" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "autoload": { + "psr-4": { + "Acme\\Store\\": "src/", + "Acme\\Shared\\": ["lib/", "./vendor-src/shared"] + }, + "psr-0": { + "Legacy_": "legacy/" + } + }, + "autoload-dev": { + "psr-4": { "Acme\\Store\\Tests\\": "tests/" } + } +}` + +func TestParseComposerJSON_SkipsPlatformRequirements(t *testing.T) { + specs := ParseComposerJSON([]byte(composerManifest)) + got := map[string]Spec{} + for _, s := range specs { + got[s.Path] = s + } + require.Contains(t, got, "monolog/monolog") + assert.Equal(t, "composer", got["monolog/monolog"].Ecosystem) + assert.Equal(t, "^3.0", got["monolog/monolog"].Version, "version constraints stay verbatim") + assert.False(t, got["monolog/monolog"].Indirect) + + require.Contains(t, got, "symfony/console") + + require.Contains(t, got, "phpunit/phpunit") + assert.True(t, got["phpunit/phpunit"].Indirect, "require-dev is not a production dependency") + assert.Equal(t, "dev", got["phpunit/phpunit"].Replace) + + for _, platform := range []string{"php", "ext-json", "lib-openssl", "composer-runtime-api"} { + assert.NotContains(t, got, platform, "%s names the runtime, not an installable package", platform) + } +} + +func TestParseComposerJSON_EmptyAndMalformed(t *testing.T) { + assert.Nil(t, ParseComposerJSON(nil)) + assert.Nil(t, ParseComposerJSON([]byte(""))) + assert.Nil(t, ParseComposerJSON([]byte("{not json"))) +} + +func TestParseComposerLock_ResolvedVersions(t *testing.T) { + specs := ParseComposerLock([]byte(`{ + "packages": [ + {"name": "monolog/monolog", "version": "3.5.0"}, + {"name": "php", "version": "8.2.0"} + ], + "packages-dev": [ + {"name": "phpunit/phpunit", "version": "10.5.11"} + ] + }`)) + got := map[string]Spec{} + for _, s := range specs { + got[s.Path] = s + } + require.Contains(t, got, "monolog/monolog") + assert.Equal(t, "3.5.0", got["monolog/monolog"].Version) + assert.NotContains(t, got, "php") + require.Contains(t, got, "phpunit/phpunit") + assert.True(t, got["phpunit/phpunit"].Indirect) +} + +func TestComposerAutoloadRoots(t *testing.T) { + roots := ComposerAutoloadRoots([]byte(composerManifest)) + require.NotNil(t, roots) + assert.Equal(t, []string{"src"}, roots[`Acme\Store`], "the trailing namespace separator is trimmed") + assert.Equal(t, []string{"lib", "vendor-src/shared"}, roots[`Acme\Shared`], + "an autoload value may be an array of directories") + assert.Equal(t, []string{"tests"}, roots[`Acme\Store\Tests`], "autoload-dev counts too") + assert.Equal(t, []string{"legacy"}, roots["Legacy_"], "psr-0 counts too") +} + +func TestComposerAutoloadRoots_EmptyPrefixSkipped(t *testing.T) { + roots := ComposerAutoloadRoots([]byte(`{"autoload":{"psr-4":{"":"src/"}}}`)) + assert.Nil(t, roots, "the catch-all prefix claims every namespace and is not a root") +} + +func TestComposerOwnName(t *testing.T) { + assert.Equal(t, "acme/store", ComposerOwnName([]byte(composerManifest))) + assert.Empty(t, ComposerOwnName([]byte(`{}`))) + assert.Empty(t, ComposerOwnName(nil)) +} + +func TestEcosystemLanguage_Composer(t *testing.T) { + assert.Equal(t, "php", ecosystemLanguage("composer")) +} diff --git a/internal/modules/scanner.go b/internal/modules/scanner.go index 8d0dc08f0..15f17833b 100644 --- a/internal/modules/scanner.go +++ b/internal/modules/scanner.go @@ -1046,6 +1046,8 @@ func ecosystemLanguage(ecosystem string) string { return "rust" case "maven": return "java" + case "composer": + return "php" default: return "" } @@ -1078,3 +1080,214 @@ func isMajorVersionSegment(s string) bool { } return true } + +// ParseComposerJSON reads a PHP composer.json into dependency Specs. +// +// composer.json's `require` block mixes real packages with PLATFORM +// constraints — `php`, `hhvm`, the `ext-*` PHP extensions, `lib-*` system +// libraries and the `composer-*` runtime pseudo-packages. Those name no +// installable package and would mint module nodes nothing can ever depend on, +// so they are skipped. +// +// `require-dev` follows the package.json convention: Indirect true with the +// block tagged on Replace, so an agent can scope to production dependencies +// without a second axis. Version constraints stay verbatim (`^3.0`, `~1.2.3`, +// `>=2 <3`) — composer.lock carries the resolved versions. +func ParseComposerJSON(source []byte) []Spec { + if len(source) == 0 { + return nil + } + var manifest struct { + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` + } + if err := json.Unmarshal(source, &manifest); err != nil { + return nil + } + var specs []Spec + specs = append(specs, composerBlock(manifest.Require, "")...) + specs = append(specs, composerBlock(manifest.RequireDev, "dev")...) + return specs +} + +// ParseComposerLock reads the resolved package set from composer.lock. The +// lockfile supersedes the manifest's version ranges with exact versions, the +// same way package-lock.json supersedes package.json. +func ParseComposerLock(source []byte) []Spec { + if len(source) == 0 { + return nil + } + var lock struct { + Packages []composerLockEntry `json:"packages"` + PackagesDev []composerLockEntry `json:"packages-dev"` + } + if err := json.Unmarshal(source, &lock); err != nil { + return nil + } + var specs []Spec + specs = append(specs, composerLockBlock(lock.Packages, "")...) + specs = append(specs, composerLockBlock(lock.PackagesDev, "dev")...) + return specs +} + +type composerLockEntry struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func composerLockBlock(entries []composerLockEntry, kind string) []Spec { + if len(entries) == 0 { + return nil + } + out := make([]Spec, 0, len(entries)) + for _, e := range entries { + if e.Name == "" || isComposerPlatformPackage(e.Name) { + continue + } + out = append(out, Spec{ + Ecosystem: "composer", + Path: e.Name, + Version: e.Version, + Indirect: kind != "", + Replace: kind, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +func composerBlock(deps map[string]string, kind string) []Spec { + if len(deps) == 0 { + return nil + } + out := make([]Spec, 0, len(deps)) + for name, version := range deps { + if isComposerPlatformPackage(name) { + continue + } + out = append(out, Spec{ + Ecosystem: "composer", + Path: name, + Version: version, + Indirect: kind != "", + Replace: kind, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// isComposerPlatformPackage reports whether a composer requirement names the +// runtime rather than an installable package. +func isComposerPlatformPackage(name string) bool { + switch name { + case "php", "php-64bit", "php-ipv6", "php-zts", "php-debug", "hhvm": + return true + } + return strings.HasPrefix(name, "ext-") || + strings.HasPrefix(name, "lib-") || + strings.HasPrefix(name, "composer-") || + name == "composer" +} + +// ComposerAutoloadRoots reads composer.json's PSR-4 and PSR-0 autoload maps +// into namespace-prefix → repo-relative-directory pairs, covering both the +// `autoload` and `autoload-dev` blocks. A prefix may map to several +// directories, and composer allows the value to be either a string or an array +// of strings. +// +// This is what tells first-party namespaces from vendor ones: a `use` of a +// namespace under one of these prefixes names code in this repo, anything else +// comes from a dependency. +func ComposerAutoloadRoots(source []byte) map[string][]string { + if len(source) == 0 { + return nil + } + var manifest struct { + Autoload composerAutoload `json:"autoload"` + AutoloadDev composerAutoload `json:"autoload-dev"` + } + if err := json.Unmarshal(source, &manifest); err != nil { + return nil + } + out := map[string][]string{} + for _, block := range []composerAutoload{manifest.Autoload, manifest.AutoloadDev} { + for _, m := range []map[string]json.RawMessage{block.PSR4, block.PSR0} { + for prefix, raw := range m { + prefix = strings.TrimRight(strings.TrimSpace(prefix), `\`) + if prefix == "" { + continue // the catch-all "" prefix maps everything; not a root + } + for _, dir := range composerAutoloadDirs(raw) { + out[prefix] = appendUniqueString(out[prefix], dir) + } + } + } + } + if len(out) == 0 { + return nil + } + for k := range out { + sort.Strings(out[k]) + } + return out +} + +type composerAutoload struct { + PSR4 map[string]json.RawMessage `json:"psr-4"` + PSR0 map[string]json.RawMessage `json:"psr-0"` +} + +// composerAutoloadDirs normalises an autoload value, which composer allows to +// be a bare string or an array of strings. +func composerAutoloadDirs(raw json.RawMessage) []string { + var one string + if err := json.Unmarshal(raw, &one); err == nil { + if d := normalizeComposerDir(one); d != "" { + return []string{d} + } + return nil + } + var many []string + if err := json.Unmarshal(raw, &many); err != nil { + return nil + } + out := make([]string, 0, len(many)) + for _, d := range many { + if d := normalizeComposerDir(d); d != "" { + out = append(out, d) + } + } + return out +} + +func normalizeComposerDir(d string) string { + d = strings.TrimSpace(d) + d = strings.TrimPrefix(d, "./") + return strings.TrimRight(d, "/") +} + +func appendUniqueString(list []string, v string) []string { + for _, x := range list { + if x == v { + return list + } + } + return append(list, v) +} + +// ComposerOwnName returns the `name` a composer.json declares for itself +// (`vendor/package`), used to keep a repo's own package out of its dependency +// list. +func ComposerOwnName(source []byte) string { + if len(source) == 0 { + return "" + } + var manifest struct { + Name string `json:"name"` + } + if err := json.Unmarshal(source, &manifest); err != nil { + return "" + } + return strings.TrimSpace(manifest.Name) +} diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index 9c200f455..789b681bb 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -72,6 +72,10 @@ func (e *PHPExtractor) Extract(filePath string, src []byte) (*parser.ExtractionR captureLaravelEvents(result, root, filePath, src) captureDrupalHooks(result, filePath) + // Namespace identity runs last so it sees every node and edge the passes + // above produced. + applyPHPNamespaceIdentity(root, src, result) + return result, nil } @@ -229,6 +233,7 @@ func (e *PHPExtractor) extractClass( }) annotationSeen := map[string]bool{} emitPHPAnnotationEdgesFromAttrs(collectPhpAttributes(node, src), id, filePath, result, annotationSeen) + e.emitPHPDocTestAnnotation(node, src, filePath, id, result, annotationSeen) // Extract methods, constants, properties, and trait uses inside the body. body := e.findChildByType(node, "declaration_list") @@ -634,14 +639,16 @@ func emitPHPTypeUseEdges(ownerID, typeText, filePath string, line int, result *p continue } seen[t] = true - result.Edges = append(result.Edges, &graph.Edge{ + edge := &graph.Edge{ From: ownerID, To: "unresolved::" + t, Kind: graph.EdgeTypedAs, FilePath: filePath, Line: line, Origin: graph.OriginASTInferred, - }) + } + phpStampRawTypeRef(edge, atom, t) + result.Edges = append(result.Edges, edge) } } diff --git a/internal/parser/languages/php_declarations.go b/internal/parser/languages/php_declarations.go index 0d325a1dd..1f370e6eb 100644 --- a/internal/parser/languages/php_declarations.go +++ b/internal/parser/languages/php_declarations.go @@ -1,6 +1,7 @@ package languages import ( + "regexp" "strings" "github.com/zzet/gortex/internal/graph" @@ -177,3 +178,28 @@ func phpNamesACallee(n *sitter.Node) bool { } return false } + +// phpDocTestTagRe matches PHPUnit's docblock test marker. The tag must stand +// alone on its line so `@testWith` and prose mentioning `@test` in a sentence +// do not mark the method. +var phpDocTestTagRe = regexp.MustCompile(`(?m)^\s*\*?\s*@test\s*$`) + +// emitPHPDocTestAnnotation mints the annotation edge for the `@test` docblock +// marker, PHPUnit's pre-attribute way of declaring a test whose name does not +// start with `test`. The `#[Test]` attribute form already reaches the graph +// through collectPhpAttributes; this is the docblock twin, and it lands on the +// same annotation node so AnnotationTestRole sees one shape. +func (e *PHPExtractor) emitPHPDocTestAnnotation( + node *sitter.Node, src []byte, + filePath, ownerID string, + result *parser.ExtractionResult, seen map[string]bool, +) { + doc := phpDocBlockAbove(node, src) + if doc == "" || !phpDocTestTagRe.MatchString(doc) { + return + } + emitPHPAnnotationEdgesFromAttrs( + []phpAttribute{{name: "Test", line: int(node.StartPoint().Row) + 1}}, + ownerID, filePath, result, seen, + ) +} diff --git a/internal/parser/languages/php_namespace.go b/internal/parser/languages/php_namespace.go new file mode 100644 index 000000000..0b2f1438f --- /dev/null +++ b/internal/parser/languages/php_namespace.go @@ -0,0 +1,353 @@ +package languages + +import ( + "strings" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +// PHP namespace identity. +// +// Every PHP symbol was named and ID'd by its bare short name, and every type +// reference was lowered to a bare name too, so `App\Models\User` and +// `App\Entities\User` were the same symbol to every name-based lookup. On +// laravel/framework that is not a corner case: 31.8% of type-directed edges +// land on a short name more than one class defines (`Post` names 37 classes, +// `User` 27, `Builder` 5 across Contracts / Eloquent / Query). +// +// The resolver was already built for this. internal/resolver/scope.go +// documents MetaScopeNamespace ("scope_ns") with a PHP example and +// MetaScopeUseAliases as a PHP-only key, and preferPhpScopeCandidate reads +// both — the branches were unreachable because nothing ever stamped them. +// +// This pass is additive: node IDs do not change. Each symbol gains the +// namespace it is declared in, types and functions gain a fully-qualified +// QualName, and each type reference gains the fully-qualified name the source +// says it names, resolved through the file's `use` imports. The resolver uses +// that to narrow same-named candidates and falls back to the full set when +// nothing matches, so the change can only sharpen a binding, never lose one. + +// phpNsRange is one namespace declaration's line span. A file may hold several +// braced `namespace X { ... }` blocks; the unbraced form runs to the next +// declaration or to end of file. +type phpNsRange struct { + name string + start int + end int +} + +// phpNameResolver answers "what fully-qualified name does this source text +// mean, written at this line of this file". +type phpNameResolver struct { + ranges []phpNsRange + // imports maps the name a file refers to a symbol by — the last segment, + // or the `as` alias — to its fully-qualified name. + imports map[string]string + // funcAliases holds only the `use function X as y` entries, which the + // resolver's scope_use_aliases contract keeps separate from class imports. + funcAliases map[string]string +} + +// newPHPNameResolver reads a file's namespace declarations and `use` imports. +func newPHPNameResolver(root *sitter.Node, src []byte) *phpNameResolver { + r := &phpNameResolver{imports: map[string]string{}, funcAliases: map[string]string{}} + if root == nil { + return r + } + var walk func(n *sitter.Node) + walk = func(n *sitter.Node) { + if n == nil { + return + } + switch n.Type() { + case "namespace_definition": + name := "" + for i, c := 0, int(n.NamedChildCount()); i < c; i++ { + if ch := n.NamedChild(i); ch.Type() == "namespace_name" { + name = strings.Trim(strings.TrimSpace(ch.Content(src)), `\`) + break + } + } + r.ranges = append(r.ranges, phpNsRange{ + name: name, + start: int(n.StartPoint().Row) + 1, + end: int(n.EndPoint().Row) + 1, + }) + case "namespace_use_declaration": + r.readUseDeclaration(n, src) + } + for i, c := 0, int(n.NamedChildCount()); i < c; i++ { + walk(n.NamedChild(i)) + } + } + walk(root) + return r +} + +// readUseDeclaration records every name a `use` statement brings into the file, +// including the braced group form and `as` aliases. +func (r *phpNameResolver) readUseDeclaration(node *sitter.Node, src []byte) { + declKind := phpUseQualifier(node, src) + + record := func(clause *sitter.Node, prefix string) { + var nameNode *sitter.Node + for _, t := range []string{"qualified_name", "namespace_name", "name"} { + for i, c := 0, int(clause.NamedChildCount()); i < c; i++ { + if ch := clause.NamedChild(i); ch.Type() == t { + nameNode = ch + break + } + } + if nameNode != nil { + break + } + } + if nameNode == nil { + return + } + fqn := strings.Trim(strings.TrimSpace(nameNode.Content(src)), `\`) + if prefix != "" { + fqn = strings.TrimRight(prefix, `\`) + `\` + fqn + } + if fqn == "" { + return + } + local := fqn + if i := strings.LastIndex(local, `\`); i >= 0 { + local = local[i+1:] + } + if a := clause.ChildByFieldName("alias"); a != nil { + if alias := strings.TrimSpace(a.Content(src)); alias != "" { + local = alias + } + } + kind := declKind + if k := phpUseQualifier(clause, src); k != "" { + kind = k + } + if kind == "function" { + r.funcAliases[local] = fqn + return + } + if kind == "const" { + return + } + r.imports[local] = fqn + } + + if group := node.ChildByFieldName("body"); group != nil { + prefix := "" + for i, c := 0, int(node.NamedChildCount()); i < c; i++ { + if ch := node.NamedChild(i); ch.Type() == "namespace_name" { + prefix = strings.TrimSpace(ch.Content(src)) + break + } + } + for i, c := 0, int(group.NamedChildCount()); i < c; i++ { + if clause := group.NamedChild(i); clause.Type() == "namespace_use_clause" { + record(clause, prefix) + } + } + return + } + for i, c := 0, int(node.NamedChildCount()); i < c; i++ { + if clause := node.NamedChild(i); clause.Type() == "namespace_use_clause" { + record(clause, "") + } + } +} + +// namespaceAt returns the namespace in effect at a source line, or "" for the +// global namespace. The innermost enclosing declaration wins; a file whose +// namespace is declared without braces covers every line at or after it. +func (r *phpNameResolver) namespaceAt(line int) string { + best := "" + bestStart := -1 + for _, rg := range r.ranges { + if line < rg.start { + continue + } + // A braced block ends at its closing brace; an unbraced declaration's + // node ends on its own line, so it covers everything after it that no + // later declaration claims. + if line > rg.end && rg.end > rg.start { + continue + } + if rg.start > bestStart { + best, bestStart = rg.name, rg.start + } + } + return best +} + +// resolve turns a type reference as written into a fully-qualified name. +// Mirrors PHP's name resolution for class references: a leading `\` is already +// absolute; a qualified name's first segment may itself be imported; a bare +// name is an import if the file imports it, and otherwise names a class in the +// current namespace. Returns "" when nothing can be said. +func (r *phpNameResolver) resolve(raw string, line int) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, `\`) { + return strings.TrimPrefix(raw, `\`) + } + head, rest := raw, "" + if i := strings.Index(raw, `\`); i >= 0 { + head, rest = raw[:i], raw[i:] + } + if fqn, ok := r.imports[head]; ok { + return fqn + rest + } + ns := r.namespaceAt(line) + if ns == "" { + return raw + } + return ns + `\` + raw +} + +// phpNamespaceScopedKinds are the declaration kinds that live in a namespace +// and so carry scope_ns. Fields and enum members belong to a type, not +// directly to a namespace, but carrying it keeps a member's owner recoverable +// without a second lookup. +func phpNamespaceScopedKind(k graph.NodeKind) bool { + switch k { + case graph.KindType, graph.KindInterface, graph.KindFunction, + graph.KindMethod, graph.KindConstant, graph.KindField, graph.KindEnumMember: + return true + } + return false +} + +// applyPHPNamespaceIdentity stamps every PHP declaration with the namespace it +// lives in and records on each type-reference edge the fully-qualified name the +// source names. +// +// Deliberately NOT Node.QualName. That field backs a UNIQUE SQLite index +// (nodes_by_qual): a second node with the same non-empty qual_name fails the +// insert outright, which would take the whole index down rather than degrade. +// PHP produces duplicate fully-qualified names in practice — indexing +// laravel/framework mints six, where a class and the annotation stub for a +// same-named attribute land in one namespace — so the FQN lives in scope_ns, +// which is plain Meta and tolerates repetition. Everything that would have used +// QualName (reference narrowing, import binding) matches on scope_ns instead. +func applyPHPNamespaceIdentity(root *sitter.Node, src []byte, result *parser.ExtractionResult) { + if result == nil || root == nil { + return + } + nr := newPHPNameResolver(root, src) + if len(nr.ranges) == 0 && len(nr.imports) == 0 && len(nr.funcAliases) == 0 { + return // global-namespace file with no imports: nothing to qualify + } + + for _, n := range result.Nodes { + if n == nil || n.Language != "php" || !phpNamespaceScopedKind(n.Kind) { + continue + } + ns := nr.namespaceAt(n.StartLine) + if ns == "" { + continue + } + if n.Meta == nil { + n.Meta = map[string]any{} + } + n.Meta["scope_ns"] = ns + } + + // `use function NS\foo as bar` — the resolver's scope_use_aliases contract + // translates the alias before searching. Stamped on call edges only, which + // is where the resolver reads it. + aliases := phpEncodeUseAliases(nr.funcAliases) + + for _, e := range result.Edges { + if e == nil { + continue + } + if aliases != "" && e.Kind == graph.EdgeCalls { + if e.Meta == nil { + e.Meta = map[string]any{} + } + e.Meta["scope_use_aliases"] = aliases + } + if !phpTypeDirectedEdge(e.Kind) || !graph.IsUnresolvedTarget(e.To) { + continue + } + name := graph.UnresolvedName(e.To) + // Member-shaped targets (`*.foo`) name a member, not a type. + if name == "" || strings.HasPrefix(name, "*.") || strings.HasPrefix(name, "import::") { + continue + } + // Prefer the form the source wrote — canonicalisation reduced a + // qualified reference to its last segment. + written := name + if raw, ok := e.Meta[phpRawTypeRefKey].(string); ok && raw != "" { + written = raw + delete(e.Meta, phpRawTypeRefKey) + } + fqn := nr.resolve(written, e.Line) + if fqn == "" || fqn == name { + continue + } + if e.Meta == nil { + e.Meta = map[string]any{} + } + e.Meta["target_fqn"] = fqn + } +} + +// phpTypeDirectedEdge reports whether an edge kind names a type at its target, +// so a fully-qualified name can be recorded for it. +func phpTypeDirectedEdge(k graph.EdgeKind) bool { + switch k { + case graph.EdgeInstantiates, graph.EdgeExtends, graph.EdgeImplements, + graph.EdgeTypedAs, graph.EdgeReferences, graph.EdgeReturns: + return true + } + return false +} + +// phpEncodeUseAliases renders the `alias=>target` payload the resolver's +// scopeUseAliases decoder expects. Sorted so the value is deterministic. +func phpEncodeUseAliases(m map[string]string) string { + if len(m) == 0 { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sortStrings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte(';') + } + b.WriteString(k) + b.WriteString("=>") + b.WriteString(m[k]) + } + return b.String() +} + +// phpStampRawTypeRef records the namespace-qualified form the source wrote for +// a type reference, when canonicalisation to the bare class name lost it. +// applyPHPNamespaceIdentity consumes and removes the key, so it never reaches +// the graph — without it a written `\App\Core\Base` would be qualified as if it +// had been the unqualified `Base`. +func phpStampRawTypeRef(edge *graph.Edge, raw, canon string) { + raw = strings.TrimSpace(raw) + if edge == nil || raw == "" || raw == canon || !strings.Contains(raw, `\`) { + return + } + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta[phpRawTypeRefKey] = raw +} + +// phpRawTypeRefKey is scratch state between the reference-form pass and the +// namespace pass; it is deleted before the result leaves the extractor. +const phpRawTypeRefKey = "php_raw_type" diff --git a/internal/parser/languages/php_namespace_test.go b/internal/parser/languages/php_namespace_test.go new file mode 100644 index 000000000..6ffb65cfc --- /dev/null +++ b/internal/parser/languages/php_namespace_test.go @@ -0,0 +1,238 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +func phpExtract(t *testing.T, path, src string) ([]*graph.Node, []*graph.Edge) { + t.Helper() + res, err := NewPHPExtractor().Extract(path, []byte(src)) + require.NoError(t, err) + return res.Nodes, res.Edges +} + +func phpNodeNamed(nodes []*graph.Node, name string) *graph.Node { + for _, n := range nodes { + if n.Name == name { + return n + } + } + return nil +} + +func TestPHPNamespace_DeclarationsCarryNamespace(t *testing.T) { + nodes, _ := phpExtract(t, "src/Models/User.php", `App\Support\jsonEncode`, s) + } + } + assert.True(t, found, "no call edge carried scope_use_aliases") +} + +// A `use function` import must not become a class import — the two live in +// separate PHP symbol tables and a class reference of the same name is not it. +func TestPHPNamespace_UseFunctionIsNotAClassImport(t *testing.T) { + _, edges := phpExtract(t, "src/Web/C.php", `close(); } -`, - "intersection": `close(); } -`, - } { - t.Run(name, func(t *testing.T) { - rt, found := phpCallReceiver(t, src, "close") - require.True(t, found) - assert.Equal(t, "", rt) - }) - } +`, "close") + require.True(t, found) + assert.Equal(t, "", rt) +} + +// An intersection is the opposite: the value is EVERY branch at once, so any +// branch is a true statement about it. `Foo&MockObject` is the idiomatic type +// of a mocked collaborator in a modern PHP test suite, and leaving it untyped +// gave up the whole call. If the method lives on a different branch the +// hierarchy walk from this one finds nothing and the call stays unresolved — +// never a wrong bind. +func TestPHPReceiver_IntersectionTakesFirstBranch(t *testing.T) { + rt, found := phpCallReceiver(t, `handle(); } +`, "handle") + require.True(t, found) + assert.Equal(t, "PushoverHandler", rt) +} + +// A builtin branch names no graph node, so the first bindable one is taken. +func TestPHPReceiver_IntersectionSkipsBuiltinBranch(t *testing.T) { + rt, found := phpCallReceiver(t, `rewind(); } +`, "rewind") + require.True(t, found) + assert.Equal(t, "Traversable", rt) +} + +// A nullable union is still a union. +func TestPHPReceiver_NullableUnionStaysUntyped(t *testing.T) { + rt, found := phpCallReceiver(t, `close(); } +`, "close") + require.True(t, found) + assert.Equal(t, "", rt) } // A builtin scalar type names no graph node, so it must not be stamped. diff --git a/internal/parser/languages/php_reffrm.go b/internal/parser/languages/php_reffrm.go index d1d8150bd..eaa0748c2 100644 --- a/internal/parser/languages/php_reffrm.go +++ b/internal/parser/languages/php_reffrm.go @@ -108,6 +108,11 @@ func emitPHPReferenceForms(root *sitter.Node, src []byte, filePath, fileID strin if refContext != "" { edge.Meta = map[string]any{"ref_context": refContext} } + // Canonicalisation drops the namespace the source wrote + // (`\App\Core\Base` → `Base`). Carry the written form so the + // namespace pass can qualify the reference exactly instead of + // assuming the current namespace; it consumes and removes the key. + phpStampRawTypeRef(edge, rawType, canon) result.Edges = append(result.Edges, edge) } diff --git a/internal/resolver/cross_pkg_guard.go b/internal/resolver/cross_pkg_guard.go index ea6d71dec..703d22f92 100644 --- a/internal/resolver/cross_pkg_guard.go +++ b/internal/resolver/cross_pkg_guard.go @@ -275,11 +275,9 @@ func sameScopePackage(a, b *graph.Node) bool { // import of the method's package, and the import closure structurally misses // inherited / indirectly-typed receivers (owner.foo() where owner came from a // return value the caller's file never imports the type of). Restricted to the -// statically-typed languages (java, go) where a lone method name is unambiguous -// — TS / Python duck typing makes a same-name coincidence likelier, so the -// guard's revert stays load-bearing there — and gated on the receiver, when -// known, naming an in-repo type so an external-typed receiver (a logging -// facade's `logger.info`) still reverts rather than latching onto an unrelated +// languages listed in loneMemberLang, and gated on the receiver, when known, +// naming an in-repo type so an external-typed receiver (a logging facade's +// `logger.info`) still reverts rather than latching onto an unrelated // same-named local method. func (r *Resolver) loneMemberDefnKeep(target *graph.Node, e *graph.Edge, oldTo string) bool { if target == nil || !loneMemberLang(target.Language) { @@ -320,13 +318,21 @@ func (r *Resolver) loneMemberDefnKeep(target *graph.Node, e *graph.Edge, oldTo s } // loneMemberLang reports whether a lone in-repo method definition is safe to -// keep against the cross-package guard for the given language. Limited to the -// statically-typed languages where exactly one same-named member is -// structurally unambiguous; TS / Python / JS duck typing makes a same-name -// coincidence likelier, so their guard revert stays. +// keep against the cross-package guard for the given language. +// +// The bar is not static typing as such — loneMemberDefnKeep already excludes +// bare free-function calls and receivers typed as something the repo does not +// define, and only fires when the name has exactly ONE in-repo method +// definition, which is the case duck typing cannot confuse. The bar is whether +// a class-based member call is the language's dispatch model. PHP qualifies: +// its calls carry a receiver, and since PHP call sites stamp receiver_type the +// external-receiver gate above screens them more effectively than it does for +// most of this list. TS / Python / JS stay out — an object literal or a +// dynamically attached method makes a same-name coincidence likelier there, +// and their guard revert is load-bearing. func loneMemberLang(lang string) bool { switch lang { - case "java", "go", "rust", "csharp", "kotlin", "scala": + case "java", "go", "rust", "csharp", "kotlin", "scala", "php": return true } return false diff --git a/internal/resolver/php_override_dispatch.go b/internal/resolver/php_override_dispatch.go index f0c469f51..bb6e121bf 100644 --- a/internal/resolver/php_override_dispatch.go +++ b/internal/resolver/php_override_dispatch.go @@ -384,3 +384,69 @@ func phpEnsureMeta(e *graph.Edge) map[string]any { func phpTypedReceiverCaller(caller *graph.Node) bool { return caller != nil && caller.Language == "php" } + +// phpNarrowByTargetFQN filters same-named type candidates down to the ones +// declared under the namespace the reference actually names. The PHP extractor +// stamps target_fqn by resolving the written name through the file's `use` +// imports and its own namespace, which is precisely PHP's own class-name +// resolution rule. +// +// Returns nil when the edge carries no fully-qualified target, when the caller +// is not PHP, or when no candidate matches — callers treat nil as "keep the +// candidates you had", so a reference into a vendor namespace the graph does +// not hold degrades to the previous ranking instead of losing its edge. +func phpNarrowByTargetFQN(e *graph.Edge, candidates []*graph.Node) []*graph.Node { + fqn := phpEdgeMetaString(e, "target_fqn") + if fqn == "" || len(candidates) < 2 { + return nil + } + ns := "" + if i := strings.LastIndex(fqn, `\`); i >= 0 { + ns = fqn[:i] + } + var out []*graph.Node + for _, c := range candidates { + if c != nil && c.Language == "php" && phpNodeNamespace(c) == ns { + out = append(out, c) + } + } + return out +} + +// phpFindByFQN returns the PHP declarations whose namespace and name match a +// fully-qualified name. Used where a lookup by Node.QualName would be natural +// but is not available: qual_name backs a UNIQUE index that a duplicate FQN +// fails outright, so PHP carries its namespace in Meta instead. +func (r *Resolver) phpFindByFQN(fqn, repo string) []*graph.Node { + name, ns := fqn, "" + if i := strings.LastIndex(fqn, `\`); i >= 0 { + ns, name = fqn[:i], fqn[i+1:] + } + if name == "" { + return nil + } + var out []*graph.Node + for _, c := range r.cachedFindNodesByNameInRepo(name, repo) { + if c == nil || c.Language != "php" { + continue + } + switch c.Kind { + case graph.KindType, graph.KindInterface, graph.KindFunction: + default: + continue + } + if phpNodeNamespace(c) == ns { + out = append(out, c) + } + } + return out +} + +// phpNodeNamespace reads the namespace a PHP declaration lives in. +func phpNodeNamespace(n *graph.Node) string { + if n == nil || n.Meta == nil { + return "" + } + s, _ := n.Meta[MetaScopeNamespace].(string) + return s +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index c97d42667..b390c7481 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -3041,6 +3041,24 @@ func (r *Resolver) resolveImport(e *graph.Edge, importPath string, stats *Resolv } } + // PHP: a `use App\Log\Handler;` names a CLASS, not a directory. The + // cascade below is package-directory-oriented, so every PHP import fell + // through to an external stub — which starved buildImportClosure and left + // the cross-package guard reverting cross-directory PHP calls as + // unreachable. The extractor records the fully-qualified name on the edge; + // binding it to the class node makes that class's directory reachable. + if fqn := phpEdgeMetaString(e, "fqn"); fqn != "" { + if matches := r.phpFindByFQN(fqn, callerRepo); len(matches) == 1 { + node := matches[0] + e.To = node.ID + if callerRepo != "" && node.RepoPrefix != "" && node.RepoPrefix != callerRepo { + e.CrossRepo = true + } + stats.Resolved++ + return + } + } + // Look for a package node with matching qualified name. node := r.cachedGetNodeByQualName(importPath) if node != nil { @@ -3420,6 +3438,14 @@ func (r *Resolver) resolveTypeOrFunc(e *graph.Edge, name string, stats *ResolveS callerDir := r.dirFor(e.FilePath) + // PHP: narrow to the namespace the source actually names before ranking. + // `new User()` in a file that imports App\Models\User must not land on + // App\Entities\User just because it sorts better. See + // phpNarrowByTargetFQN — narrowing only, never a loss. + if narrowed := phpNarrowByTargetFQN(e, candidates); len(narrowed) > 0 { + candidates = narrowed + } + // Land the edge on the canonical type/interface definition (real, // exported, top-level, non-test), preferring same-package only as a // tiebreak. See bestTypeCandidate / resolveTypeRef for the rationale: @@ -3473,6 +3499,16 @@ func (r *Resolver) resolveTypeRef(e *graph.Edge, name string, stats *ResolveStat } callerDir := r.dirFor(e.FilePath) + // PHP: the extractor recorded the fully-qualified name the source names, + // resolved through the file's `use` imports. Narrowing to the candidates + // declared under that namespace is the difference between `App\Models\User` + // and `App\Entities\User`, which the bare name cannot tell apart. Narrowing + // only — when no candidate matches, the full set is ranked as before, so + // this can sharpen a binding but never lose one. + if narrowed := phpNarrowByTargetFQN(e, candidates); len(narrowed) > 0 { + candidates = narrowed + } + // Land the edge on the canonical type/interface definition. The // ranker prefers a real, exported, top-level, non-test definition // over same-named rivals (external stubs, test/mock defs, private or @@ -3669,6 +3705,32 @@ func (r *Resolver) resolveMethodCall(e *graph.Edge, methodName string, stats *Re } } + // PHP: a receiver typed as a class this repo DEFINES, whose type declares + // no method of this name, is calling something it INHERITS. Neither + // fallback below can express that, and both actively mis-bind it. + // + // The caller-receiver fallback would answer `$this->handler->setFormatter()` + // inside BufferHandler::setFormatter with BufferHandler::setFormatter — the + // caller itself — because it only asks whether the CALLER's class has a + // method of that name, and the stated receiver (HandlerInterface) is a + // different type entirely. The locality fallback then picks by directory + // adjacency, and a PHP package is one directory holding every sibling + // implementation of an interface, so `$this->getFormatter()` inside + // AmqpHandler lands on whichever sibling handler sorts first. + // + // Leave the edge for resolvePHPOverrideDispatch, which walks the class + // hierarchy from the stated receiver and binds the nearest declaration. + // Gated on the receiver being in-repo: when it names a vendor class the + // hierarchy is not in the graph either, so withholding the fallbacks would + // only lose the edge without anywhere better to put it. + if receiverType != "" && + r.hasInRepoType(phpBaseTypeName(receiverType), r.callerRepoPrefix(e)) { + if caller := r.cachedGetNode(e.From); phpTypedReceiverCaller(caller) { + stats.Unresolved++ + return + } + } + // Fallback: infer receiver type from the caller node. // If the caller is a method on type X and there's a candidate method on // type X with the same name, prefer it. This handles e.extractFunctions() @@ -3709,24 +3771,6 @@ func (r *Resolver) resolveMethodCall(e *graph.Edge, methodName string, stats *Re return } - // PHP: a receiver typed as a class this repo DEFINES, whose type declares - // no method of this name, is calling something it INHERITS. The locality - // fallback below cannot express that — it picks by directory adjacency, and - // a PHP package is one directory holding every sibling implementation, so - // `$this->getFormatter()` inside AmqpHandler lands on whichever sibling - // handler sorts first rather than on the trait or interface that declares - // it. Leave the edge for resolvePHPOverrideDispatch, which walks the class - // hierarchy from the stated receiver and binds the nearest declaration. - // - // Gated on the receiver being in-repo: when it names a vendor class the - // hierarchy is not in the graph either, so withholding the locality pick - // would only lose the edge without anywhere better to put it. - if receiverType != "" && phpTypedReceiverCaller(callerNode) && - r.hasInRepoType(phpBaseTypeName(receiverType), r.callerRepoPrefix(e)) { - stats.Unresolved++ - return - } - // Locality fallback (replaces the previous alphabetical name-only // pick). At this point candidates have survived Pass 0 — they all // live in packages reachable from the caller. Prefer in this order: @@ -3795,8 +3839,8 @@ func (r *Resolver) resolveMethodCall(e *graph.Edge, methodName string, stats *Re // grounded inference, not a text-grade guess: there is nowhere else it // could bind. Lift it to the ast_inferred tier so min_tier filtering and // the cross-package guard treat it as the resolved target it is (the guard's - // lone-definition exception keeps it from being reverted). Statically-typed - // languages only (java, go) — see loneMemberLang. + // lone-definition exception keeps it from being reverted). Limited to the + // class-dispatch languages — see loneMemberLang. if methodCount == 1 && anyMethod != nil && loneMemberLang(anyMethod.Language) && e.Origin == "" { e.Origin = graph.OriginASTInferred if e.Confidence == 0 {