fix(php): import decomposition, enum cases, anonymous class scope — F53,F54,F55 (#1931) (#1989)

* 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 <gergomagyar@icloud.com>
This commit is contained in:
Sparsh 2026-06-04 17:27:38 +05:30 • committed by GitHub
parent 938111ad45
commit 7cbc544299
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 172 additions and 34 deletions

View file

@ -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",

View file

@ -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[] = [];

View file

@ -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

View file

@ -0,0 +1,7 @@
<?php
$service = new class {
public function execute(): void {
echo "running";
}
};
$service->execute();

View file

@ -0,0 +1,14 @@
<?php
// F54 — enum with cases
enum UserRole: string {
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
}
// F55 — anonymous class with method
$service = new class {
public function execute(): void {
echo "running";
}
};

View file

@ -0,0 +1,4 @@
<?php
// F53 — comma-separated use statements
use A, B, C;
use Foo\Bar, Baz\Qux as Quux, X\Y;

View file

@ -43,6 +43,10 @@
"captureGroups": 12,
"digest": "1c47e7020939a6cd6bb18f3732b51a16f4c75ded0fe9883a87d6706a2c624d1f"
},
"php-anonymous-class/anon.php": {
"captureGroups": 5,
"digest": "0628d6520b02da49ace196f582704c3ffe88aadb3ea20cff642699aa122d5f7f"
},
"php-app/app/Contracts/Loggable.php": {
"captureGroups": 7,
"digest": "46cf7e385db7656514b796908134a50f4a536f3adda64196e17d8c54f5cd5e3a"
@ -52,8 +56,8 @@
"digest": "40bbdb3fc0ec9b5a9bb8fbdf4a053099ac926951ecd746f2436569add3f0e3d5"
},
"php-app/app/Enums/UserRole.php": {
"captureGroups": 7,
"digest": "aadf56b87d67191b13f4e3f9fae7e8097f4c8a81d13135645ceb8c5be1063a9b"
"captureGroups": 10,
"digest": "203eb6943ff7527cc52ad93df040a4d15dbcf16beb9e78914401069c54f20bb8"
},
"php-app/app/Models/BaseModel.php": {
"captureGroups": 18,
@ -143,6 +147,14 @@
"captureGroups": 15,
"digest": "26181127fe9e9bf04431a7cc801624bde9dd284049627bb7ca0a4c9234141eec"
},
"php-coverage/enum-and-anon.php": {
"captureGroups": 9,
"digest": "83318a2833a16be704f15c2a32c0bbc9cfe0aacf693d1b82413a2eabf5ffed24"
},
"php-coverage/multi-import.php": {
"captureGroups": 7,
"digest": "4d72fbfba40f2e083aa55d1c48bb500aeb7dd615de18b48b00eaca5b26a2001e"
},
"php-deep-field-chain/Models.php": {
"captureGroups": 26,
"digest": "f20d780f2bc95ed438047c8e9bb694f5a020454161d8a20f8d5e02be68e4a602"
@ -265,7 +277,7 @@
},
"php-grouped-imports/app/Services/Main.php": {
"captureGroups": 15,
"digest": "65ea5a0c2fd2b523ab1bf2e3f632bd3debeb7613c88496362016f9e590b64972"
"digest": "b315d87f85f0899ed3883ec529b86a57f2d1a88d41b2b615e707307e2889c049"
},
"php-local-shadow/app/Services/Main.php": {
"captureGroups": 9,

View file

@ -0,0 +1,104 @@
/**
* Regression tests for PHP scope-resolution coverage gaps (issue #1931).
*/
import { describe, it, expect } from 'vitest';
import { emitPhpScopeCaptures } from '../../../src/core/ingestion/languages/php/index.js';
import type { CaptureMatch } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// F53 — comma-separated use statements
// ---------------------------------------------------------------------------
describe('F53 — comma-separated use declarations', () => {
it('use A, B, C produces 3 import names', () => {
const src = `<?php\nuse A, B, C;\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const importNames = matches.filter((m) => 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 = `<?php\nuse Foo\\Bar, Baz\\Qux as Quux;\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const importNames = matches.filter((m) => 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 = `<?php\nenum Role { case Admin; case Editor; }\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const cases = matches.filter((m) => 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 = `<?php\nenum Role { case Admin; }\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const scopes = matches.filter((m) => 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 = `<?php\nenum Role: string { case Admin = 'admin'; }\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const cases = matches.filter((m) => 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 = `<?php\n$s = new class { public function run() {} };\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const classScopes = matches.filter((m) => m['@scope.class']);
expect(classScopes.length).toBe(1);
});
it('method inside anonymous class has @scope.function', () => {
const src = `<?php\n$s = new class { public function run() {} };\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const fnScopes = matches.filter((m) => 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 = `<?php\nuse App\\Models\\{User};\n`;
const matches = emitPhpScopeCaptures(src, 'test.php') as CaptureMatch[];
const importSource = matches.find((m) => m['@import.source'])?.['@import.source']?.text;
expect(importSource).toBe('App\\Models\\User');
});
});