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
38 changes: 38 additions & 0 deletions .github/scripts/ktlint-coverage-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -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"), "무관한 컨벤션이 섞였다");
});
108 changes: 108 additions & 0 deletions .github/scripts/ktlint-coverage.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions baselineprofile/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions build-logic/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions build-logic/src/main/kotlin/JvmLintConventionPlugin.kt
Original file line number Diff line number Diff line change
@@ -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<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("org.jlleitschuh.gradle.ktlint")
configureKtlint(isAndroid = false)
}
}
}
2 changes: 2 additions & 0 deletions feature/setting/domain/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 2 additions & 13 deletions konsist/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
plugins {
id("java-library")
alias(libs.plugins.jetbrains.kotlin.jvm)
// build-logic 이 이미 클래스패스에 올려 둔 플러그인이라 버전을 다시 선언하면 충돌한다.
id("org.jlleitschuh.gradle.ktlint")
}

// 이 모듈은 convention plugin(afternote.jvm.library) 대신 java-library 를 직접 쓰느라 ktlint 만
// 빠져 있었다. resolve-pr-impact.mjs 는 «.kt 가 바뀐 모듈» 마다 `<모듈>:ktlintCheck` 를 고르므로
// (PR #1279 이후), 태스크가 없으면 konsist 소스를 건드리는 PR 의 Ktlint job 이 태스크 선택 단계에서
// 죽는다. build-logic 과 같은 방식으로 플러그인만 직접 적용한다.
ktlint {
version.set(libs.versions.ktlint)
filter {
exclude { it.file.path.contains("build/") }
}
// 아래 Java 11 고정 때문에 afternote.jvm.library(17) 를 탈 수 없어 ktlint 만 빠져 있었다 (#1419).
id("afternote.jvm.lint")
}

java {
Expand Down
Loading