diff --git a/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts index 0ca5e71e2..b7a5cd612 100644 --- a/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts +++ b/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts @@ -619,12 +619,20 @@ function foldOperands( /** * Fold an inline operand list (e.g. `API_CIS_V1 + "summary/save"`) against * `fileKey`, or null when any piece is unresolvable (skip floor). + * + * An empty result is a SUCCESS, not a skip. `static final String ROOT = "";` + * folds to `""`, which `joinPath` then resolves against the type-level prefix + * exactly as it resolves the literal `@GetMapping("")`. Collapsing it into + * `null` would make a resolved-empty path indistinguishable from an + * unresolvable one — the skip floor is reserved for "could not fold", and + * nothing else in the resolver conflates the two: `resolveOperands` in the + * shared core returns `foldExpr` unfiltered, and `resolveJavaConstant` returns + * `""` for an empty constant. */ export function foldJavaOperands( fileKey: string, operands: readonly Operand[], repo: RepoConstants, ): string | null { - const out = foldOperands(fileKey, operands, newFoldState(repo), 0); - return out === '' ? null : out; + return foldOperands(fileKey, operands, newFoldState(repo), 0); } diff --git a/gitnexus/test/unit/group/java-const-route-parity.test.ts b/gitnexus/test/unit/group/java-const-route-parity.test.ts index cfdf652a3..7fa02e2e0 100644 --- a/gitnexus/test/unit/group/java-const-route-parity.test.ts +++ b/gitnexus/test/unit/group/java-const-route-parity.test.ts @@ -211,3 +211,46 @@ public class OrderController { expect(ingestionRoutes(files)).toEqual(['POST /api/v1/orders']); }); }); + +describe('an empty-valued constant route matches its literal spelling', () => { + // Before this fix the two spellings diverged: `@GetMapping("")` was kept and + // `@GetMapping(ApiPaths.ROOT)` with `ROOT = ""` was dropped, because the fold + // collapsed a resolved-empty result into the skip floor. The class-level + // prefix is what makes an empty method path meaningful, so the parity is + // asserted with one present. + const ROOT_CONSTS = `package com.example; +public class ApiPaths { + public static final String ROOT = ""; +}`; + + const controller = (mapping: string): string => `package com.example; +import com.example.ApiPaths; +@RequestMapping("/api/v1") +public class RootController { + @GetMapping(${mapping}) + public void root() {} +}`; + + it('resolves the constant spelling to the same path as the literal one', () => { + const literal = { [CTL]: controller('""') }; + const constant = { [CONSTS]: ROOT_CONSTS, [CTL]: controller('ApiPaths.ROOT') }; + + expect(groupProviders(constant)).toEqual(groupProviders(literal)); + expect(ingestionRoutes(constant)).toEqual(ingestionRoutes(literal)); + }); + + it('emits the route on both sides rather than dropping it', () => { + // Deliberately NOT asserting the two sides produce the same string here. + // They do not, and they did not before this change either: measured on the + // LITERAL spelling, which no part of this change touches, the group side + // emits `/api/v1/` (`joinPath` appends `/` before an empty method path) + // while ingestion emits `/api/v1`. That trailing-slash divergence is a + // separate pre-existing defect; the fix here only makes the constant + // spelling reach it too, instead of silently dropping the route. The test + // above is what pins the parity that this change is responsible for — + // constant behaves as literal, on each side. + const files = { [CONSTS]: ROOT_CONSTS, [CTL]: controller('ApiPaths.ROOT') }; + expect(groupProviders(files)).toEqual(['GET /api/v1/']); + expect(ingestionRoutes(files)).toEqual(['GET /api/v1']); + }); +}); diff --git a/gitnexus/test/unit/java-route-const-resolver.test.ts b/gitnexus/test/unit/java-route-const-resolver.test.ts index 0b628f52e..b6047877c 100644 --- a/gitnexus/test/unit/java-route-const-resolver.test.ts +++ b/gitnexus/test/unit/java-route-const-resolver.test.ts @@ -792,3 +792,66 @@ describe('text blocks keep the skip floor', () => { expect(extractJavaModuleConstants(parse(src)).literals.has('X')).toBe(false); }); }); + +describe('an empty fold is a success, not a skip', () => { + // `null` from this fold is the SKIP FLOOR: it means "a piece was + // unresolvable", and every caller acts on it by dropping the route. An + // empty-valued constant is not that case — it resolved, to the empty string. + // Spring reads `@GetMapping(ROOT)` with `ROOT = ""` exactly as it reads + // `@GetMapping("")`, and the group extractor's literal branch already keeps + // the latter. Collapsing the two silently lost the route for the constant + // spelling alone. + const EMPTY = 'src/main/java/com/example/Paths.java'; + + it('folds a constant whose value is the empty string', () => { + const repo = repoOf({ + [EMPTY]: `package com.example; +public class Paths { public static final String ROOT = ""; }`, + }); + expect(foldJavaOperands(EMPTY, [{ kind: 'ref', name: 'ROOT' }], repo)).toBe(''); + }); + + it('concatenating empty constants folds to the empty string', () => { + const repo = repoOf({ + [EMPTY]: `package com.example; +public class Paths { + public static final String A = ""; + public static final String B = ""; +}`, + }); + expect( + foldJavaOperands( + EMPTY, + [ + { kind: 'ref', name: 'A' }, + { kind: 'ref', name: 'B' }, + ], + repo, + ), + ).toBe(''); + }); + + it('still floors to skip when a piece is genuinely unresolvable', () => { + // The control: the two outcomes have to stay distinguishable, or the fix + // would trade one conflation for another. + const repo = repoOf({ + [EMPTY]: `package com.example; +public class Paths { public static final String ROOT = compute(); }`, + }); + expect(foldJavaOperands(EMPTY, [{ kind: 'ref', name: 'ROOT' }], repo)).toBeNull(); + }); + + it('agrees with resolveJavaConstant, which never collapsed empty to null', () => { + // The binding used to disagree with itself: the single-constant resolver + // returns `""` (pinned by the 30-level DAG case above, whose every + // intermediate value is empty), and `resolveOperands` in the shared + // language-agnostic core returns `foldExpr` unfiltered. Only this fold + // collapsed. + const repo = repoOf({ + [EMPTY]: `package com.example; +public class Paths { public static final String ROOT = ""; }`, + }); + expect(resolveJavaConstant(EMPTY, 'ROOT', repo)).toBe(''); + expect(foldJavaOperands(EMPTY, [{ kind: 'ref', name: 'ROOT' }], repo)).toBe(''); + }); +});