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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user