Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/scripts/lint-workflow-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";

const lintWorkflow = await readFile(new URL("../workflows/lint.yml", import.meta.url), "utf8");

test("ktlint runs exactly once per pull request", () => {
// ktlint 를 실제로 돌리는 자리는 Gradle 태스크 하나뿐이다. 검사를 한 번 더 하는 액션이
// 끼면(과거 ScaCap/action-ktlint) 같은 규칙을 두 번 돌리며 job 의 30% 를 쓴다 (#1012).
const gradleInvocations = lintWorkflow.match(/^\s*run:.*ktlintCheck/gm) ?? [];
assert.equal(gradleInvocations.length, 1);
// 주석은 이력이라 남기고, 실제로 액션을 부르는 uses 줄만 금지한다.
assert.doesNotMatch(lintWorkflow, /^\s*uses:\s*ScaCap\/action-ktlint/m);
});

test("the ktlint binary version has a single source of truth", () => {
// 워크플로가 버전을 손으로 적으면 libs.versions.toml 과 표류해 두 검사 결과가 갈린다.
assert.doesNotMatch(lintWorkflow, /ktlint_version/);
});

test("ktlint still checks the whole repository, not the pull request diff", () => {
// #875: 파일을 지우면서 남은 파일에 새로 발생하는 파일 단위 위반은 diff 필터로는
// 보이지 않는다. 저장소 전체를 검사하는 성질이 사라지면 그 회귀가 되돌아온다.
assert.match(lintWorkflow, /\.\/gradlew ktlintCheck[^\n]*--continue/);
assert.doesNotMatch(lintWorkflow, /filter_mode/);
});

test("ktlint violations are rendered into the job summary even when the check fails", () => {
assert.match(lintWorkflow, /render-ktlint-summary\.mjs/);
const summaryStep = /- name: Summarize ktlint violations\n\s+if: always\(\)/;
assert.match(lintWorkflow, summaryStep);
});

test("Android Lint keeps its inline reporting — it reuses the XML instead of re-running", () => {
// #279 가 푼 문제(모듈별 위반이 수만 줄 로그에 묻힘)를 되돌리지 않는다. lintDebug 가
// 만든 XML 을 재활용하므로 검사가 두 번 돌지 않는다.
assert.match(lintWorkflow, /lint_xml_file:\s*app\/build\/reports\/lint-results-merged\.xml/);
assert.match(lintWorkflow, /\.\/gradlew lintDebug/);
});
176 changes: 176 additions & 0 deletions .github/scripts/render-ktlint-summary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env node

import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";

// ktlint-gradle 이 모듈마다 남기는 checkstyle 리포트 위치.
const REPORT_DIRECTORY = path.join("build", "reports", "ktlint");
// 모듈 경로는 feature/afternote/presentation 이 가장 깊다. 그 아래로는 내려가지 않는다.
const MAX_MODULE_DEPTH = 4;
const SKIPPED_DIRECTORIES = new Set(["build", "node_modules", ".git", ".gradle", ".idea"]);
// job summary 는 1MB 상한이라 위반 전량을 붙이지 않는다. 나머지는 Gradle 콘솔 출력에 있다.
const MAX_LISTED_VIOLATIONS = 100;

