서식 계약 테스트를 되살린다
c9a6bd2가 지운 셋 중 마지막이다. 이름과 달리 IntelliJ 코드 스타일을 판정하지 않는다. 원본 Javadoc이 선을 긋고 있듯 들여쓰기 폭과 줄바꿈 위치는 .idea/codeStyles/Project.xml이 소유하고 이 테스트는 건드리지 않는다. ideaFormatCheck와도 별개다. 검사하는 것은 도구 없이 판정할 수 있는 일곱 가지다. CRLF·탭·줄 끝 공백·파일 끝 개행·미사용 import·import 순서, 그리고 소스 수집이 실제로 되는지 확인하는 자기 방어다. Spotless를 대체하려고 만든 것이라는 점이 지금 중요하다. 그 플러그인은 빌드를 읽는 시점에 외부 저장소를 받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드가 시작되지 못한다. 이 테스트는 외부 의존이 없다. 현재 코드로 일곱 건 모두 통과한다. .java 85개에 CRLF가 없고, core.autocrlf가 true여도 .gitattributes의 `*.java text eol=lf`가 이기므로 새로 clone해도 결과가 같다. 기존 코드를 고칠 필요가 없다. 미사용 import와 줄 끝 공백과 탭을 심어 각각 해당 검사가 실패하는 것을 확인했고 확인 후 되돌렸다. 202개 테스트 전부 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을
|
||||||
|
* 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다.
|
||||||
|
*
|
||||||
|
* <p>여기서 보는 것은 <b>도구 없이도 판정할 수 있는 규칙</b>뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며
|
||||||
|
* 애플리케이션 context를 띄우지 않습니다.
|
||||||
|
*/
|
||||||
|
class CodeStyleContractTest {
|
||||||
|
|
||||||
|
private static final List<Path> 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<String> broken = violations(source -> source.raw().contains("\r\n"));
|
||||||
|
|
||||||
|
assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void noSourceContainsTabCharacters() throws IOException {
|
||||||
|
List<String> broken = violations(source -> source.raw().contains("\t"));
|
||||||
|
|
||||||
|
assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void noLineEndsWithWhitespace() throws IOException {
|
||||||
|
List<String> 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<String> broken =
|
||||||
|
violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n"));
|
||||||
|
|
||||||
|
assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다.
|
||||||
|
*
|
||||||
|
* <p>판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void noSourceKeepsAnUnusedImport() throws IOException {
|
||||||
|
List<String> 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가 생깁니다.
|
||||||
|
*
|
||||||
|
* <p>비교는 <b>세미콜론을 뗀 경로</b>로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못
|
||||||
|
* 판정합니다.
|
||||||
|
*
|
||||||
|
* <p>그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만
|
||||||
|
* 잠그는 편이 낫습니다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void importsAreOrderedStaticFirstThenAlphabetically() throws IOException {
|
||||||
|
List<String> broken = new ArrayList<>();
|
||||||
|
for (JavaSource source : sources()) {
|
||||||
|
List<String> statics = new ArrayList<>();
|
||||||
|
List<String> 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<String> violations(Predicate<JavaSource> broken) throws IOException {
|
||||||
|
return sources().stream().filter(broken).map(JavaSource::path).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다.
|
||||||
|
*/
|
||||||
|
private boolean isSorted(List<String> 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<JavaSource> sources() throws IOException {
|
||||||
|
List<JavaSource> sources = new ArrayList<>();
|
||||||
|
for (Path root : SOURCE_ROOTS) {
|
||||||
|
try (Stream<Path> 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<String> lines() {
|
||||||
|
return List.of(raw.split("\n", -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다.
|
||||||
|
*/
|
||||||
|
boolean staticImportsComeFirst() {
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user