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
+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());
}
}