Files
DarkRoom/docs/dev/code-health.md
dtourolle 84fade99ec Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 21:16:03 +02:00

18 KiB
Raw Permalink Blame History

DarkRoom — Code health and the cost of a contribution

Status: Audit · 2026-08-27 Companion to: architecture.md, technical-debt.md, view-composition.md

What it costs to add something to this codebase, measured rather than estimated, and the work that would lower the price.

technical-debt.md records compromises that were chosen — each one has a reason that outlived the person who took it. This document records the opposite: friction nobody chose, which accumulated because no single commit was responsible for it. The distinction matters when deciding what to touch. A TD entry is load-bearing until its "done when" is met; an entry here is not defending anything.

It is also not a bug list. Everything below compiles, passes 2,042 tests and ships.


1. What was measured

Every figure in this document is reproducible from a clean checkout. Worktrees under .claude/ and build output under target/ are excluded from all counts — including them roughly triples the line totals and was the first thing to get wrong.

# Lines of Rust per crate
for d in core/* ui/* platform/* apps/* tools/*; do
  [ -d "$d/src" ] && echo "$(find $d/src -name '*.rs' -exec cat {} + | wc -l) $d"
done | sort -rn

# unwrap() in production code only — split each file at its #[cfg(test)] marker
# (a naive grep counts ~1,500 and tells you nothing)

# Distinct window properties written per UI module
for f in ui/dr-ui/src/*.rs; do
  echo "$(grep -oP '\b(w|window|win|ui)\.\Kset_[a-z0-9_]+(?=\()' "$f" | sort -u | wc -l) $f"
done | sort -rn
Measure Value
Rust across 19 crates ~112,000 lines; ~78,000 after comments and blanks
Comment density 28% overall, 20–34% per crate
Test functions 2,042, plus 21 integration test files
.unwrap() in production code 3 — one in dr-gpu, two in dr-ingest
.unwrap() in test code ~1,500, which is where it belongs
unsafe blocks 9 — three of them the face scan's SIMD kernels (faces.md §9)
TRACES tags / orphan tags 793 / 0
Resolved dependencies 826
Largest function dr-ui::run — 1,855 lines
AppWindow members 282 properties + 190 callbacks

CI gates on cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, a release build, and an Android cross-check. The traceability matrix is regenerated and compared, with a pre-commit hook that keeps it in step.


2. The seams, graded

Six things a contributor might plausibly want to add. Each grade was checked against the tree.

Feature What you touch Cost
A develop operation
split toning, channel mixer
One file in core/dr-pipeline/ops/. Nothing else. Trivial
A RAW format A Format variant and magic-byte recognition. dr-decode is a generic TIFF walker carrying only 3 format-specific branches. Easy
A neighbourhood operation
dehaze, a sharpener
Rust in dr-pipeline/src/ops/ implementing Operation + DetailStage, plus a stub YAML declaring rust: and order:. Moderate
A parameter widget
a colour wheel
A WidgetKind variant, develop::supported(), the panel model, a Slint component. Moderate
A second sync backend
S3, WebDAV, a local folder
RemoteBackend is the easy half. Seven UI files construct NextcloudBackend directly and ten signatures take it concretely — see CH-2. Hard
Anything with its own UI app.slint's root component, a ~1,000-line wire(), and run() at 1,855 lines — see CH-1. Hard

The top half of that table is the good half, and it is very good. The bottom half is one problem wearing two hats: dr-ui has no seams, so every UI feature lands in the same three files.


3. What is load-bearing, and must not be "tidied"

Listed before the findings deliberately. A remediation document that only enumerates problems invites someone to fix something that was right.

The operation declaration format. ops/*.yaml + build.rs is a working plugin system that happens to resolve at build time — see display-and-extension.md §6. Its deliberate smallness is the point: the expression grammar is restricted so a declaration cannot become a second, worse place to write code. Do not "improve" it by letting a node name arbitrary Rust.

No operation is named in ui/. Verified: all fifteen built-in op ids grepped across every .rs and .slint file in ui/ yield exactly one hit, a localisation test in labels.rs:353. This is what makes "a new operation is one file" true rather than aspirational, and it is the single property most likely to be destroyed by a well-meaning special case in the panel. See CH-5.

Unimplemented widgets degrade rather than break. develop::supported() lists every WidgetKind explicitly instead of using a wildcard, so a new kind added to the core surfaces as a compile error rather than as silence, and a widget nothing draws falls back to sliders with the edit still working (ARCH §4.3a).

The mpsc-plus-timer worker shape. Slint's event loop must never block (NFR-P9). Threading is not what any finding below proposes changing.

sync_rows mutating rows in place. Replacing the model breaks slider dragging.


CH-1 — dr-ui has no view layer, and the cost is compounding

Where: ui/dr-ui/src/lib.rs:836, library_ui.rs:4374, collections_ui.rs:1457, ui/dr-ui/ui/app.slint

What it is

Every UI feature lands in the same three places: the Slint root component, one of the wire() functions, and run().

Function Lines
lib.rs::run 1,855
library_ui::wire 998
collections_ui::wire 964
identity_ui::wire 478
masks_ui::wire 357
settings_ui::wire 317

Above them sits one AppWindow carrying 282 properties and 190 callbacks, with exactly one Slint global in the whole ui/ directory — and it is not exported. Cross-view state therefore has nowhere to live except the root component, and every interaction is a callback registered inside a wire.

The core crates are healthy by the same measure: their largest functions are compose_full at 437 lines and build.rs::emit_node at 558, both of which earn their length. This is specific to dr-ui.

Why it matters more than it did

view-composition.md already diagnosed this and specified the fix, in three independently landable stages, on 2026-08-09. None of the three has landed. In the eighteen days since, the numbers that document itself used have moved:

Its measure 2026-08-09 2026-08-27
run() 500 lines 1,855
library_ui distinct window properties 24 54
lib.rs distinct window properties 23 52
collections_ui 9 11
launch_ui 16 16
set_show_* call sites 7 9
View-state booleans on AppWindow 2 5

Four modules it did not list now write window properties too: settings_ui (39), import_ui (24), identity_ui (18), masks_ui (16).

The prediction it made has also come true literally. It described app.slint compensating for two mutually exclusive booleans with if !root.show-launch && root.show-library chains. There are now five such booleans — show-launch, show-library, show-identity, show-settings, show-import — and the chains at app.slint:1405, :1445 and :1654 are five-term conjunctions. Each new view multiplies the conjunctions rather than adding to them.

None of this is difficult work. It is simply work that every feature must now do, in files every other feature is also editing — which is why two contributors working in parallel conflict by construction, and why a newcomer must read a 1,855-line startup sequence with real ordering constraints before safely inserting a line into it.

What to do

Execute view-composition.md as written. Its analysis holds and its staging is right; stage 1 alone removes the nullable-callback knot and the duplicated post-load sequence, and is worth landing whether or not stages 2 and 3 follow.

One thing to add to it, because it was not in scope there: the wire() functions. They are already sectioned internally by comment, so lifting each section into fn wire_ratings(window, ctl), fn wire_keywords(...) and so on is mechanical, checked entirely by the compiler, and can go one section per commit. It gives a feature a function to own rather than a region of one, which is what removes the conflict, and it does not wait on stage 1.

Do the extraction behind new features rather than as a big-bang refactor. The pile grows either way; the question is only whether each new feature adds to it or subtracts.

Done when: no function in dr-ui exceeds 300 lines, a new view registers itself instead of adding a boolean to AppWindow, and active-view has replaced the boolean set so both-true is unrepresentable.


CH-2 — RemoteBackend is an abstraction nothing above dr-sync uses

Where: core/dr-sync/src/lib.rs:46; seven files in ui/dr-ui/src

What it is

The trait is carefully built. Capability negotiation decides the sync strategy; range reads are documented as a hint rather than a guarantee so correctness holds either way; chunked upload is deliberately kept internal so one server's protocol cannot leak into the interface. Every choice is explained where it is made.

And then trash.rs, import.rs, export.rs, derived_sync.rs, launch_ui.rs, library.rs and settings_ui.rs each construct NextcloudBackend directly — 34 references — and ten functions take &NextcloudBackend rather than &dyn RemoteBackend. Exactly two sites in the tree take the trait object, both inside dr-sync itself.

Why it matters

The abstraction currently buys nothing it was designed for. Worse, it reads as though it does: a contributor who wants a WebDAV or local-folder backend will find a well-documented trait, implement it correctly, and only then discover that nothing above dr-sync can be handed the result.

There is no defence of this in the tree, which is what makes it an entry here rather than in technical-debt.md. It is what a single-backend application looks like when the second backend has not yet been attempted.

What to do

Change the ten signatures to &dyn RemoteBackend and construct the backend once, behind something the UI does not name — the same discipline develop.rs already applies to operations. The trait is already correct, so this is a mechanical change, and it is much cheaper now than during a second backend when it would be entangled with that backend's own problems.

Worth doing even if no second backend is ever written: it makes the sync layer testable against a fake, which today it is not.

Done when: NextcloudBackend is named in at most one file in ui/, and a stub backend can be substituted in a test without touching the UI.

Done, with one half deliberately left. ui/dr-ui/src/remote.rs is now the only file in the interface that names a connector; the ten worker functions take &dyn RemoteBackend and will accept a stub. Every method the UI ever called on a backend — get, put, list, delete, create_dir, move_to — was already on the trait, so nothing had to be added to it.

What remains is credentials. AppCredentials is an app password obtained through Login Flow v2, which is a Nextcloud protocol rather than a general notion of how one authenticates to a remote, and seven files still name it. Abstracting it needs a decision about what an account is across backends — an OAuth token, a bucket key pair and an app password have no useful common shape — and making that decision before a second backend exists would produce a confident wrong answer. It is a design problem rather than a mechanical one, and it should wait for the backend that forces it.


CH-3 — There is no path in for a contributor who is not already here

Where: repository root

What it is

No CONTRIBUTING.md. No rust-toolchain.toml, though CI pins 1.92.0 exactly. No issue or PR templates.

The documentation that exists is excellent — 7,990 lines across 14 files, including 177 numbered requirements — and all of it is written for someone who has already decided to work on this. Nothing tells a newcomer which document to read first, that core/dr-pipeline/ops/README.md is the door with the lowest bar, or that a clone without git-lfs needs one command before the build succeeds.

That last point is handled well in the code: dr-segment's build script detects an LFS pointer file and fails with an instruction rather than embedding 130 bytes and dying at inference time. It is simply not written anywhere a first-time cloner would look.

Why it matters

It is the cheapest item in this document and it gates every other contribution. A person who cannot get a first build is not going to reach the parts that are good.

What to do

Write CONTRIBUTING.md and point the first door at the operation format. "Add a develop operation" is a genuinely one-file contribution with declared tests that run under cargo test — the best first experience this codebase can offer, and it happens to teach the architecture's central idea on the way through.

Then state the three things that are currently folklore: git lfs is a prerequisite, the first build resolves 826 crates and takes a while (saying so stops it reading as a hang), and the toolchain is 1.92.0. Add rust-toolchain.toml so that last one is enforced rather than documented — a contributor on an older stable currently gets confusing type errors instead of a version message.

Done when: someone who has never seen the repository can clone it, build it, and land a new ops/*.yaml node without asking a question.


CH-4 — Coverage is counted by tagging, not by behaviour

Where: docs/traceability.md, tools/traceability

What it is

Not a new finding — display-and-extension.md §7 states it plainly, and traceability.md itself says coverage is the intersection of tagged and defined IDs. The tooling is genuinely good: 732 tags, zero orphans, denominators parsed from requirements.md at run time rather than hardcoded, regenerated in CI and guarded by a pre-commit hook.

What it cannot do is check that the code under a tag does the thing. FR-DEV-8 is currently tagged against instance-buffer plumbing a future spot-removal operation would use; FR-DEV-7 against a history row for a frontend that does not exist. Both read as covered.

Why it matters

The 55.4% figure is an overstatement of unknown size, and the risk is that it is used as a planning input. It is recorded here so that the number keeps its asterisk when read outside the document that already qualified it.

What to do

Nothing structural — the honest framing already exists in two places. Adopt display-and-extension.md's rule going forward: close a requirement with a test that would fail if the behaviour were removed, and let the percentage move slowly and mean something.

Done when: the rule is stated in CONTRIBUTING.md alongside the tag syntax, so it reaches someone adding their first tag.


CH-5 — The best invariant in the codebase is unprotected

Where: ui/dr-ui/src, ui/dr-ui/ui

What it is

"No code in ui/ names an operation" (FR-DEV-3a) is what makes the whole declarative pipeline pay off, and it is currently maintained by discipline alone. Nothing fails if someone special-cases exposure in the panel to fix a layout problem at five in the evening.

Why it matters

It is one grep, it would take an hour, and it protects the property this audit rates highest. The failure mode is silent and cumulative: the first special case is defensible, and by the fifth the panel names half the chain and "a new operation is one file" has quietly stopped being true.

What to do

A test that greps ui/dr-ui/src and ui/dr-ui/ui for every id in ops/*.yaml and fails on a hit, with the current labels.rs localisation test as its one allowed exception. It belongs in CI beside the traceability check, which is the existing precedent for a structural gate.

Done when: adding window.set_exposure_slider(...) to the panel fails CI with a message naming FR-DEV-3a.


4. Order of work

Ordered by value per hour rather than by size.

Item Effort Why first
1 CH-3 — CONTRIBUTING.md + rust-toolchain.toml Half a day Gates everything else; unblocks the trivial seam that already works
2 CH-5 — CI gate on operation names in ui/ An hour Protects the property everything else in the pipeline rests on
3 CH-2 — make RemoteBackend load-bearing 1–2 days Mechanical now, entangled later; also makes sync testable
4 CH-1 — split the wire functions Incremental No behaviour change, compiler-checked, one section per commit
5 CH-1 — view-composition.md stages 1–3 Sustained Largest and most invasive; every deferred month adds to the pile

Items 1, 2 and 3 are independent of each other and of the rest. Item 4 does not wait on item 5.


5. What this document does not claim

It measures structure, discipline and coupling. It does not assess runtime correctness, GPU shader behaviour, security posture, or whether any tagged requirement is actually implemented — CH-4 is precisely the observation that the last of those is unmeasured.

Line counts and function sizes are proxies. run() being 1,855 lines is a real problem because every feature must edit it, not because 1,855 is a bad number; build.rs::emit_node at 558 lines is not a problem at all. Where a figure appears above, the sentence around it says which of the two it is.

The audit was first measured against origin/master at d4a34ef, then re-measured after android-bundled-face-models merged in: run() moved from 1,810 lines to 1,855, and the whole- library face sweep added its fetching path to library.rs. The seam grades are unchanged by that merge — the work went into the seams that already existed rather than cutting new ones, which is itself the pressure CH-1 describes.