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
20 changes: 15 additions & 5 deletions .github/scripts/lint-workflow-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Binary file added .github/scripts/render-android-lint-summary.mjs
Binary file not shown.
214 changes: 214 additions & 0 deletions .github/scripts/render-android-lint-summary.test.mjs
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.2.1">

<issue
id="SimpleDateFormat"
severity="Warning"
message="To get local formatting use \`getDateInstance()\`, or use \`new SimpleDateFormat(String template, Locale locale)\`."
category="Correctness"
priority="6"
summary="Implied locale in date format"
explanation="Almost all callers should use \`getDateInstance()\`.&#xA;&#xA;Therefore, you should either use the form of the constructor where you pass in an explicit locale."
errorLine1=" fun format(date: Date): String = SimpleDateFormat(&quot;yyyy-MM-dd&quot;).format(date)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="/workspace/core/common/src/main/kotlin/Probe.kt"
line="7"
column="38"/>
</issue>

<issue
id="HardcodedText"
severity="Warning"
message="Hardcoded string &quot;하드코딩 &lt;꺾쇠> 포함&quot;, should use \`@string\` resource"
category="Internationalization"
priority="5"
summary="Hardcoded text"
explanation="Hardcoding text attributes directly in layout files is bad."
errorLine1=" android:text=&quot;하드코딩 &amp;lt;꺾쇠&amp;gt; 포함&quot; />"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="/workspace/core/common/src/main/res/layout/probe.xml"
line="9"
column="9"/>
</issue>

</issues>`;

const EMPTY_REPORT = `<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.2.1">

</issues>`;

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 = `<issues format="6" by="lint 9.2.1">
<issue id="GradleDependency" severity="Warning" message="A newer version is available" />
</issues>`;

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 = `<issues format="6" by="lint 9.2.1">
<issue id="OldTargetApi" severity="Warning" message="External">
<location file="${path.join(os.homedir(), ".gradle", "caches", "modules-2", "Cached.kt")}" line="3" column="1"/>
</issue>
</issues>`;

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);
});
39 changes: 11 additions & 28 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<?xml version="1.0" encoding="UTF-8"?>'
echo '<issues format="6" by="lint" client="gradle" type="baseline" name="merged">'
find . -path '*/build/reports/lint-results-debug.xml' -print | while read -r xml; do
xmllint --xpath '//issue' "$xml" 2>/dev/null || true
done
echo '</issues>'
} > "$out"
modules=$(find . -path '*/build/reports/lint-results-debug.xml' | wc -l)
issues=$(grep -c '<issue ' "$out" || echo 0)
echo "merged $issues issues from $modules modules → $out"

- name: Report Android Lint (Reviewdog)
# 모듈별 lint XML 을 job summary 한 곳에 모은다 — :feature/* · :core/* 위반이
# Actions step 로그(수만 줄)에 묻히던 문제(#279)를 여기서 푼다.
#
# 예전엔 이 자리에서 XML 을 합쳐 reviewdog(dvdandroid/action-android-lint)에 넘겼다.
# 그 도커 액션은 매 run 마다 reviewdog 을 `wget -q … | sh` 로 받는데, 설치가 실패해도
# 파이프 exit status 는 sh 의 것이라 이미지 빌드는 성공하고 entrypoint 에서
# `reviewdog: not found` 로 죽었다 — lint 위반이 0건이어도 required check 가 red 가
# 됐다. 이미지는 job 당 한 번만 빌드되므로 스텝 재시도로는 풀리지 않는다 (#1093).
# 인라인 코멘트를 job summary 로 대체한 판단은 ktlint 쪽과 같다 (#1012).
- name: Summarize Android Lint violations
if: always()
uses: dvdandroid/action-android-lint@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
level: warning
lint_xml_file: app/build/reports/lint-results-merged.xml
run: node .github/scripts/render-android-lint-summary.mjs
Loading