mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
220 lines
8.6 KiB
TypeScript
220 lines
8.6 KiB
TypeScript
/**
|
|
* Parity guard for the memoized file index in `resolvePythonImportTarget`
|
|
* (import-target.ts).
|
|
*
|
|
* The index replaces two per-import O(files) scans (the suffix match in
|
|
* `resolveAbsoluteFromFiles` and the package-existence gate in
|
|
* `hasRepoCandidate`) with O(1)/O(bucket) lookups. It MUST reproduce the exact
|
|
* resolution result — in particular the deterministic tie-break
|
|
* (fewest-segments, then lexicographic) and the false-positive gating that the
|
|
* import-target.ts comments call out. These cases pin those semantics so an
|
|
* index regression fails CI rather than silently changing resolved edges.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
isPythonImportedModule,
|
|
resolvePythonImportTarget,
|
|
} from '../../../../src/core/ingestion/languages/python/index.js';
|
|
import type { ParsedFile, ParsedImport } from 'gitnexus-shared';
|
|
|
|
function mkImport(targetRaw: string): ParsedImport {
|
|
return { kind: 'absolute', targetRaw, isRelative: false, names: [] } as unknown as ParsedImport;
|
|
}
|
|
|
|
function mkNamed(targetRaw: string, importedName: string): ParsedImport {
|
|
return { kind: 'named', targetRaw, localName: importedName, importedName };
|
|
}
|
|
|
|
function resolve(fromFile: string, files: string[], targetRaw: string): string | null {
|
|
return resolvePythonImportTarget(mkImport(targetRaw), {
|
|
fromFile,
|
|
allFilePaths: new Set(files),
|
|
});
|
|
}
|
|
|
|
describe('resolvePythonImportTarget — index parity', () => {
|
|
it('direct workspace-root hit wins', () => {
|
|
expect(
|
|
resolve('app/main.py', ['services/sync.py', 'services/__init__.py'], 'services.sync'),
|
|
).toBe('services/sync.py');
|
|
});
|
|
|
|
it('ancestor walk resolves nested namespace packages', () => {
|
|
expect(resolve('backend/routers/cron.py', ['backend/services/sync.py'], 'services.sync')).toBe(
|
|
'backend/services/sync.py',
|
|
);
|
|
});
|
|
|
|
it('suffix fallback resolves a nested vendored layout', () => {
|
|
expect(resolve('app/main.py', ['pkg/__init__.py', 'vendor/pkg/thing.py'], 'pkg.thing')).toBe(
|
|
'vendor/pkg/thing.py',
|
|
);
|
|
});
|
|
|
|
it('suffix tie-break prefers fewest path segments', () => {
|
|
expect(
|
|
resolve(
|
|
'app/main.py',
|
|
['pkg/__init__.py', 'a/pkg/models.py', 'b/c/pkg/models.py'],
|
|
'pkg.models',
|
|
),
|
|
).toBe('a/pkg/models.py');
|
|
});
|
|
|
|
it('suffix tie-break at equal depth is lexicographic', () => {
|
|
expect(
|
|
resolve(
|
|
'app/main.py',
|
|
['pkg/__init__.py', 'z/pkg/models.py', 'a/pkg/models.py'],
|
|
'pkg.models',
|
|
),
|
|
).toBe('a/pkg/models.py');
|
|
});
|
|
|
|
it('external dotted import is gated out by hasRepoCandidate (django.apps guard)', () => {
|
|
expect(resolve('app/main.py', ['accounts/apps.py'], 'django.apps')).toBeNull();
|
|
});
|
|
|
|
it('does not suffix-match a different package basename (accounts.models vs billing/models.py)', () => {
|
|
expect(
|
|
resolve('app/main.py', ['accounts/__init__.py', 'billing/models.py'], 'accounts.models'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('candidate exists but no concrete file resolves to null', () => {
|
|
expect(resolve('app/main.py', ['pkg/__init__.py'], 'pkg.ghost')).toBeNull();
|
|
});
|
|
|
|
it('package __init__ suffix resolves', () => {
|
|
expect(
|
|
resolve('app/main.py', ['pkg/__init__.py', 'x/pkg/subpkg/__init__.py'], 'pkg.subpkg'),
|
|
).toBe('x/pkg/subpkg/__init__.py');
|
|
});
|
|
|
|
it('the index is reused across imports on the same file set (no stale results)', () => {
|
|
const files = ['pkg/__init__.py', 'a/pkg/models.py', 'vendor/pkg/thing.py'];
|
|
const ctx = { fromFile: 'app/main.py', allFilePaths: new Set(files) };
|
|
expect(resolvePythonImportTarget(mkImport('pkg.models'), ctx)).toBe('a/pkg/models.py');
|
|
expect(resolvePythonImportTarget(mkImport('pkg.thing'), ctx)).toBe('vendor/pkg/thing.py');
|
|
expect(resolvePythonImportTarget(mkImport('pkg.ghost'), ctx)).toBeNull();
|
|
});
|
|
|
|
it('resolves a nested package via the parent-keyed __init__ bucket (PR #1918 P2b)', () => {
|
|
// `mypkg` is a candidate (root package), but the real target is nested under
|
|
// vendor/. `noise/sub/__init__.py` shares the parent-bucket key (`sub`) yet
|
|
// is filtered out by the full-suffix confirm — proving the parent bucket is
|
|
// a candidate set, not the answer, and that the result matches the old scan.
|
|
const files = ['mypkg/__init__.py', 'vendor/mypkg/sub/__init__.py', 'noise/sub/__init__.py'];
|
|
expect(resolve('app/main.py', files, 'mypkg.sub')).toBe('vendor/mypkg/sub/__init__.py');
|
|
});
|
|
|
|
it('resolves an explicit pkg.__init__ import via the module lookup', () => {
|
|
// `from pkg.__init__ import x` targets the package init module directly;
|
|
// it must still resolve (it goes through the `<lastSeg>.py` = `__init__.py`
|
|
// bucket, not the parent-keyed package bucket).
|
|
expect(resolve('app/main.py', ['pkg/__init__.py', 'pkg/widget.py'], 'pkg.__init__')).toBe(
|
|
'pkg/__init__.py',
|
|
);
|
|
});
|
|
|
|
it('reproduces old startsWith gating for absolute paths (PR #1918 P3a)', () => {
|
|
// Absolute file set. hasRepoCandidate must NOT gate-pass `pkg` off
|
|
// `/repo/pkg/__init__.py` the way the first #1918 index did (its prefix set
|
|
// dropped the leading slash). The old full-scan gate did
|
|
// `"/repo/pkg/__init__.py".startsWith("repo/pkg/")` === false → blocked, so
|
|
// the suffix-only file `/repo/vendor/pkg/thing.py` stays unresolved.
|
|
expect(
|
|
resolve(
|
|
'/repo/app/main.py',
|
|
['/repo/pkg/__init__.py', '/repo/vendor/pkg/thing.py'],
|
|
'pkg.thing',
|
|
),
|
|
).toBeNull();
|
|
|
|
// Control: the SAME shape with repo-relative paths (what production emits)
|
|
// gates through and resolves — proving the fix only blocks the absolute-path
|
|
// false positive, not the real relative case.
|
|
expect(
|
|
resolve(
|
|
'repo/app/main.py',
|
|
['repo/pkg/__init__.py', 'repo/vendor/pkg/thing.py'],
|
|
'pkg.thing',
|
|
),
|
|
).toBe('repo/vendor/pkg/thing.py');
|
|
});
|
|
|
|
it('ignores non-.py files in a polyglot file set (PR #1918 P3b)', () => {
|
|
// The index is .py-only; sibling .ts/.go files of the same basename must not
|
|
// affect resolution. `pkg.models` resolves to the .py, never the .ts/.go.
|
|
const files = [
|
|
'pkg/__init__.py',
|
|
'a/pkg/models.py',
|
|
'a/pkg/models.ts',
|
|
'b/pkg/models.go',
|
|
'a/pkg/helper.ts',
|
|
];
|
|
expect(resolve('app/main.py', files, 'pkg.models')).toBe('a/pkg/models.py');
|
|
// A package whose only file is non-.py is not a repo candidate → null.
|
|
expect(resolve('app/main.py', [...files, 'tsonly/widget.ts'], 'tsonly.widget')).toBeNull();
|
|
});
|
|
|
|
it('prefers a concrete imported submodule over the package initializer (#2746)', () => {
|
|
const parsed = mkNamed('pkg', 'models');
|
|
const target = resolvePythonImportTarget(parsed, {
|
|
fromFile: 'pkg/app.py',
|
|
allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']),
|
|
});
|
|
|
|
expect(target).toBe('pkg/models.py');
|
|
expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(true);
|
|
});
|
|
|
|
it('preserves an explicit package export over a same-named submodule', () => {
|
|
const parsed = mkNamed('pkg', 'models');
|
|
const packageFile = {
|
|
filePath: 'pkg/__init__.py',
|
|
localDefs: [{ qualifiedName: 'models' }],
|
|
} as unknown as ParsedFile;
|
|
const target = resolvePythonImportTarget(parsed, {
|
|
fromFile: 'pkg/app.py',
|
|
allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']),
|
|
parsedFiles: [packageFile],
|
|
});
|
|
|
|
expect(target).toBe('pkg/__init__.py');
|
|
expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false);
|
|
});
|
|
|
|
it('keeps ordinary symbols on the named-import path', () => {
|
|
const parsed = mkNamed('pkg.models', 'User');
|
|
const target = resolvePythonImportTarget(parsed, {
|
|
fromFile: 'pkg/app.py',
|
|
allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']),
|
|
});
|
|
|
|
expect(target).toBe('pkg/models.py');
|
|
expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false);
|
|
});
|
|
|
|
it('recognizes relative from-import submodules', () => {
|
|
const parsed = mkNamed('.', 'models');
|
|
const target = resolvePythonImportTarget(parsed, {
|
|
fromFile: 'pkg/app.py',
|
|
allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']),
|
|
});
|
|
|
|
expect(target).toBe('pkg/models.py');
|
|
expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(true);
|
|
});
|
|
|
|
it('falls back to a package export when no same-named submodule exists', () => {
|
|
const parsed = mkNamed('pkg', 'exported');
|
|
const target = resolvePythonImportTarget(parsed, {
|
|
fromFile: 'pkg/app.py',
|
|
allFilePaths: new Set(['pkg/__init__.py']),
|
|
});
|
|
|
|
expect(target).toBe('pkg/__init__.py');
|
|
expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false);
|
|
});
|
|
});
|