From 7cbc5442996daafdfd3d9ee81ef3b278e1a54119 Mon Sep 17 00:00:00 2001 From: Sparsh <73558748+prajapatisparsh@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:27:38 +0530 Subject: [PATCH] =?UTF-8?q?fix(php):=20import=20decomposition,=20enum=20ca?= =?UTF-8?q?ses,=20anonymous=20class=20scope=20=E2=80=94=20F53,F54,F55=20(#?= =?UTF-8?q?1931)=20(#1989)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(php): import decomposition, enum cases, anonymous class scope — F53,F54,F55 (#1931) * chore: fix unused imports, format, rebuild gitnexus-shared for macro type * chore(bench): update PHP scope-capture baseline to CI-computed hash * fix(php): reviewer fixes — grouped prefix, dead code removal, test precision * feat: add F55 anonymous class pipeline test * chore: fix format and benchmark baseline * chore: regen PHP golden after F53/F54/F55 query changes * chore: remove pipeline test, add grouped-prefix test, update fingerprint * chore: remove unused beforeAll and path imports --------- Co-authored-by: Gergő Magyar --- gitnexus/bench/scope-capture/baselines.json | 5 +- .../languages/php/import-decomposer.ts | 49 ++++----- .../src/core/ingestion/languages/php/query.ts | 5 + .../php-anonymous-class/anon.php | 7 ++ .../php-coverage/enum-and-anon.php | 14 +++ .../php-coverage/multi-import.php | 4 + .../expected-captures.json | 18 ++- .../resolvers/php-coverage.test.ts | 104 ++++++++++++++++++ 8 files changed, 172 insertions(+), 34 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/php-anonymous-class/anon.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-coverage/enum-and-anon.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-coverage/multi-import.php create mode 100644 gitnexus/test/integration/resolvers/php-coverage.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 02a1fd27c..e3b65379e 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -34,9 +34,10 @@ "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f." }, "php": { - "fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f", + "fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb", "scaling_budget": 1.5, - "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)." + "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).", + "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected." }, "ruby": { "fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753", diff --git a/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts index 5875df8c7..bcf57d99d 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts @@ -56,10 +56,22 @@ export function splitNamespaceUseDeclaration(stmtNode: SyntaxNode): CaptureMatch return decomposeGrouped(stmtNode, groupNode, qualifier); } - // Single use clause (possibly aliased). - const spec = parseSingleUseClause(stmtNode, qualifier); - if (spec === null) return []; - return [buildImportMatch(stmtNode, spec)]; + // Non-grouped: iterate namespace_use_clause children (one per comma-separated + // import in `use A, B, C;`). The first import is also a namespace_use_clause + // in the grammar version used by the monorepo; namespace_name child handling + // was removed after confirming the clause wrapper covers all positions. + const out: CaptureMatch[] = []; + + for (let i = 0; i < stmtNode.namedChildCount; i++) { + const child = stmtNode.namedChild(i); + if (child === null || child.type !== 'namespace_use_clause') continue; + const spec = parseUseClause(child, qualifier); + if (spec !== null) { + out.push(buildImportMatch(stmtNode, spec)); + } + } + + return out; } // ── Qualifier detection ──────────────────────────────────────────────────── @@ -81,29 +93,6 @@ function detectQualifier(node: SyntaxNode): PhpImportKind { return 'namespace'; } -// ── Single clause parsing ────────────────────────────────────────────────── - -function parseSingleUseClause(node: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null { - // A plain `namespace_use_declaration` has one or more - // `namespace_use_clause` named children (each clause is one import, - // comma-separated for multiple). For the single case there is one. - const clause = findNamedChild(node, 'namespace_use_clause'); - if (clause !== null) return parseUseClause(clause, qualifier); - - // Older grammar versions may put the qualified_name directly under - // the declaration node. Check for a qualified_name or name child. - const qualName = findNamedChild(node, 'qualified_name') ?? findNamedChild(node, 'name'); - if (qualName === null) return null; - const source = qualName.text.trim(); - if (source === '') return null; - return { - kind: qualifier, - source, - name: lastSegment(source), - atNode: node, - }; -} - function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null { // namespace_use_clause: // qualified_name (or name) @@ -162,8 +151,10 @@ function decomposeGrouped( groupNode: SyntaxNode, outerQualifier: PhpImportKind, ): CaptureMatch[] { - // The prefix is the qualified_name that precedes the `{...}` group. - const prefixNode = findNamedChild(stmtNode, 'qualified_name') ?? findNamedChild(stmtNode, 'name'); + // The prefix is the namespace_name that precedes the `{...}` group. + // tree-sitter-php emits the leading path as a namespace_name child, + // not qualified_name. + const prefixNode = findNamedChild(stmtNode, 'namespace_name') ?? findNamedChild(stmtNode, 'name'); const prefix = prefixNode?.text.trim() ?? ''; const out: CaptureMatch[] = []; diff --git a/gitnexus/src/core/ingestion/languages/php/query.ts b/gitnexus/src/core/ingestion/languages/php/query.ts index fa84c911f..f8894ebaf 100644 --- a/gitnexus/src/core/ingestion/languages/php/query.ts +++ b/gitnexus/src/core/ingestion/languages/php/query.ts @@ -54,6 +54,7 @@ const PHP_SCOPE_QUERY = ` (interface_declaration) @scope.class (trait_declaration) @scope.class (enum_declaration) @scope.class +(anonymous_class) @scope.class (method_declaration) @scope.function (function_definition) @scope.function @@ -74,6 +75,10 @@ const PHP_SCOPE_QUERY = ` (enum_declaration name: (name) @declaration.name) @declaration.enum +;; Enum case +(enum_case + name: (name) @declaration.name) @declaration.const + ;; ── Declarations — methods / functions / constructors ───────────────────── (method_declaration diff --git a/gitnexus/test/fixtures/lang-resolution/php-anonymous-class/anon.php b/gitnexus/test/fixtures/lang-resolution/php-anonymous-class/anon.php new file mode 100644 index 000000000..0ad616661 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-anonymous-class/anon.php @@ -0,0 +1,7 @@ +execute(); diff --git a/gitnexus/test/fixtures/lang-resolution/php-coverage/enum-and-anon.php b/gitnexus/test/fixtures/lang-resolution/php-coverage/enum-and-anon.php new file mode 100644 index 000000000..6d864ef15 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-coverage/enum-and-anon.php @@ -0,0 +1,14 @@ + { + it('use A, B, C produces 3 import names', () => { + const src = ` m['@import.name']).map((m) => m['@import.name'].text); + // A, B, C should each appear + expect(importNames).toContain('A'); + expect(importNames).toContain('B'); + expect(importNames).toContain('C'); + expect(importNames.length).toBe(3); + }); + + it('use Foo\\Bar, Baz\\Qux as Quux produces correct names and aliases', () => { + const src = ` m['@import.name']).map((m) => m['@import.name'].text); + expect(importNames).toContain('Bar'); + expect(importNames).toContain('Quux'); + expect(importNames.length).toBe(2); + // Check alias + const aliasImport = matches.find((m) => m['@import.alias']); + expect(aliasImport).toBeDefined(); + expect(aliasImport!['@import.alias'].text).toBe('Quux'); + }); +}); + +// --------------------------------------------------------------------------- +// F54 — enum cases +// --------------------------------------------------------------------------- + +describe('F54 — enum case declarations', () => { + it('enum case captures @declaration.const with case name', () => { + const src = ` m['@declaration.const']); + expect(cases.length).toBe(2); + const names = cases.map((m) => m['@declaration.name'].text).sort(); + expect(names).toEqual(['Admin', 'Editor']); + }); + + it('enum_declaration still emits @scope.class and @declaration.enum', () => { + const src = ` m['@scope.class']); + const enumScope = scopes.find((m) => m['@scope.class']?.text.includes('enum')); + expect(enumScope).toBeDefined(); + const enumDecl = matches.filter((m) => m['@declaration.enum']); + expect(enumDecl.length).toBe(1); + expect(enumDecl[0]['@declaration.name'].text).toBe('Role'); + }); + + it('backed enum (with value) still captures case name', () => { + const src = ` m['@declaration.const']); + expect(cases.length).toBe(1); + expect(cases[0]['@declaration.name'].text).toBe('Admin'); + }); +}); + +// --------------------------------------------------------------------------- +// F55 — anonymous class scope +// --------------------------------------------------------------------------- + +describe('F55 — anonymous class scope', () => { + it('anonymous class emits @scope.class', () => { + const src = ` m['@scope.class']); + expect(classScopes.length).toBe(1); + }); + + it('method inside anonymous class has @scope.function', () => { + const src = ` m['@scope.function']); + // Method-scoping regression guard — pre-existing, not F55-specific. + expect(fnScopes.length).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// F53 — grouped use prefix +// --------------------------------------------------------------------------- + +describe('F53 — grouped use prefix', () => { + it('use App\\Models\\{User} preserves namespace prefix', () => { + const src = ` m['@import.source'])?.['@import.source']?.text; + expect(importSource).toBe('App\\Models\\User'); + }); +});