Skip to content

Latest commit

 

History

History
508 lines (391 loc) · 32 KB

File metadata and controls

508 lines (391 loc) · 32 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

SteleKit is a Kotlin Multiplatform (KMP) migration of Logseq — a Markdown-based outliner/note-taking app. It targets Desktop (JVM), Android, iOS, and Web from a single shared codebase in the kmp/ module.

Bazel Build Commands

Bazel is the canonical build system. Use Bazel for all JVM/Desktop, Android, and Web work. Gradle is kept only for iOS (no Bazel KMP support yet), screenshot tests (Roborazzi), and benchmarks until those are migrated (Epic 7).

Gradle (legacy) Bazel (canonical)
./gradlew run bazel run //kmp:desktop_app
./gradlew jvmTest bazel test //kmp:jvm_tests
./gradlew allTests bazel test //...
./gradlew ciCheck bazel test //... --config=ci
./gradlew installAndroid bazel mobile-install //kmp:android_app --config=android
./gradlew packageDistributionForCurrentOS (Gradle only — see Future Epic D)
./gradlew testDebugUnitTest bazel test //kmp/src/androidUnitTest/kotlin:android_unit_tests --config=android
./gradlew wasmJsBrowserDistribution bazel build //kmp:web_app
# Launch desktop app
bazel run //kmp:desktop_app

# Run all JVM tests (excluding screenshot tests which remain Gradle-only)
bazel test //kmp:jvm_tests

# Run only business-logic tests (no UI, fastest)
bazel test //kmp:business_tests

# Build Android APK (requires ANDROID_HOME to be set)
bazel build //kmp:android_app --config=android

# Build web (WASM/JS) bundle — output: bazel-bin/kmp/web_dist.tar.gz
# Note: delegates to Gradle internally until rules_kotlin#567 lands
bazel build //kmp:web_app

# Run all Bazel tests
bazel test //...

# Cap any build/test invocation so a starved or hung action can't run for
# hours unattended — see "Bazel server orphaning" below for why this matters
# especially inside a Claude Code worktree.
timeout 30m bazel build //kmp:android_app --config=android

# MANDATORY: Re-generate SQLDelight sources whenever any .sq file changes.
# Bazel uses the committed generated sources in kmp/src/generated/sqldelight/ directly
# (it does NOT run codegen at build time). Forgetting this step causes unresolved reference
# errors in Bazel CI even though Gradle builds succeed (Gradle regenerates at build time).
./gradlew :kmp:generateCommonMainSteleDatabase
rsync -a kmp/build/generated/sqldelight/code/SteleDatabase/commonMain/ kmp/src/generated/sqldelight/
./gradlew :kmp:generateCommonMainTelemetryDatabase
rsync -a kmp/build/generated/sqldelight/code/TelemetryDatabase/commonMain/ kmp/src/generated/sqldelight-telemetry/
# Then commit kmp/src/generated/sqldelight/ and kmp/src/generated/sqldelight-telemetry/
# The CI job "SQLDelight generated sources" in ci.yml enforces this automatically.

Bazel server orphaning and runaway builds

