mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Answers every reproduced finding still open on #2980, plus the defects an
adversarial pass found in the first round of those fixes. The wrong-path group
each turned a *missing* fact into a *wrong* one, which is what this module's
skip-or-correct contract exists to prevent.
Wrong-path fixes
* Escapes were deleted from constant values. tree-sitter-java splits a
`string_literal` around its `escape_sequence` children, so joining
`string_fragment`s alone folded `"/user/{id:\\d+}"` — the standard Spring
path-variable constraint — to `/user/{id:d+}`, and a pure-escape literal to
the empty string. Worse, the LITERAL path keeps escapes verbatim, so one Java
route had two irreconcilable spellings. `stringLiteralValue` now reuses
`unquoteSpringLiteral`, the helper that literal path already uses. Java text
blocks are excluded: that helper's `"""` arm would hand back the raw block,
newline and incidental indentation included, so they keep the old skip.
* A constant-valued class prefix produced a truncated route. The new
`@value_expr` query branches were `method_declaration`-only, so
`@RequestMapping(ApiPaths.BASE)` left the prefix empty and the method route
was emitted unprefixed — a path the application does not serve, where the base
emitted nothing at all. Both subsystems now detect such a class and suppress
its method routes, the rule `classesWithArrayPrefix` already encodes for the
array form. The suppression covers ingestion's separate no-argument-mapping
loop too, without which a bare `@GetMapping` under a constant prefix still
shipped an empty-path Route while the group emitted nothing.
* A shadowed static import survived a non-foldable rebind. The rebind-drop
deleted `literals`/`exprs` but not `imports`, so a name both static-imported
and locally redeclared resolved through the stale import to the imported
value instead of skipping (#2393's Python defect, reproduced for Java).
* `resolveJavaImport` guessed where its own docstring promised null. The
nearest-shared-directory tie-break is gone: javac resolves duplicate FQNs by
classpath order, so proximity can return a src/test fixture copy.
Parity and coverage fixes
* One constant-file gate, exported as `isJavaConstantFile` and used by both the
ingestion provider and the group `prepareRepo` pre-pass. The two spellings
disagreed on a constant INTERFACE — implicitly `public static final`, so it
carries neither keyword — which the group admitted and ingestion rejected, so
the group published a contract while the graph got no Route node. It is also
modifier-order agnostic now, and its interface arm requires a String
assignment so a javadoc mentioning "interface" no longer costs a parse.
* Import ambiguity is measured over constant-DEFINING files on both sides.
Ingestion's harvest gate also admits import-only files, so handing
`resolveJavaImport` every repo key let a duplicate FQN that defines nothing
make ingestion alone floor to skip — reopening the same parity break in the
same losing direction.
* Python's constant harvest is unconditional again. The gate added here
required NAME immediately followed by `=`, so it dropped `API: str = "/api"`,
`API: Final[str] = "/api"` and every composed constant whose RHS starts with
an identifier — routes that already resolve on main. The worker now treats a
missing heuristic as "harvest" rather than "skip".
* Enum and record declarations were traversed but never collected, so a
`static final String` declared in one was absent from the map. The walk still
descends the whole body, so a type nested in an enum-constant body is kept.
* Constants composed across files through a qualified ref never resolved:
operands found inside an initializer went to the agnostic core, which only
knows bare names, so `X = BConsts.Y + "/tail"` floored to null even
acyclically. The Java binding now folds its own expressions — and carries the
core's guards with them: a `visited` stack popped on unwind, a memo of
successes, and `MAX_FOLD_LENGTH`. Without the memo a shared-descendant DAG
re-folds each child per reference; because a chain of empty strings never
accumulates output, the length cap could not stop it, and one route over a
31-line constants file took 11 s at 28 levels on the main thread.
* Dropped the dead `com.java.lang.` type normalization.
Cache
* `SCHEMA_BUMP` 70 -> 72. Leaving it at 70 was justified by "the ledger already
sits at 70, whose capture set post-dates and includes this harvest" — it does
not: 70 was cut by fe3d7e56b for #2417/#2891, an ancestor of this base. With
package.json untouched, `PARSE_CACHE_VERSION` was byte-identical across the
merge, so every same-version warm cache replayed pre-feature captures and the
feature was inert. 72 rather than 71 because open PR #3017 already claims 71
with an identical pin test — the ledger's rule is the next value above every
in-flight claim, not above origin/main.
Tests
* Regression cover for each fix above, including a gate-level test (the gate
itself had none), an import-ambiguity test, a text-block test, and a 30-level
shared-descendant DAG that fails by timeout if the memo is ever removed.
* New `group/java-const-route-parity.test.ts` drives `prepareRepo` + a
three-argument `scan`. Every existing Spring parity guard calls `scan(tree)`
with ONE argument, and the plugin drops constant-valued routes without a repo
context — so those guards were structurally blind to this whole feature.
* The pipeline e2e now proves the warm run is a REPLAY (`usedWorkerPool` false)
instead of only comparing route sets. It was not one: the test never persisted
the durable ParsedFile store, so the "warm" run reparsed through the workers
and would have passed with the cache round-trip completely broken.
* Its dist freshness gate covers every source the pipeline loads, not just
parse-worker.ts, and prints the loud message the docblock promised.
* The self-import cycle fixture now actually self-imports, so it reaches the
qualified-ref recursion and its depth cap.
* Removed the dead `WIN_POST_MAPPING` fixture and the claim behind it: Spring
alias recognition is an exact-name map on this base, so `@WinPostMapping`
extracts zero routes no matter how its value folds (#2883 is still open).
Fixtures now use annotations this branch actually recognises.
405 lines
17 KiB
TypeScript
405 lines
17 KiB
TypeScript
/**
|
|
* Unit tests for the PURE half of the Python constant resolver (#2391):
|
|
* {@link resolveConstant} / {@link resolveOperands} / {@link resolvePythonImport}.
|
|
*
|
|
* These operate on a hand-built {@link RepoConstants} map, so no tree-sitter is
|
|
* involved — the tree → ModuleConstants extraction is covered separately in the
|
|
* U2 section of this file. The scenarios mirror the plan's U1 test list: same-file
|
|
* literals/concat, single- and multi-hop imports, the issue's chained repro,
|
|
* aliasing, inline operands, the relative-import collision (KTD4), cycles, the
|
|
* depth cap, and non-foldable / unknown / package-`__init__` cases → null.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import Parser from 'tree-sitter';
|
|
import Python from 'tree-sitter-python';
|
|
import {
|
|
resolveConstant,
|
|
resolveOperands,
|
|
resolvePythonImport,
|
|
extractPythonModuleConstants,
|
|
type ModuleConstants,
|
|
type Operand,
|
|
type ImportBinding,
|
|
type RepoConstants,
|
|
} from '../../src/core/ingestion/route-extractors/python-const-resolver.js';
|
|
import { pythonProvider } from '../../src/core/ingestion/languages/python.js';
|
|
|
|
const lit = (value: string): Operand => ({ kind: 'literal', value });
|
|
const ref = (name: string): Operand => ({ kind: 'ref', name });
|
|
|
|
function mc(parts: {
|
|
literals?: Record<string, string>;
|
|
exprs?: Record<string, Operand[]>;
|
|
imports?: Record<string, ImportBinding>;
|
|
}): ModuleConstants {
|
|
return {
|
|
literals: new Map(Object.entries(parts.literals ?? {})),
|
|
exprs: new Map(Object.entries(parts.exprs ?? {})),
|
|
imports: new Map(Object.entries(parts.imports ?? {})),
|
|
};
|
|
}
|
|
|
|
const repo = (entries: Record<string, ModuleConstants>): RepoConstants =>
|
|
new Map(Object.entries(entries));
|
|
|
|
describe('resolveConstant — same file', () => {
|
|
it('resolves a bare literal', () => {
|
|
const r = repo({ 'm.py': mc({ literals: { X: '/a' } }) });
|
|
expect(resolveConstant('m.py', 'X', r)).toBe('/a');
|
|
});
|
|
|
|
it('folds a concat of two literals', () => {
|
|
const r = repo({ 'm.py': mc({ exprs: { X: [lit('/a'), lit('/b')] } }) });
|
|
expect(resolveConstant('m.py', 'X', r)).toBe('/a/b');
|
|
});
|
|
|
|
it('folds a concat referencing another same-file const', () => {
|
|
const r = repo({ 'm.py': mc({ literals: { A: '/a' }, exprs: { X: [ref('A'), lit('/b')] } }) });
|
|
expect(resolveConstant('m.py', 'X', r)).toBe('/a/b');
|
|
});
|
|
});
|
|
|
|
describe('resolveConstant — across imports', () => {
|
|
it('resolves a single import hop', () => {
|
|
const r = repo({
|
|
'app/constants.py': mc({ literals: { X: '/a' } }),
|
|
'app/routes.py': mc({ imports: { X: { module: '.constants', originalName: 'X' } } }),
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'X', r)).toBe('/a');
|
|
});
|
|
|
|
it('resolves the issue repro: chained in-module concat behind an import', () => {
|
|
const r = repo({
|
|
'app/constants.py': mc({
|
|
literals: { API_V1: '/api/v1' },
|
|
exprs: {
|
|
API_V1_WIDGETS: [ref('API_V1'), lit('/widgets')],
|
|
API_V1_WIDGETS_GET: [ref('API_V1_WIDGETS'), lit('/get')],
|
|
},
|
|
}),
|
|
'app/routes.py': mc({
|
|
imports: {
|
|
API_V1_WIDGETS_GET: { module: '.constants', originalName: 'API_V1_WIDGETS_GET' },
|
|
},
|
|
}),
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'API_V1_WIDGETS_GET', r)).toBe('/api/v1/widgets/get');
|
|
});
|
|
|
|
it('resolves a multi-module chain (base -> constants -> routes)', () => {
|
|
const r = repo({
|
|
'app/base.py': mc({ literals: { API_V1: '/api/v1' } }),
|
|
'app/constants.py': mc({
|
|
imports: { API_V1: { module: '.base', originalName: 'API_V1' } },
|
|
exprs: { WIDGETS: [ref('API_V1'), lit('/widgets')] },
|
|
}),
|
|
'app/routes.py': mc({
|
|
imports: { WIDGETS: { module: '.constants', originalName: 'WIDGETS' } },
|
|
}),
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'WIDGETS', r)).toBe('/api/v1/widgets');
|
|
});
|
|
|
|
it('resolves an aliased import via the original name', () => {
|
|
const r = repo({
|
|
'app/constants.py': mc({ literals: { X: '/a' } }),
|
|
'app/routes.py': mc({ imports: { Y: { module: '.constants', originalName: 'X' } } }),
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'Y', r)).toBe('/a');
|
|
});
|
|
});
|
|
|
|
describe('resolveOperands — inline decorator expression', () => {
|
|
it('folds an inline operand list with a const ref', () => {
|
|
const r = repo({ 'app/routes.py': mc({ literals: { API_V1: '/api/v1' } }) });
|
|
expect(resolveOperands('app/routes.py', [ref('API_V1'), lit('/widgets')], r)).toBe(
|
|
'/api/v1/widgets',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('resolveConstant — relative-import collision (KTD4)', () => {
|
|
const r = repo({
|
|
'a/constants.py': mc({ literals: { API_PREFIX: '/a' } }),
|
|
'b/constants.py': mc({ literals: { API_PREFIX: '/b' } }),
|
|
'a/routes.py': mc({
|
|
imports: { API_PREFIX: { module: '.constants', originalName: 'API_PREFIX' } },
|
|
}),
|
|
'b/routes.py': mc({
|
|
imports: { API_PREFIX: { module: '.constants', originalName: 'API_PREFIX' } },
|
|
}),
|
|
'c/routes.py': mc({
|
|
imports: { API_PREFIX: { module: 'constants', originalName: 'API_PREFIX' } },
|
|
}),
|
|
});
|
|
|
|
it('resolves each package against its own constants.py', () => {
|
|
expect(resolveConstant('a/routes.py', 'API_PREFIX', r)).toBe('/a');
|
|
expect(resolveConstant('b/routes.py', 'API_PREFIX', r)).toBe('/b');
|
|
});
|
|
|
|
it('returns null for an ambiguous absolute import (two matching files)', () => {
|
|
expect(resolveConstant('c/routes.py', 'API_PREFIX', r)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveConstant — unresolvable → null', () => {
|
|
it('breaks a cycle', () => {
|
|
const r = repo({ 'm.py': mc({ exprs: { A: [ref('B')], B: [ref('A')] } }) });
|
|
expect(resolveConstant('m.py', 'A', r)).toBeNull();
|
|
});
|
|
|
|
it('returns null past the depth cap', () => {
|
|
const exprs: Record<string, Operand[]> = {};
|
|
for (let i = 0; i < 20; i++) exprs[`A${i}`] = [ref(`A${i + 1}`)];
|
|
const r = repo({ 'm.py': mc({ exprs, literals: { A20: '/end' } }) });
|
|
expect(resolveConstant('m.py', 'A0', r)).toBeNull();
|
|
});
|
|
|
|
it('returns null on an unknown operand name', () => {
|
|
const r = repo({ 'm.py': mc({ exprs: { X: [lit('/a'), ref('MISSING')] } }) });
|
|
expect(resolveConstant('m.py', 'X', r)).toBeNull();
|
|
});
|
|
|
|
it('returns null for an unknown name', () => {
|
|
const r = repo({ 'm.py': mc({ literals: { X: '/a' } }) });
|
|
expect(resolveConstant('m.py', 'NOPE', r)).toBeNull();
|
|
});
|
|
|
|
it('returns null when a package __init__ re-export hop is not a .py module', () => {
|
|
const r = repo({
|
|
'app/constants/__init__.py': mc({ literals: { X: '/a' } }),
|
|
'app/routes.py': mc({ imports: { X: { module: '.constants', originalName: 'X' } } }),
|
|
});
|
|
// `.constants` resolves to `app/constants.py`, which does not exist (it is a
|
|
// package dir). Package __init__ re-exports are deferred (#2391 scope).
|
|
expect(resolveConstant('app/routes.py', 'X', r)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolvePythonImport', () => {
|
|
const keys = new Set(['a/constants.py', 'b/constants.py', 'app/pkg/mod.py', 'app/routes.py']);
|
|
|
|
it('resolves a relative import against the importing file package', () => {
|
|
expect(resolvePythonImport('a/routes.py', '.constants', keys)).toBe('a/constants.py');
|
|
});
|
|
|
|
it('walks up one level per extra leading dot', () => {
|
|
expect(resolvePythonImport('app/pkg/routes.py', '..routes', keys)).toBe('app/routes.py');
|
|
});
|
|
|
|
it('returns null for an ambiguous absolute suffix', () => {
|
|
expect(resolvePythonImport('a/routes.py', 'constants', keys)).toBeNull();
|
|
});
|
|
|
|
it('resolves an unambiguous absolute multi-segment import', () => {
|
|
expect(resolvePythonImport('a/routes.py', 'app.pkg.mod', keys)).toBe('app/pkg/mod.py');
|
|
});
|
|
|
|
it('returns null when the target file does not exist', () => {
|
|
expect(resolvePythonImport('a/routes.py', '.missing', keys)).toBeNull();
|
|
});
|
|
|
|
it('resolves `from . import` to the package __init__.py, not a sibling <dir>.py (#2393)', () => {
|
|
const k = new Set(['pkg/__init__.py', 'pkg/routes.py']);
|
|
expect(resolvePythonImport('pkg/routes.py', '.', k)).toBe('pkg/__init__.py');
|
|
});
|
|
|
|
it('returns null for `from . import` when the package __init__.py is absent (#2393)', () => {
|
|
expect(resolvePythonImport('pkg/routes.py', '.', new Set(['pkg/routes.py']))).toBeNull();
|
|
});
|
|
|
|
it('returns null for an over-deep relative import even if the clamped target exists (#2393)', () => {
|
|
// `from ...constants` from a repo-root file climbs two levels above the root.
|
|
// Without the guard it would clamp to a bare `constants.py`; it must return null.
|
|
const k = new Set(['constants.py', 'routes.py']);
|
|
expect(resolvePythonImport('routes.py', '...constants', k)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── U2: tree → ModuleConstants extraction (real parse) ──────────────────────
|
|
|
|
const parser = new Parser();
|
|
parser.setLanguage(Python);
|
|
const extract = (src: string): ModuleConstants => extractPythonModuleConstants(parser.parse(src));
|
|
const repoFrom = (files: Record<string, string>): RepoConstants =>
|
|
new Map(Object.entries(files).map(([k, src]) => [k, extract(src)]));
|
|
|
|
describe('extractPythonModuleConstants', () => {
|
|
it('extracts a bare string literal', () => {
|
|
const mcs = extract('X = "/a"\n');
|
|
expect(mcs.literals.get('X')).toBe('/a');
|
|
});
|
|
|
|
it('extracts a + concat as an ordered operand list', () => {
|
|
const mcs = extract('X = A + "/b"\n');
|
|
expect(mcs.exprs.get('X')).toEqual([
|
|
{ kind: 'ref', name: 'A' },
|
|
{ kind: 'literal', value: '/b' },
|
|
]);
|
|
});
|
|
|
|
it('caps recursion on a pathological deep + chain — null, not a throw (#2393)', () => {
|
|
const chain = Array.from({ length: 100 }, (_, i) => `A${i}`).join(' + ');
|
|
const mcs = extract(`X = ${chain}\n`); // depth > 64 → parseConstOperands floors to null
|
|
expect(mcs.exprs.has('X')).toBe(false);
|
|
expect(mcs.literals.has('X')).toBe(false);
|
|
});
|
|
|
|
it('folds an augmented assignment (X += "/b")', () => {
|
|
const r = new Map([['m.py', extract('X = "/a"\nX += "/b"\n')]]);
|
|
expect(resolveConstant('m.py', 'X', r)).toBe('/a/b');
|
|
});
|
|
|
|
it('applies last-wins rebind and drops a non-string rebind', () => {
|
|
const r1 = new Map([['m.py', extract('X = "/a"\nX = "/b"\n')]]);
|
|
expect(resolveConstant('m.py', 'X', r1)).toBe('/b');
|
|
const r2 = new Map([['m.py', extract('X = "/a"\nX = build()\n')]]);
|
|
expect(resolveConstant('m.py', 'X', r2)).toBeNull();
|
|
});
|
|
|
|
it('extracts from-import bindings, including aliases and relative paths', () => {
|
|
const mcs = extract('from .constants import X\nfrom pkg.mod import Y as Z\n');
|
|
expect(mcs.imports.get('X')).toEqual({ module: '.constants', originalName: 'X' });
|
|
expect(mcs.imports.get('Z')).toEqual({ module: 'pkg.mod', originalName: 'Y' });
|
|
});
|
|
|
|
it('ignores non-string assignments', () => {
|
|
const mcs = extract('N = 5\ncfg = Settings()\nP = "/p"\n');
|
|
expect(mcs.literals.has('N')).toBe(false);
|
|
expect(mcs.exprs.has('cfg')).toBe(false);
|
|
expect(mcs.literals.get('P')).toBe('/p');
|
|
});
|
|
|
|
it('resolves the full issue repro end-to-end (extractor → resolver)', () => {
|
|
const r = repoFrom({
|
|
'app/constants.py': [
|
|
'API_V1 = "/api/v1"',
|
|
'API_V1_WIDGETS = API_V1 + "/widgets"',
|
|
'API_V1_WIDGETS_GET = API_V1_WIDGETS + "/get"',
|
|
].join('\n'),
|
|
'app/routes.py': 'from .constants import API_V1_WIDGETS_GET\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'API_V1_WIDGETS_GET', r)).toBe('/api/v1/widgets/get');
|
|
});
|
|
|
|
it('survives a structured-clone round-trip (worker/cache boundary)', () => {
|
|
const cloned = structuredClone(extract('X = "/a"\nfrom .c import Y\n'));
|
|
const r = new Map([['m.py', cloned]]);
|
|
expect(resolveConstant('m.py', 'X', r)).toBe('/a');
|
|
expect(cloned.imports.get('Y')).toEqual({ module: '.c', originalName: 'Y' });
|
|
});
|
|
});
|
|
|
|
describe('extractPythonModuleConstants — binding mutual-exclusivity (#2393)', () => {
|
|
it('drops an imported name that is then rebound to a dynamic value (never the stale import)', () => {
|
|
// Python: ROUTE's live value is the getenv result → unknowable → must DROP,
|
|
// not resolve to the stale import (the skip-floor / wrong-path invariant).
|
|
const mcs = extract('from .constants import ROUTE\nROUTE = os.getenv("X")\n');
|
|
expect(mcs.imports.has('ROUTE')).toBe(false);
|
|
expect(mcs.literals.has('ROUTE')).toBe(false);
|
|
expect(mcs.exprs.has('ROUTE')).toBe(false);
|
|
const r = repoFrom({
|
|
'app/constants.py': 'ROUTE = "/imported"\n',
|
|
'app/routes.py': 'from .constants import ROUTE\nROUTE = os.getenv("X")\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'ROUTE', r)).toBeNull();
|
|
});
|
|
|
|
it('uses the local literal when a later assignment shadows an import', () => {
|
|
const r = repoFrom({
|
|
'app/constants.py': 'ROUTE = "/imported"\n',
|
|
'app/routes.py': 'from .constants import ROUTE\nROUTE = "/local"\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'ROUTE', r)).toBe('/local');
|
|
});
|
|
|
|
it('uses the import when it shadows an earlier local assignment (source order)', () => {
|
|
const r = repoFrom({
|
|
'app/constants.py': 'ROUTE = "/imported"\n',
|
|
'app/routes.py': 'ROUTE = "/local"\nfrom .constants import ROUTE\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'ROUTE', r)).toBe('/imported');
|
|
});
|
|
|
|
it('folds an augmented assignment onto an imported base (#2393)', () => {
|
|
const r = repoFrom({
|
|
'app/constants.py': 'BASE = "/api"\n',
|
|
'app/routes.py': 'from .constants import BASE\nBASE += "/v1"\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'BASE', r)).toBe('/api/v1');
|
|
});
|
|
|
|
it('folds a chain of += onto an imported base (#2393)', () => {
|
|
const r = repoFrom({
|
|
'app/constants.py': 'BASE = "/api"\n',
|
|
'app/routes.py': 'from .constants import BASE\nBASE += "/a"\nBASE += "/b"\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'BASE', r)).toBe('/api/a/b');
|
|
});
|
|
|
|
it('drops a += onto an imported base that itself cannot be resolved (skip floor holds)', () => {
|
|
const r = repoFrom({
|
|
// `.missing` does not exist → the imported base is unresolvable → drop, never
|
|
// a wrong path.
|
|
'app/routes.py': 'from .missing import BASE\nBASE += "/v1"\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'BASE', r)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('extractPythonModuleConstants — source-order snapshot (#2393)', () => {
|
|
it('snapshots an aliased imported base before a later += (no wrong path)', () => {
|
|
// Python: ROUTE captures BASE's value at the `ROUTE =` line ("/api"); the later
|
|
// `BASE += "/v1"` must NOT retroactively change ROUTE.
|
|
const r = repoFrom({
|
|
'app/constants.py': 'BASE = "/api"\n',
|
|
'app/routes.py': 'from .constants import BASE\nROUTE = BASE\nBASE += "/v1"\n',
|
|
});
|
|
expect(resolveConstant('app/routes.py', 'ROUTE', r)).toBe('/api');
|
|
expect(resolveConstant('app/routes.py', 'BASE', r)).toBe('/api/v1');
|
|
});
|
|
|
|
it('snapshots an aliased local constant before a later += (no wrong path)', () => {
|
|
const r = repoFrom({ 'm.py': 'API = "/api"\nROUTE = API\nAPI += "/x"\n' });
|
|
expect(resolveConstant('m.py', 'ROUTE', r)).toBe('/api');
|
|
expect(resolveConstant('m.py', 'API', r)).toBe('/api/x');
|
|
});
|
|
|
|
it('snapshots an aliased local constant before a later plain rebind (no wrong path)', () => {
|
|
const r = repoFrom({ 'm.py': 'API = "/api"\nROUTE = API\nAPI = "/other"\n' });
|
|
expect(resolveConstant('m.py', 'ROUTE', r)).toBe('/api');
|
|
expect(resolveConstant('m.py', 'API', r)).toBe('/other');
|
|
});
|
|
|
|
it('still folds a normal same-file reference chain (snapshot inlines bound refs)', () => {
|
|
const r = repoFrom({ 'm.py': 'A = "/a"\nB = A + "/b"\nC = B + "/c"\n' });
|
|
expect(resolveConstant('m.py', 'C', r)).toBe('/a/b/c');
|
|
});
|
|
});
|
|
|
|
describe('the Python provider harvests unconditionally (#2980 review P2)', () => {
|
|
// A cheap content gate was added on the provider here and removed on review.
|
|
// It required NAME immediately followed by `=`, so it silently dropped the
|
|
// idiomatic typed-FastAPI shapes and every composed constant whose RHS starts
|
|
// with an identifier — i.e. it REGRESSED routes that already resolve on main.
|
|
// The parse worker treats a missing heuristic as "harvest"; pin that here so
|
|
// the gate cannot come back without a decision.
|
|
it('declares no moduleConstantHeuristic', () => {
|
|
expect(pythonProvider.moduleConstantHeuristic).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
['plain', 'API = "/api/v1"\nUSERS = API + "/users"\n'],
|
|
['PEP 526 annotated', 'API: str = "/api/v1"\nUSERS: str = API + "/users"\n'],
|
|
[
|
|
'Final-annotated',
|
|
'from typing import Final\nAPI: Final[str] = "/api/v1"\nUSERS: Final[str] = API + "/users"\n',
|
|
],
|
|
['composed, identifier RHS', 'API = _base()\nUSERS = API + "/users"\n'],
|
|
])('still reaches the extractor for the %s shape', (_name, src) => {
|
|
const mc = extract(src);
|
|
expect(mc.literals.size + mc.exprs.size + mc.imports.size).toBeGreaterThan(0);
|
|
});
|
|
});
|