function decodeXml(value) {
return String(value)
.replaceAll(""", '"')
.replaceAll("'", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
}

function attribute(attributes, name) {
const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attributes);
return match ? decodeXml(match[1]) : null;
}

export function parseCheckstyleXml(xml) {
const violations = [];
const filePattern = /<file\b([^>]*)>([\s\S]*?)<\/file>/g;
let fileMatch;
while ((fileMatch = filePattern.exec(xml)) !== null) {
const file = attribute(fileMatch[1], "name");
if (!file) {
throw new Error("checkstyle file element without a name");
}
const errorPattern = /<error\b([^>]*)\/>/g;
let errorMatch;
while ((errorMatch = errorPattern.exec(fileMatch[2])) !== null) {
violations.push({
file,
line: Number(attribute(errorMatch[1], "line") ?? 0),
column: Number(attribute(errorMatch[1], "column") ?? 0),
severity: attribute(errorMatch[1], "severity") ?? "error",
message: attribute(errorMatch[1], "message") ?? "",
rule: attribute(errorMatch[1], "source") ?? "unknown",
});
}
}
return violations;
}

export async function collectReportFiles(root) {
const reports = [];

async function collectFromBuildDirectory(buildDirectory) {
const reportRoot = path.join(buildDirectory, "reports", "ktlint");
let entries;
try {
entries = await fs.readdir(reportRoot, { withFileTypes: true, recursive: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith(".xml")) {
reports.push(path.join(entry.parentPath ?? reportRoot, entry.name));
}
}
}

async function walk(directory, depth) {
let entries;
try {
entries = await fs.readdir(directory, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (entry.name === "build") {
await collectFromBuildDirectory(path.join(directory, entry.name));
continue;
}
if (SKIPPED_DIRECTORIES.has(entry.name) || entry.name.startsWith(".")) {
continue;
}
if (depth > 0) {
await walk(path.join(directory, entry.name), depth - 1);
}
}
}

await walk(root, MAX_MODULE_DEPTH);
return reports.sort();
}

export async function collectViolations(root) {
const violations = [];
for (const report of await collectReportFiles(root)) {
violations.push(...parseCheckstyleXml(await fs.readFile(report, "utf8")));
}
return violations;
}

function relativize(root, file) {
const relative = path.relative(root, file);
return relative.startsWith("..") ? file : relative;
}

function countByRule(violations) {
const counts = new Map();
for (const violation of violations) {
counts.set(violation.rule, (counts.get(violation.rule) ?? 0) + 1);
}
return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
}

export function renderSummary(violations, { root = process.cwd(), reportCount = 0 } = {}) {
if (violations.length === 0) {
return reportCount === 0
? ["## ktlint", "", "리포트가 없다 — ktlintCheck 가 실행되기 전에 중단됐는지 확인할 것."].join("\n")
: ["## ktlint", "", `✅ 위반 없음 (리포트 ${reportCount}건)`].join("\n");
}

const sorted = [...violations].sort(
(left, right) =>
left.file.localeCompare(right.file) || left.line - right.line || left.column - right.column,
);
const files = new Set(sorted.map((violation) => violation.file));
const lines = [
"## ktlint",
"",
`❌ **위반 ${sorted.length}건** — 파일 ${files.size}개, 규칙 ${countByRule(sorted).length}종`,
"",
"| 규칙 | 건수 |",
"| --- | ---: |",
];
for (const [rule, count] of countByRule(sorted)) {
lines.push(`| \`${rule}\` | ${count} |`);
}
lines.push("", "<details><summary>위반 위치</summary>", "");
for (const violation of sorted.slice(0, MAX_LISTED_VIOLATIONS)) {
lines.push(
`- \`${relativize(root, violation.file)}:${violation.line}:${violation.column}\` — ${violation.message} (\`${violation.rule}\`)`,
);
}
if (sorted.length > MAX_LISTED_VIOLATIONS) {
lines.push("", `외 ${sorted.length - MAX_LISTED_VIOLATIONS}건 — 전량은 위 Gradle 스텝 로그에 있다.`);
}
lines.push("", "</details>", "", "로컬에서 `./gradlew ktlintFormat` 으로 대부분 자동 교정된다.");
return lines.join("\n");
}

async function main() {
const root = process.env.GITHUB_WORKSPACE || process.cwd();
const reports = await collectReportFiles(root);
const violations = [];
for (const report of reports) {
violations.push(...parseCheckstyleXml(await fs.readFile(report, "utf8")));
}

const summary = renderSummary(violations, { root, reportCount: reports.length });
const target = process.env.GITHUB_STEP_SUMMARY;
if (target) {
await fs.appendFile(target, `${summary}\n`);
} else {
process.stdout.write(`${summary}\n`);
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
108 changes: 108 additions & 0 deletions .github/scripts/render-ktlint-summary.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import { collectReportFiles, collectViolations, parseCheckstyleXml, renderSummary } from "./render-ktlint-summary.mjs";

const REPORT = `<?xml version="1.0" encoding="utf-8"?>
<checkstyle version="8.0">
<file name="/workspace/core/common/src/main/java/Probe.kt">
<error line="4" column="15" severity="error" message="Missing newline after &apos;{&apos;" source="standard:statement-wrapping" />
<error line="4" column="20" severity="error" message="Missing spacing around &quot;=&quot;" source="standard:op-spacing" />
</file>
</checkstyle>`;

const EMPTY_REPORT = `<?xml version="1.0" encoding="utf-8"?>
<checkstyle version="8.0">
</checkstyle>`;

test("parses file, position, rule, and decoded message from a checkstyle report", () => {
const violations = parseCheckstyleXml(REPORT);

assert.equal(violations.length, 2);
assert.deepEqual(violations[0], {
file: "/workspace/core/common/src/main/java/Probe.kt",
line: 4,
column: 15,
severity: "error",
message: "Missing newline after '{'",
rule: "standard:statement-wrapping",
});
assert.equal(violations[1].message, 'Missing spacing around "="');
});

test("reports no violations for an empty checkstyle report", () => {
assert.deepEqual(parseCheckstyleXml(EMPTY_REPORT), []);
});

test("collects reports from every module build directory but not from the sources", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "ktlint-summary-"));
try {
const moduleReport = path.join(
root,
"feature/afternote/presentation/build/reports/ktlint/ktlintMainSourceSetCheck",
);
await fs.mkdir(moduleReport, { recursive: true });
await fs.writeFile(path.join(moduleReport, "ktlintMainSourceSetCheck.xml"), REPORT);
await fs.writeFile(path.join(moduleReport, "ktlintMainSourceSetCheck.txt"), "plain output");

const unrelated = path.join(root, "core/ui/build/reports/lint-results-debug.xml");
await fs.mkdir(path.dirname(unrelated), { recursive: true });
await fs.writeFile(unrelated, "<issues />");

const sourceDirectory = path.join(root, "core/ui/src/main/java");
await fs.mkdir(sourceDirectory, { recursive: true });
await fs.writeFile(path.join(sourceDirectory, "Sample.xml"), REPORT);

const reports = await collectReportFiles(root);
assert.equal(reports.length, 1);
assert.match(reports[0], /ktlintMainSourceSetCheck\.xml$/);

const violations = await collectViolations(root);
assert.equal(violations.length, 2);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});

test("renders a rule tally and repository relative positions when violations exist", () => {
const summary = renderSummary(parseCheckstyleXml(REPORT), { root: "/workspace", reportCount: 1 });

assert.match(summary, /❌ \*\*위반 2건\*\* — 파일 1개, 규칙 2종/);
assert.match(summary, /\| `standard:op-spacing` \| 1 \|/);
assert.match(summary, /- `core\/common\/src\/main\/java\/Probe\.kt:4:15`/);
assert.doesNotMatch(summary, /\/workspace\/core/);
assert.match(summary, /ktlintFormat/);
});

test("renders a pass line when reports exist without violations", () => {
const summary = renderSummary([], { root: "/workspace", reportCount: 3 });

assert.match(summary, /✅ 위반 없음 \(리포트 3건\)/);
});

test("distinguishes a clean run from a run that produced no report at all", () => {
const summary = renderSummary([], { root: "/workspace", reportCount: 0 });

assert.match(summary, /리포트가 없다/);
});

test("truncates the listing but keeps the total and the tally exact", () => {
const many = Array.from({ length: 150 }, (unused, index) => ({
file: "/workspace/core/common/src/main/java/Probe.kt",
line: index + 1,
column: 1,
severity: "error",
message: "Missing newline",
rule: "standard:statement-wrapping",
}));

const summary = renderSummary(many, { root: "/workspace", reportCount: 1 });

assert.match(summary, /❌ \*\*위반 150건\*\*/);
assert.match(summary, /\| `standard:statement-wrapping` \| 150 \|/);
assert.match(summary, /외 50건/);
assert.equal(summary.split("\n").filter((line) => line.startsWith("- `")).length, 100);
});
28 changes: 15 additions & 13 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,24 @@ jobs:
cache-read-only: true
validate-wrappers: true

- name: Run Ktlint
uses: ScaCap/action-ktlint@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review # PR 리뷰 댓글로 표시
android: true # Android 스타일 가이드 준수
fail_on_error: true # 에러 발견 시 빌드 실패
level: warning # Warning 이상은 댓글로 표시
ktlint_version: "1.8.0" # 로컬(libs.versions.toml)과 버전 통일

# Reviewdog 의 diff 필터는 삭제로 새로 발생한 파일 단위 위반을 놓칠 수 있다.
# Gradle 검사는 변경 라인과 무관하게 저장소 전체의 ktlint 규칙을 강제한다.
# ktlint 를 돌리는 자리는 여기 하나다. 예전에는 ScaCap/action-ktlint(reviewdog)가
# PR diff 의 추가된 줄을, 이 태스크가 저장소 전체를 각각 검사해 같은 규칙을 두 번
# 돌렸다 — reviewdog 경로만 32초, job 의 30% 였다. 인라인 코멘트의 원래 수요자는
# 로그를 뒤지기 싫은 사람인데 이 저장소 작업자는 에이전트라 로그를 직접 읽는다.
# 막는 것(ktlintCheck)만 남기고 알려주는 것은 아래 job summary 로 옮겼다 (#1012).
#
# 저장소 전체 검사는 유지한다 — 파일을 지우면서 남은 파일에 새로 발생하는 파일 단위
# 위반은 diff 필터로 보이지 않는다 (#875).
# --build-cache 는 CI 한정 — gradle.properties 의 org.gradle.caching=false 로컬 기본값은 그대로다 (#996).
- name: Run full Ktlint check
- name: Run Ktlint
run: ./gradlew ktlintCheck --continue --parallel --build-cache

# 실패한 위치를 Actions 로그 수천 줄이 아니라 job summary 상단에서 바로 보게 한다.
# ktlint-gradle 이 모듈마다 남긴 checkstyle XML 을 읽으므로 검사가 다시 돌지 않는다.
- name: Summarize ktlint violations
if: always()
run: node .github/scripts/render-ktlint-summary.mjs

android-lint:
name: Check Project Issues (Android Lint)
runs-on: ubuntu-latest
Expand Down
11 changes: 9 additions & 2 deletions build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jlleitschuh.gradle.ktlint.KtlintExtension
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType

class AndroidLintConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
Expand All @@ -16,14 +17,20 @@ class AndroidLintConventionPlugin : Plugin<Project> {
.named("libs")

extensions.configure(KtlintExtension::class.java) {
// ktlint binary 버전을 CI(ScaCap/action-ktlint) 와 동일하게 핀.
// 추가 룰셋(compose-rules 등)은 CI 가 적재하지 않으므로 로컬도 적재하지 않는다.
// ktlint binary 버전 정본은 이 카탈로그 하나다 — CI 도 같은 Gradle 태스크를
// 돌리므로 워크플로에 손으로 적은 버전이 없다 (#1012).
// 추가 룰셋(compose-rules 등)은 적재하지 않는다.
version.set(libs.findVersion("ktlint").get().requiredVersion)
debug.set(false)
verbose.set(true)
android.set(true)
outputToConsole.set(true)
ignoreFailures.set(false)
// PLAIN 은 콘솔·로컬용, CHECKSTYLE 은 CI 가 job summary 로 렌더할 기계 판독용.
reporters {
reporter(ReporterType.PLAIN)
reporter(ReporterType.CHECKSTYLE)
}
filter {
exclude { it.file.path.contains("build/") }
}
Expand Down
Loading