패키지 경계 계약 테스트를 되살린다
c9a6bd2가 dap에서 dat으로 옮기면서 docs 패키지의 계약 테스트 셋을 삭제했다. 같은 커밋이 다른 테스트는 모두 이전했으므로 의도적 판단으로 보이지만, architecture.md는 여전히 "이 규칙은 PackageBoundaryContractTest가 강제한다"고 적고 있어 문서가 없는 장치를 가리키는 상태였다. 규칙 자체는 지금도 유효하고 위반도 없다. jakarta.servlet을 쓰는 네 파일이 모두 transport/http 안에 있고, transport에서 execute나 registry를 import하는 파일도 없다. McpRouteKeyValidator는 registry 지식이 필요한데도 transport/http에 인터페이스를 두고 구현을 주입받는 방식으로 경계를 지킨다. 패키지 문자열만 dat으로 바꿔 그대로 복원한다. 소스 파일을 읽기만 하고 application context를 띄우지 않으므로 의존이 늘지 않는다. 검사가 실제로 동작하는지 두 규칙 각각에 위반 파일을 심어 확인했고 둘 다 실패를 냈다. 확인 후 삭제했다. 194개 테스트 전부 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package io.shinhanlife.dat.biz.mcp.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿
|
||||
* 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class PackageBoundaryContractTest {
|
||||
|
||||
private static final Path MAIN_SOURCES = Path.of("src", "main", "java");
|
||||
/**
|
||||
* 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다.
|
||||
*/
|
||||
private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dat/biz/mcp/transport/";
|
||||
|
||||
/**
|
||||
* 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다.
|
||||
*/
|
||||
@Test
|
||||
void servletApiStaysInsideTheTransportPackage() throws IOException {
|
||||
List<Path> leaks = sourcesImporting("jakarta.servlet").stream()
|
||||
.filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE))
|
||||
.toList();
|
||||
|
||||
assertThat(leaks)
|
||||
.withFailMessage(
|
||||
"jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n"
|
||||
+ "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.",
|
||||
leaks)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다.
|
||||
*/
|
||||
@Test
|
||||
void transportDoesNotReachIntoExecutionOrRegistry() throws IOException {
|
||||
List<Path> violations = sourcesImportingAny(List.of(
|
||||
"io.shinhanlife.dat.biz.mcp.execute.",
|
||||
"io.shinhanlife.dat.biz.mcp.registry."))
|
||||
.stream()
|
||||
.filter(path -> normalize(path).contains(TRANSPORT_PACKAGE))
|
||||
.toList();
|
||||
|
||||
assertThat(violations)
|
||||
.withFailMessage(
|
||||
"transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s",
|
||||
violations)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다.
|
||||
*/
|
||||
private List<Path> sourcesImportingAny(List<String> importPrefixes) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
|
||||
return paths.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> declaresAnyImport(path, importPrefixes))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다.
|
||||
*/
|
||||
private List<Path> sourcesImporting(String importPrefix) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
|
||||
return paths.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> declaresImport(path, importPrefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다.
|
||||
*/
|
||||
private boolean declaresImport(Path path, String importPrefix) {
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
return lines.anyMatch(line -> line.startsWith("import " + importPrefix));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다.
|
||||
*/
|
||||
private boolean declaresAnyImport(Path path, List<String> importPrefixes) {
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
return lines.anyMatch(line -> importPrefixes.stream()
|
||||
.anyMatch(importPrefix -> line.startsWith("import " + importPrefix)));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다.
|
||||
*/
|
||||
private String normalize(Path path) {
|
||||
return path.toString().replace('\\', '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user