diff --git a/.github/scripts/lint-workflow-policy.test.mjs b/.github/scripts/lint-workflow-policy.test.mjs
index 23b28d50d0..ebb899da66 100644
--- a/.github/scripts/lint-workflow-policy.test.mjs
+++ b/.github/scripts/lint-workflow-policy.test.mjs
@@ -31,9 +31,19 @@ test("ktlint violations are rendered into the job summary even when the check fa
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/);
+test("Android Lint runs exactly once per pull request", () => {
+ const gradleInvocations = lintWorkflow.match(/^\s*run:.*lintDebug/gm) ?? [];
+ assert.equal(gradleInvocations.length, 1);
+ assert.match(lintWorkflow, /\.\/gradlew lintDebug[^\n]*--continue/);
+});
+
+test("Android Lint reports through the job summary, not a Docker reviewdog action", () => {
+ // #279 가 푼 문제(모듈별 위반이 수만 줄 로그에 묻힘)를 되돌리지 않는다 — 자리만 PR
+ // 인라인 코멘트에서 job summary 로 옮겼다. 그 도커 액션은 매 run 마다 reviewdog 을
+ // 네트워크에서 받아 설치했고, 설치가 조용히 실패하면 lint 위반이 0건이어도 required
+ // check 가 red 가 됐다. 이미지는 job 당 한 번만 빌드돼 재시도로도 풀리지 않는다 (#1093).
+ assert.match(lintWorkflow, /render-android-lint-summary\.mjs/);
+ assert.match(lintWorkflow, /- name: Summarize Android Lint violations\n\s+if: always\(\)/);
+ // 주석은 이력이라 남기고, 실제로 액션을 부르는 uses 줄만 금지한다.
+ assert.doesNotMatch(lintWorkflow, /^\s*uses:\s*dvdandroid\/action-android-lint/m);
});
diff --git a/.github/scripts/render-android-lint-summary.mjs b/.github/scripts/render-android-lint-summary.mjs
new file mode 100644
index 0000000000..e8f73bb659
Binary files /dev/null and b/.github/scripts/render-android-lint-summary.mjs differ
diff --git a/.github/scripts/render-android-lint-summary.test.mjs b/.github/scripts/render-android-lint-summary.test.mjs
new file mode 100644
index 0000000000..f5ccc88d03
--- /dev/null
+++ b/.github/scripts/render-android-lint-summary.test.mjs
@@ -0,0 +1,214 @@
+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,
+ moduleOf,
+ parseLintXml,
+ renderSummary,
+} from "./render-android-lint-summary.mjs";
+
+// lint 9.2.1 이 실제로 남긴 리포트를 그대로 옮겼다. 여는 태그가 여러 줄이고,
+// message 의 닫는 꺾쇠는 이스케이프되지 않는다 (`<꺾쇠> 포함`).
+const REPORT = `
+
+
+
+
+
+
+
+
+
+
+`;
+
+const EMPTY_REPORT = `
+
+
+`;
+
+async function writeReport(root, modulePath, xml) {
+ const report = path.join(root, modulePath, "build", "reports", "lint-results-debug.xml");
+ await fs.mkdir(path.dirname(report), { recursive: true });
+ await fs.writeFile(report, xml);
+ return report;
+}
+
+test("parses a multi-line issue tag, decodes entities, and keeps a raw closing bracket", () => {
+ const violations = parseLintXml(REPORT, { module: ":core:common" });
+
+ assert.equal(violations.length, 2);
+ assert.deepEqual(violations[0], {
+ module: ":core:common",
+ id: "SimpleDateFormat",
+ severity: "Warning",
+ message:
+ "To get local formatting use `getDateInstance()`, or use `new SimpleDateFormat(String template, Locale locale)`.",
+ file: "/workspace/core/common/src/main/kotlin/Probe.kt",
+ line: 7,
+ column: 38,
+ });
+ // `[^>]*` 로 여는 태그를 끊으면 여기서 잘려 두 번째 위반을 통째로 잃는다.
+ assert.equal(violations[1].id, "HardcodedText");
+ assert.equal(violations[1].message, 'Hardcoded string "하드코딩 <꺾쇠> 포함", should use `@string` resource');
+ assert.equal(violations[1].line, 9);
+});
+
+test("reports no violations for a clean report", () => {
+ assert.deepEqual(parseLintXml(EMPTY_REPORT), []);
+});
+
+test("reads what it can from a report lint truncated mid-issue", () => {
+ const truncated = REPORT.slice(0, REPORT.indexOf('id="HardcodedText"'));
+
+ const violations = parseLintXml(truncated);
+
+ assert.equal(violations.length, 1);
+ assert.equal(violations[0].id, "SimpleDateFormat");
+});
+
+test("keeps an issue that carries no location", () => {
+ const withoutLocation = `
+
+`;
+
+ const violations = parseLintXml(withoutLocation, { module: ":app" });
+
+ assert.equal(violations.length, 1);
+ assert.equal(violations[0].file, null);
+ assert.equal(violations[0].line, 0);
+});
+
+test("drops violations that live in the dependency cache", () => {
+ const cached = `
+
+
+
+`;
+
+ assert.deepEqual(parseLintXml(cached), []);
+});
+
+test("derives the Gradle module path from the report location", () => {
+ assert.equal(moduleOf("/workspace", "/workspace/core/common/build/reports/lint-results-debug.xml"), ":core:common");
+ assert.equal(
+ moduleOf("/workspace", "/workspace/feature/afternote/presentation/build/reports/lint-results-debug.xml"),
+ ":feature:afternote:presentation",
+ );
+});
+
+test("collects one report per module across core and feature trees", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "android-lint-summary-"));
+ try {
+ await writeReport(root, "core/common", REPORT);
+ await writeReport(root, path.join("feature", "afternote", "presentation"), REPORT);
+ await writeReport(root, "app", EMPTY_REPORT);
+ // lint 의 다른 리포트와 소스 트리의 XML 은 입력이 아니다.
+ const noise = path.join(root, "core/ui/build/reports/lint-results-release.xml");
+ await fs.mkdir(path.dirname(noise), { recursive: true });
+ await fs.writeFile(noise, REPORT);
+ const source = path.join(root, "core/ui/src/main/res/layout");
+ await fs.mkdir(source, { recursive: true });
+ await fs.writeFile(path.join(source, "view.xml"), REPORT);
+
+ const reports = await collectReportFiles(root);
+ assert.equal(reports.length, 3);
+
+ const violations = await collectViolations(root);
+ assert.equal(violations.length, 4);
+ // #279 회귀 가드 — :app 밖의 모듈 위반이 귀속과 함께 살아 있어야 한다.
+ assert.deepEqual(
+ [...new Set(violations.map((violation) => violation.module))].sort(),
+ [":core:common", ":feature:afternote:presentation"],
+ );
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+});
+
+test("renders a rule tally with severity and repository relative positions", () => {
+ const summary = renderSummary(parseLintXml(REPORT, { module: ":core:common" }), {
+ root: "/workspace",
+ reportCount: 1,
+ });
+
+ assert.match(summary, /❌ \*\*위반 2건\*\* — 모듈 1개, 규칙 2종/);
+ assert.match(summary, /\| `HardcodedText` \| Warning \| 1 \|/);
+ assert.match(summary, /- `:core:common` `core\/common\/src\/main\/kotlin\/Probe\.kt:7:38`/);
+ assert.doesNotMatch(summary, /\/workspace\/core/);
+});
+
+test("renders a pass line when every module reported without violations", () => {
+ assert.match(renderSummary([], { root: "/workspace", reportCount: 26 }), /✅ 위반 없음 \(모듈 26개\)/);
+});
+
+test("distinguishes a clean run from a run that produced no report at all", () => {
+ assert.match(renderSummary([], { root: "/workspace", reportCount: 0 }), /리포트가 없다/);
+});
+
+test("collapses a multi-line message onto one list line", () => {
+ const summary = renderSummary(
+ [
+ {
+ module: ":app",
+ id: "Recycle",
+ severity: "Error",
+ message: "첫 줄\n\n둘째 줄",
+ file: "/workspace/app/src/main/kotlin/Probe.kt",
+ line: 3,
+ column: 0,
+ },
+ ],
+ { root: "/workspace", reportCount: 1 },
+ );
+
+ assert.match(summary, /- `:app` `app\/src\/main\/kotlin\/Probe\.kt:3` — 첫 줄 둘째 줄 \(`Recycle`\)/);
+});
+
+test("truncates the listing but keeps the total and the tally exact", () => {
+ const many = Array.from({ length: 150 }, (unused, index) => ({
+ module: ":core:common",
+ id: "HardcodedText",
+ severity: "Warning",
+ message: "Hardcoded string",
+ file: "/workspace/core/common/src/main/res/layout/probe.xml",
+ line: index + 1,
+ column: 9,
+ }));
+
+ const summary = renderSummary(many, { root: "/workspace", reportCount: 1 });
+
+ assert.match(summary, /❌ \*\*위반 150건\*\*/);
+ assert.match(summary, /\| `HardcodedText` \| Warning \| 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 7337f1548d..f947fa816f 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -80,32 +80,15 @@ jobs:
- name: Run Android Lint
run: ./gradlew lintDebug --continue --parallel --build-cache
- # 모듈별 lint XML 을 한 파일로 합쳐 Reviewdog 가 :feature/* · :core/* 위반도
- # PR 인라인 코멘트로 띄우도록 한다. 이전엔 :app XML 한 개만 전달돼 다른 모듈 위반이
- # Actions step 로그(수만 줄)에 묻혔다.
- - name: Merge module lint XMLs into one
- if: always()
- run: |
- set -e
- out=app/build/reports/lint-results-merged.xml
- mkdir -p app/build/reports
- {
- echo ''
- echo ''
- find . -path '*/build/reports/lint-results-debug.xml' -print | while read -r xml; do
- xmllint --xpath '//issue' "$xml" 2>/dev/null || true
- done
- echo ''
- } > "$out"
- modules=$(find . -path '*/build/reports/lint-results-debug.xml' | wc -l)
- issues=$(grep -c '