mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-30 06:42:43 +00:00
fix(scan): canonicalize traversal without order-sensitive bindings (#2482)
This commit is contained in:
commit
02f1250385
15 changed files with 393 additions and 42 deletions
|
|
@ -93,6 +93,7 @@ export interface FinalizeHooks {
|
|||
targetRaw: string,
|
||||
fromFile: string,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
parsedImport?: ParsedImport,
|
||||
): string | readonly string[] | null;
|
||||
|
||||
/**
|
||||
|
|
@ -348,7 +349,12 @@ function makeEdgeDrafts(
|
|||
];
|
||||
}
|
||||
|
||||
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
|
||||
const targetFile = hooks.resolveImportTarget(
|
||||
parsed.targetRaw ?? '',
|
||||
file.filePath,
|
||||
workspace,
|
||||
parsed,
|
||||
);
|
||||
|
||||
// Edge is unresolvable at the file level — mark unresolved now.
|
||||
if (targetFile === null) {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ export type ParsedImport =
|
|||
readonly localName: string;
|
||||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
/** Provider-specific imported symbol category when module and symbol
|
||||
* namespaces have distinct resolution rules (for example PHP). */
|
||||
readonly importedSymbolKind?: 'type' | 'function' | 'const';
|
||||
/**
|
||||
* Set by providers when `targetRaw` already names the imported symbol
|
||||
* rather than only its containing module. Consumers that compose
|
||||
|
|
@ -127,6 +130,8 @@ export type ParsedImport =
|
|||
readonly alias: string;
|
||||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. */
|
||||
readonly importedSymbolKind?: 'type' | 'function' | 'const';
|
||||
/** See the same field on the `named` variant. */
|
||||
readonly targetIncludesImportedName?: boolean;
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@
|
|||
"_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": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
|
||||
"fingerprint": "31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb",
|
||||
"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). | #95: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).",
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected."
|
||||
},
|
||||
"ruby": {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ export const walkRepositoryPaths = async (
|
|||
}
|
||||
}
|
||||
|
||||
// Filesystem/glob traversal order is not stable across filesystems or repeated
|
||||
// scans. Canonicalize once at the scan boundary so every downstream phase sees
|
||||
// the same repository order.
|
||||
entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||
|
||||
if (skippedLarge > 0) {
|
||||
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
||||
const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
|||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const';
|
||||
type PhpImportedSymbolKind = 'type' | 'function' | 'const';
|
||||
|
||||
interface PhpImportSpec {
|
||||
readonly kind: PhpImportKind;
|
||||
readonly symbolKind: PhpImportedSymbolKind;
|
||||
/** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */
|
||||
readonly source: string;
|
||||
/** Local binding name — last source segment for plain imports, the
|
||||
|
|
@ -119,6 +121,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport
|
|||
if (alias !== '') {
|
||||
return {
|
||||
kind: 'alias',
|
||||
symbolKind: symbolKindFor(qualifier),
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
|
|
@ -130,6 +133,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport
|
|||
|
||||
return {
|
||||
kind: qualifier,
|
||||
symbolKind: symbolKindFor(qualifier),
|
||||
source,
|
||||
name: lastSegment(source),
|
||||
atNode: clause,
|
||||
|
|
@ -214,6 +218,7 @@ function parseInnerClause(
|
|||
if (alias !== '') {
|
||||
return {
|
||||
kind: 'alias',
|
||||
symbolKind: symbolKindFor(qualifier),
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
|
|
@ -225,6 +230,7 @@ function parseInnerClause(
|
|||
|
||||
return {
|
||||
kind: qualifier,
|
||||
symbolKind: symbolKindFor(qualifier),
|
||||
source,
|
||||
name: lastSegment(innerPath),
|
||||
atNode: clause,
|
||||
|
|
@ -237,6 +243,7 @@ function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMat
|
|||
const m: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.symbol-kind': syntheticCapture('@import.symbol-kind', spec.atNode, spec.symbolKind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
|
|
@ -254,6 +261,12 @@ function lastSegment(path: string): string {
|
|||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
function symbolKindFor(kind: PhpImportKind): PhpImportedSymbolKind {
|
||||
if (kind === 'function') return 'function';
|
||||
if (kind === 'const') return 'const';
|
||||
return 'type';
|
||||
}
|
||||
|
||||
/** Find the first named child with a given node type. */
|
||||
function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
|
||||
import type { ComposerConfig } from '../../language-config.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
|
@ -26,6 +27,89 @@ export interface PhpResolveContext {
|
|||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function normalizePhpPath(value: string): string {
|
||||
return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function namespaceDirectories(
|
||||
targetRaw: string,
|
||||
composerConfig: ComposerConfig | null,
|
||||
resolved: string | null,
|
||||
): string[] {
|
||||
const directories = new Set<string>();
|
||||
if (resolved !== null) {
|
||||
const normalizedResolved = normalizePhpPath(resolved);
|
||||
const separator = normalizedResolved.lastIndexOf('/');
|
||||
if (separator >= 0) directories.add(normalizedResolved.slice(0, separator));
|
||||
}
|
||||
|
||||
if (composerConfig === null) return [...directories];
|
||||
|
||||
const normalizedTarget = normalizePhpPath(targetRaw);
|
||||
const mappings = [...composerConfig.psr4.entries()].sort((left, right) => {
|
||||
const lengthDifference = right[0].length - left[0].length;
|
||||
return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]);
|
||||
});
|
||||
for (const [namespacePrefix, directoryPrefix] of mappings) {
|
||||
const normalizedPrefix = normalizePhpPath(namespacePrefix);
|
||||
if (
|
||||
normalizedTarget !== normalizedPrefix &&
|
||||
!normalizedTarget.startsWith(`${normalizedPrefix}/`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, '');
|
||||
const separator = remainder.lastIndexOf('/');
|
||||
const relativeNamespace = separator >= 0 ? remainder.slice(0, separator) : '';
|
||||
directories.add(
|
||||
normalizePhpPath(
|
||||
relativeNamespace === '' ? directoryPrefix : `${directoryPrefix}/${relativeNamespace}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
return [...directories];
|
||||
}
|
||||
|
||||
// A scope-resolution pass shares one stable parsedFiles array across imports.
|
||||
const phpDirectoryIndexCache = new WeakMap<
|
||||
readonly ParsedFile[],
|
||||
ReadonlyMap<string, readonly ParsedFile[]>
|
||||
>();
|
||||
|
||||
function directoryAliases(filePath: string): string[] {
|
||||
const normalizedPath = normalizePhpPath(filePath);
|
||||
const separator = normalizedPath.lastIndexOf('/');
|
||||
if (separator < 0) return [''];
|
||||
|
||||
const parent = normalizedPath.slice(0, separator);
|
||||
const aliases = new Set([parent]);
|
||||
const segments = parent.split('/').filter(Boolean);
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
aliases.add(segments.slice(index).join('/'));
|
||||
}
|
||||
return [...aliases];
|
||||
}
|
||||
|
||||
function filesByDirectory(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): ReadonlyMap<string, readonly ParsedFile[]> {
|
||||
const cached = phpDirectoryIndexCache.get(parsedFiles);
|
||||
if (cached) return cached;
|
||||
|
||||
const mutable = new Map<string, ParsedFile[]>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const directory of directoryAliases(parsed.filePath)) {
|
||||
const files = mutable.get(directory) ?? [];
|
||||
files.push(parsed);
|
||||
mutable.set(directory, files);
|
||||
}
|
||||
}
|
||||
phpDirectoryIndexCache.set(parsedFiles, mutable);
|
||||
return mutable;
|
||||
}
|
||||
|
||||
// ─── loadResolutionConfig ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -117,6 +201,7 @@ export function resolvePhpImportTargetInternal(
|
|||
_fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
context?: ImportResolutionContext,
|
||||
): string | null {
|
||||
if (targetRaw === '') return null;
|
||||
|
||||
|
|
@ -129,7 +214,7 @@ export function resolvePhpImportTargetInternal(
|
|||
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
|
||||
const allFileList = [...allFiles];
|
||||
|
||||
return resolvePhpImportInternal(
|
||||
const resolved = resolvePhpImportInternal(
|
||||
targetRaw,
|
||||
composerConfig,
|
||||
allFiles,
|
||||
|
|
@ -137,4 +222,45 @@ export function resolvePhpImportTargetInternal(
|
|||
allFileList,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const parsedImport = context?.parsedImport;
|
||||
const symbolKind =
|
||||
parsedImport?.kind === 'named' || parsedImport?.kind === 'alias'
|
||||
? parsedImport.importedSymbolKind
|
||||
: undefined;
|
||||
if (
|
||||
context === undefined ||
|
||||
parsedImport === undefined ||
|
||||
(symbolKind !== 'function' && symbolKind !== 'const')
|
||||
) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const importedName = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).at(-1);
|
||||
if (importedName === undefined) return resolved;
|
||||
|
||||
const directories = namespaceDirectories(targetRaw, composerConfig, resolved);
|
||||
const directoryIndex = filesByDirectory(context.parsedFiles);
|
||||
const candidateFiles = [
|
||||
...new Set(
|
||||
directories.flatMap((directory) => directoryIndex.get(normalizePhpPath(directory)) ?? []),
|
||||
),
|
||||
];
|
||||
const expectedType = symbolKind === 'function' ? 'Function' : 'Variable';
|
||||
const declaringFiles = candidateFiles.filter((parsed) =>
|
||||
parsed.localDefs.some((def) => {
|
||||
if (def.type !== expectedType) return false;
|
||||
const simpleName = (def.qualifiedName ?? '').split(/[\\.]/).at(-1);
|
||||
return simpleName === importedName;
|
||||
}),
|
||||
);
|
||||
|
||||
if (declaringFiles.length > 1) return null;
|
||||
if (declaringFiles.length === 1) return declaringFiles[0].filePath;
|
||||
|
||||
// PHP constants are not currently emitted as local definitions. A single
|
||||
// file in the namespace directory is still unambiguous; multiple files must
|
||||
// fail closed rather than inheriting Set iteration order.
|
||||
if (symbolKind === 'const' && candidateFiles.length === 1) return candidateFiles[0].filePath;
|
||||
return resolved;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,19 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
|
|||
const sourceCap = captures['@import.source'];
|
||||
const nameCap = captures['@import.name'];
|
||||
const aliasCap = captures['@import.alias'];
|
||||
const symbolKindCap = captures['@import.symbol-kind'];
|
||||
|
||||
const kind = kindCap?.text;
|
||||
if (kind === undefined || sourceCap === undefined) return null;
|
||||
|
||||
const source = sourceCap.text.trim();
|
||||
if (source === '') return null;
|
||||
const importedSymbolKind =
|
||||
symbolKindCap?.text === 'function' || symbolKindCap?.text === 'const'
|
||||
? symbolKindCap.text
|
||||
: kind === 'function' || kind === 'const'
|
||||
? kind
|
||||
: 'type';
|
||||
|
||||
switch (kind) {
|
||||
case 'namespace': {
|
||||
|
|
@ -39,6 +46,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
|
|||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
importedSymbolKind,
|
||||
};
|
||||
}
|
||||
case 'alias': {
|
||||
|
|
@ -53,6 +61,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
|
|||
importedName,
|
||||
alias,
|
||||
targetRaw: source,
|
||||
importedSymbolKind,
|
||||
};
|
||||
}
|
||||
case 'function': {
|
||||
|
|
@ -64,6 +73,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
|
|||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
importedSymbolKind,
|
||||
};
|
||||
}
|
||||
case 'const': {
|
||||
|
|
@ -74,6 +84,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
|
|||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
importedSymbolKind,
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -354,8 +354,8 @@ const phpScopeResolver: ScopeResolver = {
|
|||
languageProvider: phpProvider,
|
||||
importEdgeReason: 'php-scope: use',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) =>
|
||||
resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig, context),
|
||||
|
||||
loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath),
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,13 @@ export function populateRustRangeBindings(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish per-type member bindings for the whole workspace before resolving
|
||||
// assignments. Otherwise an importer processed before its defining file can
|
||||
// miss a field or identity-method type solely because of file order.
|
||||
const scopeMap = new Map(parsed.scopes.map((scope) => [scope.id, scope]));
|
||||
processFieldTypeBindings(tree.rootNode, parsed, scopeMap);
|
||||
processIdentityMethodBindings(parsed);
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
|
|
@ -122,8 +129,6 @@ export function populateRustRangeBindings(
|
|||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
|
||||
processFieldTypeBindings(tree.rootNode, parsed, scopeMap);
|
||||
processIdentityMethodBindings(parsed);
|
||||
processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes);
|
||||
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
|
||||
processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes);
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ import type {
|
|||
Callsite,
|
||||
ConstraintContext,
|
||||
ParsedFile,
|
||||
ParsedImport,
|
||||
ReferenceSite,
|
||||
ScopeId,
|
||||
SupportedLanguages,
|
||||
|
|
@ -302,6 +303,11 @@ export type ReceiverMemberResolution =
|
|||
| { readonly kind: 'resolved'; readonly definition: SymbolDefinition }
|
||||
| { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] };
|
||||
|
||||
export interface ImportResolutionContext {
|
||||
readonly parsedFiles: readonly ParsedFile[];
|
||||
readonly parsedImport?: ParsedImport;
|
||||
}
|
||||
|
||||
/** Re-exported for ScopeResolver consumers — same shape as
|
||||
* `RegistryProviders.constraintCompatibility`'s third parameter. */
|
||||
export type { ConstraintContext } from 'gitnexus-shared';
|
||||
|
|
@ -340,12 +346,18 @@ export interface ScopeResolver {
|
|||
* orchestrator). TypeScript uses this to thread `tsconfig.json` path
|
||||
* aliases through to the standard resolver. Languages that don't
|
||||
* need any extra config ignore the parameter.
|
||||
*
|
||||
* `context.parsedFiles` is the complete, read-only language workspace. It is
|
||||
* optional so resolvers that only need paths retain their existing shape.
|
||||
* `context.parsedImport` is the exact import being finalized. PHP uses both
|
||||
* when a PSR-4 import names a function instead of a file.
|
||||
*/
|
||||
resolveImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
context?: ImportResolutionContext,
|
||||
): string | readonly string[] | null;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -561,8 +561,11 @@ export function runScopeResolution(
|
|||
const resolutionConfig = input.resolutionConfig;
|
||||
const finalized = finalizeScopeModel(parsedFiles, {
|
||||
hooks: {
|
||||
resolveImportTarget: (targetRaw, fromFile) =>
|
||||
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) =>
|
||||
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, {
|
||||
parsedFiles,
|
||||
parsedImport,
|
||||
}),
|
||||
expandsWildcardTo: (targetModuleScope) =>
|
||||
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
|
||||
mergeBindings: (existing, incoming, scopeId) =>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
},
|
||||
"php-abstract-dispatch/src/Repositories/SqlRepository.php": {
|
||||
"captureGroups": 14,
|
||||
"digest": "5905bb450b29d4e186d74b50f70f07a54c8e0d4b8c3748c998c1579e72283215"
|
||||
"digest": "584da0b8d38ba3b2e45513e24ee17e0366bcfe48e353c274daed367250a0ccd9"
|
||||
},
|
||||
"php-abstract-dispatch/src/app.php": {
|
||||
"captureGroups": 10,
|
||||
"digest": "52ce761f1d53a56034124fa5e674863bce9092c1b523f6d71139c4f335e8b9f0"
|
||||
"digest": "af85999f2ddf2bf718cc419553efd09425919835460bd6739277cc570cb2d3c4"
|
||||
},
|
||||
"php-alias-imports/app/Models/Repo.php": {
|
||||
"captureGroups": 14,
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
"php-alias-imports/app/Services/Main.php": {
|
||||
"captureGroups": 15,
|
||||
"digest": "bf8e1b8079956e7ef9ebfcb3ca38f547e9760da5c83856e93163efe6961067b9"
|
||||
"digest": "685798f8164a494d7dc794cf6076ff5ccade0e79fe570947a824be0099011739"
|
||||
},
|
||||
"php-ambiguous/app/Models/Dispatchable.php": {
|
||||
"captureGroups": 6,
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
},
|
||||
"php-ambiguous/app/Services/UserHandler.php": {
|
||||
"captureGroups": 12,
|
||||
"digest": "1c47e7020939a6cd6bb18f3732b51a16f4c75ded0fe9883a87d6706a2c624d1f"
|
||||
"digest": "87bbd866d8748b7cd4932aea209abc1e17f9eca518c0a5bb0dc9572cc5cd95be"
|
||||
},
|
||||
"php-anonymous-class/anon.php": {
|
||||
"captureGroups": 5,
|
||||
|
|
@ -61,15 +61,15 @@
|
|||
},
|
||||
"php-app/app/Models/BaseModel.php": {
|
||||
"captureGroups": 18,
|
||||
"digest": "be7ca5be7e28417afdef2e45c8cfed9e04594b341b0d676e7a00fdd84c94e42a"
|
||||
"digest": "7d5a013f34f909846e3a91cef4b3daa8638034e5c6939d90ccd7dae34c8f9272"
|
||||
},
|
||||
"php-app/app/Models/User.php": {
|
||||
"captureGroups": 27,
|
||||
"digest": "b6b08be1af66cbd757cfd715389725952c0e2a8e5e910ed715594c0b07570a37"
|
||||
"digest": "718b9ba0238e2139ff34027d68cb135c4698a2fc401a4f85200864b1e27a9a78"
|
||||
},
|
||||
"php-app/app/Services/UserService.php": {
|
||||
"captureGroups": 38,
|
||||
"digest": "80b8557e183052f25222cfda8879e08b1613752001f58670ce7b3dbf4b8bee28"
|
||||
"digest": "a911c5af5042738b1ca9aeb71dad06f4918d8c41e84b6a27ffaa0eadfe56ca22"
|
||||
},
|
||||
"php-app/app/Traits/HasTimestamps.php": {
|
||||
"captureGroups": 11,
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
},
|
||||
"php-assignment-chain/app/Services/AppService.php": {
|
||||
"captureGroups": 15,
|
||||
"digest": "36c5358d7f724cc0906e087828c86d9a29fe0629e2bad6105967fe8d671aa6fe"
|
||||
"digest": "0e9a9ef52a987dbd12c9478a75d5019c2a0059ab4b862924597d2ecd32f03aa7"
|
||||
},
|
||||
"php-call-result-binding/App.php": {
|
||||
"captureGroups": 23,
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
},
|
||||
"php-calls/app/Services/UserService.php": {
|
||||
"captureGroups": 7,
|
||||
"digest": "94f1cdfb0c444dc9e8116d1bbf824dc88584046539b2cbc48297729eb49e41a1"
|
||||
"digest": "84e2e65cbb4026ef9e67ac2710d1304c8dffe335f2b1b1cb70123157a04acf50"
|
||||
},
|
||||
"php-calls/app/Utils/OneArg/log.php": {
|
||||
"captureGroups": 5,
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
},
|
||||
"php-child-extends-parent/src/App.php": {
|
||||
"captureGroups": 11,
|
||||
"digest": "0c8a7c7edaa20009b71f22fef98230119a4738d2a21c8b88d64047c3c932fd36"
|
||||
"digest": "6e8b1d8f2e9c02eebcf8e2b6cacc15dfbd48c9fc03011c03d70afd265c38f3dc"
|
||||
},
|
||||
"php-child-extends-parent/src/Child.php": {
|
||||
"captureGroups": 5,
|
||||
|
|
@ -125,7 +125,7 @@
|
|||
},
|
||||
"php-constructor-calls/app.php": {
|
||||
"captureGroups": 8,
|
||||
"digest": "23ef0f05446126892e598872a0b3c3d2f96e3b0dc50e301f1fa784a56ebc81d4"
|
||||
"digest": "0f3e6cf60248ae02ca0a744d3fa4f67b2d8421858c29243c4fcaf60763c7ac3d"
|
||||
},
|
||||
"php-constructor-promotion-fields/Models.php": {
|
||||
"captureGroups": 22,
|
||||
|
|
@ -145,7 +145,7 @@
|
|||
},
|
||||
"php-constructor-type-inference/app/Services/AppService.php": {
|
||||
"captureGroups": 15,
|
||||
"digest": "26181127fe9e9bf04431a7cc801624bde9dd284049627bb7ca0a4c9234141eec"
|
||||
"digest": "5aee8297c1f3e032523fe871571ee4410422b7ee932b259e9f36b0cab4d79c8f"
|
||||
},
|
||||
"php-coverage/enum-and-anon.php": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
},
|
||||
"php-coverage/multi-import.php": {
|
||||
"captureGroups": 7,
|
||||
"digest": "4d72fbfba40f2e083aa55d1c48bb500aeb7dd615de18b48b00eaca5b26a2001e"
|
||||
"digest": "e3746c5f7a68dc4dd9d658159077eb272573e27dfc29fedeadfbd6ab7ef35a10"
|
||||
},
|
||||
"php-deep-field-chain/Models.php": {
|
||||
"captureGroups": 26,
|
||||
|
|
@ -245,7 +245,7 @@
|
|||
},
|
||||
"php-fqn-cross-namespace/app/Services/Service.php": {
|
||||
"captureGroups": 15,
|
||||
"digest": "94a2bec57ccde7aa663ce2027c7f36151d18ee257830663365bc14f9d4d5f703"
|
||||
"digest": "e711548813d38a0db268d89a7edff1d31bbaa9ff0399898e09f8e93bdbc1c785"
|
||||
},
|
||||
"php-grandparent-resolution/app/Models/A.php": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -265,7 +265,7 @@
|
|||
},
|
||||
"php-grandparent-resolution/app/Services/App.php": {
|
||||
"captureGroups": 12,
|
||||
"digest": "5d0c3524e1ca41ee57bd05fd0a2831804598fca8079ea4ece38045d9c4bb1113"
|
||||
"digest": "7273ba524f587c01ca3ba9ededfdfef635c20fd9c6ade95225e81aff07ace6a6"
|
||||
},
|
||||
"php-grouped-imports/app/Models/Repo.php": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -277,11 +277,11 @@
|
|||
},
|
||||
"php-grouped-imports/app/Services/Main.php": {
|
||||
"captureGroups": 15,
|
||||
"digest": "b315d87f85f0899ed3883ec529b86a57f2d1a88d41b2b615e707307e2889c049"
|
||||
"digest": "aef9ddf30722a053664637b7fefdfcdd541baf09b24e65a3a0d80fe920189828"
|
||||
},
|
||||
"php-local-shadow/app/Services/Main.php": {
|
||||
"captureGroups": 9,
|
||||
"digest": "b4b3e35399501d98521dc9d9281a4e5c94449b580cc6cb9b3ed4187e79ee2c07"
|
||||
"digest": "52549cd82f7e2e69fb8bb81bca4e5e3487e216f4305023b77579de92374b9ee3"
|
||||
},
|
||||
"php-local-shadow/app/Utils/Logger.php": {
|
||||
"captureGroups": 5,
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
},
|
||||
"php-member-calls/app/Services/UserService.php": {
|
||||
"captureGroups": 11,
|
||||
"digest": "6f6d5d34edd1cd4e32ea77db7e4b07b73de9ca09bb658123455820af8228d0cc"
|
||||
"digest": "dfe3ef69a8d6dbd95cf5a6bdc754914973b7208ad0c9575bf2595a0f84ac22cf"
|
||||
},
|
||||
"php-method-chain-binding/App.php": {
|
||||
"captureGroups": 48,
|
||||
|
|
@ -309,7 +309,7 @@
|
|||
},
|
||||
"php-method-enrichment/src/app.php": {
|
||||
"captureGroups": 11,
|
||||
"digest": "52791f6945c8c4ae083b816bd3af239bce44f0e97cbf80cdc119f4f366015138"
|
||||
"digest": "ebfa35aa3eb065977f922ae72f353d5bf31dd49956c3e9aaef349718e555b7a7"
|
||||
},
|
||||
"php-mro-arity-mismatch/app/Models/ChildModel.php": {
|
||||
"captureGroups": 16,
|
||||
|
|
@ -325,11 +325,11 @@
|
|||
},
|
||||
"php-mro-arity-mismatch/app/Services/Caller.php": {
|
||||
"captureGroups": 22,
|
||||
"digest": "d51fdbcf226f31f9220a506cceb9f538642ff99d238a1828b6b43fce6193f664"
|
||||
"digest": "20f76e10fc598152597ba8df71deccb698761938bfde8cf7c3437044c8fcdcf1"
|
||||
},
|
||||
"php-namespace-fallback-isolation/src/App/Caller.php": {
|
||||
"captureGroups": 13,
|
||||
"digest": "d5040d068fd8227388da7f25cc471f154adb37cd6bbfb78b5cac00f44bf45734"
|
||||
"digest": "48f132404e2d00efbf49302a69e5ab31540eb0eac71cd2fca43a8a0eb4533d98"
|
||||
},
|
||||
"php-namespace-fallback-isolation/src/App/Utils/Caller.php": {
|
||||
"captureGroups": 8,
|
||||
|
|
@ -353,7 +353,7 @@
|
|||
},
|
||||
"php-nullable-receiver/app/Services/AppService.php": {
|
||||
"captureGroups": 13,
|
||||
"digest": "cea6ae3f9e32a3e0448a279034bcf039b1d5af6334ab1b1ae43d9c55b6ca094d"
|
||||
"digest": "52e6db35e8fa4174ce698fec802b9b034dcda8f49fd7ab98cd9e884a6bd9777f"
|
||||
},
|
||||
"php-overload-dispatch/src/Services/Formatter.php": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -365,7 +365,7 @@
|
|||
},
|
||||
"php-overload-dispatch/src/app.php": {
|
||||
"captureGroups": 10,
|
||||
"digest": "d11621fbaec9f5015e61f35b5c2747b9f2da090a346adc5fcc10f191af61f048"
|
||||
"digest": "d29646b0c2d1e072ee0265acf641f99f3c443b57859c2582b782d5a485bfb089"
|
||||
},
|
||||
"php-parent-resolution/app/Models/BaseModel.php": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -421,7 +421,7 @@
|
|||
},
|
||||
"php-receiver-resolution/app/Services/AppService.php": {
|
||||
"captureGroups": 13,
|
||||
"digest": "59783c7af75e9075f00f425984ca1ba7a4c556bb0301e2eb2c3fdcaa94cd547f"
|
||||
"digest": "5e92226af09405c7fc4a02d3aeafcedc8462f3e525b8f67b8509f38091488505"
|
||||
},
|
||||
"php-response-shapes/api/items.php": {
|
||||
"captureGroups": 11,
|
||||
|
|
@ -445,7 +445,7 @@
|
|||
},
|
||||
"php-return-type/app/Services/UserService.php": {
|
||||
"captureGroups": 17,
|
||||
"digest": "fcc2d78ac4bdde1ac5179e6623a35299465bcbf9bb016375100bcb636624d043"
|
||||
"digest": "ac4851815d7a450ab92f7f68ccadabf219d2534b99ef1b65ced0a344188f2a69"
|
||||
},
|
||||
"php-self-this-resolution/app/Models/Repo.php": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -481,7 +481,7 @@
|
|||
},
|
||||
"php-transitive-traits/app/Models/Consumer.php": {
|
||||
"captureGroups": 18,
|
||||
"digest": "c4524f4fa18f6e7f0fd76a11920a6a65ab547ab4cf0fa5799b42b7678e14db08"
|
||||
"digest": "19b4d42452d84a4da0d1f05a03880561cee4d77ae0efd382351933875787ad96"
|
||||
},
|
||||
"php-transitive-traits/app/Traits/TraitA.php": {
|
||||
"captureGroups": 8,
|
||||
|
|
@ -501,7 +501,7 @@
|
|||
},
|
||||
"php-typed-properties/app/Services/UserService.php": {
|
||||
"captureGroups": 12,
|
||||
"digest": "2ec84482a3e2332f3f15ebb3f2d8d01d1d56e62da256127ece571a8c2e0c070c"
|
||||
"digest": "9f0540a4f7f322ee96dad4437c4e41e087f29a6993d2659fc48bd26e3277e963"
|
||||
},
|
||||
"php-typed-property-dedup/app/Models/UserRepo.php": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
},
|
||||
"php-typed-property-dedup/app/Services/Mixed.php": {
|
||||
"captureGroups": 14,
|
||||
"digest": "9f4ba6bd183c20acaad547b6a713e80498eb70eaabaa7b416650bb64098bde0d"
|
||||
"digest": "97d09b46f6e66bef700f4686e5988c98372853203885b9a2d99dafce6fdc8343"
|
||||
},
|
||||
"php-unresolved-receiver-arity/app/Models/Handler.php": {
|
||||
"captureGroups": 21,
|
||||
|
|
@ -529,7 +529,7 @@
|
|||
},
|
||||
"php-use-function-const/app/Services/Calculator.php": {
|
||||
"captureGroups": 15,
|
||||
"digest": "d6996da68f917c3ae6b52b530d166e8266f5053ce6c9b76e1643cb61edcafa38"
|
||||
"digest": "b2206861c8550b6d2c7610ff167c42f4236c7249c87584b418e05f687233ad22"
|
||||
},
|
||||
"php-use-function-const/app/Utils/helpers.php": {
|
||||
"captureGroups": 6,
|
||||
|
|
@ -537,7 +537,7 @@
|
|||
},
|
||||
"php-variadic-arity-minimum/app/Services/Caller.php": {
|
||||
"captureGroups": 29,
|
||||
"digest": "d5669fe609abae6b7db19c15c0cb795859e0b4b0570ae83fbe80a392f3775ca8"
|
||||
"digest": "4ac7be9ff21c52b76630cdcfde0eb41af43e126c2de5db327e9f82c46c3aabbc"
|
||||
},
|
||||
"php-variadic-arity-minimum/app/Utils/Logger.php": {
|
||||
"captureGroups": 18,
|
||||
|
|
@ -545,7 +545,7 @@
|
|||
},
|
||||
"php-variadic-resolution/app/Services/AppService.php": {
|
||||
"captureGroups": 9,
|
||||
"digest": "8b5298358fba8f578b2470f9d93ffbc1089d1ef3208c1142185880b4dc174751"
|
||||
"digest": "6591007d2f4ba85b25a59c11c3ae200452fbad23cacb1ebc04291a074b534dd8"
|
||||
},
|
||||
"php-variadic-resolution/app/Utils/Logger.php": {
|
||||
"captureGroups": 7,
|
||||
|
|
|
|||
|
|
@ -1621,6 +1621,11 @@ describe('PHP cross-file binding propagation', () => {
|
|||
(e) => e.sourceFilePath.includes('Main') && e.targetFilePath.includes('UserFactory'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
|
||||
const unrelatedEdge = imports.find(
|
||||
(e) => e.sourceFilePath.includes('Main') && e.targetFilePath.endsWith('/Models/User.php'),
|
||||
);
|
||||
expect(unrelatedEdge).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves $u->save() in run() to User#save via cross-file return type propagation', () => {
|
||||
|
|
|
|||
39
gitnexus/test/unit/filesystem-walker-order.test.ts
Normal file
39
gitnexus/test/unit/filesystem-walker-order.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { glob } from 'glob';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('glob', () => ({ glob: vi.fn() }));
|
||||
vi.mock('../../src/config/ignore-service.js', () => ({
|
||||
createIgnoreFilter: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { walkRepositoryPaths } from '../../src/core/ingestion/filesystem-walker.js';
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.mocked(glob).mockReset();
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('walkRepositoryPaths ordering', () => {
|
||||
it('returns accepted files in canonical path order when glob order is unstable', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-scan-order-'));
|
||||
temporaryRoots.push(root);
|
||||
await Promise.all(
|
||||
['zeta.ts', 'alpha.ts', 'middle.ts'].map((file) =>
|
||||
fs.writeFile(path.join(root, file), `export const ${file[0]} = true;\n`),
|
||||
),
|
||||
);
|
||||
vi.mocked(glob).mockResolvedValue(['zeta.ts', 'alpha.ts', 'middle.ts']);
|
||||
|
||||
const result = await walkRepositoryPaths(root);
|
||||
|
||||
expect(result.map((entry) => entry.path)).toEqual(['alpha.ts', 'middle.ts', 'zeta.ts']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ComposerConfig } from '../../../../src/core/ingestion/language-config.js';
|
||||
import { 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('finds a unique function declaration when the symbol name is not a filename', () => {
|
||||
const user = '/repo/app/Models/User.php';
|
||||
const factory = '/repo/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 = '/repo/app/Models/functions.php';
|
||||
const services = '/repo/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 = '/repo/app/Models/First.php';
|
||||
const second = '/repo/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('resolves a constant only when its namespace directory has one candidate file', () => {
|
||||
const constants = '/repo/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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue