Merge branch 'main' into codex/prune-local-const-symbols

This commit is contained in:
Gergő Magyar 2026-06-07 05:29:29 +01:00 • committed by GitHub
commit d10bbf36b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 169 additions and 1 deletions

View file

@ -55,6 +55,7 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
interface SpringRouteBinding {
method: string;
path: string;
ownerPrefix?: string;
}
interface SpringMethodInfo {
@ -395,6 +396,25 @@ function joinPath(prefix: string, methodPath: string): string {
return `/${cleanPrefix}/${cleanSub}`;
}
function joinInheritedSpringPath(
controllerPrefix: string,
inheritedPath: string,
inheritedOwnerPrefix = '',
): string {
const joined = joinPath(controllerPrefix, inheritedPath);
const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
const cleanInherited = inheritedPath.replace(/^\/+/, '');
if (!cleanPrefix) return joined;
if (
cleanPrefix === cleanOwnerPrefix &&
(cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`))
) {
return `/${cleanInherited}`;
}
return joined;
}
function getNodeName(node: Parser.SyntaxNode): string | null {
return node.childForFieldName('name')?.text ?? null;
}
@ -634,6 +654,7 @@ function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[
const routes = method.routes.map((route) => ({
method: route.method,
path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path,
ownerPrefix: type.classPrefix,
}));
if (routes.length > 0) methodMap.set(method.name, routes);
}
@ -651,7 +672,7 @@ function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[
const routes = routeMap.get(method.name) ?? [];
return routes.map((route) => ({
method: route.method,
path: joinPath(type.classPrefix, route.path),
path: joinInheritedSpringPath(type.classPrefix, route.path, route.ownerPrefix),
}));
});

View file

@ -952,6 +952,153 @@ public class UserController implements UserApi {
);
});
it('does not duplicate inherited Spring prefixes already present on the controller', async () => {
const dir = path.join(tmpDir, 'spring-inherited-prefix-dedup');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/DataReleaseFacade.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/open/ai")
public interface DataReleaseFacade {
@GetMapping("/query")
Object query();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/BaseFacadeService.java'),
`
package com.example.controller;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/open/ai")
public abstract class BaseFacadeService {
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'),
`
package com.example.controller;
import com.example.rest.DataReleaseFacade;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/open/ai")
public class DataReleaseFacadeImpl extends BaseFacadeService implements DataReleaseFacade {
@Override
public Object query() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const queryRoute = providers.find((c) => c.contractId === 'http::GET::/open/ai/query');
expect(queryRoute).toBeDefined();
expect(toPosixPath(queryRoute!.symbolRef.filePath)).toBe(
'src/controller/DataReleaseFacadeImpl.java',
);
expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/open/ai/query')).toBe(
undefined,
);
});
it('still combines distinct inherited Spring prefixes that share a leading segment', async () => {
const dir = path.join(tmpDir, 'spring-interface-shared-leading-prefix');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/DataReleaseApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/open/ai")
public interface DataReleaseApi {
@GetMapping("/query")
Object query();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'),
`
package com.example.controller;
import com.example.rest.DataReleaseApi;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/open")
public class DataReleaseFacadeImpl implements DataReleaseApi {
@Override
public Object query() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/open/open/ai/query'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/query')).toBeUndefined();
});
it('keeps a controller prefix when a prefix-less interface method starts with the same path', async () => {
const dir = path.join(tmpDir, 'spring-interface-method-prefix-overlap');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/UserApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
public interface UserApi {
@GetMapping("/users/{id}")
Object getUser();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example.controller;
import com.example.rest.UserApi;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController implements UserApi {
@Override
public Object getUser() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/users/users/{param}'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/users/{param}')).toBeUndefined();
});
it('skips ambiguous inherited routes when interfaces share a simple name', async () => {
const dir = path.join(tmpDir, 'spring-interface-simple-name-collision');
fs.mkdirSync(path.join(dir, 'src/a'), { recursive: true });