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 '