From 6589b893663056cd67ab0fe29fefb3acc441c5c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 29 Aug 2026 10:15:11 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20ktlintCheck=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=203=EC=A2=85=20=EC=88=98=EB=B3=B5=EA=B3=BC?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve-pr-impact.mjs 는 .kt·.kts 가 바뀐 모듈마다 <모듈>:ktlintCheck 를 고르면서(#1279) 그 태스크가 실재하는지는 보지 않는다. 없으면 Ktlint job 이 태스크 선택 단계에서 죽는다. 모든 모듈이 자기 build.gradle.kts 를 갖고 그 파일이 .kts 라, 위험 범위는 「.kt 를 가진 모듈」이 아니라 등록된 모듈 전부다. 컨벤션을 안 타는 3개가 비어 있었다 — :baselineprofile · :feature:setting:domain · :konsist. JvmLintConventionPlugin 을 신설해 JVM 쪽 ktlint 만 떼어 붙일 수 있게 했다. Java 11 을 자기 사정으로 고정하는 모듈은 afternote.jvm.library(17) 를 탈 수 없어 그동안 설정을 복붙할 수밖에 없었다 — afternote.android.lint 의 JVM 짝이다. 가드는 build-logic 의 컨벤션 사슬을 전이 폐포로 따라가 «ktlint 를 주는 플러그인» 을 계산하므로, 컨벤션이 새로 생겨도 목록을 손으로 고칠 필요가 없다. 현행 코드에 대고 먼저 실패시켜 3개를 정확히 지목함을 확인한 뒤 고쳤다. --- .../scripts/ktlint-coverage-policy.test.mjs | 38 ++++++ .github/scripts/ktlint-coverage.mjs | 108 ++++++++++++++++++ baselineprofile/build.gradle.kts | 5 + build-logic/build.gradle.kts | 4 + .../main/kotlin/JvmLintConventionPlugin.kt | 19 +++ feature/setting/domain/build.gradle.kts | 2 + konsist/build.gradle.kts | 2 + 7 files changed, 178 insertions(+) create mode 100644 .github/scripts/ktlint-coverage-policy.test.mjs create mode 100644 .github/scripts/ktlint-coverage.mjs create mode 100644 build-logic/src/main/kotlin/JvmLintConventionPlugin.kt diff --git a/.github/scripts/ktlint-coverage-policy.test.mjs b/.github/scripts/ktlint-coverage-policy.test.mjs new file mode 100644 index 0000000000..c2923e59ee --- /dev/null +++ b/.github/scripts/ktlint-coverage-policy.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import test from "node:test"; + +import { inspectKtlintCoverage, resolveKtlintBearingConventions } from "./ktlint-coverage.mjs"; + +const root = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); + +test("every registered module has a ktlintCheck task", async () => { + // resolve-pr-impact.mjs 는 `.kt`·`.kts` 가 바뀐 모듈마다 `<모듈>:ktlintCheck` 를 고르고, + // 그 태스크가 실재하는지는 확인하지 않는다 (#1419). 모든 모듈이 자기 build.gradle.kts 를 + // 갖고 그 파일이 `.kts` 라, 태스크가 없는 모듈은 자기 빌드 스크립트만 고쳐도 Ktlint job 을 + // 태스크 선택 단계에서 죽인다. 전역 폴백(globalKtlintChange)이 걸린 PR 은 이 구멍을 지나쳐 + // 잠복하므로, 새 모듈이 추가되는 시점에 여기서 잡는다. + const modules = await inspectKtlintCoverage(root); + const uncovered = modules.filter(({ hasKtlint }) => !hasKtlint).map(({ projectPath }) => projectPath); + assert.deepEqual( + uncovered, + [], + `ktlintCheck 가 없는 모듈: ${uncovered.join(", ")} — 컨벤션 플러그인을 타거나 ` + + `id("org.jlleitschuh.gradle.ktlint") 를 직접 적용해야 한다`, + ); +}); + +test("the convention chain that carries ktlint is discovered transitively", async () => { + // 목록을 손으로 적으면 컨벤션이 새로 생길 때 표류한다. 사슬을 타고 계산하는지 확인한다 — + // android.lint 가 ktlint 를 직접 적용하는 시드이고, android.library 가 그걸 apply 하며, + // android.library.compose 가 다시 android.library 를 apply 한다. + const bearing = await resolveKtlintBearingConventions(root); + assert.ok(bearing.has("afternote.android.lint"), "직접 적용 시드를 못 찾았다"); + assert.ok(bearing.has("afternote.android.library"), "1단계 전이를 못 따라갔다"); + assert.ok(bearing.has("afternote.android.library.compose"), "2단계 전이를 못 따라갔다"); + assert.ok(bearing.has("afternote.jvm.library"), "JVM 쪽 시드를 못 찾았다"); + assert.ok(bearing.has("afternote.jvm.domain"), "JVM 전이를 못 따라갔다"); + // ktlint 와 무관한 컨벤션까지 물들면 판정이 무의미해진다. + assert.ok(!bearing.has("afternote.kover"), "무관한 컨벤션이 섞였다"); +}); diff --git a/.github/scripts/ktlint-coverage.mjs b/.github/scripts/ktlint-coverage.mjs new file mode 100644 index 0000000000..0b26d4537e --- /dev/null +++ b/.github/scripts/ktlint-coverage.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +// 어떤 Gradle 모듈이 ktlintCheck 태스크를 갖는지 빌드 스크립트만 읽어 판정한다. +// +// resolve-pr-impact.mjs 는 `.kt`·`.kts` 가 바뀐 모듈마다 `<모듈>:ktlintCheck` 를 고르는데 +// (PR #1279), 그 태스크가 실재하는지는 보지 않는다. 없으면 Ktlint job 이 태스크 선택 단계에서 +// 죽는다 (#1419). 모든 모듈은 자기 build.gradle.kts 를 갖고 그 파일 자체가 `.kts` 라, 위험 범위는 +// 「.kt 를 가진 모듈」이 아니라 **등록된 모듈 전부**다. +// +// ktlint 는 두 경로로 붙는다 — 모듈이 직접 적용하거나, ktlint 를 적용하는 컨벤션 플러그인을 +// 타거나. 컨벤션은 서로를 apply 하며 사슬을 이루므로(android.feature → android.library.compose → +// android.library → android.lint) 여기서 그 사슬의 전이 폐포를 계산한다. 컨벤션이 새로 생겨도 +// 목록을 손으로 고칠 필요가 없다. + +import fs from "node:fs/promises"; +import path from "node:path"; + +const KTLINT_PLUGIN_ID = "org.jlleitschuh.gradle.ktlint"; + +function moduleDirectory(projectPath) { + return projectPath.replace(/^:/, "").replaceAll(":", "/"); +} + +/** build-logic 의 gradlePlugin 블록에서 컨벤션 id ↔ 구현 클래스 짝을 읽는다. */ +function parseConventionRegistrations(buildLogicSource) { + const registrations = new Map(); + const blockPattern = /register\("[^"]+"\)\s*\{([^}]*)\}/g; + for (const [, body] of buildLogicSource.matchAll(blockPattern)) { + const id = body.match(/id\s*=\s*"([^"]+)"/)?.[1]; + const implementationClass = body.match(/implementationClass\s*=\s*"([^"]+)"/)?.[1]; + if (id && implementationClass) { + registrations.set(id, implementationClass); + } + } + return registrations; +} + +/** 컨벤션 플러그인 소스가 apply 하는 플러그인 id 를 모은다. */ +function parseAppliedPlugins(source) { + return [...source.matchAll(/pluginManager\.apply\("([^"]+)"\)/g)].map((match) => match[1]); +} + +/** + * ktlint 를 결과적으로 적용하는 컨벤션 플러그인 id 집합을 전이 폐포로 구한다. + * 시드는 ktlint 를 직접 apply 하는 컨벤션이고, 그 컨벤션을 apply 하는 컨벤션도 함께 물든다. + */ +export async function resolveKtlintBearingConventions(root) { + const buildLogicSource = await fs.readFile( + path.join(root, "build-logic", "build.gradle.kts"), + "utf8", + ); + const registrations = parseConventionRegistrations(buildLogicSource); + + const appliedByConvention = new Map(); + for (const [id, implementationClass] of registrations) { + const source = await fs.readFile( + path.join(root, "build-logic", "src", "main", "kotlin", `${implementationClass}.kt`), + "utf8", + ); + appliedByConvention.set(id, parseAppliedPlugins(source)); + } + + const bearing = new Set(); + for (const [id, applied] of appliedByConvention) { + if (applied.includes(KTLINT_PLUGIN_ID)) { + bearing.add(id); + } + } + // 사슬을 타고 올라간다 — 물든 컨벤션을 apply 하는 컨벤션도 ktlint 를 받는다. + for (let changed = true; changed; ) { + changed = false; + for (const [id, applied] of appliedByConvention) { + if (bearing.has(id)) continue; + if (applied.some((appliedId) => bearing.has(appliedId))) { + bearing.add(id); + changed = true; + } + } + } + return bearing; +} + +/** 모듈 빌드 스크립트의 plugins 블록에 선언된 id 를 뽑는다. */ +function declaredPluginIds(moduleBuildSource) { + return [...moduleBuildSource.matchAll(/id\("([^"]+)"\)/g)].map((match) => match[1]); +} + +/** settings.gradle.kts 에 등록된 모든 모듈의 ktlint 보유 여부를 판정한다. */ +export async function inspectKtlintCoverage(root) { + const settings = await fs.readFile(path.join(root, "settings.gradle.kts"), "utf8"); + const projectPaths = [...settings.matchAll(/include\("(:[^"]+)"\)/g)].map((match) => match[1]); + const bearingConventions = await resolveKtlintBearingConventions(root); + + const modules = []; + for (const projectPath of projectPaths) { + const directory = moduleDirectory(projectPath); + const source = await fs.readFile(path.join(root, directory, "build.gradle.kts"), "utf8"); + const declared = declaredPluginIds(source); + modules.push({ + projectPath, + directory, + hasKtlint: + declared.includes(KTLINT_PLUGIN_ID) || + declared.some((id) => bearingConventions.has(id)), + }); + } + return modules; +} diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts index 461f534eb0..60a0340094 100644 --- a/baselineprofile/build.gradle.kts +++ b/baselineprofile/build.gradle.kts @@ -2,6 +2,11 @@ plugins { // AGP는 루트의 com.android.application apply-false로 이미 classpath에 있으므로 버전 없는 ID를 쓴다. id("com.android.test") alias(libs.plugins.androidx.baselineprofile) + // 이 모듈은 컨벤션(afternote.android.library) 대신 com.android.test 를 직접 쓰느라 ktlint 만 + // 빠져 있었다. resolve-pr-impact.mjs 가 «.kt·.kts 가 바뀐 모듈» 마다 `<모듈>:ktlintCheck` 를 + // 고르므로(#1279 이후), 태스크가 없으면 이 모듈을 건드리는 PR 의 Ktlint job 이 태스크 선택 + // 단계에서 죽는다 (#1419). 카탈로그 버전·리포터 설정을 공유하도록 컨벤션으로 붙인다. + id("afternote.android.lint") } android { diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index c109a88469..035535410f 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -101,6 +101,10 @@ gradlePlugin { id = "afternote.android.lint" implementationClass = "AndroidLintConventionPlugin" } + register("jvmLint") { + id = "afternote.jvm.lint" + implementationClass = "JvmLintConventionPlugin" + } register("androidDatastore") { id = "afternote.android.datastore" implementationClass = "AndroidDatastoreConventionPlugin" diff --git a/build-logic/src/main/kotlin/JvmLintConventionPlugin.kt b/build-logic/src/main/kotlin/JvmLintConventionPlugin.kt new file mode 100644 index 0000000000..7c722e45a0 --- /dev/null +++ b/build-logic/src/main/kotlin/JvmLintConventionPlugin.kt @@ -0,0 +1,19 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * 컨벤션 밖에서 java-library 를 직접 쓰는 JVM 모듈에 ktlint 만 붙인다. + * + * [JvmLibraryConventionPlugin] 은 Java/Kotlin 타깃까지 통일하므로, 타깃을 자기 사정으로 고정해야 + * 하는 모듈(현재 `:konsist`·`:feature:setting:domain` 이 Java 11)은 그것을 탈 수 없다. 그렇다고 + * ktlint 설정을 모듈마다 복붙하면 카탈로그 버전·리포터 설정이 갈라진다 — `afternote.android.lint` + * 의 JVM 짝이다. + */ +class JvmLintConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jlleitschuh.gradle.ktlint") + configureKtlint(isAndroid = false) + } + } +} diff --git a/feature/setting/domain/build.gradle.kts b/feature/setting/domain/build.gradle.kts index 96a85f5e06..df319083de 100644 --- a/feature/setting/domain/build.gradle.kts +++ b/feature/setting/domain/build.gradle.kts @@ -2,6 +2,8 @@ plugins { id("java-library") alias(libs.plugins.jetbrains.kotlin.jvm) id("afternote.kover") + // 아래 Java 11 고정 때문에 afternote.jvm.library(17) 를 탈 수 없어 ktlint 만 빠져 있었다 (#1419). + id("afternote.jvm.lint") } java { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index c1a06d05d9..bc02ecfb27 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -1,6 +1,8 @@ plugins { id("java-library") alias(libs.plugins.jetbrains.kotlin.jvm) + // 아래 Java 11 고정 때문에 afternote.jvm.library(17) 를 탈 수 없어 ktlint 만 빠져 있었다 (#1419). + id("afternote.jvm.lint") } java { sourceCompatibility = JavaVersion.VERSION_11