# SlopGuard (Kotlin): complete interface reference (agent-ingestible) slopguard-kotlin measures complex, undertested code in Kotlin and Android projects. It computes a weighted CRAP (Change Risk Anti-Patterns) score combining cyclomatic and cognitive complexity with line coverage, and prints a structured report you can pipe into jq or fail CI on. It is the Kotlin sibling of slopguard-swift, slopguard-typescript and slopguard-go: same formula, same JSON schema, same UX. Status: alpha (v0.1.x). The analyzer is stable and self-tested; the CLI surface and JSON schema may move before v1.0. License: MIT. Repo: https://github.com/JeevanThandi/slopguard-kotlin ## Why this matters for coding agents Autonomous/background agents iterate by changing code and running tests. Two codebase properties decide how well that loop works: 1. Coverage: an agent editing untested code cannot tell a fix from a regression. Coverage is the agent's only feedback signal. 2. Complexity: deeply nested, many-branched methods are where agents (like humans) make mistakes. wCRAP captures both in one number per method. Use it as a CI gate (agents cannot merge new slop) or as a work queue (feed the JSON to an agent: "get every method under 30"). ## The formula wCRAP(m) = (cyc × cog) × (1 − cov/100)³ + sqrt(cyc × cog) - cyc: cyclomatic complexity (McCabe), parsed via the Kotlin compiler's PSI front-end. Counts if, for, while, do, each non-else when branch, catch, the elvis ?:, and each && / ||. - cog: cognitive complexity (SonarSource 2023 spec): penalises nesting, charges a whole when once, ignores early-exit shapes (plain return/break/continue). - cov: line coverage percentage in [0, 100], gathered by slopguard-kotlin itself by driving the project's own Gradle test run with Kover or JaCoCo and parsing the resulting JaCoCo-format XML. Never user-supplied. It cannot be hand-fed or faked; the only way to lower a score without refactoring is to write tests that actually execute the code. - Semantics: cov = 100 → score collapses to sqrt(cyc × cog) (complexity floor). cov = 0 → full quadratic penalty (cyc × cog) + sqrt(cyc × cog). The cubed factor sharply rewards partial coverage: 50% coverage removes 87.5% of the risk term. - Inputs are clamped (complexity ≥ 0, coverage in [0,100]); score is always ≥ 0. - Default "crappy" threshold: 30 (matches the original CRAP paper). - Weighting rationale: a flat 50-branch when (cyc≈50, cog=1) scores like a small method; a deeply nested 3-branch tangle (cyc=3, cog=12) scores like medium-complex code. ## Install Build from source (requires JDK 17+): git clone https://github.com/JeevanThandi/slopguard-kotlin.git cd slopguard-kotlin ./gradlew :app:installDist # the launcher lands at app/build/install/slopguard-kotlin/bin/slopguard-kotlin export PATH="$PWD/app/build/install/slopguard-kotlin/bin:$PATH" Requirements: JDK 17+. One runtime dependency: kotlin-compiler-embeddable (used purely as a PSI parser — the Kotlin analogue of SwiftSyntax / the TypeScript compiler API). JSON and CLI parsing are hand-rolled on the standard library. ## Subcommands | Command | Purpose | |---|---| | analyze | Walk a directory of Kotlin sources, drive `gradle test` + Kover/JaCoCo for coverage, emit a wCRAP report (text or JSON). Default subcommand. | | version | Print version metadata as JSON. | `analyze` is the default and `--path` defaults to the current directory; a bare `slopguard-kotlin` in a module root just works. ## analyze: all flags | Flag | Default | Meaning | |---|---|---| | -p, --path | . | Directory of Kotlin sources, or a single .kt file. | | -t, --threshold | 30 | wCRAP score above which a method/type is flagged isCrappy. | | --coverage-tool | kover | Coverage tool to drive. Both emit the same JaCoCo-format XML, so one parser handles either. | | --gradle-test-task | test | Gradle test task to run, e.g. testDebugUnitTest for an Android variant. | | --gradle-report-task | koverXmlReport | Gradle task that emits the coverage XML (jacocoTestReport under --coverage-tool jacoco). | | --project-dir | nearest Gradle root | Directory the Gradle run executes in. Walked up from --path when omitted. | | --no-coverage | off | Skip the Gradle run; complexity only. Every method reads 0% coverage (worst-case scores). | | --coverage-file | — | A JaCoCo/Kover report XML to join instead of running Gradle. For CI that already produced one. | | --include | — | Glob(s) of files to include. Repeat or pass space-separated. | | --exclude | — | Extra exclude glob(s), combined with built-in defaults (build, .gradle, *Test.kt, src/test, generated code). | | --no-default-excludes | off | Start with an empty exclude list (e.g. to analyze test code itself). | | --json | off | Emit JSON to stdout (default is pretty text). | | --fail-over | — | Exit code 2 if any method's wCRAP exceeds this value. The CI gate. | | -v, --verbose | off | Stream Gradle output and subprocess chatter to stderr. | | --quiet | off | Silence all stderr progress chatter. Wins over --verbose. Stdout unaffected. | ## Exit codes and streams | Code | Meaning | |---|---| | 0 | Analysis completed; nothing exceeded --fail-over (or flag not passed). | | 1 | Analysis error (bad path, test/coverage failure, parse error). With --json a structured error envelope goes to stderr. | | 2 | Quality gate tripped: at least one method's wCRAP exceeds --fail-over. | Stream discipline: the report (text or JSON) goes to stdout; progress markers ("slopguard: running gradle test with coverage…") go to stderr. Piped stdout is always clean. ## How coverage works Coverage is an artifact of the analysis, not an input, mirroring how slopguard-swift drives `xcodebuild test`, slopguard-typescript drives vitest/jest, and slopguard-go drives `go test`. slopguard-kotlin drives the project's own Gradle test run: 1. Project discovery. Walk up from --path to the nearest Gradle root (settings.gradle[.kts], gradlew, or build.gradle[.kts]). Override with --project-dir. 2. Test run. Run the project's own `./gradlew --continue`. --continue means failing tests don't abort — partial coverage is still useful (a note is attached). Pick the coverage tool with --coverage-tool: - Kover (default) — report task koverXmlReport, which emits build/reports/kover/report.xml by default (no xml.required opt-in needed). Just apply org.jetbrains.kotlinx.kover — the Kotlin-first, lowest-config path. - JaCoCo — --coverage-tool jacoco uses report task jacocoTestReport. Apply the jacoco plugin with XML output enabled: tasks.jacocoTestReport { reports { xml.required.set(true) } }. Override either default with --gradle-test-task / --gradle-report-task (e.g. Android variant tasks). 3. Join. Parse the XML report into a per-line index, resolve its package-qualified file names against the analyzed paths (longest-suffix + basename fallback for CI-vs-local path mismatches), and join per-method line coverage onto the parsed declarations. Both JaCoCo and Kover emit the same JaCoCo XML interchange format — Kover does so deliberately for ecosystem compatibility — so one parser handles either, and a report your CI already generated is supported via --coverage-file regardless of which tool produced it. ## What counts as a method Top-level functions, member functions (in classes, objects, interfaces, enums), secondary constructors, init blocks, and custom property accessors (get/set with a body). Lambdas and local functions don't get their own entry; their branches count toward the enclosing function, with a cognitive nesting bump, per the Sonar spec. Abstract / expect functions with no body are skipped. Types are gathered by lexical nesting (Outer.Inner.bar rolls up into the Inner type, not Outer), matching the Swift/TypeScript ports — not the receiver-based rollup that is unique to the Go port. Default excludes: build/, .gradle/, generated code, *Test.kt / *Tests.kt, the src/test and src/androidTest source sets, testFixtures/, and sample apps. Analyze excluded code with --no-default-excludes. ## JSON report schema (schemaVersion "2", shared with the sibling ports) Top level: schemaVersion, generatedAt, coverageAvailable, summary, methods[], types[]. JSON object keys are emitted alphabetically and pretty-printed, so output is diff-stable in CI. summary: - files, types, methods counts + crappy counts - avgCrap, maxCrap, average cyclomatic/cognitive/weighted complexity - weighted coverage across analyzed methods (when gathered) - coverageAvailable: false under --no-coverage or when no coverage XML was found methods[], every analyzed function/method/constructor/init/accessor: - id: STABLE identifier "file#qualifiedName@line". Track methods across runs with this. - qualifiedName: "Type.method(signature)" - file, line, endLine: location relative to analyzed root - kind: function | method | constructor | initializer | getter | setter - typeName: enclosing type name (lexical), null for top-level functions - complexity: cyclomatic (McCabe) - cognitiveComplexity: SonarSource 2023 - weightedComplexity: sqrt(cyc × cog) - coverage: line coverage percent 0–100 - crap: the wCRAP score - isCrappy: true when crap > threshold types[], per-class / interface / object / enum aggregation (rolled up by lexical nesting): - aggregatedCrap: formula applied to the type's totals (total burden, comparable across types) - maxCrap: worst single-method offender ("biggest fire") - isCrappy, weighted coverage, method counts Example (real output from slopguard-kotlin analyzing its own sources, --no-coverage): { "qualifiedName": "ComplexityCalculator.walk", "file": "core/src/main/kotlin/dev/slopguard/core/analysis/ComplexityVisitor.kt", "line": 81, "kind": "method", "typeName": "ComplexityCalculator", "complexity": 19, "cognitiveComplexity": 12, "weightedComplexity": 15.10, "coverage": 0, "crap": 243.10, "isCrappy": true, "id": "core/src/main/kotlin/dev/slopguard/core/analysis/ComplexityVisitor.kt#ComplexityCalculator.walk@81" } ## Quickstart commands # Zero-config: analyze the current module (drives gradle test + Kover for coverage) slopguard-kotlin analyze --path src/main/kotlin # Scan a directory and print the top crappy methods slopguard-kotlin analyze --path src/main/kotlin --threshold 30 # Android with Kover (the default): scope to a variant report task slopguard-kotlin analyze --path app/src/main/kotlin \ --gradle-test-task testDebugUnitTest --gradle-report-task koverXmlReportDebug # Prefer JaCoCo? Opt in with --coverage-tool jacoco slopguard-kotlin analyze --path src/main/kotlin --coverage-tool jacoco # Multi-module: point at the module whose tests should run slopguard-kotlin analyze --path feature/login/src/main/kotlin --project-dir feature/login # Full JSON for CI / downstream tooling slopguard-kotlin analyze --path src/main/kotlin --json | jq '.methods | sort_by(-.crap)[:10]' # Fail CI when any method's CRAP exceeds 50 slopguard-kotlin analyze --path src/main/kotlin --fail-over 50 # Complexity only (skip the test run) slopguard-kotlin analyze --path src/main/kotlin --no-coverage # Join a Kover/JaCoCo report CI already produced slopguard-kotlin analyze --path src/main/kotlin \ --coverage-file build/reports/kover/report.xml ## Agent integration ### CLAUDE.md / AGENTS.md drop-in ## Quality gate: SlopGuard Before declaring any Kotlin task complete, run: slopguard-kotlin analyze --path src/main/kotlin --json --quiet - The report is JSON on stdout; `.methods[]` entries with `isCrappy: true` exceed the wCRAP threshold (30). - If your change ADDED a crappy method, refactor it or cover it with tests until its `crap` score is below 30. Coverage is gathered by the tool itself by driving your Gradle test run (Kover/JaCoCo). Write real tests; the number cannot be hand-fed. - CI runs `--fail-over 50` and will reject the PR at exit code 2. - Full interface reference: https://www.slopguard.dev/llms-full.kotlin.txt ### Recipes # Agent work queue: worst methods first, with stable IDs slopguard-kotlin analyze --json --quiet \ | jq '[.methods[] | select(.isCrappy)] | sort_by(-.crap) | map({id, crap, coverage, file, line})' # Coverage gaps: complex code no test touches slopguard-kotlin analyze --json --quiet \ | jq '.methods[] | select(.complexity >= 5 and .coverage <= 50)' # Regression check around an agent change before=$(slopguard-kotlin analyze --json --quiet | jq '.summary.maxCrap') # … agent edits code … after=$(slopguard-kotlin analyze --json --quiet | jq '.summary.maxCrap') # Fast pre-commit sweep, no test run (use a high fail-over: coverage reads 0%) slopguard-kotlin analyze --no-coverage --quiet --fail-over 200 ### CI gate (GitHub Actions) name: slop-gate on: [pull_request] jobs: slopguard: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: { distribution: temurin, java-version: 17 } - name: Build slopguard-kotlin run: | git clone --depth 1 https://github.com/JeevanThandi/slopguard-kotlin.git /tmp/sg cd /tmp/sg && ./gradlew :app:installDist echo "/tmp/sg/app/build/install/slopguard-kotlin/bin" >> "$GITHUB_PATH" - name: Gate on wCRAP run: slopguard-kotlin analyze --path src/main/kotlin --fail-over 50 Threshold guidance: flag at 30 (classic CRAP paper threshold); hard-fail CI at 50. Under --no-coverage all methods read 0% coverage, so use a much higher fail-over (e.g. 200) for complexity-only sweeps. ## Security / supply-chain posture - One third-party runtime dependency: kotlin-compiler-embeddable, used purely as a PSI parser. JSON and CLI parsing are hand-rolled on the standard library. - The only subprocess slopguard-kotlin spawns is the project's own Gradle test run. - No network, no telemetry, no source mutation. - MIT licensed. - Dogfooded: CI keeps the suite green, and asserts the analyzer stays under its own threshold against its own sources and the sample-apps/ fixtures. ## Library use Everything the CLI does is exported. The pipeline lives in the coverage module, the pure analyzer in core: import dev.slopguard.core.ProgressReporter import dev.slopguard.core.Verbosity import dev.slopguard.core.analysis.AnalysisOptions import dev.slopguard.coverage.AnalysisPipeline import dev.slopguard.coverage.CoverageMode import dev.slopguard.coverage.PipelineArgs val report = AnalysisPipeline.run( PipelineArgs( sourcePath = "src/main/kotlin", analysisOptions = AnalysisOptions(), coverageMode = CoverageMode.AUTO, threshold = 30.0, reporter = ProgressReporter(Verbosity.NORMAL), ), ) For complexity-only analysis with no I/O, parse with KotlinParser and walk with ComplexityVisitor directly.