GitNexus/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts
azizur100389 e87b1c3ffd
fix(php): gate imports by Composer autoload map (#2987)
* fix(php): gate imports by Composer autoload map

* fix(php): handle Composer catch-all mappings

* test(php): clarify Composer fallback coverage

* bench(php): fold Composer into canonical arm

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-25 07:56:41 +01:00

476 lines
16 KiB
TypeScript

import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
loadComposerConfig,
type ComposerConfig,
} from '../../../../src/core/ingestion/language-config.js';
import {
loadPhpComposerConfig,
resolvePhpImportTargetInternal,
} from '../../../../src/core/ingestion/languages/php/import-target.js';
const composerConfig: ComposerConfig = { psr4: new Map([['App', 'app']]) };
function parsedFile(filePath: string, definitions: readonly SymbolDefinition[]): ParsedFile {
return { filePath, localDefs: definitions } as ParsedFile;
}
function definition(
filePath: string,
type: SymbolDefinition['type'],
name: string,
): SymbolDefinition {
return {
nodeId: `def:${filePath}:${type}:${name}`,
filePath,
type,
qualifiedName: name,
};
}
const functionImport: ParsedImport = {
kind: 'named',
localName: 'getUser',
importedName: 'getUser',
targetRaw: 'App\\Models\\getUser',
importedSymbolKind: 'function',
};
describe('resolvePhpImportTargetInternal declaration selection', () => {
it('rejects namespaces outside an authoritative PSR-4 map', () => {
const files = new Set(['app/Models/User.php', 'lib/Legacy/Missing.php']);
expect(
resolvePhpImportTargetInternal(
'Vendor\\Ghost\\Missing',
'app/Main.php',
files,
composerConfig,
),
).toBeNull();
expect(
resolvePhpImportTargetInternal('App\\Models\\User', 'app/Main.php', files, composerConfig),
).toBe('app/Models/User.php');
});
it('rejects ambiguous function and constant declaration fallbacks', () => {
const first = 'app/Ghost/First.php';
const second = 'app/Ghost/Second.php';
const parsedFiles = [
parsedFile(first, [
definition(first, 'Function', 'missing'),
definition(first, 'Variable', 'MISSING'),
]),
parsedFile(second, [
definition(second, 'Function', 'missing'),
definition(second, 'Variable', 'MISSING'),
]),
];
const files = new Set(parsedFiles.map((parsed) => parsed.filePath));
for (const [name, importedSymbolKind] of [
['missing', 'function'],
['MISSING', 'const'],
] as const) {
const parsedImport: ParsedImport = {
kind: 'named',
localName: name,
importedName: name,
targetRaw: `App\\Ghost\\${name}`,
importedSymbolKind,
};
expect(
resolvePhpImportTargetInternal(
parsedImport.targetRaw,
'app/Main.php',
files,
composerConfig,
{ parsedFiles, parsedImport },
),
).toBeNull();
}
});
it('preserves suffix fallback without authoritative namespace evidence', () => {
const files = new Set(['lib/Legacy/Missing.php']);
const importPath = 'Vendor\\Ghost\\Missing';
expect(resolvePhpImportTargetInternal(importPath, 'app/Main.php', files)).toBe(
'lib/Legacy/Missing.php',
);
expect(
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, { psr4: new Map() }),
).toBe('lib/Legacy/Missing.php');
expect(
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, {
psr4: new Map([['', 'src']]),
}),
).toBeNull();
expect(
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, {
psr4: new Map([['App', 'app']]),
hasUnmodeledAutoload: true,
}),
).toBe('lib/Legacy/Missing.php');
});
it('resolves catch-all PSR-4 class and function imports inside the configured root', () => {
const user = '/repo/src/Vendor/Models/User.php';
const helpers = '/repo/src/Vendor/Models/helpers.php';
const parsedFiles = [
parsedFile(user, [definition(user, 'Class', 'Vendor\\Models\\User')]),
parsedFile(helpers, [definition(helpers, 'Function', 'Vendor\\Models\\findUser')]),
];
const config: ComposerConfig = { psr4: new Map([['', '/repo/src']]) };
const files = new Set(parsedFiles.map((parsed) => parsed.filePath));
expect(
resolvePhpImportTargetInternal('Vendor\\Models\\User', '/repo/app/Main.php', files, config),
).toBe(user);
const parsedImport: ParsedImport = {
kind: 'named',
localName: 'findUser',
importedName: 'findUser',
targetRaw: 'Vendor\\Models\\findUser',
importedSymbolKind: 'function',
};
expect(
resolvePhpImportTargetInternal(parsedImport.targetRaw, '/repo/app/Main.php', files, config, {
parsedFiles,
parsedImport,
}),
).toBe(helpers);
});
it('does not suffix-resolve outside an authoritative catch-all directory', () => {
const decoy = '/repo/legacy/Vendor/Ghost/Missing.php';
expect(
resolvePhpImportTargetInternal(
'Vendor\\Ghost\\Missing',
'/repo/app/Main.php',
new Set([decoy]),
{ psr4: new Map([['', '/repo/src']]) },
),
).toBeNull();
});
it('does not fabricate a class edge from a root-mapped sibling file', () => {
const config: ComposerConfig = { psr4: new Map([['App', '']]) };
const first = new Set(['Sibling.php', 'Other.php']);
const reversed = new Set([...first].reverse());
expect(resolvePhpImportTargetInternal('App\\Missing', 'Main.php', first, config)).toBeNull();
expect(resolvePhpImportTargetInternal('App\\Missing', 'Main.php', reversed, config)).toBeNull();
});
it('keeps function and constant imports inside a relative catch-all root', () => {
const decoy = 'legacy/src/Vendor/Ghost/helpers.php';
const parsedFiles = [
parsedFile(decoy, [
definition(decoy, 'Function', 'Vendor\\Ghost\\missing'),
definition(decoy, 'Variable', 'Vendor\\Ghost\\MISSING'),
]),
];
const config: ComposerConfig = { psr4: new Map([['', 'src']]) };
const files = new Set([decoy]);
for (const [name, importedSymbolKind] of [
['missing', 'function'],
['MISSING', 'const'],
] as const) {
const parsedImport: ParsedImport = {
kind: 'named',
localName: name,
importedName: name,
targetRaw: `Vendor\\Ghost\\${name}`,
importedSymbolKind,
};
expect(
resolvePhpImportTargetInternal(parsedImport.targetRaw, 'app/Main.php', files, config, {
parsedFiles,
parsedImport,
}),
).toBeNull();
}
});
it('loads production and development PSR-4 mappings', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: { 'psr-4': { 'App\\': 'app\\' }, classmap: ['legacy/'] },
'autoload-dev': { 'psr-4': { 'Tests\\': ['tests/', 'fallback-tests/'] } },
}),
);
const config = loadPhpComposerConfig(repo);
expect([...(config?.psr4.entries() ?? [])]).toEqual([
['App', 'app'],
['Tests', 'tests'],
]);
expect(config?.hasUnmodeledAutoload).toBe(true);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('normalizes leading dot segments and preserves catch-all array fallback', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-catch-all-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({ autoload: { 'psr-4': { '': ['./src/', './lib/'] } } }),
);
const config = loadPhpComposerConfig(repo);
expect(config?.psr4.get('')).toBe('src');
expect(config?.hasUnmodeledAutoload).toBe(true);
expect(
resolvePhpImportTargetInternal(
'Vendor\\Models\\User',
'app/Main.php',
new Set(['lib/Vendor/Models/User.php']),
config,
),
).toBe('lib/Vendor/Models/User.php');
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('unions package-local Composer mappings using repository-relative roots', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-monorepo-'));
try {
mkdirSync(join(repo, 'packages', 'admin'), { recursive: true });
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({ autoload: { 'psr-4': { 'App\\': './src/' } } }),
);
writeFileSync(
join(repo, 'packages', 'admin', 'composer.json'),
JSON.stringify({ autoload: { 'psr-4': { 'Admin\\': './src/' } } }),
);
const config = loadPhpComposerConfig(repo);
expect([...(config?.psr4.entries() ?? [])]).toEqual([
['App', 'src'],
['Admin', 'packages/admin/src'],
]);
expect(
resolvePhpImportTargetInternal(
'Admin\\Controller',
'src/Main.php',
new Set(['packages/admin/src/Controller.php']),
config,
),
).toBe('packages/admin/src/Controller.php');
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('does not let autoload-dev establish authority or override production mappings', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-dev-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: { 'psr-4': { 'App\\': 'src/' } },
'autoload-dev': { 'psr-4': { 'App\\': 'tests/app/', 'Tests\\': 'tests/' } },
}),
);
const config = loadPhpComposerConfig(repo);
expect(config?.psr4.get('App')).toBe('src');
expect(config?.authoritativePsr4).toEqual(new Set(['App']));
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({ 'autoload-dev': { 'psr-4': { 'Tests\\': 'tests/' } } }),
);
const devOnly = loadPhpComposerConfig(repo);
expect(devOnly?.authoritativePsr4?.size).toBe(0);
expect(
resolvePhpImportTargetInternal(
'Vendor\\Ghost\\Missing',
'tests/Main.php',
new Set(['legacy/Vendor/Ghost/Missing.php']),
devOnly,
),
).toBe('legacy/Vendor/Ghost/Missing.php');
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('fails open for unmodeled development autoload and ignores invalid PSR-4 sections', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-unmodeled-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: { 'psr-4': [] },
'autoload-dev': { 'psr-0': { Legacy_: 'tests/legacy/' } },
}),
);
const config = loadPhpComposerConfig(repo);
expect(config?.psr4.size).toBe(0);
expect(config?.hasUnmodeledAutoload).toBe(true);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('keeps both Composer config loaders conservative for unmodeled autoload entries', async () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-shared-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: {
'psr-4': { 'App\\': './app/' },
files: ['src/helpers.php'],
},
}),
);
const config = await loadComposerConfig(repo);
expect([...(config?.psr4.entries() ?? [])]).toEqual([['App', 'app']]);
expect(config?.hasUnmodeledAutoload).toBe(false);
expect(loadPhpComposerConfig(repo)?.hasUnmodeledAutoload).toBe(false);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('finds a unique function declaration when the symbol name is not a filename', () => {
const user = 'app/Models/User.php';
const factory = 'app/Models/UserFactory.php';
const parsedFiles = [
parsedFile(user, [definition(user, 'Class', 'User')]),
parsedFile(factory, [definition(factory, 'Function', 'getUser')]),
];
expect(
resolvePhpImportTargetInternal(
functionImport.targetRaw,
'/repo/app/Main.php',
new Set(parsedFiles.map((parsed) => parsed.filePath)),
composerConfig,
{ parsedFiles, parsedImport: functionImport },
),
).toBe(factory);
});
it('reuses directory selection without leaking candidates across namespaces', () => {
const models = 'app/Models/functions.php';
const services = 'app/Services/functions.php';
const parsedFiles = [
parsedFile(models, [definition(models, 'Function', 'getUser')]),
parsedFile(services, [definition(services, 'Function', 'getUser')]),
];
const first = resolvePhpImportTargetInternal(
functionImport.targetRaw,
'/repo/app/Main.php',
new Set(parsedFiles.map((parsed) => parsed.filePath)),
composerConfig,
{ parsedFiles, parsedImport: functionImport },
);
const second = resolvePhpImportTargetInternal(
functionImport.targetRaw,
'/repo/app/Main.php',
new Set(parsedFiles.map((parsed) => parsed.filePath)),
composerConfig,
{ parsedFiles, parsedImport: functionImport },
);
expect(first).toBe(models);
expect(second).toBe(models);
});
it('fails closed when the namespace has duplicate function declarations', () => {
const first = 'app/Models/First.php';
const second = 'app/Models/Second.php';
const parsedFiles = [
parsedFile(first, [definition(first, 'Function', 'getUser')]),
parsedFile(second, [definition(second, 'Function', 'getUser')]),
];
expect(
resolvePhpImportTargetInternal(
functionImport.targetRaw,
'/repo/app/Main.php',
new Set(parsedFiles.map((parsed) => parsed.filePath)),
composerConfig,
{ parsedFiles, parsedImport: functionImport },
),
).toBeNull();
});
it('never resolves into a different root that shares a directory suffix', () => {
const app = 'app/Models/functions.php';
const vendor = 'vendor/pkg/app/Models/helpers.php';
const parsedFiles = [
parsedFile(app, []),
parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]),
];
const result = resolvePhpImportTargetInternal(
functionImport.targetRaw,
'/repo/app/Main.php',
new Set(parsedFiles.map((parsed) => parsed.filePath)),
composerConfig,
{ parsedFiles, parsedImport: functionImport },
);
expect(result).not.toBe(vendor);
});
it('stays out of suffix-colliding roots even when both declare the function', () => {
const app = 'app/Models/functions.php';
const vendor = 'vendor/pkg/app/Models/helpers.php';
const parsedFiles = [
parsedFile(app, [definition(app, 'Function', 'getUser')]),
parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]),
];
const result = resolvePhpImportTargetInternal(
functionImport.targetRaw,
'/repo/app/Main.php',
new Set(parsedFiles.map((parsed) => parsed.filePath)),
composerConfig,
{ parsedFiles, parsedImport: functionImport },
);
expect(result).not.toBe(vendor);
});
it('resolves a constant only when its namespace directory has one candidate file', () => {
const constants = 'app/Config/constants.php';
const parsedFiles = [parsedFile(constants, [])];
const parsedImport: ParsedImport = {
kind: 'named',
localName: 'MAX_RETRIES',
importedName: 'MAX_RETRIES',
targetRaw: 'App\\Config\\MAX_RETRIES',
importedSymbolKind: 'const',
};
expect(
resolvePhpImportTargetInternal(
parsedImport.targetRaw,
'/repo/app/Main.php',
new Set([constants]),
composerConfig,
{ parsedFiles, parsedImport },
),
).toBe(constants);
});
});