Skip to content
Closed
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
52 changes: 29 additions & 23 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,14 @@ reviews:

## Module README (must enforce)

- If the PR changes any production Kotlin under app/, core/<module>/, or feature/<module>/
(paths containing src/main), the matching module README.md must be updated in the same PR
(see docs/MODULE_README_TEMPLATE.md). No exceptions for "internal-only", formatting-adjacent,
or "behavior unchanged" claims when production sources in that module changed.
- Flag missing module README updates whenever production Kotlin in that module changed.
- Ask for a module README update only when the PR changes something the README documents:
module ownership or purpose, public API types other modules use, package structure, Gradle
dependencies, threading/lifecycle, or persisted names and IDs (prefs keys, database and
worker/service names, deep links, ID schemes). Bug fixes, UI tweaks and refactors need none.
- Flag README edits that add change history or dates, plan/PR/decision labels, exact sizes,
colours or timings, lists of individual tests, manual test steps, or server and security
details, or that push a module README past about 2,000 words (see
docs/MODULE_README_TEMPLATE.md). Prefer rewriting an existing line over adding a new one.
- Do not require hand-edits to root CHANGELOG.md or README Upcoming (changelog-on-merge owns those).

## Unit tests (must enforce)
Expand Down Expand Up @@ -108,16 +111,16 @@ reviews:
Feature module rules:
- Must not import other feature packages or PostHog.
- Jetpack Compose: unstable params, heavy work in composition, keyed lazy items.
- If any production Kotlin under feature/<name>/src/main/ changed, require
feature/<name>/README.md updated in the same PR (unconditional).
- Module README updates follow the "Module README" rules above (only when documented
contracts change).
- Require matching JVM src/test coverage for new/changed logic helpers and ViewModel logic.
- path: "core/**/*.kt"
instructions: |
Core module rules:
- Respect module ownership in ARCHITECTURE.md (catalog vs playback vs downloads, etc.).
- DAO/repository/network must not block the main thread.
- If any production Kotlin under core/<name>/src/main/ changed, require
core/<name>/README.md updated in the same PR (unconditional).
- Module README updates follow the "Module README" rules above (only when documented
contracts change).
- Require hermetic JVM tests for new/changed engines, DAOs math, and pure helpers.
- path: "core/network/**"
instructions: |
Expand All @@ -127,18 +130,19 @@ reviews:
instructions: |
App shell: AppContainer is the only composition root. No second graph.
NavHost lifecycle-aware collection.
If any production Kotlin under app/src/main/ changed, require app/README.md updated
in the same PR (unconditional).
Module README updates follow the "Module README" rules above (only when documented
contracts change).
Prefer JVM tests for pure helpers; do not demand androidTest.
- path: "**/src/main/**/*.kt"
instructions: |
For each substantially changed main source, check the PR also adds or extends a related
*Test.kt under src/test for that behavior. Flag gaps explicitly.
- path: "**/README.md"
instructions: |
Module READMEs must match MODULE_README_TEMPLATE sections.
Whenever production Kotlin in the same module changed in the PR, the README must also
change in the PR (unconditional).
Module READMEs must match MODULE_README_TEMPLATE sections and its "What doesn't belong"
list. They describe the module as it is now: flag history, dates, plan or PR labels,
design measurements, test-by-test lists, server or security details, text after
"See also", and growth past about 2,000 words.
Flag READMEs that claim androidTest/Espresso/instrumented suites (removed).

auto_review:
Expand All @@ -160,8 +164,8 @@ reviews:
enabled: true
instructions: |
Do NOT manually edit CHANGELOG.md or README Upcoming Changes on PRs — the changelog-on-merge workflow handles both after merge.
If reviewing a PR, ensure the PR title/body clearly describes user-visible and technical changes for Groq to summarize.
CHANGELOG uses developer/Keep a Changelog tone; README uses listener-friendly plain English.
Developer wording goes in the PR template's changelog region; a listener-facing change edits release-notes/upcoming.md.
CHANGELOG uses developer/Keep a Changelog tone; listener notes use plain listener English.

