summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline1
-rwxr-xr-x.githooks/pre-commit94
-rw-r--r--.gitignore1
-rw-r--r--CLAUDE.md114
-rw-r--r--app/build.gradle.kts55
-rw-r--r--app/src/main/res/values/themes.xml6
-rw-r--r--build.gradle.kts9
-rw-r--r--config/detekt/baseline.xml77
-rw-r--r--config/detekt/detekt.yml16
9 files changed, 368 insertions, 5 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
new file mode 100644
index 0000000..fb1088c
--- /dev/null
+++ b/.coverage-baseline
@@ -0,0 +1 @@
+0.00
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..389280a
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# Quality gate -- see CLAUDE.md "Quality gate -- every commit" for what each step is and why.
+# Activate once per clone with: git config core.hooksPath .githooks
+set -euo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+fail() {
+ echo "pre-commit: $1" >&2
+ exit 1
+}
+
+echo "== [1/8] Build =="
+./gradlew :app:compileDebugKotlin -q
+
+echo "== [2/8] Tests =="
+./gradlew :app:testDebugUnitTest -q
+
+echo "== [3/8] Static analysis: lint =="
+if ! ./gradlew :app:lintDebug -q; then
+ fail "lint failed -- fix all lint ERRORS before committing (warnings are fine to accumulate, see CLAUDE.md)."
+fi
+
+echo "== [4/8] Static analysis: detekt (code-quality auditor) =="
+if ! ./gradlew :app:detekt -q; then
+ fail "detekt found new issues beyond the tracked baseline. Fix them, or if they're genuinely pre-existing debt being touched incidentally, regenerate the baseline with './gradlew :app:detektBaseline' and explain why in the commit message."
+fi
+
+echo "== [5/8] Coverage must not decrease =="
+./gradlew :app:jacocoTestReport -q
+REPORT="app/build/reports/jacoco/report.xml"
+BASELINE_FILE=".coverage-baseline"
+if [ -f "$REPORT" ]; then
+ CURRENT=$(python3 - "$REPORT" <<'PY'
+import sys, xml.etree.ElementTree as ET
+root = ET.parse(sys.argv[1]).getroot()
+line = next(c for c in root.findall('counter') if c.get('type') == 'LINE')
+missed, covered = int(line.get('missed')), int(line.get('covered'))
+total = missed + covered
+print(f"{(covered / total * 100) if total else 0.0:.2f}")
+PY
+)
+else
+ CURRENT="0.00"
+fi
+BASELINE=$(cat "$BASELINE_FILE" 2>/dev/null || echo "0.00")
+
+DROPPED=$(python3 -c "print(1 if float('$CURRENT') < float('$BASELINE') else 0)")
+if [ "$DROPPED" = "1" ]; then
+ if [ -n "${COVERAGE_EXCEPTION:-}" ]; then
+ echo "pre-commit: coverage dropped ${BASELINE}% -> ${CURRENT}% -- allowed via COVERAGE_EXCEPTION: ${COVERAGE_EXCEPTION}"
+ echo "$CURRENT" > "$BASELINE_FILE"
+ git add "$BASELINE_FILE"
+ else
+ fail "coverage dropped ${BASELINE}% -> ${CURRENT}%. If this drop is the correct outcome (e.g. deleting dead covered code), commit with COVERAGE_EXCEPTION=\"reason\" git commit ... Otherwise, add tests."
+ fi
+else
+ echo "$CURRENT" > "$BASELINE_FILE"
+ git add "$BASELINE_FILE"
+ echo "pre-commit: coverage ${BASELINE}% -> ${CURRENT}%"
+fi
+
+echo "== [6/8] WebView JavaScript justification =="
+STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM -- '*.kt' || true)
+if [ -n "$STAGED_KT" ]; then
+ for f in $STAGED_KT; do
+ [ -f "$f" ] || continue
+ ADDED_JS_LINES=$(git diff --cached -U0 -- "$f" | grep -E '^\+.*(javaScriptEnabled\s*=\s*true|setJavaScriptEnabled\(true\))' || true)
+ if [ -n "$ADDED_JS_LINES" ]; then
+ if ! grep -q '@SuppressLint("SetJavaScriptEnabled")' "$f"; then
+ fail "$f enables JavaScript on a WebView with no @SuppressLint(\"SetJavaScriptEnabled\") + justification comment nearby (see ReaderExtractor.kt, LinkReaderScreen.kt, BrowserScreen.kt for the pattern). This app's security posture depends on every JS-enabled site being deliberate and explained."
+ fi
+ fi
+ done
+fi
+
+echo "== [7/8] Manifest permission diff =="
+if git diff --cached --name-only | grep -q '^app/src/main/AndroidManifest.xml$'; then
+ if git diff --cached -- app/src/main/AndroidManifest.xml | grep -qE '^\+.*<uses-permission'; then
+ echo "pre-commit: WARNING -- AndroidManifest.xml adds a new <uses-permission>. Make sure the commit message says why."
+ fi
+fi
+
+echo "== [8/8] Secret scan =="
+if [ -z "${SECRET_SCAN_OVERRIDE:-}" ]; then
+ SECRET_HIT=$(git diff --cached -U0 -- . ':!*.lock' | grep -E '^\+' | grep -viE '^\+\+\+' | grep -E '(api[_-]?key|secret|password|token)["'\'' ]*[:=]["'\'' ]*[A-Za-z0-9_\-]{16,}' || true)
+ if [ -n "$SECRET_HIT" ]; then
+ echo "$SECRET_HIT" >&2
+ fail "possible hardcoded secret in the staged diff (see above). If this is a false positive (e.g. an obviously-fake test fixture key), commit with SECRET_SCAN_OVERRIDE=1 git commit ..."
+ fi
+else
+ echo "pre-commit: secret scan skipped (SECRET_SCAN_OVERRIDE set)"
+fi
+
+echo "pre-commit: all checks passed."
diff --git a/.gitignore b/.gitignore
index c8541c4..f2375f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ local.properties
*.iml
.idea/
*.hprof
+.claude/settings.local.json
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..59b2350
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,114 @@
+# FeeDreader — Agent Instructions
+
+Personal Android RSS/Atom reader, `org.terst.feedreader`. Built for Peter's own use, greenfield,
+hands-on-testing-rounds workflow (sideload a debug build, use it, batch feedback). See `BACKLOG.md`
+for open work.
+
+## Test-driven development is mandatory
+
+Every change to production code (`app/src/main/`) follows red-green-refactor, in that literal order:
+
+1. **Write the test first**, for the specific behavior you're about to add or change. Not a test
+ for the whole feature at the end — the test that would fail without the change you're about to
+ make.
+2. **Run it. Confirm it fails, and fails for the right reason** — an assertion mismatch that
+ demonstrates the missing behavior, not a compile error, not a typo, not a test that was
+ accidentally already passing.
+3. Only now write the minimal production code to make it pass.
+4. Run again. Confirm green.
+5. Refactor if needed, keeping it green.
+6. Run the full quality gate (below) before committing.
+
+**If it's discovered that production code was written before, or without, a preceding failing
+test** — mid-session, in review, by the pre-commit hook, however it surfaces — **the fix is to
+discard the whole diff and redo it properly**, test first. Do not backfill tests onto code that
+already exists to make the rule look satisfied after the fact: a test written against working code
+tends to just confirm what the code already does, not drive the design or catch what the code
+missed. `git diff` to see what's uncommitted, stash or note anything worth keeping conceptually,
+then `git checkout -- <files>` (or `git reset` for staged-but-uncommitted work) and start over from
+a failing test. This applies to every agent working in this repo, not just whoever wrote the
+original rule.
+
+**Scoping exception:** pure resource/config/manifest changes with no meaningful unit-testable
+behavior (an XML theme parent, a manifest permission, a Gradle version bump) don't get a red step —
+there's nothing to fail. These are instead covered by the static-analysis gate (lint/detekt) acting
+as the regression check. If you're unsure whether something falls in this bucket, default to
+writing the test.
+
+## Quality gate — every commit
+
+Enforced by `.githooks/pre-commit` (see below; run `git config core.hooksPath .githooks` once per
+clone to activate it — this is a manual one-time step per checkout, not automatic, since hooks
+aren't cloned by default):
+
+1. **Build** — `./gradlew compileDebugKotlin` must succeed.
+2. **Tests** — `./gradlew testDebugUnitTest` must pass in full.
+3. **Static analysis / code-quality audit** — `./gradlew detekt lintDebug` must both come back
+ clean: zero lint errors (warnings are fine to accumulate but should trend down, not up — see
+ "periodic checks"), zero new detekt findings beyond the tracked baseline.
+4. **Coverage must not decrease** — Jacoco line coverage over `app/src/main/java` excluding
+ `ui/**` (Compose screens are impractical to unit-test for coverage and would make the metric
+ meaningless — see `app/build.gradle.kts` Jacoco exclusions) is compared against
+ `.coverage-baseline`. A commit that drops it fails.
+ - **Justifiable exceptions exist** — e.g. deleting dead code that happened to be covered,
+ where the deletion is the point (percentage can dip when the denominator shrinks even though
+ real risk went down). These aren't silently allowed: set
+ `COVERAGE_EXCEPTION="reason for the drop"` when committing, which the hook logs and lets
+ through instead of blocking. Don't reach for this to avoid writing a test you were supposed to
+ write — it's for cases where the drop itself is the correct outcome.
+ - A commit that *raises* coverage automatically updates `.coverage-baseline` as part of the
+ hook.
+5. **WebView JavaScript justification** — any new or changed `javaScriptEnabled = true` /
+ `setJavaScriptEnabled(true)` site must carry a `@SuppressLint("SetJavaScriptEnabled")` plus an
+ inline comment explaining why (matches the existing pattern in `ReaderExtractor.kt`,
+ `LinkReaderScreen.kt`, `BrowserScreen.kt`). This app's whole security posture around untrusted
+ article content rests on JS being off almost everywhere — a new JS-enabled surface is exactly
+ the kind of thing that should never land silently.
+6. **Manifest permission diff** — if `AndroidManifest.xml`'s `<uses-permission>` list changed, the
+ hook prints a warning requiring the commit message to say why. Doesn't block (permissions
+ sometimes need to land before the feature using them), but makes it impossible to miss in review.
+7. **Secret scan** — a lightweight grep for API-key-shaped strings and common credential patterns
+ across the staged diff. Blocks on a hit; override only if you're certain it's a false positive
+ (e.g. a test fixture with an obviously-fake key).
+8. **Schema-migration test requirement** — if `AppDatabase.kt`'s `version = N` changed, the diff
+ must also touch a migration test file (Room's `MigrationTestHelper`, run against a real
+ pre-migration DB). No mechanism currently exists for this (see Task: Room migration tests below)
+ — until it does, this check is a documented rule, not yet hook-enforced.
+
+Never bypass this hook with `--no-verify` without asking first, same as the general git-safety
+rules — if a check is wrong or too strict for a specific case, fix the check or use the documented
+exception mechanism, don't route around it silently.
+
+## Periodic checks (not per-commit — too heavy, or need a human/agent read, not a mechanical gate)
+
+- **Dependency freshness** — `./gradlew lintDebug` already reports outdated dependency versions via
+ its `GradleDependency` check (no extra plugin needed — the `com.github.ben-manes.versions` /
+ `io.github.ben-manes.versions` plugin was tried during setup and reproducibly broke
+ `kspDebugKotlin` in this environment, so it's deliberately not used here). Batch-upgrade
+ periodically from those findings rather than drip-updating one dependency per unrelated commit.
+- **Dead-code / reachability sweep** — the kind of audit that found the orphaned `/tabs/meals`-style
+ routes on doot and the dead DAO methods here: re-run after a significant feature batch, not every
+ commit. Trace every nav route, DAO method, and setting to a real caller.
+- **Room migration correctness** — beyond the per-commit "a test exists" check above, periodically
+ verify the full migration chain (v1→latest) runs clean against a realistic seeded DB, not just the
+ latest single-step migration in isolation.
+- **Detekt/lint baseline review** — baselines exist so new code doesn't inherit old debt as a blocker,
+ but a stale baseline just hides debt forever. Periodically review what's in it and pay some down.
+
+## Why these specific checks
+
+Every check above traces back to something that actually happened in this codebase, not a generic
+checklist:
+- The lint zero-errors gate exists because `themes.xml`'s theme parent silently required API 29
+ against a minSdk of 26 — a real crash risk on real devices that sat unnoticed until an audit found
+ it.
+- The WebView-JS-justification check exists because this app's core security property (JS off for
+ untrusted article content) depends entirely on that staying true at every callsite, and one
+ existing site (`ReaderWebView.kt`) already didn't carry the same justification pattern as the
+ other three.
+- The dead-code sweep exists because three DAO methods (`ArticleDao.delete`,
+ `FilterRuleDao.getAll`, `ArticleDao.markAllReadForFeed`) were superseded by newer methods and left
+ behind, undetected until an audit.
+- The migration-test requirement exists because `AppDatabase` is on its 11th schema version with
+ zero migration tests, several of which are one-off hardcoded data repairs — exactly the kind of
+ change most likely to corrupt a real, non-reproducible production DB if wrong.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 32313ab..03b240e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -4,6 +4,8 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
+ id("io.gitlab.arturbosch.detekt")
+ id("jacoco")
}
android {
@@ -19,6 +21,9 @@ android {
}
buildTypes {
+ debug {
+ enableUnitTestCoverage = true
+ }
release {
isMinifyEnabled = false
}
@@ -70,3 +75,53 @@ dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
testImplementation("androidx.room:room-testing:2.6.1")
}
+
+detekt {
+ buildUponDefaultConfig = true
+ allRules = false
+ config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
+ baseline = file("$rootDir/config/detekt/baseline.xml")
+}
+
+tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
+ reports {
+ xml.required.set(true)
+ html.required.set(true)
+ txt.required.set(false)
+ sarif.required.set(false)
+ }
+}
+
+// JVM unit-test line coverage over testable business logic. Compose screens under ui/ are
+// excluded -- they're impractical to unit-test for coverage and would make the metric
+// meaningless (see CLAUDE.md). Room- and KSP-generated code is excluded too, since coverage
+// there measures generated boilerplate, not anything we wrote.
+tasks.register<JacocoReport>("jacocoTestReport") {
+ dependsOn("testDebugUnitTest")
+ group = "verification"
+ description = "Generates a Jacoco coverage report for the debug unit tests."
+
+ reports {
+ xml.required.set(true)
+ html.required.set(true)
+ }
+
+ val fileFilter = listOf(
+ "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
+ "android/**/*.*",
+ "**/ui/**",
+ "**/*_Impl.class", "**/*_Impl$*.class",
+ "**/di/**"
+ )
+ val kotlinDebugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") {
+ exclude(fileFilter)
+ }
+ val javaDebugTree = fileTree("${layout.buildDirectory.get()}/intermediates/javac/debug/classes") {
+ exclude(fileFilter)
+ }
+ classDirectories.setFrom(files(kotlinDebugTree, javaDebugTree))
+ sourceDirectories.setFrom(files("${project.projectDir}/src/main/java"))
+ executionData.setFrom(fileTree(layout.buildDirectory.get()) {
+ include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", "jacoco/testDebugUnitTest.exec")
+ })
+}
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 9c5f0ff..b1eb502 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <style name="Theme.FeedReader" parent="android:Theme.DeviceDefault.DayNight">
+ <!-- DayNight requires API 29 (minSdk here is 26) and buys nothing anyway: Compose owns all
+ real theming via darkColorScheme() regardless of system day/night state (see
+ MainActivity.kt), so this base window theme only ever needs to supply window chrome
+ (no title/action bar), not day/night-aware colors. -->
+ <style name="Theme.FeedReader" parent="android:Theme.DeviceDefault">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
diff --git a/build.gradle.kts b/build.gradle.kts
index ed1c30b..d79f059 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,8 @@
plugins {
id("com.android.application") version "8.3.2" apply false
- id("org.jetbrains.kotlin.android") version "2.0.0" apply false
- id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" apply false
- id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false
- id("com.google.devtools.ksp") version "2.0.0-1.0.22" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
+ id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false
}
diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml
new file mode 100644
index 0000000..bd02fbe
--- /dev/null
+++ b/config/detekt/baseline.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" ?>
+<SmellBaseline>
+ <ManuallySuppressedIssues/>
+ <CurrentIssues>
+ <ID>ComplexCondition:FeedParser.kt$FeedParser$inEntry &amp;&amp; rel == "enclosure" &amp;&amp; type?.startsWith("image/") == true &amp;&amp; href != null</ID>
+ <ID>CyclomaticComplexMethod:ArticleListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class) @Composable fun ArticleListScreen( feedId: String, onOpenArticle: (String) -&gt; Unit, onBack: () -&gt; Unit, onOpenFeedList: () -&gt; Unit )</ID>
+ <ID>CyclomaticComplexMethod:FeedFetchWorker.kt$FeedFetchWorker$private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase): Int</ID>
+ <ID>CyclomaticComplexMethod:FeedParser.kt$FeedParser$private fun parseAtom(parser: XmlPullParser): ParsedFeed</ID>
+ <ID>CyclomaticComplexMethod:FeedParser.kt$FeedParser$private fun parseRss(parser: XmlPullParser): ParsedFeed</ID>
+ <ID>CyclomaticComplexMethod:LinkReaderScreen.kt$@SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @Composable fun LinkReaderScreen(url: String, onBack: () -&gt; Unit, onOpenLink: (String) -&gt; Unit, onOpenBrowser: (String) -&gt; Unit)</ID>
+ <ID>CyclomaticComplexMethod:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -&gt; Unit, onOpenLink: (String) -&gt; Unit, onOpenBrowser: (String) -&gt; Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -&gt; Unit, onNext: () -&gt; Unit, onNextKeepUnread: () -&gt; Unit )</ID>
+ <ID>LongMethod:AddFeedScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddFeedScreen(onDone: () -&gt; Unit)</ID>
+ <ID>LongMethod:ArticleListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class) @Composable fun ArticleListScreen( feedId: String, onOpenArticle: (String) -&gt; Unit, onBack: () -&gt; Unit, onOpenFeedList: () -&gt; Unit )</ID>
+ <ID>LongMethod:FeedFetchWorker.kt$FeedFetchWorker$private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase): Int</ID>
+ <ID>LongMethod:FeedListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedListScreen( onOpenFeed: (String) -&gt; Unit, onOpenUnread: () -&gt; Unit, onOpenOlder: () -&gt; Unit, onOpenSaved: () -&gt; Unit, onAddFeed: () -&gt; Unit, onSettings: () -&gt; Unit )</ID>
+ <ID>LongMethod:FeedParser.kt$FeedParser$private fun parseAtom(parser: XmlPullParser): ParsedFeed</ID>
+ <ID>LongMethod:FeedParser.kt$FeedParser$private fun parseRss(parser: XmlPullParser): ParsedFeed</ID>
+ <ID>LongMethod:LinkReaderScreen.kt$@SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @Composable fun LinkReaderScreen(url: String, onBack: () -&gt; Unit, onOpenLink: (String) -&gt; Unit, onOpenBrowser: (String) -&gt; Unit)</ID>
+ <ID>LongMethod:MainActivity.kt$@Composable fun FeedReaderApp()</ID>
+ <ID>LongMethod:ReaderExtractor.kt$ReaderExtractor.&lt;no name provided&gt;$override fun onPageFinished(view: WebView, finishedUrl: String)</ID>
+ <ID>LongMethod:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -&gt; Unit, onOpenLink: (String) -&gt; Unit, onOpenBrowser: (String) -&gt; Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -&gt; Unit, onNext: () -&gt; Unit, onNextKeepUnread: () -&gt; Unit )</ID>
+ <ID>LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(onBack: () -&gt; Unit)</ID>
+ <ID>LongParameterList:ArticleListScreen.kt$( article: ArticleListItem, highlighted: Boolean, showFeedName: Boolean, showReadState: Boolean, onClick: () -&gt; Unit, onMarkReadFrom: () -&gt; Unit )</ID>
+ <ID>LongParameterList:Daos.kt$FeedDao$( id: String, title: String, siteUrl: String?, lastFetchedAt: Long, etag: String?, lastModified: String?, lastError: String? )</ID>
+ <ID>LongParameterList:FeedListScreen.kt$( onOpenFeed: (String) -&gt; Unit, onOpenUnread: () -&gt; Unit, onOpenOlder: () -&gt; Unit, onOpenSaved: () -&gt; Unit, onAddFeed: () -&gt; Unit, onSettings: () -&gt; Unit )</ID>
+ <ID>LongParameterList:ReaderScreen.kt$( articleId: String, onBack: () -&gt; Unit, onOpenLink: (String) -&gt; Unit, onOpenBrowser: (String) -&gt; Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -&gt; Unit, onNext: () -&gt; Unit, onNextKeepUnread: () -&gt; Unit )</ID>
+ <ID>LongParameterList:ReaderScreen.kt$( feedId: String, articleId: String, onBack: () -&gt; Unit, onOpenLink: (String) -&gt; Unit, onOpenBrowser: (String) -&gt; Unit, onNavigateToArticle: (String) -&gt; Unit )</ID>
+ <ID>LongParameterList:ReaderWebView.kt$( context: Context, onOpenLink: (String) -&gt; Unit, onLongPressLink: (String) -&gt; Unit = {}, onYouTubeVideo: (String) -&gt; Unit = {}, onScrollChanged: (Int) -&gt; Unit = {}, onPageFinished: (WebView) -&gt; Unit = {} )</ID>
+ <ID>LongParameterList:ReaderWebView.kt$( url: String, onDismiss: () -&gt; Unit, onOpenInBrowser: () -&gt; Unit, onShare: () -&gt; Unit, onSaveToStash: () -&gt; Unit, onSaveForLater: () -&gt; Unit )</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$10</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$11</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$3</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$4</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$5</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$6</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$7</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$8</ID>
+ <ID>MagicNumber:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$9</ID>
+ <ID>MagicNumber:Daos.kt$500</ID>
+ <ID>MagicNumber:FeedFetchWorker.kt$FeedFetchWorker$304</ID>
+ <ID>MagicNumber:ReaderWebView.kt$16f</ID>
+ <ID>MagicNumber:ReaderWebView.kt$9f</ID>
+ <ID>MagicNumber:StashClient.kt$StashClient$200</ID>
+ <ID>MagicNumber:StashClient.kt$StashClient$201</ID>
+ <ID>MagicNumber:StashClient.kt$StashClient$35</ID>
+ <ID>MagicNumber:StashClient.kt$StashClient$401</ID>
+ <ID>MagicNumber:StashClient.kt$StashClient$403</ID>
+ <ID>MagicNumber:StashClient.kt$StashClient$409</ID>
+ <ID>MatchingDeclarationName:ReaderWebView.kt$ReaderScrollPositions</ID>
+ <ID>MaxLineLength:Daos.kt$ArticleDao$"WHERE (articles.isRead = 0 OR articles.readAt &gt;= :sinceFetch) AND (articles.publishedAt IS NULL OR articles.publishedAt &gt;= :cutoff) "</ID>
+ <ID>MaxLineLength:Daos.kt$ArticleDao$"WHERE (articles.isRead = 0 OR articles.readAt &gt;= :sinceFetch) AND articles.publishedAt IS NOT NULL AND articles.publishedAt &lt; :cutoff "</ID>
+ <ID>MaxLineLength:Entities.kt$ArticleEntity$@ColumnInfo(index = true) override val feedId: String?</ID>
+ <ID>MaxLineLength:Entities.kt$ArticleEntity$val imageUrl: String? = null</ID>
+ <ID>MaxLineLength:Entities.kt$ArticleEntity$val readAt: Long? = null</ID>
+ <ID>MaxLineLength:LinkReaderScreen.kt$loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)</ID>
+ <ID>MaxLineLength:LinkReaderScreen.kt$webView.loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)</ID>
+ <ID>MaxLineLength:ReaderScreen.kt$loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null)</ID>
+ <ID>MaxLineLength:ReaderScreen.kt$webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null)</ID>
+ <ID>MaxLineLength:ReaderWebView.kt$"&lt;figure&gt;&lt;a href=\"https://www.youtube.com/watch?v=$id\"&gt;&lt;img src=\"https://i.ytimg.com/vi/$id/hq720.jpg\" alt=\"YouTube video\"&gt;&lt;/a&gt;&lt;figcaption&gt;▶ Watch on YouTube&lt;/figcaption&gt;&lt;/figure&gt;"</ID>
+ <ID>MaxLineLength:SettingsScreen.kt$DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false })</ID>
+ <ID>MaxLineLength:SettingsScreen.kt$DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false })</ID>
+ <ID>NestedBlockDepth:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$override fun migrate(db: SupportSQLiteDatabase)</ID>
+ <ID>NestedBlockDepth:FeedFetchWorker.kt$FeedFetchWorker$private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase): Int</ID>
+ <ID>NestedBlockDepth:FeedParser.kt$FeedParser$private fun parseAtom(parser: XmlPullParser): ParsedFeed</ID>
+ <ID>NestedBlockDepth:FeedParser.kt$FeedParser$private fun parseRss(parser: XmlPullParser): ParsedFeed</ID>
+ <ID>NestedBlockDepth:Opml.kt$Opml$fun parse(input: InputStream): List&lt;OpmlFeed&gt;</ID>
+ <ID>ReturnCount:ReaderWebView.kt$&lt;no name provided&gt;$override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean</ID>
+ <ID>TooGenericExceptionCaught:AddFeedScreen.kt$e: Exception</ID>
+ <ID>TooGenericExceptionCaught:AppDatabase.kt$AppDatabase.Companion$e: Exception</ID>
+ <ID>TooGenericExceptionCaught:AppDatabase.kt$AppDatabase.Companion.&lt;no name provided&gt;$e: Exception</ID>
+ <ID>TooGenericExceptionCaught:FeedFetchWorker.kt$FeedFetchWorker$e: Exception</ID>
+ <ID>TooGenericExceptionCaught:MainActivity.kt$MainActivity$e: Exception</ID>
+ <ID>TooGenericExceptionCaught:MorningFetchReceiver.kt$MorningFetchReceiver$e: Exception</ID>
+ <ID>TooGenericExceptionCaught:MorningFetchReceiver.kt$MorningFetchReceiver.Companion$e: Exception</ID>
+ <ID>TooManyFunctions:Daos.kt$ArticleDao</ID>
+ </CurrentIssues>
+</SmellBaseline>
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
new file mode 100644
index 0000000..442435a
--- /dev/null
+++ b/config/detekt/detekt.yml
@@ -0,0 +1,16 @@
+# Builds on detekt's default ruleset (see app/build.gradle.kts: buildUponDefaultConfig = true).
+# Only overrides/additions relative to the defaults belong here -- keep this file small so
+# upgrading detekt pulls in sane new defaults automatically instead of us silently diverging.
+
+style:
+ MaxLineLength:
+ maxLineLength: 140 # several files carry long, deliberate rationale comments (see
+ # ReaderExtractor.kt) -- 120 (detekt's default) wrapped mid-explanation too aggressively.
+ ForbiddenComment:
+ active: false # this codebase deliberately doesn't use TODO/FIXME markers (see CLAUDE.md
+ # "why these checks") -- nothing to forbid.
+
+naming:
+ FunctionNaming:
+ ignoreAnnotated: ["Composable"] # Jetpack Compose's own naming convention is PascalCase for
+ # composable functions -- that's the correct style here, not a violation.