Add requirements traceability gate and Gitea pipelines

Ports JellyTau's traceability tooling to Rust, carrying across the bug it
was repaired for. That gate divided a traced count by frozen literal
denominators; the requirements file outgrew them and it reported 158%
coverage, so it could never fail its own threshold.

Two rules, both enforced by the extractor's own tests:

  - denominators parsed from docs/requirements.md at run time
  - coverage is |traced ∩ defined| / |defined|, never a raw traced count

The gate additionally fails hard on a misconfigured run — zero
requirements parsed or zero files scanned — rather than reporting a
plausible 0%, and on any orphan tag naming a requirement that does not
exist.

Adapted for DarkRoom: IDs are FR-CAT-1 / NFR-P13 / FR-DEV-3a shapes
rather than JellyTau's fixed three digits, and decisions (D), spikes (S),
milestone items (M) and test ids remain taggable while being excluded
from the denominator — counting them inflated it by 25.

Also adds dr-sync: the RemoteBackend trait and capability model, so the
Nextcloud connector is one implementation rather than the only shape the
engine understands. No mature Nextcloud crate exists (reqwest_dav is too
thin), so the connector will be hand-rolled over reqwest per D7.

Gitea workflows follow the same style: containerised, commented with the
reasoning, desktop and Android on every push, plus a CI check that no
core/ crate depends on the UI toolkit (ARCH §6.5a).

