This commit is contained in:
glier 2026-08-27 17:08:43 +03:00 committed by GitHub
commit 86fa6745d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 116 additions and 2 deletions

View file

@ -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);
}

View file

@ -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']);
});
});

View file

@ -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('');
});
});