diff --git a/.github/scripts/lint-workflow-policy.test.mjs b/.github/scripts/lint-workflow-policy.test.mjs new file mode 100644 index 0000000000..23b28d50d0 --- /dev/null +++ b/.github/scripts/lint-workflow-policy.test.mjs @@ -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/); +}); diff --git a/.github/scripts/render-ktlint-summary.mjs b/.github/scripts/render-ktlint-summary.mjs new file mode 100644 index 0000000000..37eff121cd --- /dev/null +++ b/.github/scripts/render-ktlint-summary.mjs @@ -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("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +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 = /]*)>([\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 = /]*)\/>/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("", "
위반 위치", ""); + 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("", "
", "", "로컬에서 `./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(); +} diff --git a/.github/scripts/render-ktlint-summary.test.mjs b/.github/scripts/render-ktlint-summary.test.mjs new file mode 100644 index 0000000000..be25551d8f --- /dev/null +++ b/.github/scripts/render-ktlint-summary.test.mjs @@ -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 = ` + + + + + +`; + +const EMPTY_REPORT = ` + +`; + +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, ""); + + 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); +}); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ff995f2f3c..7337f1548d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt index fa890301e0..53fd04514b 100644 --- a/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidLintConventionPlugin.kt @@ -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 { override fun apply(target: Project) { @@ -16,14 +17,20 @@ class AndroidLintConventionPlugin : Plugin { .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/") } }