iru-kotlin-code-quality
Run a JVM Kotlin project's wired static-analysis tools — detekt (Gradle `io.gitlab.arturbosch.detekt` 1.x / `dev.detekt` 2.x, or Maven `com.github.ozsie:detekt-maven-plugin`, or a project-provided det
SKILL.md.claude/skills/iru-kotlin-code-quality/SKILL.md
GitHubKotlin Code Quality
Run whichever Kotlin static-analysis tools a JVM project actually has wired — detekt and ktlint — and report every
issue they find. This skill only runs static analysis and reports results: it does not fix issues, and it never
adds or modifies detekt/ktlint/Spotless configuration (a missing tool is reported, not installed — that's a
follow-up the user drives). Android modules (com.android.library/com.android.application applied) are out of
scope — hand those to iru-android-code-quality, which also runs Android Lint — and so are Kotlin Multiplatform
modules (kotlin("multiplatform") applied), which belong to iru-kmp-code-quality.
Step 0 — Resolve inputs and the build tool
<scope> (optional) is a file path, a class/file simple name, or a module name/path. It narrows the build to the
owning module(s) in Step 2 and filters the report in Step 3 — never a per-file tool selector, since neither detekt
nor ktlint is invoked per file here. No <scope> → every module, everything reported. If the calling
skill/prompt passes explicit key: value lines (e.g. module: service, build: gradle, a baseline list), honor
them and skip the matching inference below.
Detect the build tool from the repository root — never assume:
- Gradle:
settings.gradle.kts/settings.gradleorbuild.gradle.kts/build.gradlepresent. Use./gradlew(gradlew.baton Windows) when the wrapper exists, otherwisegradle. Modules come frominclude(...)lines in the settings file (:service,:adapters:web, …); a single-project build has none. - Maven:
pom.xmlpresent. Use./mvnw(mvnw.cmdon Windows) when the wrapper exists, otherwisemvn. Modules come from the root pom's<modules>(recursively for nested aggregators). - Both present: prefer whichever one the CI workflow (
.github/workflows/*.yml) or contributor guide (CLAUDE.md,README,CONTRIBUTING) actually invokes; ask the user only if neither says.
For a <scope> naming a file/class, locate it with find . -name '<Name>.kt' -not -path '*/build/*' -not -path '*/target/*' and map it to the module whose directory contains it. A .java scope target is not this skill's —
say so and point to iru-java-code-quality.
Skip any module that applies an Android Gradle plugin, and note it in the report as "Android module — use
iru-android-code-quality". If a Kotlin module also has Java sources (src/main/java/**/*.java), mention in the
report that those Java files are covered by iru-java-code-quality (Checkstyle/PMD/SpotBugs), not by detekt/ktlint.
Prerequisites: a JDK the build accepts on JAVA_HOME (check the toolchain/<maven.compiler.release>/jvmTarget
the project pins rather than trusting the machine's default java).
Step 1 — Verify which tools are wired
Check the build files of every in-scope module and any parent/convention location (root build.gradle.kts
subprojects {}/allprojects {} blocks, buildSrc//build-logic/ convention plugins, gradle/libs.versions.toml
aliases, a parent pom's <pluginManagement>/<plugins>) before running anything — a "zero issues" result from a
tool that was never configured is meaningless and must not be reported as a clean pass:
Gradle
- detekt — plugin
io.gitlab.arturbosch.detekt(1.x) ordev.detekt(2.x), applied directly, viaalias(libs.plugins.detekt), or through a convention plugin. Confirm with./gradlew :<module>:tasks --all | grep -i detekt(root project:./gradlew tasks --all). - ktlint — either (a)
org.jlleitschuh.gradle.ktlint(check taskktlintCheck), or (b) Spotless (com.diffplug.spotless) with akotlin { ktlint(...) }block (check taskspotlessKotlinCheck, aggregated byspotlessCheck). Two different plugins wrapping the same engine — detect which (if either) and use its own task name; aspotless {}block with onlyjava {}/kotlinGradle {}does not lint Kotlin sources.
Maven
- detekt —
com.github.ozsie:detekt-maven-plugindeclared in the module's<build><plugins>or inherited from a parent's<plugins>/<pluginManagement>. Read its<configuration>/<execution>for<config>,<baseline>,<report>entries and<failBuildOnMaxIssuesReached>— they matter in Step 2. - ktlint — either
com.github.gantsign.maven:ktlint-maven-plugin(read its<reporters>block: acheckstylereporter with an<output>gives a parseable XML file; without it, findings only reach the console), orcom.diffplug.spotless:spotless-maven-pluginwith a<kotlin><ktlint/></kotlin>block.
detekt CLI fallback (either build tool) — when no detekt plugin is wired but the project clearly runs the CLI
itself (a CI step, Makefile/script, or contributor guide invoking detekt/detekt-cli against a committed
detekt.yml), and command -v detekt (or detekt-cli) finds it installed, run it the way the project does.
Never download a detekt or ktlint binary to fill the gap; if the CLI isn't installed, report detekt as "wired via
CLI in <where>, CLI not installed locally — not run". The same applies to a standalone ktlint CLI.
Also note whether a custom detekt config exists (detekt.yml/detekt.yaml, config/detekt/detekt.yml, or
whatever config.setFrom(...)/<config> points to), whether a baseline file is in use (detekt-baseline.xml,
<baseline>), and whether buildUponDefaultConfig is set — those change what "detekt found nothing" means
(bundled defaults vs. this project's own bar vs. "only new issues beyond the baseline"). For ktlint, rules come
from .editorconfig (ktlint_* properties, ktlint_code_style) — note it if present.
If a tool isn't wired, skip it and say so in the report (e.g. "detekt: not wired (no detekt plugin in any build file, no CLI usage found)"). If neither tool is wired anywhere, report exactly that — "No Kotlin static
analysis configured (detekt/ktlint not wired)" — and stop; do not add configuration.
Step 2 — Run each wired tool
Neither detekt (without type resolution) nor ktlint needs compiled classes, so no clean compile is required —
unless the project only wires detekt's type-resolution tasks (detektMain/detektTest), which compile first.
Gradle (prefix each task with :<module> for a scoped run; omit the prefix to run every module that applies
the plugin):
./gradlew :<module>:detekt # detekt 1.x / 2.x, no type resolution
./gradlew :<module>:detektMain # only if the project relies on type-resolution tasks instead
./gradlew :<module>:detektMainSourceSet # detekt 2.x per-source-set task, if that's what the project/CI runs
./gradlew :<module>:ktlintCheck # org.jlleitschuh.gradle.ktlint
./gradlew :<module>:spotlessKotlinCheck # Spotless with kotlin { ktlint() }
Name every wired task in one invocation and add --continue so a failing ktlint/Spotless check doesn't stop
detekt from running (./gradlew :service:detekt :service:ktlintCheck --continue). If a run fails with a
configuration-cache error unrelated to the findings, retry once with --no-configuration-cache and note it.
Maven — use fully qualified groupId:artifactId:goal coordinates (the version comes from the pom's
<plugins>/<pluginManagement>, and it avoids prefix-resolution surprises in multi-module builds); scope with
-pl <modules> -am (comma-separated module paths; reports produced for upstream -am modules are ignored in
Step 3):
./mvnw -B -pl <modules> -am \
com.github.ozsie:detekt-maven-plugin:check \
com.github.gantsign.maven:ktlint-maven-plugin:check \
-Ddetekt.failBuildOnMaxIssuesReached=false \
'-Ddetekt.report=xml:${project.build.directory}/detekt.xml' \
-Dktlint.failOnViolation=false
./mvnw -B -pl <modules> -am com.diffplug.spotless:spotless-maven-plugin:check # if Spotless lints Kotlin
-Ddetekt.report=...is only needed when the pom configures no<report>of its own (explicit pom configuration wins over the user property). Keep it single-quoted with${project.build.directory}— Maven interpolates it per module; a plain relativexml:target/detekt.xmlresolves against the directorymvnwas started in, so every module would overwrite the root's file.failBuildOnMaxIssuesReached=false/failOnViolation=falseonly keep the run going so every report gets written — they don't hide anything; a non-zero exit with them set means something else broke.- ktlint-maven-plugin has no reporter user property: without a
checkstylereporter in the pom, parse the console lines instead (Step 3). - detekt-maven-plugin's default
inputis${basedir}/src— test sources are analyzed too; that's the project's own choice, report them like any other file.
Exit codes: detekt (Gradle) fails when issues exceed maxIssues unless ignoreFailures = true;
ktlintCheck/spotlessCheck/spotless:check fail on any violation by design. A non-zero exit from a lint task is
information (violations at/above the configured threshold exist), not "the tool is broken" — always go on and parse
the report. If the build fails for an unrelated reason (compile error, dependency resolution, bad configuration),
report that and stop for the affected tool — don't read a stale report from an earlier run (check the report's
mtime against the run if in doubt).
Step 3 — Parse each generated report, scoped to <scope> if given
Locate reports rather than assuming one fixed name — plugin versions and project overrides move them:
- detekt, Gradle:
find <module>/build/reports/detekt -name '*.xml' -o -name '*.sarif'— 1.x writesdetekt.xml/detekt.sarif(plus.html/.md/.txt) fordetektandmain.xml/test.xmlfordetektMain/detektTest; 2.x (dev.detekt, checked on 2.0.0-alpha.6) calls the XML report "checkstyle" in its DSL/console but still writesdetekt.xml— match by extension anyway, since a project can rename outputs. - detekt, Maven: whatever
<report>(or the-Ddetekt.reportyou passed) names —<module>/target/detekt.xmlby this skill's default. - ktlint, Gradle plugin:
find <module>/build/reports/ktlint -type f— one directory per check task (ktlintMainSourceSetCheck,ktlintTestSourceSetCheck,ktlintKotlinScriptCheck), each holding<task>.txt(plain — the only reporter on by default) and, whenktlint { reporters { reporter(ReporterType. CHECKSTYLE) } }is configured,<task>.xml(also.sarif/.json/.htmlfor those reporter types). Prefer the.xmlwhen present. - ktlint, Maven plugin: the
checkstylereporter's<output>(commonlytarget/ktlint.xml), else console.
Checkstyle-format XML (detekt XML and ktlint checkstyle reporter) — one parser for both:
<file name="/abs/path/Foo.kt">
<error line="9" column="21" severity="warning" message="This expression contains a magic number..." source="detekt.MagicNumber"/>
</file>
<file name="src/main/kotlin/com/example/Foo.kt">
<error line="5" column="15" severity="error" message="Newline expected after opening parenthesis" source="standard:class-signature"/>
</file>
import xml.etree.ElementTree as ET
from collections import Counter
def parse_checkstyle(path, tool, scope=None):
issues = []
for f in ET.parse(path).getroot().findall("file"):
fname = f.get("name")
if scope and scope not in fname:
continue
for e in f.findall("error"):
src = e.get("source", "")
# detekt: "detekt.MagicNumber" -> "MagicNumber"; ktlint: "standard:class-signature" kept whole
rule = src.rsplit(".", 1)[-1] if tool == "detekt" else src
issues.append((fname, e.get("line"), rule, e.get("severity"), e.get("message")))
return issues
issues = parse_checkstyle("<report.xml>", "detekt", scope=None)
print("total:", len(issues), "by rule:", dict(Counter(i[2] for i in issues)),
"by severity:", dict(Counter(i[3] for i in issues)))
A <file> with no <error> children is clean. Paths vary — detekt 1.x (Gradle and Maven) and the Gradle ktlint
plugin write absolute paths; detekt 2.x and the Maven ktlint plugin write module-relative ones — so match <scope>
as a substring. Severity is Checkstyle's error/warning/info scale (detekt maps each rule's configured
severity — 1.x defaults to warning, 2.x to error; ktlint reports everything as error) — keep each tool's
severities in its own bucket.
detekt SARIF (when only sarif is enabled): JSON runs[].results[], each with ruleId (e.g.
detekt.style.MagicNumber — report the last segment), level (error/warning/note), and
locations[0].physicalLocation.{artifactLocation.uri, region.startLine} (uri is a file:// URL).
ktlint plain output (Gradle .txt report, the Maven plain reporter, or console lines): one issue per line,
<path>:<line>:<col>: <message> (<rule-id>), e.g.
src/main/kotlin/com/example/Foo.kt:8:36: Missing newline after '{' (standard:statement-wrapping). The Gradle
plugin's .txt file embeds ANSI color escapes (\x1b[90m…\x1b[0m) around the path and rule id — strip them
(re.sub(r'\x1b\[[0-9;]*m', '', line)) before splitting. The Gradle console echo of the same findings
(<path>:<line>:<col> <message>) omits the rule id — read the report file, not the console. The Maven
plugin's console echo without a plain reporter ([ERROR] <path>:<line>:<col>: <message>) carries no rule
id — bucket those lines under unknown-rule and say the rule ids need a checkstyle/plain reporter in the pom.
Spotless — no report file; parse the console. A formatting-only violation lists files under The following files had format violations: followed by a diff per file (one formatting bucket, file count, no line/rule). A
lint error ktlint can't auto-fix carries a line and rule id — report it as such: Gradle prints There were N lint error(s), they must be fixed or suppressed. then <path>:L9 ktlint(standard:if-else-wrapping) <message>; Maven
prints Step 'ktlint' found problem in 'Foo.kt': then Foo.kt:L9 ktlint(standard:if-else-wrapping) <message>.
Lint errors are reported instead of (not alongside) the format-violation list, so a file can still have
formatting violations hidden behind them — say so when only lint errors were printed.
For a scoped run, an empty extraction for a tool means that tool found nothing in scope, not that the report is
missing — only treat it as suspicious when no <file>/result entry matches the expected path shape at all.
Step 4 — Classify, diff against a baseline if given, and report
Bucket every issue by tool, then rule id (detekt rule name, ktlint ruleset:rule-id, Spotless formatting) —
never merge buckets across tools even when names look alike, the same convention iru-android-code-quality uses.
Report: an overall total across all tools first, then per tool (and per module when more than one ran):
whether it ran or was skipped as "not wired" (Step 1), which config/baseline/.editorconfig governed it, the
total issue count, the severity breakdown, and one line per rule bucket with its count — plus the file:line list
when the scope is smaller than the whole project. For each issue in an itemized list give file:line, rule id,
severity, and message. Add the Step 0 notes (Android modules skipped, Java sources left to
iru-java-code-quality) at the end.
Baseline diff — when the prompt supplies a baseline (a prior run's per-tool/per-rule counts or itemized list,
the contract iru-java-code-one-task-group/iru-android-code-one-task-group use for their code-quality skill):
report only issues that are new since the baseline (a rule whose count increased, a rule that appears now but
didn't before, or a new file:line entry when the baseline was itemized). Decreased/unchanged rules are not
regressions and are omitted from the diff (they still appear in the plain counts). If a tool wasn't run in the
baseline (unwired then, newly wired since), treat all of its current issues as new.
Do not fix any issue, run ktlintFormat/ktlint:format/spotlessApply/detekt autoCorrect, create a detekt
baseline, or modify any configuration — those are follow-ups the user (or a coding skill) drives explicitly.
Known quirks / verification notes
- Maven path verified locally (Maven 3.9, JDK 25, Kotlin 2.4.21) in
$TMPDIR/iru-verify/kotlin/iru-kotlin-code-quality/:detekt-maven-plugin1.23.8checkwith the-Dflags above wrotedetekt.xml/detekt.sarifin the format shown (absolute paths,source="detekt.<Rule>", SARIFruleIddetekt.style.<Rule>);ktlint-maven-plugin3.7.1checkwith acheckstylereporter wrote relative-path XML withsource="standard:<rule>", and without one only console lines with no rule id; Spotless maven 3.10.4kotlin { ktlint }reported a non-auto-fixable ktlint error asStep 'ktlint' found problem …; a two-module reactor confirmed-plscoping and that only the${project.build.directory}form of-Ddetekt.reportlands per module. The Checkstyle parser above was run against those real reports (scoped and unscoped). - detekt 1.23.x embeds Kotlin compiler 2.0.21 and still parsed Kotlin 2.4 sources (with JDK
sun.misc.Unsafedeprecation warnings on JDK 24+ — harmless); very new syntax can still trip it — report a detekt parse error as a tool limitation, not a code issue. - Gradle path verified in Docker (
gradle:8-jdk21, Gradle 8.14.6, Kotlin JVM plugin 2.4.21) under$TMPDIR/iru-verify/kotlin/iru-kotlin-code-quality-gradle{,2}/:io.gitlab.arturbosch.detekt1.23.8detekt→build/reports/detekt/detekt.{xml,sarif,html,md,txt}(absolute paths,warning),detektMain→main.{xml,…}, task fails withAnalysis failed with N weighted issues.but still writes the reports;dev.detekt2.0.0-alpha.6 registersdetekt,detektMain/detektTest(type resolution) anddetektMainSourceSet/detektTestSourceSet, writesdetekt.xmlwith relative paths anderrorseverity;org.jlleitschuh.gradle.ktlint14.2.0ktlintCheckwrote only ANSI-colored.txtby default and.xmlwith aCHECKSTYLEreporter (absolute paths,source="standard:<rule>"); Spotless Gradle 8.10.4spotlessKotlinCheckproduced both console shapes described in Step 3.--continuelet every task run despite failures. The Step 3 parser ran correctly on the Gradle detekt 1.x/2.x and ktlint XML reports. - The Maven plugins are third-party community plugins (detekt and ktlint ship no official Maven integration) —
look up current versions at run time on Maven Central if a report looks wrong for the version in use; October
2026 fallbacks: detekt-maven-plugin 1.23.8, ktlint-maven-plugin 3.7.1, spotless-maven-plugin 3.10.4;
Gradle: detekt 1.23.8 (2.x still alpha),
org.jlleitschuh.gradle.ktlint14.2.0, Spotless 8.10.4.