GitNexus/gitnexus/test/unit/python-const-resolver.test.ts
Gergő Magyar 5f4964b4e6
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
fix: resolve imported/composed FastAPI route path constants (#2391) (#2393)
* feat(routes): add pure Python string-constant resolver (#2391 U1)

* feat(routes): extract Python module constants from tree (#2391 U2)

* feat(routes): capture non-literal FastAPI decorator args + per-file constants, bump parse-cache schema (#2391 U3)

* feat(routes): resolve composed decorator route constants in parse-impl + skip floor (#2391 U4)

* feat(routes): resolve composed FastAPI route constants in group HTTP-contract layer (#2391 U5)

* test(routes): multi-hop, ingestion↔group parity, and warm-cache regression locks (#2391 U6)

* docs(routes): mark the language-agnostic seam for cross-language const resolution (#2391)

* refactor(routes): extract language-agnostic constant-fold core; Python becomes a binding (#2391)

The fold, cycle guard, and depth cap now live in constant-resolver.ts and take a
pluggable ImportResolver. python-const-resolver.ts supplies the Python import
semantics + tree extractor and re-exports the same surface, so no call site
changes. A Spring/Kotlin/C# binding can now reuse the core with its own resolver
(proven by constant-resolver.test.ts driving it with a Java-style resolver).

* fix(routes): treat the constant-fold cycle guard as a recursion stack (#2391)

The `visited` set in `foldName` was added-to but never removed on unwind, so a
constant referenced more than once in a single fold — `A + A`, a reused
separator (`SLASH + PATH + SLASH`), or a diamond `X = P + Q` where P and Q share
a base — tripped the cycle guard on its second occurrence and the whole route
was silently dropped by the skip floor. Pop the guard in `finally` so it tracks
the ACTIVE resolution stack, not every name ever seen: a true cycle (a name
still on the stack) is still caught, but a name that already resolved and popped
folds again. Re-computation stays bounded by MAX_RESOLVE_DEPTH, so no blowup is
reintroduced.

Locked in constant-resolver.test.ts (A+A, reused separator, shared-base
diamond); the pre-existing real-cycle and depth-cap cases still return null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): make module-constant binding writes mutually exclusive (#2391)

`extractPythonModuleConstants` kept `literals`, `exprs`, and `imports` as three
independent maps: `setName` cleared literals+exprs but never `imports`, and an
import never cleared a prior literal/expr. Since `foldName` checks
literals > exprs > imports regardless of source order, a name that was both
imported and locally (re)assigned kept both bindings and the wrong one won —
`from .c import ROUTE; ROUTE = os.getenv(...)` resolved the STALE import instead
of dropping, a confidently wrong route path (the exact skip-floor invariant
this feature is meant to uphold).

Treat the three maps as one logical namespace: any write to one clears the
other two for that name (via `imports.delete` in `setName` and a `bindImport`
helper), so last-binding-in-source-order wins, matching Python. An import both
imported and dynamically rebound now drops. Folding `+=`/`+` onto an imported
base remains deferred (it drops safely, never a stale value).

Locked in python-const-resolver.test.ts: dynamic-rebind drops, literal-shadows-
import, import-shadows-literal, and `+=`-on-import drops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): widen the group cost-gate to catch literal-leading concats (#2391)

`NONLITERAL_ROUTE_DECORATOR_RE` required the first decorator argument to START
with an identifier, so a string-literal-leading concat like
`@router.get("/api" + SUFFIX)` never tripped `hasComposedRoute`. When such a
route was the ONLY composed shape in a repo, the group layer left `constantsByFile`
empty and dropped the route, while the ingestion side (which has no gate)
resolved `/api/users` and emitted a Route node — an R4 provider/graph parity break.

Widen the gate to also fire on a string-literal-leading `+`-concat, detected by a
`+` before the closing paren on the decorator line. Gating on the `+` (not merely
a leading quote) keeps a plain literal route `@router.get("/x")` OFF the gate, so a
literal-only repo still pays no parse pass.

Locked in fastapi-composed-provider.test.ts: a sole literal-leading concat now
resolves (parseCalls>0 + provider emitted), plus previously-uncovered
`@app.<verb>(CONST)` EXPR-branch resolution; the literal-only no-parse gate case
still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): correct package-init and over-deep relative import resolution (#2391)

Two edges in `resolvePythonImport`:

- `from . import X` (empty module after the dots) resolved to a sibling
  `<dir>.py` instead of the package `<dir>/__init__.py`. Resolve the bare-package
  case to `__init__.py`.
- An over-deep relative import (more extra dots than the importing file has
  directory levels) silently clamped `dirOf('')` to `''` and could match an
  unrelated root-level `<name>.py` — a wrong file. Guard with `walk > depth →
  null` so an import that escapes above the repo root drops (skip floor).

Both preserve the exact-match / ambiguity→null behavior for ordinary relative and
absolute imports.

Locked in python-const-resolver.test.ts: `from . import` → `__init__.py` (and
null when absent), and an over-deep import returns null even when the clamped
target file exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): bound parseConstOperands recursion depth (#2391)

`parseConstOperands` recursed on `binary_operator` children with no depth bound.
A stack overflow is not currently reachable (tree-sitter caps expression nesting
below the JS stack limit, so it throws on a deep `+`-chain before this runs), but
add a depth guard (cap 64, mirroring the fold engine's MAX_RESOLVE_DEPTH) as
defense-in-depth: a pathological chain now floors to null (skip) rather than
relying on tree-sitter's limit. The `depth` parameter defaults to 0, so all
existing callers are unaffected.

Locked in python-const-resolver.test.ts: a 100-term `+` chain yields no binding
(null) instead of throwing; ordinary short chains still fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(routes): read each .py once in buildPythonRepoContext (#2391)

The group repo-context builder read every `.py` file from disk twice: once in
the `include_router` cross-file pre-pass and again in the #2391 constant
cost-gate loop — an unconditional 2x read on every Python repo, on every group
extraction. Hoist a single read pass that populates one `pyContents` map (and
computes the composed-route cost gate); both the include_router pre-pass and the
constant-map pass now consume the cached content. Behavior-preserving — a
literal-only repo still does one read and zero parses.

Covered by the existing group unit + integration suites (R4 parity and
include_router prefix joins unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(routes): tidy constant-resolver docs and declaration order (#2391)

Three no-behavior nits from the PR #2393 review:
- Name `conditional_expression` (`x if c else y`) in the `parseConstOperands`
  jsdoc list of shapes that deferred to null.
- Move `NONLITERAL_ROUTE_DECORATOR_RE` above `buildPythonRepoContext`, which
  references it — it read as a forward reference before (runtime-safe, but
  confusing).
- Correct the integration-test comment that called `/v2/api/v1/widgets/get`
  "ingestion-only garnish": the group side emits it too (asserted separately);
  the four paths in that block are the shared-parity set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(routes): fold `X += "…"` onto an imported base constant (#2391)

Previously `from .c import BASE; BASE += "/v1"` dropped (the extractor could not
represent "the imported prior value" as an operand without self-referencing X and
tripping the cycle guard). Preserve the imported prior under a synthetic `$imp$N`
key — `$` can never appear in a Python identifier, so it cannot collide with a
real name — and reference it, so the augmented assignment folds to
`<imported BASE>/v1`. Extractor-only: no change to the `Operand` type, the fold
core, or the cache shape, so no SCHEMA_BUMP. An imported base that is itself
unresolvable still drops (skip floor preserved — never a wrong path).

Locked in python-const-resolver.test.ts: single and chained `+=` fold onto an
imported base; an unresolvable base still drops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routes): resolve bare decorator constants via the by-name entry (#2391)

The group `resolveExprArg` hand-built `[{ kind: 'ref', name }]` and called
`resolveOperands` for a bare-constant decorator argument — exactly what the
language-agnostic core's `resolveConstant(file, name, repo)` seam does. Call it
directly for the identifier case. This gives the previously test-only by-name
entry point a real production caller (it is the documented reuse seam for future
JVM/other bindings), drops the synthetic operand construction, and lets the now-
unused `Operand` type import go. Behavior-identical — the `+`-concat path still
parses to an operand list and folds via `resolveOperands`.

Guarded by the existing group provider suite (bare-constant and concat cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(routes): parse each .py once in buildPythonRepoContext (#2391)

The repo-context builder ran two parse loops — the include_router prefix pre-pass
and the #2391 constant-map pass — so an include_router file in a composed repo was
tree-sitter-parsed twice. Merge them into a single pass that parses each `.py` at
most once and feeds both extractions from the same tree; a file that needs neither
pass is still not parsed at all (cost gates unchanged). Complements the earlier
single-read-pass change (this is the single-parse counterpart).

Behavior-preserving (prefixes, R4 parity, and cost gates verified by the group +
integration suites). Locked with a parseCalls assertion: a file needing both
passes is parsed once, not twice.

Note: a cross-run (cross-process) constant-map cache — the other deferred perf
idea — remains out of scope; it needs disk persistence + invalidation and would
add hashing/IO cost on the common path, so it fails the minimal-change bar this
single-parse dedup meets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): bound constant-fold work and output to prevent OOM (#2391)

The `finally`-popped cycle guard (recursion-stack semantics) correctly folds
diamonds/repeated refs, but popping the guard removed the accidental work cap the
old seen-ever set provided: a wide shared-descendant DAG re-folds each child once
per reference, and a self-multiplying concat (`X = A + A; A = B + B; …`) builds a
genuinely exponential string. Reviewers reproduced ~16.8M folds escalating to
`RangeError: Invalid string length` and heap OOM — and neither fold call site is
wrapped in try/catch, so it crashed the whole phase rather than dropping the route.

Two complementary bounds, both flooring to null (skip), never a wrong value:
- a never-popped `memo` in `foldName` caps recomputation at O(nodes) (successes
  only — a null may be transient on a cyclic branch);
- a `MAX_FOLD_LENGTH` (8192) cap in `foldExpr` drops a fold whose output grows
  past any real route path, bounding the string size the depth cap does not.

Corrects the prior "≤ 2^8 folds" comment (output grows multiplicatively, not
additively). Locked with a 64^4-fanout construction that now drops in ~ms instead
of OOMing; diamonds/cycles/depth-cap behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): snapshot assignment RHS refs at the assignment line (#2391)

`ROUTE = BASE` was stored as a lazy `ref(BASE)`, resolved against BASE's FINAL
binding. So `ROUTE = BASE; BASE += "/v1"` (or `ROUTE = API; API = "/other"`)
resolved ROUTE to the MUTATED value — a confidently wrong path, since Python
assigns by value at the `ROUTE =` line. This was latent for local constants at
the base of this feature and the `+=`-on-import work extended it to imports.

Snapshot each assignment/`+=` RHS reference to a bound name into that name's
current frozen value at the assignment line (`freeze`/`snapshot`): a literal
value, a copy of the current expr (whose refs are already frozen), or an import
preserved under a `$imp$N` alias. Unbound refs (forward references) stay lazy.
A later rebind of the aliased name can no longer change the earlier binding.
`freeze` also unifies the previous `currentOps` + inline import-alias logic.

Locked in python-const-resolver.test.ts: aliased-import-then-`+=`,
aliased-local-then-`+=`, aliased-local-then-rebind all resolve to the pre-mutation
value; normal reference chains still fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): fold group identifier args via resolveOperands for parity (#2391)

Resolving a bare-constant decorator arg through `resolveConstant` entered
`foldName` at depth 0, whereas the ingestion side folds `routePathOperands`
through `resolveOperands([{ref}])`, entering at depth 1. At the MAX_RESOLVE_DEPTH
boundary the group tolerated one more hop than ingestion, so a deep alias/re-export
chain resolved in the group provider set but dropped from the graph Route nodes —
an R4 parity break. Restore the operand-list path in the group so both subsystems
share identical fold-entry depth. (`resolveConstant` reverts to the documented
agnostic-core seam.)

Locked in constant-resolver.test.ts: a 4-hop chain that `resolveOperands([ref])`
drops but `resolveConstant` resolves, documenting why the group must use the
operand-list entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): match multiline literal-leading concats in the cost gate (#2391)

`NONLITERAL_ROUTE_DECORATOR_RE` used `[^)\n]*` so it only saw a literal-leading
`+`-concat when the `+` was on the same line as the opening quote. A
Black-formatted `@router.get(\n "/api"\n + SUFFIX\n)` therefore failed the gate,
and when it was the only composed route in a repo the group dropped it while
ingestion (which parses the tree, not the raw line) resolved it — an R4 parity
break. Drop the `\n` exclusion: `[^)]*` spans the wrapped argument but stays
bounded by the decorator's own closing paren, so a plain literal route still
never trips the gate.

Locked in fastapi-composed-provider.test.ts with a multiline concat fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): bump SCHEMA_BUMP for changed extractor output + E2E snapshot lock (#2391)

`extractPythonModuleConstants` now emits DIFFERENT `moduleConstants` for the same
source (binding mutual-exclusivity clears stale imports; RHS refs are snapshotted;
`$imp$N` aliases). That output is cached verbatim in the parse cache, so a warm
shard built at the pre-fix version would replay stale — in one case actively
wrong — folded values, and the correctness fixes would silently no-op on upgrade.
Bump SCHEMA_BUMP 11→12 to force re-extraction (same warm-cache-replay class the
original 10→11 bump addressed for the field addition).

Also adds the first end-to-end coverage for the new behavior through the real
ingestion pipeline: app/snapshot.py aliases a constant (`SNAP = API_V1`) then
mutates the source (`API_V1 += "/mutated"`), and the test asserts the Route node
is `/api/v1`, never `/api/v1/mutated` — a case the pure-function unit tests
covered but the pipeline did not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:23:05 +01:00

379 lines
15 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';
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');
});
});