mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* test(accuracy): retire repaired Python accessor gaps * fix(python): connect module accessor resolution * fix(python): resolve calls through module-returning accessors Bind `_pc().target()` and `m = _pc(); m.target()` to the module's function when a local accessor returns an imported module, and count capped-out dropped names in the impact lower bound. Closes #3497 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(python): share module-accessor position key (#3542) - Reuse the capture-side positionKey in the receiver namespace resolver instead of a second line:col helper plus an inline template. - Note why the synthetic { moduleScope, scopes: [] } input is sufficient for createNamespaceTargetCache (sealed disk-backed ParsedFile shape). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(python): reuse subtype-dispatch positionKey (#3542) - Export the existing line:col positionKey from subtype-dispatch instead of defining an identical copy in module-accessors, so both Python capture side channels key positions through one helper. - Apply prettier to the Function-scope indexing line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ci): unique test titles and rebaseline Python benches (#3542) - impact-epistemic-lower-bound: `%j` rendered null, NaN and Infinity all as `null`, so the test-completeness audit rejected the duplicate identities. Use `%s` so every parameterized case has a distinct title. - python-scope: capture fingerprint changes with the new module-accessor capture facts (golden captures were already updated). - receiver-resolution: +4 Python call drops (125 -> 129). The two-step `x = f(); x.m()` form is now counted as an unresolved call instead of vanishing: fastapi-depends `router.get/post` (unknown, x3) and python-method-chain-binding `user.get_city()` (in-program; `User` has no `get_city`, so the site is genuinely unresolvable). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { ParsedFile } from 'gitnexus-shared';
|
|
import { pythonProvider } from '../../../../src/core/ingestion/languages/python.js';
|
|
import { emitPythonScopeCaptures } from '../../../../src/core/ingestion/languages/python/captures.js';
|
|
import type { PythonCaptureSideChannel } from '../../../../src/core/ingestion/languages/python/module-accessors.js';
|
|
import { pythonScopeResolver } from '../../../../src/core/ingestion/languages/python/scope-resolver.js';
|
|
import { collectPythonSubtypeDispatchSideChannel } from '../../../../src/core/ingestion/languages/python/subtype-dispatch.js';
|
|
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
|
|
|
|
function snapshot(filePath: string) {
|
|
return pythonProvider.collectCaptureSideChannel?.(filePath) as
|
|
| PythonCaptureSideChannel
|
|
| undefined;
|
|
}
|
|
|
|
function capture(source: string, filePath = 'accessors.py') {
|
|
emitPythonScopeCaptures(source, filePath);
|
|
const result = snapshot(filePath);
|
|
expect(result?.kind).toBe('python-capture');
|
|
return result!.moduleAccessors;
|
|
}
|
|
|
|
describe('Python module accessor capture facts', () => {
|
|
it.each([
|
|
['import facade', 'facade'],
|
|
['import package.facade as lazy', 'lazy'],
|
|
['from . import facade as lazy', 'lazy'],
|
|
['from package import facade', 'facade'],
|
|
['import unused, facade', 'facade'],
|
|
])('retains exact callable and returned binding positions for %s', (statement, name) => {
|
|
expect(capture(`def lazy_module():\n ${statement}\n return ${name}\n`)).toEqual([
|
|
{
|
|
definitionLine: 1,
|
|
definitionColumn: 0,
|
|
status: 'accepted',
|
|
returnedName: name,
|
|
returnLine: 3,
|
|
returnColumn: 11,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('allows a docstring and comments without changing return ownership', () => {
|
|
expect(
|
|
capture(
|
|
'def lazy_module():\n # documentation\n """Load lazily."""\n import facade\n # after import\n return facade\n',
|
|
),
|
|
).toEqual([
|
|
{
|
|
definitionLine: 1,
|
|
definitionColumn: 0,
|
|
status: 'accepted',
|
|
returnedName: 'facade',
|
|
returnLine: 6,
|
|
returnColumn: 11,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'conditional return',
|
|
'def lazy_module():\n import facade\n if flag:\n return facade',
|
|
],
|
|
[
|
|
'mixed returns',
|
|
'def lazy_module():\n import facade\n if flag:\n return other\n return facade',
|
|
],
|
|
['implicit fallthrough', 'def lazy_module():\n import facade'],
|
|
['bare return', 'def lazy_module():\n import facade\n return'],
|
|
['decorator', '@decorate\ndef lazy_module():\n import facade\n return facade'],
|
|
['generator', 'def lazy_module():\n import facade\n yield 1\n return facade'],
|
|
['coroutine', 'async def lazy_module():\n import facade\n return facade'],
|
|
[
|
|
'finally override',
|
|
'def lazy_module():\n import facade\n try:\n return facade\n finally:\n return other',
|
|
],
|
|
['parameter', 'def lazy_module(facade):\n import facade\n return facade'],
|
|
['optional parameter', 'def lazy_module(flag=False):\n import facade\n return facade'],
|
|
[
|
|
'reassignment',
|
|
'def lazy_module():\n import facade\n facade = other\n return facade',
|
|
],
|
|
[
|
|
'later assignment',
|
|
'def lazy_module():\n import facade\n return facade\n facade = other',
|
|
],
|
|
['deletion', 'def lazy_module():\n import facade\n del facade\n return facade'],
|
|
[
|
|
'global redirection',
|
|
'def lazy_module():\n global facade\n import facade\n return facade',
|
|
],
|
|
[
|
|
'nonlocal redirection',
|
|
'def lazy_module():\n nonlocal facade\n import facade\n return facade',
|
|
],
|
|
['unimported return', 'def lazy_module():\n import facade\n return other'],
|
|
['outer import only', 'import facade\ndef lazy_module():\n return facade'],
|
|
['returned attribute', 'def lazy_module():\n import package\n return package.facade'],
|
|
[
|
|
'import rebinding',
|
|
'def lazy_module():\n import facade\n import other as facade\n return facade',
|
|
],
|
|
[
|
|
'interpolated string',
|
|
'def lazy_module():\n f"{side_effect()}"\n import facade\n return facade',
|
|
],
|
|
])('records a declined candidate for %s', (_label, source) => {
|
|
const facts = capture(source);
|
|
expect(facts).toHaveLength(1);
|
|
expect(facts[0]).toEqual({
|
|
definitionLine: source.startsWith('@') || source.startsWith('import ') ? 2 : 1,
|
|
definitionColumn: 0,
|
|
status: 'declined',
|
|
});
|
|
});
|
|
|
|
it('keeps nested callable returns and yields separate from the outer callable', () => {
|
|
expect(
|
|
capture(
|
|
[
|
|
'def outer():',
|
|
' def nested():',
|
|
' import facade',
|
|
' return facade',
|
|
' def generator():',
|
|
' yield 1',
|
|
' return nested()',
|
|
].join('\n'),
|
|
),
|
|
).toEqual([
|
|
{ definitionLine: 1, definitionColumn: 0, status: 'declined' },
|
|
{
|
|
definitionLine: 2,
|
|
definitionColumn: 4,
|
|
status: 'accepted',
|
|
returnedName: 'facade',
|
|
returnLine: 4,
|
|
returnColumn: 15,
|
|
},
|
|
{ definitionLine: 5, definitionColumn: 4, status: 'declined' },
|
|
]);
|
|
});
|
|
|
|
it('records assigned bare no-argument calls at their exact reference and lexical scope', () => {
|
|
const parsed = extractParsedFile(
|
|
pythonProvider,
|
|
[
|
|
'def first():',
|
|
' module = lazy_module()',
|
|
' other = lazy_module()',
|
|
' argument = lazy_module(1)',
|
|
' keyword = lazy_module(flag=True)',
|
|
' qualified = package.lazy_module()',
|
|
' obj.field = lazy_module()',
|
|
'def second():',
|
|
' module = lazy_module()',
|
|
].join('\n'),
|
|
'assigned.py',
|
|
)!;
|
|
const assignments = parsed.callResultAssignmentSites ?? [];
|
|
expect(assignments.map(({ lhs }) => lhs)).toEqual(['module', 'other', 'module']);
|
|
expect(assignments.map(({ callSite }) => callSite.startLine)).toEqual([2, 3, 9]);
|
|
for (const assignment of assignments) {
|
|
const reference = parsed.referenceSites.find(
|
|
(site) =>
|
|
site.atRange.startLine === assignment.callSite.startLine &&
|
|
site.atRange.startCol === assignment.callSite.startCol &&
|
|
site.name === 'lazy_module',
|
|
);
|
|
expect(reference?.atRange).toEqual(assignment.callSite);
|
|
expect(reference?.inScope).toBe(assignment.inScope);
|
|
}
|
|
expect(assignments[0]?.inScope).toBe(assignments[1]?.inScope);
|
|
expect(assignments[0]?.inScope).not.toBe(assignments[2]?.inScope);
|
|
});
|
|
|
|
it.each([
|
|
['if', 'if flag:\n module = lazy_module()'],
|
|
['else', 'if flag:\n pass\n else:\n module = lazy_module()'],
|
|
['for', 'for value in values:\n module = lazy_module()'],
|
|
['with', 'with manager():\n module = lazy_module()'],
|
|
['while', 'while flag:\n module = lazy_module()'],
|
|
['try', 'try:\n module = lazy_module()\n except Exception:\n pass'],
|
|
['except', 'try:\n pass\n except Exception:\n module = lazy_module()'],
|
|
['finally', 'try:\n pass\n finally:\n module = lazy_module()'],
|
|
['match', 'match value:\n case 1:\n module = lazy_module()'],
|
|
])('retains generic provenance but declines %s assignment eligibility', (_label, body) => {
|
|
const parsed = extractParsedFile(
|
|
pythonProvider,
|
|
`def caller():\n ${body}\n`,
|
|
'conditional.py',
|
|
)!;
|
|
const assignments = parsed.callResultAssignmentSites ?? [];
|
|
expect(assignments).toHaveLength(1);
|
|
expect(snapshot('conditional.py')).toHaveProperty('callResultAssignments', [
|
|
{
|
|
callLine: assignments[0]!.callSite.startLine,
|
|
callColumn: assignments[0]!.callSite.startCol,
|
|
straightLine: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('stops conditional ancestry at the owning callable and records straight-line module assignments', () => {
|
|
const parsed = extractParsedFile(
|
|
pythonProvider,
|
|
[
|
|
'module = lazy_module()',
|
|
'if flag:',
|
|
' def nested():',
|
|
' module = lazy_module()',
|
|
].join('\n'),
|
|
'ownership.py',
|
|
)!;
|
|
expect(parsed.callResultAssignmentSites).toHaveLength(2);
|
|
expect(snapshot('ownership.py')).toHaveProperty('callResultAssignments', [
|
|
{ callLine: 1, callColumn: 9, straightLine: true },
|
|
{ callLine: 4, callColumn: 17, straightLine: true },
|
|
]);
|
|
});
|
|
|
|
it('composes clone-safe accessor and subtype facts and restores both after reset', () => {
|
|
const source = [
|
|
'def lazy_module():',
|
|
' import facade',
|
|
' return facade',
|
|
'class Worker:',
|
|
' def target(self, value):',
|
|
' return self.target(value)',
|
|
'module = lazy_module()',
|
|
].join('\n');
|
|
capture(source);
|
|
const before = snapshot('accessors.py')!;
|
|
const subtypeBefore = collectPythonSubtypeDispatchSideChannel('accessors.py');
|
|
expect(before.subtypeDispatch).toEqual(subtypeBefore);
|
|
expect(subtypeBefore?.simplePositionalCalls).toHaveLength(2);
|
|
expect(subtypeBefore?.positionalCapacities).toHaveLength(1);
|
|
const clone = structuredClone(before);
|
|
expect(JSON.parse(JSON.stringify(before))).toEqual(clone);
|
|
|
|
emitPythonScopeCaptures('', 'accessors.py');
|
|
expect(snapshot('accessors.py')).toBeUndefined();
|
|
expect(collectPythonSubtypeDispatchSideChannel('accessors.py')).toBeUndefined();
|
|
pythonScopeResolver.applyCaptureSideChannel!({
|
|
filePath: 'accessors.py',
|
|
captureSideChannel: clone,
|
|
} as ParsedFile);
|
|
expect(snapshot('accessors.py')).toEqual(before);
|
|
expect(collectPythonSubtypeDispatchSideChannel('accessors.py')).toEqual(subtypeBefore);
|
|
|
|
pythonScopeResolver.applyCaptureSideChannel!({ filePath: 'accessors.py' } as ParsedFile);
|
|
expect(snapshot('accessors.py')).toBeUndefined();
|
|
expect(collectPythonSubtypeDispatchSideChannel('accessors.py')).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
undefined,
|
|
null,
|
|
{},
|
|
[{ callLine: 1, callColumn: 9, straightLine: 'yes' }],
|
|
[{ callLine: 0, callColumn: 9, straightLine: true }],
|
|
])(
|
|
'clears stale assignment eligibility when restored facts are absent or malformed: %j',
|
|
(facts) => {
|
|
emitPythonScopeCaptures('module = lazy_module()', 'restore.py');
|
|
expect(snapshot('restore.py')).toHaveProperty('callResultAssignments', [
|
|
{ callLine: 1, callColumn: 9, straightLine: true },
|
|
]);
|
|
pythonScopeResolver.applyCaptureSideChannel!({
|
|
filePath: 'restore.py',
|
|
captureSideChannel: {
|
|
kind: 'python-capture',
|
|
moduleAccessors: [],
|
|
callResultAssignments: facts,
|
|
},
|
|
} as unknown as ParsedFile);
|
|
expect(snapshot('restore.py')).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it('does not leak facts between files or reuse stale facts on recapture', () => {
|
|
capture('def lazy_module():\n import facade\n return facade', 'first.py');
|
|
capture('def lazy_module():\n return other', 'second.py');
|
|
expect(snapshot('first.py')!.moduleAccessors[0]?.status).toBe('accepted');
|
|
expect(snapshot('second.py')!.moduleAccessors[0]?.status).toBe('declined');
|
|
emitPythonScopeCaptures('', 'first.py');
|
|
expect(snapshot('first.py')).toBeUndefined();
|
|
expect(snapshot('second.py')!.moduleAccessors[0]?.status).toBe('declined');
|
|
});
|
|
|
|
it('remaps accessor and assignment facts to original notebook coordinates', () => {
|
|
const source =
|
|
'def lazy_module():\n import facade\n return facade\nmodule = lazy_module()';
|
|
const captures = emitPythonScopeCaptures(source, 'notebook.ipynb', undefined, {
|
|
sourceKind: 'pre-extracted-script',
|
|
notebookSegments: [
|
|
{ extractStartLine: 0, extractEndLine: 3, jsonStartLine: 20, jsonEndLine: 23 },
|
|
],
|
|
});
|
|
expect(snapshot('notebook.ipynb')?.moduleAccessors).toEqual([
|
|
{
|
|
definitionLine: 21,
|
|
definitionColumn: 0,
|
|
status: 'accepted',
|
|
returnedName: 'facade',
|
|
returnLine: 23,
|
|
returnColumn: 11,
|
|
},
|
|
]);
|
|
expect(snapshot('notebook.ipynb')).toHaveProperty('callResultAssignments', [
|
|
{ callLine: 24, callColumn: 9, straightLine: true },
|
|
]);
|
|
expect(
|
|
captures.find((match) => match['@call-result-assignment.call'])?.[
|
|
'@call-result-assignment.call'
|
|
]?.range.startLine,
|
|
).toBe(24);
|
|
});
|
|
});
|