pre_merge_checks:
title:
Expand All @@ -185,15 +189,17 @@ reviews:
Hilt/Koin/Dagger/MockK; a second PlaybackRepository or AppContainer graph;
catalog depending on designsystem or playback; broken identity/storage contracts
without migration. Request changes with a concrete fix pointing at ARCHITECTURE.md.
- name: Module README updated
- name: Module README kept true
mode: error
instructions: |
Fail whenever the PR changes any production Kotlin under app/src/main/,
core/<module>/src/main/, or feature/<module>/src/main/ unless the matching
module README.md is also modified in the same PR (see docs/MODULE_README_TEMPLATE.md).
This is unconditional — do not exempt "internal-only", "no API change", or
"behavior unchanged" claims. Pure test-only PRs (no src/main production Kotlin
changes) may pass. Do not require CHANGELOG.md edits.
Fail if the PR changes a contract a module README documents (module ownership or
purpose, public API types other modules use, package structure, Gradle dependencies,
threading/lifecycle, or persisted names and IDs such as prefs keys, database and
worker/service names, deep links and ID schemes) without updating that module's
README.md. Do not fail for bug fixes, UI tweaks or refactors that leave those
contracts unchanged. Also fail if a module README edit adds change history or dates,
plan/PR/decision labels, or server and security details
(see docs/MODULE_README_TEMPLATE.md). Do not require CHANGELOG.md edits.
- name: JVM tests for changed logic
mode: error
instructions: |
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ boxlore is a Kotlin Android app with `:app`, `:core:*`, and `:feature:*` modules
- Keep cohesive capabilities in focused modules. Obtain explicit maintainer approval before module extraction or architectural decoupling.
- Preserve application/storage names, legacy worker and service identities, deep links, `rss:`/negative IDs, and cache-key contracts. Keep one UI-scoped `PlaybackRepository` and service-owned Smart Queue refill.
- Every Android UI or behavior change, including shared components and edits initially targeting phones, must handle the affected tablet, foldable, and resizable-window states. Follow the [adaptive UI and state contract](ARCHITECTURE.md#adaptive-ui-and-state) for state retention, window transitions, and required JVM coverage; tablet support is not optional follow-up work.
- Update the affected module README in the same change using [the module template](docs/MODULE_README_TEMPLATE.md).
- Keep the module README true. Update it in the same change only when the module's purpose, public API, structure, dependencies, threading, or stable names and IDs change, and rewrite the affected lines rather than adding new ones ([the module template](docs/MODULE_README_TEMPLATE.md)). Bug fixes, UI tweaks and refactors need no README edit. Never record history, dates, plan or PR labels, design measurements, test-by-test lists, or server and security details.
- Add or extend hermetic JVM `src/test` coverage for changed logic. Bug fixes require a regression test for the failure mode, including shared behavior where applicable. Do not add Compose instrumentation or emulator CI.
- Before changing catalog scripts, read [scripts/README.md](scripts/README.md). Catalog sync runs outside GitHub Actions; pushing code does not deploy it. Production operations require the internal runbook.
- Keep work scoped to this repository. Preserve unrelated edits and avoid destructive cleanup of developer checkouts.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package cx.aswin.boxlore.core.testing.architecture

import java.io.File
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

/**
* Module READMEs describe the module as it is now and stay short (`docs/MODULE_README_TEMPLATE.md`):
* at most [WORD_BUDGET] words, no history markers (dates, plan or decision labels) and nothing but
* list items after "See also".
*
* READMEs written before this check are listed in `module-readme-budget.txt` with their size at the
* time. They may shrink but never grow past those numbers, so a change rewrites a README instead of
* appending to it.
*/
class ModuleReadmeBudgetTest {
private val projectRoot: File =
System
.getProperty("boxlore.projectRoot")
?.let(::File)
?.takeIf { it.isDirectory }
?: File(".").canonicalFile

@Test
fun `module READMEs stay within their budget and hold no history markers`() {
val allowances = allowances()
val problems =
moduleFolders().flatMap { folder ->
val measured = ReadmeMeasure.of(File(projectRoot, "$folder/README.md").readText())
val allowed = allowances[folder] ?: ReadmeMeasure(WORD_BUDGET, 0, 0)
buildList {
val wordLimit = maxOf(allowed.words, WORD_BUDGET)
if (measured.words > wordLimit) {
add("$folder/README.md has ${measured.words} words (limit $wordLimit): rewrite lines instead of adding them")
}
if (measured.historyMarkers > allowed.historyMarkers) {
add("$folder/README.md has ${measured.historyMarkers} dates or plan labels (limit ${allowed.historyMarkers}): history belongs in the PR")
}
if (measured.linesAfterSeeAlso > allowed.linesAfterSeeAlso) {
add("$folder/README.md has ${measured.linesAfterSeeAlso} non-list lines after \"See also\" (limit ${allowed.linesAfterSeeAlso})")
}
}
}
assertTrue(
problems.isEmpty(),
"Module READMEs must follow docs/MODULE_README_TEMPLATE.md:\n" + problems.joinToString("\n"),
)
}

@Test
fun `the allowance list names only real modules`() {
val stale = allowances().keys - moduleFolders().toSet()
assertTrue(stale.isEmpty(), "module-readme-budget.txt lists folders that are not modules: $stale")
}

@Test
fun `measuring counts words, history markers and lines after See also`() {
val text =
"""
# `:core:sample`

Redesigned 2026-10-17, then again on 30 Oct (P4 PR 16, decision D8; see (D9, below)).

## See also

- [ARCHITECTURE.md](../ARCHITECTURE.md)
A stray paragraph.
## Extra notes
""".trimIndent()

val measured = ReadmeMeasure.of(text)

assertEquals(text.split(Regex("""[ \t\n\u000B\f\r]+""")).count { it.isNotEmpty() }, measured.words)
assertEquals(5, measured.historyMarkers)
assertEquals(2, measured.linesAfterSeeAlso)
assertEquals(ReadmeMeasure(3, 0, 0), ReadmeMeasure.of("Plain current description."))
}

private fun moduleFolders(): List<String> {
val settings = File(projectRoot, "settings.gradle.kts").readText()
return Regex("""include\("(:[^"]+)"\)""")
.findAll(settings)
.map { it.groupValues[1] }
.filter { it == ":app" || it.startsWith(":core:") || it.startsWith(":feature:") }
.map { it.removePrefix(":").replace(':', '/') }
.filter { File(projectRoot, "$it/README.md").isFile }
.toList()
}

private fun allowances(): Map<String, ReadmeMeasure> = File(projectRoot, ALLOWANCES)
.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.associate { line ->
val parts = line.split(Regex("""\s+"""))
require(parts.size == 4) { "$ALLOWANCES: expected 'folder words markers lines', got '$line'" }
parts[0] to ReadmeMeasure(parts[1].toInt(), parts[2].toInt(), parts[3].toInt())
}

private data class ReadmeMeasure(
val words: Int,
val historyMarkers: Int,
val linesAfterSeeAlso: Int,
) {
companion object {
private val whitespace = Regex("""[ \t\n\u000B\f\r]+""")
private val markers =
listOf(
Regex("""\b20\d{2}-\d{2}-\d{2}\b"""),
Regex("""\b\d{1,2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\b"""),
Regex("""\bP\d+[a-z]? PR \d+\b"""),
Regex("""\bdecision D\d+\b"""),
Regex("""\(D\d+[,)]"""),
)
private val seeAlso = Regex("""^##\s+See also\s*$""")

fun of(text: String): ReadmeMeasure {
val lines = text.split('\n')
val seeAlsoAt = lines.indexOfFirst { seeAlso.matches(it) }
val after =
if (seeAlsoAt == -1) {
0
} else {
lines.drop(seeAlsoAt + 1).count { line ->
val trimmed = line.trim()
trimmed.isNotEmpty() && !trimmed.startsWith("- ") && !trimmed.startsWith("* ")
}
}
return ReadmeMeasure(
words = text.split(whitespace).count { it.isNotEmpty() },
historyMarkers = markers.sumOf { it.findAll(text).count() },
linesAfterSeeAlso = after,
)
}
}
}

private companion object {
const val WORD_BUDGET = 2_000
const val ALLOWANCES = "core/testing/src/test/resources/module-readme-budget.txt"
}
}
27 changes: 27 additions & 0 deletions core/testing/src/test/resources/module-readme-budget.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Module READMEs written before ModuleReadmeBudgetTest, with their size when it was added.
# A listed README may shrink but never grow past these numbers (or past 2,000 words, if larger).
# Remove a line once its README is rewritten to docs/MODULE_README_TEMPLATE.md; lower it when one shrinks.
# module folder | words | history markers | lines after "See also"
app 11211 12 17
core/analytics 2762 1 0
core/catalog 14252 11 2
core/database 6696 5 2
core/designsystem 7116 5 5
core/domain 807 2 0
core/downloads 1646 2 0
core/model 1759 3 2
core/network 3221 4 2
core/playback 6984 2 0
core/prefs 2738 5 0
core/ranking 839 1 2
core/rss 7276 5 0
core/testing 665 0 1
feature/briefing 1207 0 1
feature/explore 3514 0 4
feature/history 2642 1 0
feature/home 11372 7 5
feature/info 14605 4 1
feature/library 3828 1 3
feature/onboarding 5671 7 1
feature/player 3605 0 2
feature/settings 6988 11 3
17 changes: 17 additions & 0 deletions docs/MODULE_README_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ Copy into `<module>/README.md` when creating or updating a Gradle module.
- A change that creates or moves module ownership must update this README in the same change.
- Root `README.md` / CHANGELOG are not substitutes for module READMEs.
- Cross-cutting maps: [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`docs/TESTING.md`](TESTING.md).
- Describe the module as it is now. Update the README only when something it documents changes
(purpose, public API, structure, dependencies, threading, stable names and IDs), and rewrite the
affected lines instead of adding new ones.
- Keep it short: 500–2,000 words, one idea per bullet, nothing after "See also". Link to the one
place a shared topic is described (episode IDs, adaptive layouts, analytics events) instead of
repeating it. `ModuleReadmeBudgetTest` in `:core:testing` checks the length and the patterns below.

## What doesn't belong

| Instead of… | Put it in… |
| :--- | :--- |
| History: "used to", "redesigned on", dates, versions it changed in | The PR description and git history |
| Plan, phase, PR or decision labels | Nowhere public |
| Exact sizes, colours, timings, copy | The code (KDoc for the reason) |
| One bullet per test | A short table: area → test class |
| Manual acceptance steps | The PR's test plan |
| Server, proxy, anti-abuse or operations detail | Private maintainer notes |

---

Expand Down
1 change: 1 addition & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ Run `node --test .github/scripts/test_announcement_admin.cjs` and the Python sui
| Kover per-module | **≥ 70%** on logic-heavy modules (ratchet as suites land) | module verify |
| Architecture | ARCHITECTURE.md boundaries | scripts + `ArchitectureGuardTest` + dependencyGuard |
| New code | `*ViewModel` / `*Repository` need matching `*Test.kt` (allowlists for stubs / Media3 / hard AndroidViewModels) | `ArchitectureGuardTest` |
| `ModuleReadmeBudgetTest` | Module READMEs stay under 2,000 words with no dates, plan labels or text after "See also"; older READMEs may only shrink (`core/testing/src/test/resources/module-readme-budget.txt`) |

### Current Kover floor

Expand Down
Loading