From 973c650bed029640f589247d16d47266c31470fb Mon Sep 17 00:00:00 2001 From: koseokmin Date: Tue, 15 Sep 2026 17:43:33 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=ED=91=9C=20=EA=B3=84=EC=95=BD=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=90=98=EC=82=B4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit architecture.md의 클래스 책임 표는 코드 구조를 문서에 복제한 것이라 rename이나 package 이동이 있으면 조용히 낡는다. 이 테스트는 그 이유로 만들어졌고, 삭제된 원본 Javadoc에 "패키지 재구성 한 번에 네 개의 이름이 죽은 적이 있다"고 적혀 있다. c9a6bd2의 dap에서 dat으로의 개명에서 같은 일이 다시 일어났다. 테스트가 그 커밋에서 함께 삭제되어 드리프트를 잡지 못했고, dc6f602에서 사람이 손으로 대조해 아홉 건을 고쳐야 했다. 패키지 문자열만 dat으로 바꿔 복원한다. 표의 클래스 행 서른 개를 모두 읽어 src/main/java에 해당 소스가 있는지 확인한다. 패키지 경계 표의 일곱 행은 첫 칸이 소문자 패키지명이라 정규식이 의도대로 건너뛰므로 사각지대는 없다. 존재하지 않는 클래스 행을 표에 심어 실패를 내는 것까지 확인했고 확인 후 되돌렸다. 195개 테스트 전부 통과. Co-Authored-By: Claude Opus 5 --- .../ArchitectureDocumentContractTest.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java new file mode 100644 index 0000000..132f6d4 --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java @@ -0,0 +1,74 @@ +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.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +/** + * {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네 + * 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다. + */ +class ArchitectureDocumentContractTest { + + private static final Path ARCHITECTURE = Path.of("docs", "architecture.md"); + private static final Path MAIN_PACKAGE = + Path.of("src", "main", "java", "io", "shinhanlife", "dat", "biz", "mcp"); + /** + * 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다. + */ + private static final Pattern TABLE_ROW = + Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|"); + + /** + * 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다. + */ + @Test + void everyDocumentedClassPathStillExists() throws IOException { + List documented = documentedTypes(); + + // 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다. + assertThat(documented) + .withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.") + .hasSizeGreaterThan(10); + + List missing = documented.stream().filter(type -> !sourceExists(type)).toList(); + + assertThat(missing) + .withFailMessage( + "architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n" + + "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.", + missing) + .isEmpty(); + } + + /** + * 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다. + */ + private List documentedTypes() throws IOException { + try (Stream lines = Files.lines(ARCHITECTURE)) { + return lines.map(TABLE_ROW::matcher) + .filter(Matcher::find) + .map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2))) + .distinct() + .toList(); + } + } + + /** + * 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다. + */ + private boolean sourceExists(DocumentedType type) { + return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java")); + } + + private record DocumentedType(String name, String packagePath) { + } +}