Each workspace directory (including every Claude Code agent worktree under .claude/worktrees/) gets its own persistent Bazel server, keyed by a hash of that path under ~/.cache/bazel/_bazel_$USER/. Worktree teardown does not run bazel shutdown first, so when an agent's worktree is removed, its Bazel server doesn't stop — it just keeps running with no client left to hand results to. startup --max_idle_secs=1800 (this repo's .bazelrc) reaps a server that's sitting idle after that, but it does not help a server that's stuck actively running: Bazel has no default wall-clock timeout on a build/compile action (only bazel test has --test_timeout), so a single starved action can run for hours. This happened for real on 2026-09-05: a KotlinCompile action ran 62,206s (~17.3h) inside an abandoned worktree's Bazel server on a machine running many concurrent agents, consuming ~22GB RAM across its worker processes before being found and killed by hand.

Mitigations in place:

  • startup --max_idle_secs=1800 in .bazelrc — reap truly-idle orphans within 30m instead of 3h.
  • Wrap any bazel invocation you expect to run unattended (agent sessions, scripts) in timeout <N> bazel ... so a starved build can't run indefinitely.

If you're about to end an agent session/worktree that ran Bazel, run bazel shutdown from inside it first — it only tears down that workspace's own server, not anyone else's.

To find and clear existing orphans:

# List each running server's output_base and workspace_directory, flagging any
# whose workspace no longer exists on disk (workspace_directory is in the
# server's `cmdline` file, not `server.pid.txt` — that one's just a bare PID).
for d in ~/.cache/bazel/_bazel_$USER/*/; do
  [ -f "$d/server/cmdline" ] || continue
  ws=$(tr '\0' '\n' < "$d/server/cmdline" | grep -oP '(?<=--workspace_directory=).*')
  [ -d "$ws" ] || echo "ORPHAN: $d -> $ws"
done

# Kill a specific orphaned server + its worker processes by output_base hash
pgrep -f '<output_base_hash>' | xargs -r kill -9

Gradle Build & Run Commands

Always use ./gradlew, never the system gradle command. The wrapper pins Gradle 9.5.0; the system install is 9.3.1 and cannot share daemons with wrapper builds — using it silently doubles the daemon count and memory footprint.

# Run desktop app
./gradlew run

# Run all tests
./gradlew allTests

# Run JVM (desktop/shared) tests only
./gradlew jvmTest

# Run a single test class
./gradlew jvmTest --tests "dev.stapler.stelekit.SomeTest"

# Run Android tests
./gradlew testDebugUnitTest

# Build and install on connected Android device
./gradlew installAndroid

# Package desktop distributable
./gradlew packageDistributionForCurrentOS

# Run all CI checks locally (detekt + jvmTest + Android unit tests + assembleDebug)
# Also compiles androidTest/ and WASM test sources to catch platform-specific type errors without a device.
./gradlew ciCheck
# UI/screenshot tests require a display. Use the appropriate wrapper for your environment:
#   Wayland (native display available):   ./gradlew ciCheck                  # display is already set
#   X11 (DISPLAY set):                    ./gradlew ciCheck                  # display is already set
#   Headless Linux / SSH (no display):    xvfb-run --auto-servernum ./gradlew ciCheck
# Automatic detection (try Wayland/X11 first, fall back to xvfb-run):
# [ -n "$WAYLAND_DISPLAY" ] || [ -n "$DISPLAY" ] && ./gradlew ciCheck || xvfb-run --auto-servernum ./gradlew ciCheck
# Run instrumented tests on a connected device/emulator (adb must see the device):
./gradlew ciCheck -PciInstrumentedTests
# README sync is not covered by ciCheck — run separately:
# bash scripts/generate-readme.sh && git diff --exit-code README.md

# Run wasmJs tests in a real headless browser (not just compiled — CI only compiles wasmJs
# test sources today, see ci.yml's "Compile wasmJs test sources" step comment).
./gradlew :kmp:wasmJsBrowserTest
# If this fails with "No provider for framework:mocha" / "Cannot load webpack": Kotlin's
# shared web-tooling installer defaults to Yarn Berry's `pnpm` node linker, which Karma's
# plugin auto-discovery can't see through (isolated node_modules). Fix once per machine:
./scripts/fix-wasm-karma-tooling.sh

# Lint all GitHub Actions workflow files (mirrors the workflow-lint CI job)
# Install once: curl -sSfL https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz | tar -xz -C ~/.local/bin actionlint
actionlint -color

# Security audit all workflow files (uses uvx — no persistent install required)
uvx 'zizmor==1.25.2' .

# Run benchmark locally — mirrors CI, generates flamegraph PNGs (requires async-profiler + librsvg)
./scripts/benchmark-local.sh /path/to/your/graph   # real graph (recommended — most representative)
./scripts/benchmark-local.sh                        # synthetic XLARGE only (7978 pages, matches real graph scale)
# BENCH_CONFIG=SMALL ./scripts/benchmark-local.sh  # quick smoke (200 pages, same as CI)

# Or run the Gradle task directly (flamegraph PNGs require flamegraph.pl + rsvg-convert separately)
./gradlew :kmp:jvmTestProfile -PgraphPath=/path/to/your/graph
# Outputs to kmp/build/reports/:
#   graph-load.jfr              — raw JFR recording (alloc events)
#   graph-load-wall.jfr         — async-profiler wall-clock recording (all thread states)
#   graph-load-alloc.collapsed  — allocation stacks (collapsed, flamegraph-ready)
#   graph-load-cpu.collapsed    — wall-clock stacks filtered to DefaultDispatcher-worker-*
#                                 (Kotlin coroutine pool, Gradle/Kryo noise excluded)
#                                 Falls back to JFR CPU samples if async-profiler not found.
#   flamegraph.html             — interactive allocation flamegraph
#
# Wall-clock mode (macOS): brew install async-profiler
# Wall-clock mode (Linux): place async-profiler-4.4-linux-x64/ in repo root, or set AP_LIB=
# CI uploads flamegraph-alloc.png and flamegraph-cpu.png as individual artifacts
# viewable directly in the browser (no download required).

Module Structure

All shared code lives in kmp/src/:

Source Set Purpose
commonMain Platform-agnostic UI, domain, repository, DB, parser
jvmMain Desktop entry point, file watching, JVM logging
androidMain Android entry point, driver factory
iosMain iOS driver
jsMain Web (enabled via gradle.properties enableJs=true)
jvmTest / commonTest / businessTest Tests

Architecture

SteleKit follows a layered architecture inside kmp/src/commonMain/kotlin/dev/stapler/stelekit/:

UI (Compose)       → ui/ (App.kt, screens/, components/)
ViewModel          → ui/StelekitViewModel.kt, ui/LogseqViewModel.kt
Repository         → repository/ (Page, Block, Search, Journal)
Database/Files     → db/ (GraphManager, GraphLoader, GraphWriter)
Domain Models      → model/
Parser             → parser/ + outliner/
Platform abstracts → platform/

Key Data Flow

  1. Startup: StelekitAppGraphManager.addGraph(path) creates per-graph RepositorySet (PageRepository, BlockRepository, SearchRepository)
  2. Page load: StelekitViewModel.navigateTo()GraphLoader reads markdown → OutlinerPipeline builds block tree → saved to repositories
  3. Editing: BlockEditorBlockStateManager (local state) → debounced 500ms → GraphWriter.saveBlock() writes to disk
  4. External changes: GraphLoader.externalFileChanges (SharedFlow) detects disk writes → emits DiskConflict → user resolves in UI

Multi-Graph Support

GraphManager maintains multiple isolated graphs simultaneously. Each graph has its own RepositorySet and CoroutineScope. Repository backends: IN_MEMORY (tests), SQLDELIGHT (production).

State Management

  • StelekitViewModel: central StateFlow-based state (navigation, open page, search)
  • AppState: global UI flags (sidebar, search dialog, command palette)
  • BlockStateManager: isolated block editing state per block

Error handling — Arrow Either

All repository and service methods use Arrow's Either<DomainError, T> for error-returning operations. Do not use Result<T>, nullable returns, or thrown exceptions for domain errors at repository boundaries.

// Return success
return Unit.right()
return page.right()

// Return failure
return DomainError.DatabaseError.WriteFailed(e.message ?: "unknown").left()

// Consume at call site
result.onLeft { e -> logger.error("failed: ${e.message}") }
result.getOrNull()           // null on failure
result.fold({ err -> … }, { value -> … })

Rules:

  • Repository interfaces return Either<DomainError, T> for suspend fun and Flow<Either<DomainError, T>> for reactive queries.
  • Never let SQLite exceptions propagate raw — wrap in DomainError.DatabaseError.WriteFailed.
  • getOrNull() is fine for internal callers that treat failure as absence. Use .fold or .onLeft when the error needs to be surfaced.
  • Arrow is already on the classpath via commonMain; import arrow.core.Either, arrow.core.left, arrow.core.right.

Database

SQLDelight 2.3.2 generates type-safe Kotlin from .sq files in kmp/src/commonMain/sqldelight/. Schema in SteleDatabase.sq.

Adding a new table — mandatory migration rule

Every CREATE TABLE IF NOT EXISTS <name> added to SteleDatabase.sq must also appear in MigrationRunner.all (db/MigrationRunner.kt).

Why: DriverFactory.createDriver() calls SteleDatabase.Schema.create(driver) first, but on any existing database that call fails immediately (its first CREATE TABLE pages has no IF NOT EXISTS), the exception is swallowed, and all subsequent DDL is silently skipped. MigrationRunner.applyAll() is the only mechanism that creates new tables on existing user databases.

MigrationRunnerSchemaSyncTest (businessTest) enforces this automatically — it reads SteleDatabase.sq, extracts all IF NOT EXISTS table names, and asserts each appears in MigrationRunner.all. It will fail at CI time if you forget.

Write enforcement — @DirectSqlWrite

Never call mutating methods (insert*, update*, delete*, upsert*, transaction) directly on SteleDatabaseQueries. All writes are gated behind @DirectSqlWrite on RestrictedDatabaseQueries (db/RestrictedDatabaseQueries.kt).

Rules:

  • Route writes through DatabaseWriteActor (preferred) — use actor.execute { ... } or the typed methods (saveBlock, savePage, etc.).
  • If a helper class needs to write inside an actor lambda, inject RestrictedDatabaseQueries and annotate the private write function @OptIn(DirectSqlWrite::class).
  • Migration-time writers (MigrationRunner, UuidMigration) run before the actor exists and may carry @OptIn(DirectSqlWrite::class) at class level — this is the only approved class-level opt-in outside the actor.
  • When you add a new query to SteleDatabase.sq that performs INSERT/UPDATE/DELETE/UPSERT, add a corresponding forwarding stub to RestrictedDatabaseQueries annotated @DirectSqlWrite.

Coroutine dispatcher and database connection pool

The database layer uses PlatformDispatcher.DB for all SQL work and DatabaseWriteActor to serialize writes. Follow these rules when adding or modifying repository code.

Dispatcher matrix

Context Dispatcher Mechanism
Read Flow (SQLDelight generated) PlatformDispatcher.DB mapToList(DB) / mapToOneOrNull(DB)
Read flow { } block with raw SQL PlatformDispatcher.DB .flowOn(DB) at end of chain
Write suspend fun PlatformDispatcher.DB withContext(DB) { ... }
DatabaseWriteActor internal scope PlatformDispatcher.Default Owns its own CoroutineScope
Non-database IO (files, network) PlatformDispatcher.IO Direct or withContext(IO)

Read pattern — use in every SqlDelight*Repository

Prefer asDbFlowList / asDbFlowOrNull (defined in repository/DbFlowExtensions.kt). These combine asFlow() + mapToList/mapToOneOrNull + map + catchDbError() into one call, so the closed-DB guard is structurally impossible to forget:

// ✓ Preferred — list query, guard built in
override fun getPages(limit: Int, offset: Int): Flow<Either<DomainError, List<Page>>> =
    queries.selectAllPagesPaginated(limit.toLong(), offset.toLong())
        .asDbFlowList(PlatformDispatcher.DB) { it.toModel() }

// ✓ Preferred — single-or-null query, guard built in
override fun getPageByUuid(uuid: PageUuid): Flow<Either<DomainError, Page?>> =
    queries.selectPageByUuid(uuid.value)
        .asDbFlowOrNull(PlatformDispatcher.DB) { it.toModel() }

// ✓ Manual chain required when mid-chain operators (e.g. conflate) are needed
override fun getAllPages(): Flow<Either<DomainError, List<Page>>> =
    queries.selectAllPages()
        .asFlow()
        .conflate()                                 // ← prevents O(N²) scans on bulk import
        .mapToList(PlatformDispatcher.DB)
        .map { list -> list.map { it.toModel() }.right() }
        .catchDbError()                             // ← must always terminate manual chains

// ✓ Custom flow with raw SQL calls (property parsing, hierarchy traversal, etc.)
override fun getBlockHierarchy(rootUuid: BlockUuid): Flow<Either<DomainError, List<BlockWithDepth>>> = flow {
    try {
        // Synchronous SQL here — flowOn switches the whole upstream to DB dispatcher
        emit(queries.selectBlockByUuid(rootUuid.value).executeAsOneOrNull()
            ?.let { buildHierarchy(it) }.right())
    } catch (e: CancellationException) { throw e }
    catch (e: Exception) { emit(DomainError.DatabaseError.ReadFailed(e.message ?: "unknown").left()) }
}.flowOn(PlatformDispatcher.DB)                     // ← always at the end of the chain

Write pattern — use in every SqlDelight*Repository

override suspend fun savePage(page: Page): Either<DomainError, Unit> = withContext(PlatformDispatcher.DB) {
    try {
        queries.transaction { queries.insertPage(...); queries.updatePage(...) }
        Unit.right()
    } catch (e: CancellationException) { throw e }
    catch (e: Exception) {
        DomainError.DatabaseError.WriteFailed(e.message ?: "unknown").left()
    }
}

All write calls reach the repository through DatabaseWriteActor, which serializes them to a single coroutine. The withContext(PlatformDispatcher.DB) inside the repository ensures the write always executes on a pooled DB connection thread, regardless of which thread the actor is currently running on.

Platform mapping for PlatformDispatcher.DB

Platform Value Rationale
JVM Dispatchers.IO PooledJdbcSqliteDriver pre-creates 8 connections at startup — unlimited read concurrency; pool bounds total connection count
Android Dispatchers.IO Android SQLite driver manages its own native connection pool
iOS Dispatchers.Default Native driver, GCD handles threading
WASM/JS Dispatchers.Default Single-threaded runtime

Never do

// ✗ Hardcode Dispatchers.IO — wrong on iOS/WASM and bypasses pool abstraction
.mapToList(Dispatchers.IO)

// ✗ Use PlatformDispatcher.DB for non-database IO
withContext(PlatformDispatcher.DB) { File("...").readText() }

// ✗ Use PlatformDispatcher.IO for database reads — bypasses pool abstraction on JVM
.mapToList(PlatformDispatcher.IO)

// ✗ Write SQL without withContext(DB) — inconsistent thread safety across platforms
override suspend fun addReference(...): Result<Unit> {
    queries.insertBlockReference(...)  // missing withContext
}

// ✗ Pass rememberCoroutineScope() into any class that touches the DB
val scope = rememberCoroutineScope()
val actor = remember { DatabaseWriteActor(repo, scope) }  // scope cancelled on recomposition

Coroutine scope ownership — rememberCoroutineScope must not escape composition

Never pass a rememberCoroutineScope() result to a class that outlives the composable. Compose cancels rememberCoroutineScope scopes when the composable leaves the composition; any object still holding that scope will throw ForgottenCoroutineScopeException on its next launch.

Rules:

  • Any class instantiated inside remember { } must own its CoroutineScope internally: private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default).
  • rememberCoroutineScope() is for transient UI work only — event handlers, one-shot animations, button callbacks — never for objects stored in remember.
  • Long-lived classes expose results as StateFlow/Flow; composables collect them with collectAsState(). They do not accept a caller-supplied scope.
  • To audit: grep for remember.*scope or scope.*remember and confirm no rememberCoroutineScope() value is flowing into a constructor stored by remember { }.

Violation pattern (forbidden):

val scope = rememberCoroutineScope()
val manager = remember { SomeManager(scope) }  // scope will be cancelled on recomposition

Correct pattern:

// SomeManager.kt
class SomeManager(...) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
// Composable
val manager = remember { SomeManager() }

Uncaught coroutine Throwables kill the process on Android — guard long-lived scopes

An uncaught Throwable (notably OutOfMemoryError) escaping any coroutine reaches the platform default uncaught-exception handler. On Android that handler kills the process ("app keeps stopping"); on desktop JVM it only prints — so this class of crash never reproduces on desktop. Under heap pressure the OOM is thrown in whichever coroutine allocates next, not necessarily the one doing the heavy work, so per-call-site catch(Throwable) is not sufficient.

Rules:

  • Every long-lived CoroutineScope that hosts user-path collectors or fire-and-forget launches must attach a CoroutineExceptionHandler (see StelekitViewModel.scope, GraphLoader.parallelScope). Surface errors as fatalError UI state where possible.
  • Standing collect { } bodies and stateIn upstream chains on such scopes are the unguarded vectors — a repository flow's catchDbError() does not protect them.
  • Regression tests: StelekitViewModelCrashReproductionTest, PageNameIndexResilienceTest, LargeGraphWarmStartCrashTest (8 030-page warm start with a recording default uncaught-exception handler).

Graph-scale reads must be paginated, projected, or chunked — never O(graph)

Every DB write invalidates SQLDelight queries on the written table, so a standing collector of an unbounded query re-materializes its entire result set per write burst. During graph import/reconcile on an 8 000+ page graph this causes GC thrash (UI hang) and OutOfMemoryError (crash) on Android. PageRepository therefore has no getAllPages() / unbounded getUnloadedPages() at all — the absence is compile-time enforced. Do not add unbounded reads back to any repository interface.

Patterns, by consumer type:

  • Standing UI observers (sidebar, etc.): bounded queries only — getFavoritePages() (WHERE is_favorite = 1), getPages(limit, offset), getPageByUuid point lookups.
  • Standing whole-graph observers (e.g. PageNameIndex): use a projection (getPageNameEntries() — name + is_journal only), plus conflate() + distinctUntilChanged() + debounce as backpressure, plus Throwable guards.
  • Bulk reconcile (GraphLoader.loadDirectory): per-chunk IN-clause lookups — getPagesByNames(chunk) / getJournalPagesByDates(chunk) — never a full-table preload. IN lists chunked ≤500 (SQLITE_MAX_VARIABLE_NUMBER = 999 on Android API < 30).
  • Background indexing (GraphLoader.indexRemainingPages): drain loop over getUnloadedPages(limit, offset) (INDEX_BATCH_SIZE = 100); offset advances past permanently-failing rows via an attempted-UUID set so the loop is guaranteed to terminate; countUnloadedPages() provides the O(1) progress denominator.
  • Whole-graph one-shots (export, migration tooling, benchmarks, tests): getAllPagesSnapshot() — a suspend interface method that pages through getPages(limit, offset) in bounded batches (never a single unbounded query, never a reactive flow).
  • Do not pin full-table snapshots in fields (the former cachedAllPages pattern is forbidden).

Regression tests: LargeGraphWarmStartCrashTest (asserts ≤100-row batches across a full 8 030-page warm start), GraphLoaderIndexBatchingTest (bounded drain + termination with permanently-failing pages), QueryPlanAuditTest (audits query plans for the bounded query set).

Android Application.onCreate — catch Throwable, not Exception

Application.onCreate() must use catch (e: Throwable), not catch (e: Exception). Native library loading failures (UnsatisfiedLinkError, NoClassDefFoundError) are Error subclasses, not Exception. Catching only Exception lets them propagate uncaught and crash the app at startup before the UI is shown. See SteleKitApplication.kt.

Repository Flow resilience — use asDbFlowList / asDbFlowOrNull, never raw asFlow()

When GraphManager.shutdown() or switchGraph() closes the database, any Compose LaunchedEffect still collecting a repository Flow will hit IllegalStateException on the closed driver and crash the main thread.

The guard: catchDbError() converts this to Either.Left(DomainError.ReadFailed) so the UI degrades gracefully instead of crashing.

The enforcement: catchDbError() is defined once in repository/DbFlowExtensions.kt (not copy-pasted per file). The preferred way to apply it is through the typed helpers that build it in automatically:

// ✓ Guard is structural — you cannot get asDbFlowList without catchDbError
queries.selectAllPages().asDbFlowList(PlatformDispatcher.DB) { it.toModel() }

// ✓ For manual chains that need mid-chain operators — guard must be explicit
queries.selectAllPages().asFlow().conflate().mapToList(DB).map { ... }.catchDbError()

// ✗ Raw asFlow() chain without catchDbError — will crash on DB close
queries.selectAllPages().asFlow().mapToList(DB).map { ... }

flow { try/catch } blocks that call executeAsList() / executeAsOneOrNull() directly already handle the exception inline — they do not need catchDbError().

Regression tests: UpgradeResilienceTest (TC-UPGRADE-001 exercises every Flow-backed read across all repositories against a closed DB — any future method missing the guard will fail here), RepositoryFlowResilienceTest, GraphManagerDatabaseLifecycleTest.

GitHub Actions — workflow_call propagates caller's event_name

When a workflow is called via workflow_call, github.event_name inside the called workflow reflects the caller's triggering event (e.g. push), not workflow_call. A job if: condition checking github.event_name == 'workflow_call' will always be false when called from a push-triggered parent. Remove the job-level if: entirely and rely on the workflow-level on: triggers instead.

Testing Infrastructure

See kmp/TESTING_README.md for the exploratory/performance testing guide (jank detection, profiling, SLO alerts). Test source sets:

  • commonTest — shared utilities, and the default home for any test that only touches commonMain code (pure functions, domain models, parsers) — see kotest guidance below
  • businessTest — business logic without UI (depends on commonTest)
  • jvmTest — JVM UI + integration tests (uses Roborazzi for screenshot tests; also runs everything in businessTest)
  • androidUnitTest — Android local unit tests (Robolectric)
  • iosTest — iOS-target tests
  • wasmJsTest — Web (WASM/JS) tests, only compiled when -PenableJs=true

Testing best practices

  • Test pure logic in commonMain/commonTest, not per-platform. If a function doesn't touch a platform API, it belongs in commonMain with its test in commonTest — one test run covers JVM, Android, iOS, and wasmJs simultaneously instead of four copies drifting apart. HostReconciliation.kt / HostReconciliationTest.kt is the reference example.
  • Prefer property-based tests over enumerating examples for pure functions with a large or structured input space (parsers, classifiers, encoders, anything with an equality/symmetry invariant). kotest-property is on the classpath in commonTest — use Arb/checkAll (wrapped in runTest { } from kotlinx-coroutines-test) to assert invariants across many generated inputs rather than a fixed example table. Keep a handful of example-based @Tests alongside for the obvious/named cases — property tests are for edge cases you wouldn't think to enumerate, not a replacement for readable baseline coverage.
  • kotest-assertions-core and kotest-property are plain KMP libraries, not the Kotest Spec runner. They're used from ordinary kotlin.test-annotated @Test functions (no StringSpec/FunSpec, no Kotest Gradle plugin, no KSP) — this project deliberately did not adopt the Kotest test framework/runner because its wasmJs support is feature-limited (annotation-based config doesn't work there) and JUnit5 (kotlin.test) already covers every target this project builds for.
  • Root-cause failing tests before loosening assertions. A flaky or failing test is a signal, not an obstacle — see the "No fix without root cause" rule; don't add tolerances, retries, or @Ignore to make a red test green without first stating why it's red.
  • Regression tests for structural invariants (e.g. the SQLDelight/MigrationRunner sync check, the @DirectSqlWrite write-gating enforcement, the bounded-read audits) belong in businessTest or jvmTest next to the mechanism they guard — see the existing examples referenced throughout this file's architecture sections above.

Release Process

Releases are managed by Release Please (.github/workflows/release.yml), driven by Conventional Commits on main. There is no manual version bump — version.txt and CHANGELOG.md are only ever edited by the bot.

  1. Every push to main runs the release-please job, which opens or updates a single standing PR titled chore(main): release X.Y.Z (find it with gh pr list --search "head:release-please"). It aggregates every fix:/feat: commit since the last release into CHANGELOG.md, bumps version.txt, and computes the next semver bump from the commit types (fix: → patch, feat: → minor, !/BREAKING CHANGE: → major).
  2. This PR is docs/config-only (version.txt, CHANGELOG.md, .release-please-manifest.json) — it never contains source changes, so it does not need the adversarial code-review gate; the source changes it summarizes were already reviewed in their own commits/PRs.
  3. Merging that PR is what cuts the release. On merge, release-please sets release_created=true and the same workflow run builds and publishes: Android release APK, Desktop (Linux/Windows/macOS) distributables, a GitHub Release tagged vX.Y.Z, the Homebrew formula, and the F-Droid index.
  4. The website redeploys independently of releases. .github/workflows/pages.yml triggers on every push to main (not just release merges) and rebuilds/deploys the wasmJs web app via ./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true — it does not pass -PappVersion, so the web build's version string always falls back to whatever is currently committed in version.txt. This means a plain push to main (before any release PR is merged) already ships the latest web app under the previous version number.
  5. To force an immediate release without waiting for a release-please PR merge, use workflow_dispatch on release.yml with an explicit version input (e.g. v1.2.3) — this skips Release Please and builds/publishes immediately: gh workflow run release.yml -f version=v1.2.3.
  6. App version at runtime is resolved by the shared resolveAppVersion() function in kmp/build.gradle.kts: explicit -PappVersion (used by CI release builds, sourced from the release tag) → committed version.txt (local/dev builds and the web deploy) → "dev" fallback. JVM/Desktop reads it via -Dapp.version system property (DeviceInfo.jvm.kt); wasmJs has no runtime system-property equivalent, so it's baked in at compile time by the generateWasmVersionInfo Gradle task into a generated WASM_APP_VERSION constant consumed by DeviceInfo.js.kt.

Key Files

File Role
kmp/build.gradle.kts All dependencies, targets, SQLDelight config
kmp/src/commonMain/.../ui/App.kt Root Compose composable, screen routing
kmp/src/commonMain/.../ui/AppState.kt Global app state model
kmp/src/commonMain/.../db/GraphManager.kt Multi-graph lifecycle
kmp/src/commonMain/.../db/GraphLoader.kt File import and markdown parsing
kmp/src/commonMain/.../db/GraphWriter.kt File export and conflict detection
kmp/src/commonMain/.../model/Models.kt Page, Block, Property data classes with built-in validation
kmp/src/commonMain/.../repository/RepositoryFactory.kt Backend abstraction
kmp/src/commonMain/.../repository/DbFlowExtensions.kt catchDbError, asDbFlowList, asDbFlowOrNull — shared closed-DB guard helpers
kmp/src/jvmMain/.../desktop/Main.kt Desktop entry point
kmp/src/commonMain/sqldelight/.../SteleDatabase.sq SQLDelight schema