diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/docs/CodeStyleContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/CodeStyleContractTest.java new file mode 100644 index 0000000..e448e4c --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/CodeStyleContractTest.java @@ -0,0 +1,215 @@ +package io.shinhanlife.dat.biz.mcp.docs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +/** + * Java 소스의 기계적 서식 규칙을 빌드에서 강제하는 계약 테스트입니다. 이전에는 Spotless Gradle 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을 + * 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다. + * + *

여기서 보는 것은 도구 없이도 판정할 수 있는 규칙뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며 + * 애플리케이션 context를 띄우지 않습니다. + */ +class CodeStyleContractTest { + + private static final List SOURCE_ROOTS = + List.of(Path.of("src", "main", "java"), Path.of("src", "test", "java")); + /** + * {@code import a.b.C;}와 {@code import static a.b.C.d;}에서 마지막 이름만 뽑는다. + */ + private static final Pattern IMPORT = Pattern.compile("^import (?:static )?[\\w.]*?(\\w+);"); + + /** + * 모든 Java 소스가 LF 줄바꿈만 쓰는지 확인합니다. CRLF가 섞이면 Linux 컨테이너에서 문제가 되고, 한 번 섞인 파일은 이후 모든 변경의 diff가 파일 전체로 부풀어 실제 변경을 가립니다. + */ + @Test + void everySourceUsesUnixLineEndings() throws IOException { + List broken = violations(source -> source.raw().contains("\r\n")); + + assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty(); + } + + /** + * 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다. + */ + @Test + void noSourceContainsTabCharacters() throws IOException { + List broken = violations(source -> source.raw().contains("\t")); + + assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty(); + } + + /** + * 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다. + */ + @Test + void noLineEndsWithWhitespace() throws IOException { + List broken = + violations( + source -> + source.lines().stream() + .anyMatch(line -> !line.equals(line.stripTrailing()))); + + assertThat(broken).withFailMessage("줄 끝에 공백이 있는 파일: %s", broken).isEmpty(); + } + + /** + * 파일이 개행 하나로 끝나는지 확인합니다. 개행이 없으면 마지막 줄을 고칠 때 diff가 두 줄로 보이고, 여러 개면 의미 없는 빈 줄이 쌓입니다. + */ + @Test + void everySourceEndsWithExactlyOneNewline() throws IOException { + List broken = + violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n")); + + assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty(); + } + + /** + * 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다. + * + *

판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다. + */ + @Test + void noSourceKeepsAnUnusedImport() throws IOException { + List unused = new ArrayList<>(); + for (JavaSource source : sources()) { + String body = + String.join( + "\n", + source.lines().stream().filter(line -> !line.startsWith("import ")).toList()); + for (String line : source.lines()) { + Matcher matcher = IMPORT.matcher(line); + if (matcher.find() && !containsWord(body, matcher.group(1))) { + unused.add(source.path() + " -> " + matcher.group(1)); + } + } + } + + assertThat(unused).withFailMessage("사용하지 않는 import: %s", unused).isEmpty(); + } + + /** + * {@code import}가 static 먼저, 그다음 알파벳 순으로 놓였는지 확인합니다. 순서가 제각각이면 같은 import를 두 사람이 다른 자리에 넣어 실제 변경과 무관한 diff가 생깁니다. + * + *

비교는 세미콜론을 뗀 경로로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못 + * 판정합니다. + * + *

그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만 + * 잠그는 편이 낫습니다. + */ + @Test + void importsAreOrderedStaticFirstThenAlphabetically() throws IOException { + List broken = new ArrayList<>(); + for (JavaSource source : sources()) { + List statics = new ArrayList<>(); + List regular = new ArrayList<>(); + for (String line : source.lines()) { + if (line.startsWith("import static ")) { + statics.add(line.substring("import static ".length()).replace(";", "")); + } else if (line.startsWith("import ")) { + regular.add(line.substring("import ".length()).replace(";", "")); + } + } + if (!isSorted(statics) || !isSorted(regular)) { + broken.add(source.path()); + } + if (!source.staticImportsComeFirst()) { + broken.add(source.path() + " (static import가 일반 import 뒤에 있음)"); + } + } + + assertThat(broken).withFailMessage("import 순서가 어긋난 파일: %s", broken).isEmpty(); + } + + /** + * 검사 대상 소스가 실제로 수집되는지 확인합니다. 경로가 바뀌어 목록이 비면 위 검사들이 모두 조용히 통과하므로 최소 개수를 함께 고정합니다. + */ + @Test + void theSourceSetIsActuallyScanned() throws IOException { + assertThat(sources()) + .withFailMessage("Java 소스를 찾지 못했습니다. SOURCE_ROOTS 경로가 바뀌었는지 확인하세요.") + .hasSizeGreaterThan(50); + } + + /** + * 규칙을 어긴 파일 경로를 모읍니다. 어떤 파일인지 알려주지 않으면 고칠 수가 없습니다. + */ + private List violations(Predicate broken) throws IOException { + return sources().stream().filter(broken).map(JavaSource::path).toList(); + } + + /** + * 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다. + */ + private boolean isSorted(List values) { + return values.equals(values.stream().sorted().toList()); + } + + /** + * 이름이 식별자 경계에 맞게 등장하는지 확인합니다. {@code List}를 찾을 때 {@code ArrayList}가 걸리지 않아야 합니다. + */ + private boolean containsWord(String text, String word) { + return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(text).find(); + } + + /** + * main과 test의 모든 Java 소스를 읽어 옵니다. + */ + private List sources() throws IOException { + List sources = new ArrayList<>(); + for (Path root : SOURCE_ROOTS) { + try (Stream paths = Files.walk(root)) { + for (Path path : paths.filter(path -> path.toString().endsWith(".java")).toList()) { + sources.add( + new JavaSource( + path.toString().replace('\\', '/'), + new String(Files.readAllBytes(path), StandardCharsets.UTF_8))); + } + } + } + return sources; + } + + /** + * 검사 대상 소스 하나의 경로와 원본 내용입니다. 줄바꿈 검사 때문에 줄 단위가 아니라 원본 문자열을 그대로 들고 있어야 합니다. + */ + private record JavaSource(String path, String raw) { + + /** + * 줄 단위 검사를 위해 개행으로만 나눕니다. CR이 남아 있으면 줄 끝 공백 검사에서도 함께 드러납니다. + */ + List lines() { + return List.of(raw.split("\n", -1)); + } + + /** + * 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다. + */ + boolean staticImportsComeFirst() { + List lines = lines(); + int lastStatic = -1; + int firstRegular = Integer.MAX_VALUE; + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + if (line.startsWith("import static ")) { + lastStatic = index; + } else if (line.startsWith("import ") && firstRegular == Integer.MAX_VALUE) { + firstRegular = index; + } + } + return lastStatic < firstRegular; + } + } +}