Coverage today: 13.3% (19/143). 50 tests passing.
This commit is contained in:
2026-08-09 08:01:32 +02:00
parent 82a5e21ec6
commit 0f202fd3f9
19 changed files with 1913 additions and 22 deletions
+116
View File
@@ -0,0 +1,116 @@
name: Build and test
# Desktop and Android are built on every push, per the v0.1 decision to carry
# both platforms from the first commit. An Android break is then caught the day
# it lands rather than at a porting milestone.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
desktop:
runs-on: linux/amd64
name: Desktop (Linux)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: desktop-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Slint and winit need these at build time; the runner image is minimal.
- name: Build dependencies
run: |
apt-get update -qq
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# GPU tests skip themselves where no adapter is present rather than
# failing — CI runners generally have none, and a test that cannot run is
# not evidence either way.
- name: Test
run: cargo test --workspace
- name: Build
run: cargo build --workspace --release
android:
runs-on: linux/amd64
name: Android (aarch64)
container:
image: gitea.tourolle.paris/dtourolle/darkroom-android:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
/opt/cargo/registry
target-android
key: android-${{ hashFiles('**/Cargo.lock') }}
# Only the core crates cross-compile today; the UI and app crates join
# once the Android shell exists (milestone v0.1, FR-PLAT-AND-*).
- name: Cross-compile core
env:
CARGO_TARGET_DIR: target-android
run: cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android
# The linker targets MIN_API, not the compile SDK. cargo-ndk otherwise
# defaults to API 21, far below the Vulkan floor this app needs — and the
# mismatch is invisible until a device refuses to install.
- name: Verify minimum API level
env:
CARGO_TARGET_DIR: target-android
run: |
set -e
cargo ndk -t arm64-v8a build -p dr-gpu --release
SO=$(find target-android -name 'libdr_gpu*' -o -name '*.so' | head -1)
if [ -n "$SO" ]; then
echo "checking $SO"
readelf -p .comment "$SO" 2>/dev/null | head -5 || true
fi
layering:
runs-on: linux/amd64
name: Layer separation
steps:
- name: Checkout
uses: actions/checkout@v4
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
# `use slint::` costs headless golden-image testing and the
# one-operation-two-presentations property together, and nothing else
# would notice.
- name: Core crates must not depend on the UI
run: |
set -e
FAILED=0
for crate in dr-types dr-gpu dr-sync; do
if cargo tree -p "$crate" -e normal 2>/dev/null | grep -qE '\bslint\b|\bi-slint'; then
echo "FAIL: $crate depends on Slint (ARCH §6.5a)"
FAILED=1
else
echo "ok: $crate"
fi
done
exit $FAILED
+99
View File
@@ -0,0 +1,99 @@
name: Traceability
# Mirrors JellyTau's traceability gate, including the reason it exists.
#
# That gate divided a traced count by frozen literal denominators while the
# requirements file grew past them, reported 158% coverage, and so could never
# fail its own threshold. Two rules follow, and the extractor's own tests
# enforce both:
#
# 1. Denominators are parsed from docs/requirements.md at run time.
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
#
# This job is static analysis of source comments plus markdown parsing, so it
# needs no GPU and no Android SDK — only the Rust toolchain.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
traceability:
runs-on: linux/amd64
name: Requirement traces
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# The gate's own arithmetic is the thing being trusted, so its tests run
# before it does. Untested gate logic is exactly how JellyTau's 158% went
# unnoticed for months.
- name: Test the extractor
run: cargo test -p traceability
# Structural failures are unconditional and do not depend on the coverage
# threshold: zero requirements parsed, zero files scanned, a ratio above
# 100%, or any orphan tag all fail the build. A misconfigured run must not
# report a plausible-looking 0%.
- name: Traceability gate
run: cargo run -q -p traceability -- check
- name: Regenerate matrix and check it is committed
run: |
set -e
cargo run -q -p traceability -- report
if ! git diff --quiet docs/traceability.md; then
echo ""
echo "docs/traceability.md is out of date."
echo "Run: cargo run -p traceability -- report"
git diff --stat docs/traceability.md
exit 1
fi
# Advisory, not blocking: not every file implements a requirement, and a
# tag on every function is noise that rots faster than it helps. Tag the
# unit that decides.
- name: Check changed files for tags
if: github.event_name == 'pull_request'
run: |
set -e
CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" \
| grep -E '\.(rs|slint|wgsl)$' || true)
[ -z "$CHANGED" ] && { echo "No source files changed."; exit 0; }
MISSING=0
for file in $CHANGED; do
case "$file" in
*/tests/*|*/test_*|tools/*) continue ;;
esac
[ -f "$file" ] || continue
if ! grep -q 'TRACES:' "$file"; then
echo " no TRACES tag: $file"
MISSING=$((MISSING + 1))
fi
done
if [ "$MISSING" -gt 0 ]; then
echo ""
echo "$MISSING changed file(s) carry no requirement tag."
echo "Format: /// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1"
echo " (comma separates IDs, pipe groups types)"
fi
- name: Summary
if: always()
run: head -30 docs/traceability.md || true
Generated
+19
View File
@@ -1102,6 +1102,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "dr-sync"
version = "0.1.0"
dependencies = [
"async-trait",
"dr-types",
"log",
"thiserror 2.0.20",
]
[[package]]
name = "dr-types"
version = "0.1.0"
@@ -5158,6 +5168,15 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "traceability"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
]
[[package]]
name = "tracing"
version = "0.1.44"
+14
View File
@@ -3,8 +3,10 @@ resolver = "2"
members = [
"core/dr-types",
"core/dr-gpu",
"core/dr-sync",
"ui/dr-ui",
"apps/darkroom-desktop",
"tools/traceability",
]
[workspace.package]
@@ -18,6 +20,7 @@ repository = "https://github.com/dtourolle/DarkRoom"
# Internal
dr-types = { path = "core/dr-types" }
dr-gpu = { path = "core/dr-gpu" }
dr-sync = { path = "core/dr-sync" }
dr-ui = { path = "ui/dr-ui" }
# GPU + UI
@@ -31,6 +34,17 @@ thiserror = "2"
log = "0.4"
env_logger = "0.11"
pollster = "0.4"
# Networking — no mature Nextcloud crate exists; the connector is hand-rolled
# over reqwest (D7). reqwest_dav was evaluated and is too thin to build on.
reqwest = { version = "0.13", default-features = false, features = ["rustls-tls", "stream", "json"] }
quick-xml = "0.41"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
url = "2.5"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.23"
bytemuck = { version = "1", features = ["derive"] }
[profile.dev]
+3 -3
View File
@@ -1,9 +1,9 @@
//! DarkRoom desktop entry point.
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn"),
)
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn",
))
.init();
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
+26 -6
View File
@@ -7,16 +7,30 @@ fn main() {
env_logger::init();
let ctx = pollster::block_on(GpuContext::new_headless()).unwrap();
println!("adapter: {}\n", ctx.adapter_name());
println!("{:>12} {:>10} {:>10} {:>8}", "size", "compute", "+readback", "fps");
println!(
"{:>12} {:>10} {:>10} {:>8}",
"size", "compute", "+readback", "fps"
);
for &(w, h) in &[(840u32, 692u32), (1280, 720), (1920, 1080), (2048, 1152), (3840, 2160)] {
for &(w, h) in &[
(840u32, 692u32),
(1280, 720),
(1920, 1080),
(2048, 1152),
(3840, 2160),
] {
let rt = RenderTarget::new(&ctx, w, h).unwrap();
// warm
for i in 0..10 { rt.render(i as f32 * 0.01); let _ = pollster::block_on(rt.read_pixels()); }
for i in 0..10 {
rt.render(i as f32 * 0.01);
let _ = pollster::block_on(rt.read_pixels());
}
let n = 40;
let t0 = Instant::now();
for i in 0..n { rt.render(i as f32 * 0.01); }
for i in 0..n {
rt.render(i as f32 * 0.01);
}
ctx.device.poll(wgpu::Maintain::Wait);
let compute = t0.elapsed().as_secs_f64() / n as f64;
@@ -27,7 +41,13 @@ fn main() {
}
let full = t1.elapsed().as_secs_f64() / n as f64;
println!("{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
w, h, compute * 1000.0, full * 1000.0, 1.0 / full);
println!(
"{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
w,
h,
compute * 1000.0,
full * 1000.0,
1.0 / full
);
}
}
+1
View File
@@ -1,3 +1,4 @@
/// TRACES: NFR-R7 | NFR-R8
/// Failures from the GPU layer.
///
/// `DeviceLost` is deliberately a distinct variant rather than folded into a
+5 -13
View File
@@ -116,6 +116,7 @@ struct Params {
/// Stands in for the develop pipeline in v0.1. What matters is the shape:
/// compute writes a texture, the texture is handed to the compositor, and
/// pixels never travel back through the CPU.
/// TRACES: FR-DEV-4 | R4
pub struct RenderTarget {
ctx: GpuContext,
texture: wgpu::Texture,
@@ -142,9 +143,7 @@ impl RenderTarget {
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gradient"),
source: wgpu::ShaderSource::Wgsl(
include_str!("shaders/gradient.wgsl").into(),
),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/gradient.wgsl").into()),
});
let bind_group_layout =
@@ -282,12 +281,8 @@ impl RenderTarget {
return;
}
let (texture, view) = Self::create_texture(&self.ctx, width, height);
self.bind_group = Self::create_bind_group(
&self.ctx,
&self.bind_group_layout,
&view,
&self.params_buf,
);
self.bind_group =
Self::create_bind_group(&self.ctx, &self.bind_group_layout, &view, &self.params_buf);
self.texture = texture;
self.view = view;
self.width = width;
@@ -369,10 +364,7 @@ impl RenderTarget {
}
let buf = &slot.as_ref().unwrap().0;
let mut enc = self
.ctx
.device
.create_command_encoder(&Default::default());
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::ImageCopyTexture {
texture: &self.texture,
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "dr-sync"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
async-trait.workspace = true
thiserror.workspace = true
log.workspace = true
+139
View File
@@ -0,0 +1,139 @@
//! What a backend can do.
//!
//! Declared rather than assumed, because the operations that matter most for
//! performance are not universal (ARCH §8.1).
/// TRACES: FR-NC-4
/// How a backend reports what changed.
///
/// This is the single most consequential capability: it determines whether a
/// no-op sync over a large library costs one request or thousands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeDetection {
/// The backend maintains a change feed; we present a cursor and receive
/// what changed since. Cheapest possible.
DeltaCursor,
/// Directory ETags propagate upward, so an unchanged parent proves an
/// unchanged subtree. Nextcloud. One request proves a whole library
/// unchanged.
PropagatingEtags,
/// ETags exist per entry but do not propagate. The tree must be walked,
/// though ETags still prevent re-downloading unchanged content.
LocalEtags,
/// Modification times only. Walk and compare — vulnerable to clock skew
/// and coarse timestamp granularity.
Timestamps,
}
/// Constraints on chunked upload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChunkConstraints {
pub min_chunk: u64,
pub max_chunk: u64,
/// Bodies at or below this go in a single request.
pub single_shot_below: u64,
pub max_chunks: u32,
}
/// TRACES: FR-NC-3
/// Whether the server can render thumbnails, and for what.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerPreviews {
/// No server-side rendering.
None,
/// Common web formats only. **This is stock Nextcloud** — no RAW preview
/// provider ships with it, so RAW thumbnails must come from local
/// embedded-preview extraction (ARCH §6.7).
CommonFormatsOnly,
/// RAW included, e.g. via the `camerarawpreviews` app or an Imaginary
/// backend. Detected per-account, never assumed.
IncludingRaw,
}
/// The full capability set.
#[derive(Debug, Clone)]
pub struct Capabilities {
pub change_detection: ChangeDetection,
/// Identity survives server-side rename and move, so a move is not
/// mistaken for delete-plus-add of a large file.
pub stable_ids: bool,
/// Byte-range reads. Without these, embedded-preview extraction is
/// impossible and remote browsing must download whole files.
pub range_reads: bool,
pub chunked_upload: Option<ChunkConstraints>,
/// Many small objects in one request.
pub bulk_upload: bool,
/// Conditional write (If-Match), for conflict-safe sidecar updates.
pub conditional_write: bool,
pub server_previews: ServerPreviews,
}
impl Capabilities {
/// The weakest backend the engine will still drive: listing and whole-file
/// transfer, nothing more. Everything degrades but stays correct.
pub fn minimal() -> Self {
Self {
change_detection: ChangeDetection::Timestamps,
stable_ids: false,
range_reads: false,
chunked_upload: None,
bulk_upload: false,
conditional_write: false,
server_previews: ServerPreviews::None,
}
}
/// Whether remote browsing can avoid downloading whole files.
///
/// Where false, the UI must not browse a remote library on a metered
/// connection without explicit consent (FR-NC-12).
pub fn can_browse_cheaply(&self) -> bool {
self.range_reads || matches!(self.server_previews, ServerPreviews::IncludingRaw)
}
/// Whether sidecar conflicts can be detected reliably.
///
/// Without conditional writes the engine falls back to comparing revision
/// counters inside the sidecar, which narrows the race but does not close
/// it — surfaced as a reduced-safety mode.
pub fn safe_concurrent_writes(&self) -> bool {
self.conditional_write
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minimal_backend_degrades_but_stays_usable() {
let caps = Capabilities::minimal();
assert!(!caps.can_browse_cheaply());
assert!(!caps.safe_concurrent_writes());
}
#[test]
fn server_raw_previews_substitute_for_range_reads() {
// A server that renders RAW thumbnails makes browsing cheap even
// without range support.
let caps = Capabilities {
server_previews: ServerPreviews::IncludingRaw,
..Capabilities::minimal()
};
assert!(caps.can_browse_cheaply());
}
#[test]
fn common_format_previews_do_not_help_raw() {
// Stock Nextcloud: previews exist, but not for RAW, so range reads
// remain the only cheap path.
let caps = Capabilities {
server_previews: ServerPreviews::CommonFormatsOnly,
..Capabilities::minimal()
};
assert!(!caps.can_browse_cheaply());
}
}
+82
View File
@@ -0,0 +1,82 @@
/// Failures from a remote backend.
#[derive(Debug, thiserror::Error)]
pub enum RemoteError {
#[error("not authenticated")]
Unauthenticated,
#[error("authentication rejected")]
AuthFailed,
#[error("not found: {0}")]
NotFound(String),
/// The backend does not support this operation. Expected, not a bug —
/// callers check capabilities and adapt.
#[error("operation unsupported by this backend: {0}")]
Unsupported(&'static str),
/// A conditional write failed: the remote changed underneath us. Triggers
/// the sidecar merge path (ARCH §8.5).
#[error("precondition failed — remote was modified")]
PreconditionFailed,
#[error("quota exceeded")]
QuotaExceeded,
#[error("network error: {0}")]
Network(String),
#[error("unexpected server response: {status} {detail}")]
Server { status: u16, detail: String },
#[error("malformed response: {0}")]
Protocol(String),
#[error("operation cancelled")]
Cancelled,
}
impl RemoteError {
/// Whether retrying might succeed.
pub fn is_transient(&self) -> bool {
match self {
RemoteError::Network(_) => true,
RemoteError::Server { status, .. } => {
// 5xx and 429 are worth retrying; other 4xx are not.
*status >= 500 || *status == 429
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transient_errors_are_retryable() {
assert!(RemoteError::Network("timeout".into()).is_transient());
assert!(RemoteError::Server {
status: 503,
detail: String::new()
}
.is_transient());
assert!(RemoteError::Server {
status: 429,
detail: String::new()
}
.is_transient());
}
#[test]
fn client_errors_are_not_retryable() {
assert!(!RemoteError::Server {
status: 404,
detail: String::new()
}
.is_transient());
assert!(!RemoteError::PreconditionFailed.is_transient());
assert!(!RemoteError::AuthFailed.is_transient());
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Pluggable remote storage for DarkRoom.
//!
//! Defines the [`RemoteBackend`] trait and the capability model the sync
//! engine adapts to. Only the Nextcloud connector is implemented
//! (`dr-sync-nextcloud`), but the boundary is designed so other backends can
//! be added without touching the engine.
//!
//! # Why capabilities rather than a common denominator
//!
//! Nextcloud's fast path relies on a behaviour that is *not* a WebDAV
//! guarantee: directory ETags propagate up the tree, so an unchanged root
//! ETag proves nothing anywhere in the library changed. That single property
//! turns a no-op sync over 50k images into one HTTP request.
//!
//! A trait built to what every backend can do would force full enumeration
//! every time — the exact cost the design exists to avoid. So backends
//! declare what they support and the engine picks a strategy (ARCH §8.1).
use std::ops::Range;
use async_trait::async_trait;
pub mod capability;
pub mod error;
pub mod types;
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
pub use error::RemoteError;
pub use types::{
Cursor, Identity, Precondition, RemoteChange, RemoteEntry, RemoteId, RemotePath, Validator,
};
/// TRACES: FR-NC-12
/// A remote storage backend.
///
/// Implementations are expected to be cheap to clone or to be used behind an
/// `Arc`; the engine may call them concurrently.
#[async_trait]
pub trait RemoteBackend: Send + Sync {
/// What this backend supports. Read once at connect time and used to pick
/// a sync strategy.
fn capabilities(&self) -> &Capabilities;
/// Human-readable backend name, for logs and the UI.
fn name(&self) -> &str;
// ---- discovery --------------------------------------------------------
/// List one directory level.
///
/// `since` carries the validator the caller last saw, so backends able to
/// skip unchanged entries may do so. Backends that cannot simply ignore
/// it.
async fn list(
&self,
dir: &RemotePath,
since: Option<&Validator>,
) -> Result<Vec<RemoteEntry>, RemoteError>;
/// Fetch a directory's validator without listing its contents.
///
/// The cheap probe that makes ETag pruning work: one request against the
/// root answers "did anything change?". Backends without
/// [`ChangeDetection::PropagatingEtags`] return
/// [`RemoteError::Unsupported`].
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError>;
/// Ask what changed since a cursor.
///
/// Only meaningful for [`ChangeDetection::DeltaCursor`] backends; others
/// return [`RemoteError::Unsupported`].
async fn delta(&self, cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError>;
// ---- transfer ---------------------------------------------------------
/// Fetch an object, optionally a byte range.
///
/// The range is a hint, not a guarantee: backends without range support
/// may return the whole object, and the caller slices. Correctness holds
/// either way; [`Capabilities::range_reads`] says whether it was cheap.
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError>;
/// Upload, optionally guarded by a precondition.
///
/// Backends handle chunking internally based on body size — chunked
/// upload is an implementation detail, not part of this interface, since
/// exposing it would leak one server's protocol into the abstraction.
async fn put(
&self,
path: &RemotePath,
body: Vec<u8>,
precond: Option<Precondition>,
) -> Result<Validator, RemoteError>;
/// Upload many small objects.
///
/// Defaults to sequential [`put`](Self::put) calls; backends with a bulk
/// endpoint override it. Sidecars are the motivating case — hundreds of
/// a few KB each.
async fn put_many(
&self,
items: Vec<(RemotePath, Vec<u8>)>,
) -> Result<Vec<Result<Validator, RemoteError>>, RemoteError> {
let mut out = Vec::with_capacity(items.len());
for (path, body) in items {
out.push(self.put(&path, body, None).await);
}
Ok(out)
}
/// Delete an object.
async fn delete(&self, id: &RemoteId, precond: Option<Precondition>)
-> Result<(), RemoteError>;
// ---- optional ---------------------------------------------------------
/// Server-rendered thumbnail, where available.
///
/// `Ok(None)` means the server has no preview for this object — which is
/// common for RAW, since stock Nextcloud ships no RAW preview provider
/// (ARCH §6.7). Callers must have a local fallback.
async fn thumbnail(&self, _id: &RemoteId, _size: u32) -> Result<Option<Vec<u8>>, RemoteError> {
Ok(None)
}
}
/// TRACES: FR-NC-4 | FR-NC-12
/// Which sync strategy the engine should use for a backend.
///
/// Derived from capabilities at connect time. Reported to the user so a slow
/// backend is visibly slow rather than mysteriously slow (FR-NC-12).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncStrategy {
/// Ask the server what changed. Cheapest.
Delta,
/// Probe the root ETag; recurse only where it differs. One request when
/// nothing changed.
EtagPruning,
/// Enumerate the tree, using per-entry ETags to avoid re-downloading.
FullListing,
/// Enumerate and compare modification times. Degraded — clock skew and
/// second-granularity timestamps both cause misses.
TimestampCompare,
}
impl SyncStrategy {
pub fn for_capabilities(caps: &Capabilities) -> Self {
match caps.change_detection {
ChangeDetection::DeltaCursor => SyncStrategy::Delta,
ChangeDetection::PropagatingEtags => SyncStrategy::EtagPruning,
ChangeDetection::LocalEtags => SyncStrategy::FullListing,
ChangeDetection::Timestamps => SyncStrategy::TimestampCompare,
}
}
/// A short description for the UI.
pub fn describe(self) -> &'static str {
match self {
SyncStrategy::Delta => "server change feed",
SyncStrategy::EtagPruning => "incremental (ETag pruning)",
SyncStrategy::FullListing => "full listing, cached by ETag",
SyncStrategy::TimestampCompare => "full listing by timestamp (degraded)",
}
}
/// Whether this strategy can prove "nothing changed" cheaply.
///
/// Where false, every sync costs at least one request per folder, which
/// the UI should warn about on large libraries.
pub fn has_cheap_noop(self) -> bool {
matches!(self, SyncStrategy::Delta | SyncStrategy::EtagPruning)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strategy_follows_capability() {
let mut caps = Capabilities::minimal();
assert_eq!(
SyncStrategy::for_capabilities(&caps),
SyncStrategy::TimestampCompare
);
caps.change_detection = ChangeDetection::PropagatingEtags;
assert_eq!(
SyncStrategy::for_capabilities(&caps),
SyncStrategy::EtagPruning
);
}
#[test]
fn only_delta_and_pruning_have_cheap_noop() {
assert!(SyncStrategy::Delta.has_cheap_noop());
assert!(SyncStrategy::EtagPruning.has_cheap_noop());
// These cost at least one request per folder, every time.
assert!(!SyncStrategy::FullListing.has_cheap_noop());
assert!(!SyncStrategy::TimestampCompare.has_cheap_noop());
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Vocabulary shared by every remote backend.
//!
//! Deliberately protocol-neutral: nothing here names WebDAV, HTTP, or
//! Nextcloud. A backend translates these into its own protocol, which is what
//! keeps the sync engine reusable (ARCH §8.3).
pub use dr_types::Validator;
/// A path on the remote, always `/`-separated and rooted at the account's
/// library root — never a local filesystem path.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RemotePath(String);
impl RemotePath {
pub fn new(path: impl Into<String>) -> Self {
let p: String = path.into();
// Normalise so `/a/b`, `a/b` and `a/b/` compare equal — otherwise the
// same directory can be listed twice under different spellings.
let trimmed = p.trim_matches('/');
RemotePath(trimmed.to_string())
}
pub fn root() -> Self {
RemotePath(String::new())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_root(&self) -> bool {
self.0.is_empty()
}
/// Append a single path segment.
pub fn join(&self, segment: &str) -> Self {
let seg = segment.trim_matches('/');
if self.0.is_empty() {
RemotePath(seg.to_string())
} else {
RemotePath(format!("{}/{}", self.0, seg))
}
}
/// The final segment, for display.
pub fn name(&self) -> &str {
self.0.rsplit('/').next().unwrap_or(&self.0)
}
/// The containing directory, or `None` at the root.
pub fn parent(&self) -> Option<RemotePath> {
let (head, _) = self.0.rsplit_once('/')?;
Some(RemotePath(head.to_string()))
}
}
impl std::fmt::Display for RemotePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(if self.0.is_empty() { "/" } else { &self.0 })
}
}
/// A backend-stable object identifier.
///
/// Where the backend supports stable ids (`Capabilities::stable_ids`) this
/// survives server-side rename and move, so a move is detected as a move
/// rather than a delete plus a re-download of a large file (FR-NC-5).
/// Otherwise it falls back to the path, and moves cost a transfer.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RemoteId {
/// A server-assigned identifier, e.g. Nextcloud's `oc:fileid`.
Stable(u64),
/// No stable identity available; the path is the identity.
Path(RemotePath),
}
/// One entry returned by a directory listing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteEntry {
pub id: RemoteId,
pub path: RemotePath,
pub kind: EntryKind,
pub validator: Validator,
pub size: u64,
/// Unix seconds, where the backend reports one.
pub modified: Option<i64>,
/// Whether the server claims a renderable preview exists. Advisory: stock
/// Nextcloud reports none for RAW (ARCH §6.7).
pub has_preview: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Directory,
}
/// A change reported by a delta-capable backend.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteChange {
Upserted(RemoteEntry),
Deleted(RemoteId),
}
/// An opaque position in a backend's change feed.
///
/// Persisted between syncs. Backends may expire cursors; the engine treats an
/// expired cursor as a signal to fall back to a full listing rather than an
/// error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cursor(String);
impl Cursor {
pub fn new(token: impl Into<String>) -> Self {
Cursor(token.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A conditional-write guard.
///
/// `IfMatch` is optimistic concurrency for sidecar updates: a failure means
/// another device wrote first, which triggers the per-operation merge rather
/// than an overwrite (ARCH §8.5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Precondition {
/// Write only if the remote still matches this validator.
IfMatch(Validator),
/// Write only if nothing exists — creation without clobbering.
IfAbsent,
}
/// Who we are connected as.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub user_id: String,
pub display_name: Option<String>,
pub server: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_normalise_so_spellings_compare_equal() {
// Otherwise the same directory is listed twice under two spellings.
assert_eq!(
RemotePath::new("/Photos/2026/"),
RemotePath::new("Photos/2026")
);
assert_eq!(
RemotePath::new("Photos/2026/"),
RemotePath::new("Photos/2026")
);
}
#[test]
fn root_is_empty_and_displays_as_slash() {
let r = RemotePath::root();
assert!(r.is_root());
assert_eq!(r.to_string(), "/");
assert_eq!(RemotePath::new("/"), r);
}
#[test]
fn join_from_root_does_not_double_separator() {
assert_eq!(RemotePath::root().join("Photos").as_str(), "Photos");
assert_eq!(
RemotePath::new("Photos").join("2026").as_str(),
"Photos/2026"
);
// A segment arriving with separators is still joined once.
assert_eq!(
RemotePath::new("Photos").join("/2026/").as_str(),
"Photos/2026"
);
}
#[test]
fn name_and_parent() {
let p = RemotePath::new("Photos/2026/IMG_0042.CR3");
assert_eq!(p.name(), "IMG_0042.CR3");
assert_eq!(p.parent().unwrap().as_str(), "Photos/2026");
assert_eq!(RemotePath::root().parent(), None);
}
#[test]
fn stable_ids_differ_from_path_ids() {
// Identity is what makes a server-side move cheap; the two forms must
// not be conflated.
let a = RemoteId::Stable(42);
let b = RemoteId::Path(RemotePath::new("Photos/x.CR3"));
assert_ne!(a, b);
}
}
+3
View File
@@ -20,6 +20,7 @@ pub struct ImageId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VersionId(pub u64);
/// TRACES: FR-CAT-1a | FR-PLAT-AND-1
/// An opaque, re-resolvable reference to source image data.
///
/// **Never a filesystem path.** Android's Storage Access Framework provides no
@@ -66,6 +67,7 @@ impl fmt::Display for SourceRef {
}
}
/// TRACES: FR-RAW-1
/// Image formats recognised at the catalog level.
///
/// Recognition is by extension only; whether a decoder can actually handle the
@@ -106,6 +108,7 @@ impl Format {
}
}
/// TRACES: FR-NC-6c
/// How much of an image is available locally (FR-NC-6c).
///
/// Surfaced in the UI so a user always knows what they have — the failure
+187
View File
@@ -0,0 +1,187 @@
# Requirements traceability matrix
<!-- GENERATED FILE — do not edit by hand. -->
<!-- Regenerate: cargo run -p traceability -- report -->
Denominators are parsed from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage is the intersection of tagged and defined IDs over defined IDs, so it cannot exceed 100%.
## Summary
| Metric | Value |
|---|---|
| Source files scanned | 16 |
| TRACES tags found | 16 |
| Requirements defined | 143 |
| Requirements covered | 19 |
| **Coverage** | **13.3%** (19/143) |
### By type
| Type | Covered | Defined |
|---|---|---|
| FR | 13 | 90 |
| NFR | 4 | 47 |
| R | 2 | 6 |
## Orphan tags
A tag naming an ID `requirements.md` does not define — what renumbering produces, and what a typo produces.
_None._
## Tagged requirements
| ID | Tagged in |
|---|---|
| FR-CAT-1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505) |
| FR-CAT-1a | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) |
| FR-CAT-2 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) |
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:167`](../ui/dr-ui/src/lib.rs#L167) |
| FR-NC-12 | [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127), [`core/dr-sync/src/lib.rs:33`](../core/dr-sync/src/lib.rs#L33) |
| FR-NC-3 | [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41) |
| FR-NC-4 | [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127) |
| FR-NC-6c | [`core/dr-types/src/lib.rs:111`](../core/dr-types/src/lib.rs#L111) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) |
| FR-RAW-1 | [`core/dr-types/src/lib.rs:70`](../core/dr-types/src/lib.rs#L70) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) |
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) |
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
| NFR-P1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| R1 | [`tools/traceability/src/lib.rs:489`](../tools/traceability/src/lib.rs#L489), [`tools/traceability/src/lib.rs:493`](../tools/traceability/src/lib.rs#L493) |
| R4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) |
## Not yet tagged
124 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
<details><summary>Show untagged requirements</summary>
- FR-CAT-10
- FR-CAT-11
- FR-CAT-12
- FR-CAT-13
- FR-CAT-14
- FR-CAT-3
- FR-CAT-4
- FR-CAT-5
- FR-CAT-6
- FR-CAT-7
- FR-CAT-8
- FR-CAT-9
- FR-CULL-1
- FR-CULL-2
- FR-CULL-3
- FR-CULL-4
- FR-CULL-5
- FR-CULL-6
- FR-CULL-7
- FR-DEV-1
- FR-DEV-2
- FR-DEV-3
- FR-DEV-3a
- FR-DEV-3b
- FR-DEV-3c
- FR-DEV-3d
- FR-DEV-3e
- FR-DEV-3f
- FR-DEV-3g
- FR-DEV-5
- FR-DEV-6
- FR-DEV-7
- FR-DEV-8
- FR-DSP-2
- FR-DSP-3
- FR-DSP-4
- FR-DSP-5
- FR-DSP-6
- FR-DSP-7
- FR-DSP-8
- FR-EXP-1
- FR-EXP-2
- FR-EXP-3
- FR-EXP-4
- FR-EXP-5
- FR-EXP-6
- FR-EXP-7
- FR-EXP-8
- FR-EXP-9
- FR-NC-1
- FR-NC-10
- FR-NC-11
- FR-NC-2
- FR-NC-5
- FR-NC-6
- FR-NC-6a
- FR-NC-6b
- FR-NC-7
- FR-NC-8
- FR-NC-9
- FR-PLAT-AND-2
- FR-PLAT-AND-3
- FR-PLAT-AND-4
- FR-PLAT-AND-5
- FR-PLAT-AND-6
- FR-PLAT-LIN-1
- FR-PLAT-LIN-2
- FR-PLAT-LIN-3
- FR-RAW-2
- FR-RAW-3
- FR-RAW-4
- FR-RAW-5
- FR-UI-3
- FR-UI-4
- FR-UI-5
- FR-UI-6
- FR-UI-7
- NFR-A11Y-1
- NFR-A11Y-2
- NFR-A11Y-3
- NFR-ARCH-1
- NFR-ARCH-2
- NFR-ARCH-3
- NFR-ARCH-4
- NFR-COMPAT-1
- NFR-COMPAT-2
- NFR-OPS-2
- NFR-OPS-3
- NFR-OPS-4
- NFR-P10
- NFR-P11
- NFR-P12
- NFR-P13
- NFR-P14
- NFR-P15
- NFR-P2
- NFR-P3
- NFR-P4
- NFR-P5
- NFR-P6
- NFR-P7
- NFR-P8
- NFR-P9
- NFR-PORT-1
- NFR-PORT-2
- NFR-PORT-3
- NFR-R1
- NFR-R2
- NFR-R3
- NFR-R4
- NFR-R5
- NFR-R6
- NFR-RES-1
- NFR-RES-2
- NFR-RES-3
- NFR-RES-4
- NFR-SEC-1
- NFR-SEC-2
- NFR-SEC-3
- NFR-SEC-4
- R2
- R3
- R5
- R6
</details>
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "traceability"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[lib]
name = "traceability"
path = "src/lib.rs"
[[bin]]
name = "traces"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
serde = { workspace = true }
serde_json.workspace = true
+541
View File
@@ -0,0 +1,541 @@
//! Requirements traceability for DarkRoom.
//!
//! Extracts `TRACES:` tags from source, counts what `requirements.md` actually
//! defines, and reports coverage as the intersection of the two.
//!
//! # Why the arithmetic is written this way
//!
//! Adapted from the JellyTau tooling, including the bug it was repaired for.
//! That gate divided a traced count by *frozen literal* denominators; the
//! requirements file grew past them, and it reported **158% coverage**. A gate
//! reporting over 100% cannot fail its own threshold, so it silently stopped
//! being a gate at all.
//!
//! Two rules follow, and both are enforced by tests here:
//!
//! 1. **Denominators are parsed from `requirements.md` at run time.** Never
//! hardcoded, never cached.
//! 2. **Coverage is `|traced ∩ defined| / |defined|`.** Using the raw traced
//! count as the numerator is precisely what lets a ratio exceed 100%, since
//! a tag naming a deleted requirement would count as covered. Such tags are
//! reported as orphans instead.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
/// Requirement ID prefixes that participate in coverage.
///
/// Test identifiers (UT, IT) are a separate taxonomy: they are *evidence* for
/// requirements, not requirements themselves. Counting them would inflate both
/// numerator and denominator, and flagging them as orphans would bury real
/// typos in noise.
pub const REQUIREMENT_TYPES: &[&str] = &["FR", "NFR", "R"];
/// Prefixes recognised in tags but deliberately excluded from coverage.
///
/// `D` are decisions, `S` spikes, `M` milestone items, `AC` acceptance
/// criteria, `UT`/`IT` tests. All are legitimate things to tag against, and
/// none is a requirement — counting them would inflate the denominator by 25
/// and make coverage look worse than it is, which is the same class of defect
/// as JellyTau's inflated ratio, just in the other direction.
pub const NON_REQUIREMENT_TYPES: &[&str] = &["UT", "IT", "AC", "M", "S", "D"];
/// One `TRACES:` tag found in source.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TraceEntry {
pub file: String,
pub line: usize,
pub context: String,
pub requirements: Vec<String>,
}
/// What `requirements.md` defines — the coverage denominators.
#[derive(Debug, Clone, Default)]
pub struct DefinedRequirements {
pub ids: BTreeSet<String>,
pub by_type: BTreeMap<String, usize>,
}
impl DefinedRequirements {
pub fn total(&self) -> usize {
self.ids.len()
}
}
/// The coverage result.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Coverage {
pub covered: usize,
pub total: usize,
pub percent: f64,
/// Tagged in source but absent from `requirements.md` — a typo, or a
/// requirement that was renumbered or deleted. Never counted as covered.
pub orphaned: Vec<String>,
/// Defined but never tagged anywhere.
pub untraced: Vec<String>,
}
/// Parse the requirement IDs a markdown document *defines*.
///
/// A requirement is defined by a bolded heading-style declaration
/// (`**FR-CAT-1 — …**`) or as the leading cell of a table row (`| R1 | … |`).
/// Both forms appear in DarkRoom's requirements.md.
///
/// Deliberately *not* a bare scan for anything matching the ID shape: that
/// counts cross-references in prose and in "relates to" columns as
/// definitions, inflating the denominator. IDs are deduplicated because a
/// requirement may legitimately appear in both a definition and a summary
/// table.
pub fn parse_defined_requirements(markdown: &str) -> DefinedRequirements {
let mut ids = BTreeSet::new();
for line in markdown.lines() {
let trimmed = line.trim_start();
// Form 1: a bolded definition, e.g. `**FR-CAT-1 — Scan.**`
if let Some(rest) = trimmed.strip_prefix("**") {
if let Some(id) = leading_id(rest) {
ids.insert(id);
continue;
}
}
// Form 2: leading table cell, e.g. `| **R1** | … |` or `| R1 | … |`
if let Some(rest) = trimmed.strip_prefix('|') {
let cell = rest.trim().trim_start_matches("**");
if let Some(id) = leading_id(cell) {
ids.insert(id);
}
}
}
// Only requirement types enter the register. Decisions, spikes, milestone
// items and test ids are all taggable, but none is a requirement, and
// counting them would inflate the denominator.
let ids: BTreeSet<String> = ids.into_iter().filter(|id| is_requirement(id)).collect();
let mut by_type: BTreeMap<String, usize> = BTreeMap::new();
for id in &ids {
*by_type.entry(type_of(id).to_string()).or_insert(0) += 1;
}
DefinedRequirements { ids, by_type }
}
/// Extract a requirement ID anchored at the start of `s`.
///
/// Accepts `FR-CAT-1`, `NFR-P13`, `R1`, `FR-DEV-3a` — DarkRoom uses
/// alphanumeric segments and an optional trailing letter, not the fixed
/// three-digit form JellyTau assumed.
fn leading_id(s: &str) -> Option<String> {
let bytes = s.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_uppercase() {
return None;
}
let mut end = 0;
let mut seen_digit = false;
for (i, c) in s.char_indices() {
match c {
'A'..='Z' | '0'..='9' | '-' => {
if c.is_ascii_digit() {
seen_digit = true;
}
end = i + c.len_utf8();
}
'a'..='z' if seen_digit => {
// Trailing variant letter, e.g. FR-DEV-3a.
end = i + c.len_utf8();
}
_ => break,
}
}
if end == 0 || !seen_digit {
return None;
}
let id = s[..end].trim_end_matches('-').to_string();
// Must have a recognised prefix, or arbitrary capitalised words match.
let ty = type_of(&id);
if REQUIREMENT_TYPES.contains(&ty) || NON_REQUIREMENT_TYPES.contains(&ty) {
Some(id)
} else {
None
}
}
/// The type prefix of an ID: `FR-CAT-1` → `FR`, `R1` → `R`.
pub fn type_of(id: &str) -> &str {
let end = id
.find(|c: char| !c.is_ascii_uppercase())
.unwrap_or(id.len());
&id[..end]
}
/// Whether an ID participates in coverage.
pub fn is_requirement(id: &str) -> bool {
REQUIREMENT_TYPES.contains(&type_of(id))
}
/// Extract every `TRACES:` tag from a source file's text.
pub fn extract_from_text(text: &str, path: &str) -> Vec<TraceEntry> {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
for (idx, line) in lines.iter().enumerate() {
let Some(pos) = line.find("TRACES:") else {
continue;
};
let tail = &line[pos + "TRACES:".len()..];
let ids = parse_ids(tail);
if ids.is_empty() {
continue;
}
out.push(TraceEntry {
file: path.to_string(),
line: idx + 1,
context: find_context(&lines, idx),
requirements: ids,
});
}
out
}
/// Parse the ID list from a tag body: `FR-CAT-1, FR-CAT-2 | NFR-P1`.
///
/// The pipe groups types for readability; both separators are treated alike.
fn parse_ids(s: &str) -> Vec<String> {
s.split(['|', ','])
.filter_map(|part| leading_id(part.trim()))
.collect()
}
/// The nearest preceding declaration, for the report's context column.
fn find_context(lines: &[&str], from: usize) -> String {
const MARKERS: &[&str] = &[
"pub fn ",
"fn ",
"pub struct ",
"struct ",
"pub enum ",
"enum ",
"impl ",
"pub trait ",
"trait ",
"#[test]",
"component ",
"export ",
];
// Look forward first — a doc comment precedes what it documents.
for line in lines.iter().skip(from + 1).take(6) {
if MARKERS.iter().any(|m| line.contains(m)) {
return trim_body(line);
}
}
// Then backward, for tags placed inside a body.
for i in (from.saturating_sub(6)..from).rev() {
if MARKERS.iter().any(|m| lines[i].contains(m)) {
return lines[i].trim().trim_end_matches('{').trim().to_string();
}
}
"—".to_string()
}
/// Strip a trailing body opener so the context reads as a signature.
///
/// Handles both `fn f() {` and `fn f() {}` — the latter is why this is a
/// helper rather than a single `trim_end_matches('{')`.
fn trim_body(line: &str) -> String {
line.trim()
.trim_end_matches("{}")
.trim_end()
.trim_end_matches('{')
.trim_end()
.to_string()
}
/// Compute coverage as the intersection of traced and defined IDs.
///
/// This signature is the fix for the 158% bug: `defined` is required, so there
/// is nowhere for a frozen denominator to hide.
/// TRACES: NFR-OPS-1
pub fn compute_coverage(traced: &BTreeSet<String>, defined: &DefinedRequirements) -> Coverage {
let traced_reqs: BTreeSet<&String> = traced.iter().filter(|id| is_requirement(id)).collect();
let covered: Vec<&String> = traced_reqs
.iter()
.filter(|id| defined.ids.contains(**id))
.copied()
.collect();
let orphaned: Vec<String> = traced_reqs
.iter()
.filter(|id| !defined.ids.contains(**id))
.map(|s| s.to_string())
.collect();
let untraced: Vec<String> = defined
.ids
.iter()
.filter(|id| !traced.contains(*id))
.cloned()
.collect();
let total = defined.total();
Coverage {
covered: covered.len(),
total,
percent: if total == 0 {
0.0
} else {
(covered.len() as f64 / total as f64) * 100.0
},
orphaned,
untraced,
}
}
/// Source file extensions scanned for tags.
pub const SOURCE_SUFFIXES: &[&str] = &[".rs", ".slint", ".wgsl"];
/// Directories never scanned.
const EXCLUDED: &[&str] = &["target", "target-android", ".git", "node_modules", "temp"];
/// Walk `roots` under `base`, returning every source file.
pub fn collect_sources(base: &Path, roots: &[&str]) -> Vec<PathBuf> {
let mut out = Vec::new();
for root in roots {
let dir = base.join(root);
if dir.exists() {
walk(&dir, &mut out);
}
}
out.sort();
out
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if EXCLUDED.iter().any(|e| *e == name) {
continue;
}
if path.is_dir() {
walk(&path, out);
} else if SOURCE_SUFFIXES.iter().any(|s| name.ends_with(s)) {
out.push(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---- the 158% regression ------------------------------------------
#[test]
fn coverage_never_exceeds_one_hundred_percent() {
// The JellyTau failure, reproduced: more tags than the register
// defines. Every extra tag must land in `orphaned`, never inflate
// the numerator.
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
let traced: BTreeSet<String> = ["FR-CAT-1", "FR-CAT-2", "FR-CAT-3", "FR-CAT-4"]
.iter()
.map(|s| s.to_string())
.collect();
let cov = compute_coverage(&traced, &defined);
assert_eq!(cov.covered, 1);
assert_eq!(cov.total, 1);
assert_eq!(cov.percent, 100.0);
assert!(cov.percent <= 100.0, "coverage must never exceed 100%");
assert_eq!(cov.orphaned, vec!["FR-CAT-2", "FR-CAT-3", "FR-CAT-4"]);
}
#[test]
fn orphan_tags_are_reported_not_counted() {
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
let traced: BTreeSet<String> = ["FR-CAT-1", "FR-TYPO-9"]
.iter()
.map(|s| s.to_string())
.collect();
let cov = compute_coverage(&traced, &defined);
assert_eq!(cov.covered, 1);
assert_eq!(cov.orphaned, vec!["FR-TYPO-9"]);
}
#[test]
fn empty_register_is_zero_not_a_division_by_zero() {
let defined = parse_defined_requirements("");
let traced = BTreeSet::new();
let cov = compute_coverage(&traced, &defined);
assert_eq!(cov.percent, 0.0);
assert_eq!(cov.total, 0);
}
// ---- denominator parsing ------------------------------------------
#[test]
fn definitions_come_from_declarations_not_cross_references() {
// Only the first line defines FR-CAT-1. The prose reference and the
// "relates to" cell must not each count as another definition.
let md = "\
**FR-CAT-1 — Scan.** The app shall scan roots.
This is discussed in FR-CAT-1 and also FR-CAT-1 again.
| Spike | Answers | Relates to |
|---|---|---|
| S1 | whether it works | FR-CAT-1 |
";
let defined = parse_defined_requirements(md);
// FR-CAT-1 once, despite three mentions. S1 is a spike, not a
// requirement, so it does not enter the register at all.
assert_eq!(defined.total(), 1);
assert!(defined.ids.contains("FR-CAT-1"));
}
#[test]
fn decisions_and_spikes_are_not_requirements() {
// D and S are taggable but are not requirements; counting them would
// inflate the denominator and understate real coverage.
let md = "\
**FR-CAT-1 — Scan.**
| D1 | Language | Rust |
| S1 | Zero-copy spike | proves ARCH 6.1 |
";
let defined = parse_defined_requirements(md);
assert_eq!(defined.total(), 1, "only FR-CAT-1 is a requirement");
assert!(defined.ids.contains("FR-CAT-1"));
assert!(!defined.ids.contains("D1"));
assert!(!defined.ids.contains("S1"));
}
#[test]
fn tagging_a_decision_neither_covers_nor_orphans() {
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
let traced: BTreeSet<String> = ["FR-CAT-1", "D1"].iter().map(|s| s.to_string()).collect();
let cov = compute_coverage(&traced, &defined);
assert_eq!(cov.covered, 1);
assert!(cov.orphaned.is_empty(), "D1 is a valid tag, not an orphan");
}
#[test]
fn table_row_ids_are_definitions() {
let md = "| **R1** | Cross-platform | Same core on both |\n\
| R2 | Efficient display | 60fps |\n";
let defined = parse_defined_requirements(md);
assert!(defined.ids.contains("R1"));
assert!(defined.ids.contains("R2"));
}
#[test]
fn darkroom_id_shapes_parse() {
// Not JellyTau's fixed three-digit form.
assert_eq!(leading_id("FR-CAT-1 — Scan").as_deref(), Some("FR-CAT-1"));
assert_eq!(
leading_id("NFR-P13 — Next image").as_deref(),
Some("NFR-P13")
);
assert_eq!(
leading_id("FR-DEV-3a — Descriptors").as_deref(),
Some("FR-DEV-3a")
);
assert_eq!(leading_id("R1 | Cross-platform").as_deref(), Some("R1"));
}
#[test]
fn prose_is_not_an_id() {
assert_eq!(leading_id("The app shall scan"), None);
assert_eq!(leading_id("GPU results never"), None);
// A recognised prefix with no digits is not an ID either.
assert_eq!(leading_id("FR without a number"), None);
}
// ---- tag extraction -----------------------------------------------
#[test]
fn extracts_tags_with_both_separators() {
let src = "\
/// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1
pub fn scan() {}
";
let traces = extract_from_text(src, "x.rs");
assert_eq!(traces.len(), 1);
assert_eq!(
traces[0].requirements,
vec!["FR-CAT-1", "FR-CAT-2", "NFR-P1"]
);
assert_eq!(traces[0].line, 1);
assert_eq!(traces[0].context, "pub fn scan()");
}
#[test]
fn context_looks_forward_then_backward() {
// Doc comments precede their item.
let fwd = extract_from_text("// TRACES: R1\npub struct Catalog;", "x.rs");
assert_eq!(fwd[0].context, "pub struct Catalog;");
// A tag inside a body refers to the enclosing item.
let back = extract_from_text("pub fn render() {\n // TRACES: R1\n}", "x.rs");
assert_eq!(back[0].context, "pub fn render()");
}
#[test]
fn a_tag_with_no_ids_is_ignored() {
let traces = extract_from_text("// TRACES: see the design doc\n", "x.rs");
assert!(traces.is_empty());
}
#[test]
fn test_ids_are_extracted_but_not_counted_as_requirements() {
let traces = extract_from_text("// TRACES: FR-CAT-1 | UT-001\nfn f(){}", "x.rs");
assert_eq!(traces[0].requirements, vec!["FR-CAT-1", "UT-001"]);
// UT is a separate taxonomy: evidence, not a requirement.
assert!(is_requirement("FR-CAT-1"));
assert!(!is_requirement("UT-001"));
// So it neither covers nor orphans.
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
let traced: BTreeSet<String> = ["FR-CAT-1", "UT-001"]
.iter()
.map(|s| s.to_string())
.collect();
let cov = compute_coverage(&traced, &defined);
assert_eq!(cov.covered, 1);
assert!(
cov.orphaned.is_empty(),
"UT must not be reported as orphaned"
);
}
#[test]
fn type_prefixes_split_correctly() {
assert_eq!(type_of("FR-CAT-1"), "FR");
assert_eq!(type_of("NFR-P13"), "NFR");
assert_eq!(type_of("R1"), "R");
assert_eq!(type_of("UT-001"), "UT");
}
#[test]
fn untraced_requirements_are_listed() {
let defined = parse_defined_requirements("**FR-A-1 — One.**\n**FR-B-2 — Two.**\n");
let traced: BTreeSet<String> = ["FR-A-1"].iter().map(|s| s.to_string()).collect();
let cov = compute_coverage(&traced, &defined);
assert_eq!(cov.untraced, vec!["FR-B-2"]);
}
}
+244
View File
@@ -0,0 +1,244 @@
//! Traceability gate and matrix generator.
//!
//! ```text
//! traces report # write docs/traceability.md
//! traces json # machine-readable, to stdout
//! traces check # gate: non-zero exit on failure
//! ```
//!
//! The gate fails hard on a *misconfigured run* — zero requirements parsed, or
//! zero source files scanned — rather than reporting a plausible-looking 0%.
//! A gate that cannot distinguish "nothing is tagged" from "I read nothing" is
//! how JellyTau's reported 158% went unnoticed for months.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use traceability::*;
/// Directories scanned for tags.
const SOURCE_ROOTS: &[&str] = &["core", "ui", "apps", "tools"];
/// Minimum coverage the gate accepts.
///
/// 0 today: the requirements register is written but the code that implements
/// it barely exists. Ratchet upward as tags land; never reset downward.
/// Structural failures below are unconditional and do not depend on this.
const MIN_COVERAGE: f64 = 0.0;
fn main() -> Result<()> {
let base = repo_root()?;
let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into());
let req_path = base.join("docs/requirements.md");
let markdown = std::fs::read_to_string(&req_path)
.with_context(|| format!("reading {}", req_path.display()))?;
let defined = parse_defined_requirements(&markdown);
let files = collect_sources(&base, SOURCE_ROOTS);
let mut entries = Vec::new();
for file in &files {
let text = std::fs::read_to_string(file).unwrap_or_default();
let rel = file
.strip_prefix(&base)
.unwrap_or(file)
.to_string_lossy()
.to_string();
entries.extend(extract_from_text(&text, &rel));
}
let traced: BTreeSet<String> = entries
.iter()
.flat_map(|e| e.requirements.iter().cloned())
.collect();
let coverage = compute_coverage(&traced, &defined);
match mode.as_str() {
"json" => println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"filesScanned": files.len(),
"tagsFound": entries.len(),
"defined": defined.total(),
"coverage": coverage,
}))?
),
"check" => {
print_summary(&files, &entries, &defined, &coverage);
gate(&files, &defined, &coverage)?;
println!("\ntraceability gate: PASS");
}
_ => {
let out = base.join("docs/traceability.md");
let md = render(&files, &entries, &defined, &coverage);
std::fs::write(&out, md)?;
print_summary(&files, &entries, &defined, &coverage);
println!("\nwrote {}", out.display());
}
}
Ok(())
}
/// Structural checks that fail regardless of the coverage threshold.
fn gate(files: &[PathBuf], defined: &DefinedRequirements, cov: &Coverage) -> Result<()> {
// A run that parsed nothing is misconfigured, not passing.
if defined.total() == 0 {
bail!("no requirements parsed from docs/requirements.md — misconfigured, not 0% coverage");
}
if files.is_empty() {
bail!("no source files scanned — misconfigured, not 0% coverage");
}
// Arithmetic invariant. If this ever trips, the numerator has stopped
// being an intersection — the exact JellyTau defect.
if cov.percent > 100.0 {
bail!(
"coverage {:.1}% exceeds 100% — numerator is not an intersection of traced and defined",
cov.percent
);
}
if !cov.orphaned.is_empty() {
bail!(
"{} orphan tag(s) naming requirements that do not exist: {}",
cov.orphaned.len(),
cov.orphaned.join(", ")
);
}
if cov.percent < MIN_COVERAGE {
bail!(
"coverage {:.1}% is below the {:.1}% threshold",
cov.percent,
MIN_COVERAGE
);
}
Ok(())
}
fn print_summary(
files: &[PathBuf],
entries: &[TraceEntry],
defined: &DefinedRequirements,
cov: &Coverage,
) {
println!("files scanned {}", files.len());
println!("tags found {}", entries.len());
println!("requirements {}", defined.total());
println!(
"coverage {:.1}% ({}/{})",
cov.percent, cov.covered, cov.total
);
if !cov.orphaned.is_empty() {
println!("orphan tags {}", cov.orphaned.join(", "));
}
}
fn render(
files: &[PathBuf],
entries: &[TraceEntry],
defined: &DefinedRequirements,
cov: &Coverage,
) -> String {
let mut m = String::new();
m.push_str("# Requirements traceability matrix\n\n");
m.push_str("<!-- GENERATED FILE — do not edit by hand. -->\n");
m.push_str("<!-- Regenerate: cargo run -p traceability -- report -->\n\n");
m.push_str(
"Denominators are parsed from [`requirements.md`](requirements.md) at run time, \
never hardcoded. Coverage is the intersection of tagged and defined IDs over \
defined IDs, so it cannot exceed 100%.\n\n",
);
m.push_str("## Summary\n\n| Metric | Value |\n|---|---|\n");
m.push_str(&format!("| Source files scanned | {} |\n", files.len()));
m.push_str(&format!("| TRACES tags found | {} |\n", entries.len()));
m.push_str(&format!("| Requirements defined | {} |\n", defined.total()));
m.push_str(&format!("| Requirements covered | {} |\n", cov.covered));
m.push_str(&format!(
"| **Coverage** | **{:.1}%** ({}/{}) |\n\n",
cov.percent, cov.covered, cov.total
));
m.push_str("### By type\n\n| Type | Covered | Defined |\n|---|---|---|\n");
let mut covered_by_type: BTreeMap<&str, usize> = BTreeMap::new();
for id in &defined.ids {
if entries.iter().any(|e| e.requirements.contains(id)) {
*covered_by_type.entry(type_of(id)).or_insert(0) += 1;
}
}
for (ty, count) in &defined.by_type {
m.push_str(&format!(
"| {} | {} | {} |\n",
ty,
covered_by_type.get(ty.as_str()).copied().unwrap_or(0),
count
));
}
m.push('\n');
m.push_str("## Orphan tags\n\n");
m.push_str(
"A tag naming an ID `requirements.md` does not define — what renumbering produces, \
and what a typo produces.\n\n",
);
if cov.orphaned.is_empty() {
m.push_str("_None._\n\n");
} else {
for id in &cov.orphaned {
m.push_str(&format!("- `{id}`\n"));
}
m.push('\n');
}
m.push_str("## Tagged requirements\n\n| ID | Tagged in |\n|---|---|\n");
let mut by_req: BTreeMap<&String, Vec<&TraceEntry>> = BTreeMap::new();
for e in entries {
for r in &e.requirements {
if defined.ids.contains(r) {
by_req.entry(r).or_default().push(e);
}
}
}
for (id, es) in &by_req {
let mut locs: Vec<String> = es
.iter()
.map(|e| format!("[`{}:{}`](../{}#L{})", e.file, e.line, e.file, e.line))
.collect();
locs.sort();
locs.dedup();
m.push_str(&format!("| {} | {} |\n", id, locs.join(", ")));
}
m.push('\n');
m.push_str("## Not yet tagged\n\n");
m.push_str(&format!(
"{} of {} requirements have no implementation tag. Expected while the \
codebase is young; each should gain one as it is built.\n\n",
cov.untraced.len(),
defined.total()
));
if !cov.untraced.is_empty() {
m.push_str("<details><summary>Show untagged requirements</summary>\n\n");
for id in &cov.untraced {
m.push_str(&format!("- {id}\n"));
}
m.push_str("\n</details>\n");
}
m
}
/// The repo root, found by walking up from the executable's manifest dir.
fn repo_root() -> Result<PathBuf> {
let mut dir = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
while !dir.join("docs/requirements.md").exists() {
if !dir.pop() {
bail!("could not locate repo root (no docs/requirements.md above the tool)");
}
}
Ok(dir)
}
+2
View File
@@ -164,6 +164,7 @@ pub fn run() -> Result<()> {
/// maximised 4K canvas costs 4× a 1080p one for detail nobody is looking at
/// while dragging. Real zoom-to-1:1 will render the visible crop at full
/// resolution instead of scaling the whole canvas up.
/// TRACES: FR-DSP-1
const MAX_RENDER_DIM: u32 = 2048;
fn clamp_render_size(w: u32, h: u32) -> (u32, u32) {
@@ -184,6 +185,7 @@ fn clamp_render_size(w: u32, h: u32) -> (u32, u32) {
///
/// A threshold in logical pixels, not a device check — a narrow desktop window
/// gets the compact layout exactly as a tablet in portrait would.
/// TRACES: FR-UI-1 | FR-UI-2
const EXPANDED_MIN_WIDTH: f32 = 820.0;
fn apply_layout_class(window: &AppWindow, width: f32) {