use once_cell::sync::Lazy;
use regex::Regex;
use crate::model::{
EvidenceKind, HazardStatus, Kind, LinkKind, Project, Requirement, SafetyFunctionStatus, Sil,
Status,
};
#[derive(Debug, Clone, serde::Serialize)]
pub struct Finding {
pub error: bool,
pub field: &'static str,
pub rule_code: &'static str,
pub message: String,
}
impl Finding {
fn err(code: &'static str, field: &'static str, message: impl Into<String>) -> Self {
Self {
error: true,
field,
rule_code: code,
message: message.into(),
}
}
fn warn(code: &'static str, field: &'static str, message: impl Into<String>) -> Self {
Self {
error: false,
field,
rule_code: code,
message: message.into(),
}
}
}
#[allow(dead_code)]
pub const RULES: &[(&str, &str)] = &[
("REQ-V-0001", "title is required"),
("REQ-V-0002", "title is too short (min 5 characters)"),
("REQ-V-0003", "title is too long (max 120 characters)"),
("REQ-V-0004", "title ends with a period (warn)"),
("REQ-V-0005", "statement is required"),
(
"REQ-V-0006",
"statement must be a complete sentence (>=5 words)",
),
("REQ-V-0007", "statement is too long (>80 words, warn)"),
(
"REQ-V-0008",
"statement must contain a normative modal verb",
),
("REQ-V-0009", "statement contains a weasel word (warn)"),
("REQ-V-0010", "statement looks compound (warn)"),
("REQ-V-0011", "statement must not be a question"),
("REQ-V-0012", "rationale is required"),
("REQ-V-0013", "rationale is very short (warn)"),
(
"REQ-V-0014",
"functional requirement is missing acceptance criteria",
),
("REQ-V-0015", "acceptance criterion is too vague (warn)"),
("REQ-V-0016", "link target does not exist"),
("REQ-V-0017", "self-link not allowed"),
(
"REQ-V-0018",
"status requires acceptance for functional requirement",
),
(
"REQ-V-0019",
"verifies-link source at Implemented status or later has no test record (verification claim without evidence; suppressed below Implemented)",
),
(
"REQ-V-0020",
"duplicate-intent: another non-obsolete requirement is semantically very similar (Jaccard title+statement similarity >= 65%)",
),
(
"REQ-V-0021",
"link cycle detected (graph-level; one finding per cycle)",
),
(
"REQ-V-0022",
"statement stacks uncertainty hedges (perhaps, probably, maybe, possibly, might) (warn)",
),
(
"REQ-V-0023",
"external statement-quality hook flagged this requirement (opt-in via REQ_CONFORM_LLM_CMD)",
),
(
"REQ-V-0024",
"verified status but latest test record outcome is Fail — structural contradiction (warn)",
),
("REQ-V-0025", "hazard is missing its free-text harm narrative"),
(
"REQ-V-0026",
"hazard is assessed (or beyond) but missing one or more C/F/P/W risk parameters",
),
(
"REQ-V-0027",
"hazard is mitigated (or beyond) but no live safety function mitigates it",
),
(
"REQ-V-0028",
"safety mitigates/realizes link points at a non-existent target",
),
(
"REQ-V-0029",
"safety function is allocated (or beyond) but mitigates no hazard",
),
(
"REQ-V-0030",
"safety requirement is Verified but carries no passing evidence",
),
(
"REQ-V-0031",
"SIL 3/4 safety requirement verified on inspection-only evidence (error without an audited --force exception; warn with one)",
),
(
"REQ-V-0032",
"requirement is Verified but has no passing verification dossier and is not verification-exempt",
),
(
"REQ-V-0033",
"safety requirement is Verified but lacks a genuine verification dossier (exemptions are not allowed for safety requirements)",
),
(
"REQ-V-0034",
"safety requirement is Verified on an agent's dossier but lacks a human confirmation of the verification result (run `req verification confirm`)",
),
(
"REQ-V-0035",
"safety requirement is Verified but its verified source has drifted (stale) — re-verify and have a human re-confirm",
),
(
"REQ-V-0036",
"safety requirement's inherited SIL rose above the SIL its evidence was justified against — re-verify at the current level",
),
(
"REQ-V-0037",
"safety requirement was verified by the same actor who authored it (independence of assessment, warn)",
),
(
"REQ-V-0038",
"safety requirement has a genuine dossier and awaits a human co-sign to reach Verified (advisory)",
),
];
static HEDGE_WORDS: &[&str] = &[
"perhaps",
"probably",
"maybe",
"possibly",
"might",
"roughly",
"potentially",
];
static HEDGE_RES: Lazy<Vec<(&'static str, Regex)>> = Lazy::new(|| {
HEDGE_WORDS
.iter()
.map(|w| {
let re = Regex::new(&format!(r"(?i)\b{}\b", regex::escape(w)))
.expect("hedge-word regex compiles");
(*w, re)
})
.collect()
});
static WEASEL_WORDS: &[&str] = &[
"etc",
"and/or",
"user-friendly",
"easy to use",
"robust",
"fast",
"efficient",
"flexible",
"approximately",
"as appropriate",
"if possible",
"tbd",
"to be determined",
"various",
"some",
"many",
"few",
"minimal",
"maximal",
"state-of-the-art",
"seamless",
];
static WEASEL_RES: Lazy<Vec<(&'static str, Regex)>> = Lazy::new(|| {
WEASEL_WORDS
.iter()
.map(|w| {
let re = Regex::new(&format!(r"(?i)\b{}\b", regex::escape(w)))
.expect("weasel-word regex compiles");
(*w, re)
})
.collect()
});
static MODAL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\b(shall|must|should|will)\b").unwrap());
static URL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b[a-z][a-z0-9+.-]*://\S+").unwrap());
static BACKTICK_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"`[^`]*`").unwrap());
static PRIORITY_LABEL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\b(must|should|could|wont)(-priorit(?:y|ies))\b").unwrap());
fn strip_non_prose(s: &str) -> String {
let no_urls = URL_RE.replace_all(s, " ");
let no_code = BACKTICK_RE.replace_all(&no_urls, " ");
let no_priority_labels = PRIORITY_LABEL_RE.replace_all(&no_code, " ");
no_priority_labels.into_owned()
}
pub fn conform_requirement(r: &Requirement) -> Vec<Finding> {
let mut out = Vec::new();
let title = r.title.trim();
let title_chars = title.chars().count();
if title.is_empty() {
out.push(Finding::err(
"REQ-V-0001",
"title",
"title is required — write a short imperative phrase naming the obligation",
));
} else if title_chars < 5 {
out.push(Finding::err(
"REQ-V-0002",
"title",
format!(
"title is too short ({} chars; min 5) — expand to a phrase a reviewer can grep for",
title_chars
),
));
} else if title_chars > 120 {
out.push(Finding::err(
"REQ-V-0003",
"title",
format!(
"title is too long ({} chars; max 120) — move detail into the statement, keep the title scannable",
title_chars
),
));
}
if title.ends_with('.') {
out.push(Finding::warn(
"REQ-V-0004",
"title",
"trailing period on title — titles are noun phrases, not sentences; drop it",
));
}
let stmt = r.statement.trim();
if stmt.is_empty() {
out.push(Finding::err(
"REQ-V-0005",
"statement",
"statement is required — write the obligation as one sentence with a normative modal verb",
));
} else {
let words = stmt.split_whitespace().count();
if words < 5 {
out.push(Finding::err(
"REQ-V-0006",
"statement",
format!(
"statement is {} words; needs ≥5 — a complete obligation usually reads `<actor> shall <verb> <object> <condition>`",
words
),
));
}
if words > 80 {
out.push(Finding::warn(
"REQ-V-0007",
"statement",
format!(
"statement is {} words — try `req split <id>` to break it into atomic obligations",
words
),
));
}
let prose = strip_non_prose(stmt);
if !MODAL_RE.is_match(&prose) {
out.push(Finding::err(
"REQ-V-0008",
"statement",
"statement has no normative modal verb (shall / must / should / will) — see `req help best-practice` for which to use when",
));
}
for (w, re) in WEASEL_RES.iter() {
if re.is_match(&prose) {
out.push(Finding::warn(
"REQ-V-0009",
"statement",
format!(
"vague term '{}' — replace with a measurable criterion (specific number, named protocol, exact behaviour)",
w
),
));
}
}
let modal_hits = MODAL_RE.find_iter(&prose).count();
let and_joins = prose.to_lowercase().matches(" and ").count();
let comma_count = prose.matches(',').count();
let looks_enumeration = and_joins == 1 && comma_count >= 2;
let looks_compound =
prose.contains(';') || modal_hits > 1 || (and_joins >= 2 && !looks_enumeration);
if looks_compound {
let reason = if prose.contains(';') {
"semicolon detected"
} else if modal_hits > 1 {
"repeated modal verb (`shall X and shall Y`)"
} else {
"multiple `and` joins (`A and B and C`)"
};
out.push(Finding::warn(
"REQ-V-0010",
"statement",
format!(
"compound statement — {} — try `req split <id>` to break it into atomic siblings",
reason
),
));
}
let hedge_words_found: Vec<&str> = HEDGE_RES
.iter()
.filter(|(_, re)| re.is_match(&prose))
.map(|(w, _)| *w)
.collect();
if hedge_words_found.len() >= 2 {
let quoted = hedge_words_found
.iter()
.map(|w| format!("`{}`", w))
.collect::<Vec<_>>()
.join(", ");
out.push(Finding::warn(
"REQ-V-0022",
"statement",
format!(
"uncertainty hedges stacked: {} — commit to a concrete behaviour and state what the system actually does",
quoted
),
));
}
if stmt.contains('?') {
out.push(Finding::err(
"REQ-V-0011",
"statement",
"statement contains `?` — a requirement is an obligation, not a question; rephrase as a positive `<actor> shall <verb>` clause",
));
}
}
if r.rationale.trim().is_empty() {
out.push(Finding::err(
"REQ-V-0012",
"rationale",
"rationale is required — explain WHY this requirement exists (which need it serves, which past mistake it prevents, which stakeholder asked)",
));
} else {
let word_count = r.rationale.split_whitespace().count();
if word_count < 3 {
out.push(Finding::warn(
"REQ-V-0013",
"rationale",
format!(
"rationale is only {} word(s) — a useful rationale names the cause or constraint, not just the consequence",
word_count
),
));
}
}
if matches!(r.kind, Kind::Functional) && r.acceptance.is_empty() {
out.push(Finding::err(
"REQ-V-0014",
"acceptance",
"functional requirement has no acceptance criteria — add at least one observable behaviour a reviewer can check off (`req update <id> --add-acceptance \"...\"`)",
));
}
for (i, ac) in r.acceptance.iter().enumerate() {
if ac.split_whitespace().count() < 3 {
out.push(Finding::warn(
"REQ-V-0015",
"acceptance",
format!(
"acceptance #{} is only {} word(s) — name a concrete observable, e.g. `req conform exits 0 on a clean project`",
i + 1,
ac.split_whitespace().count()
),
));
}
}
out
}
pub const DUP_INTENT_THRESHOLD: f64 = 0.65;
fn token_set(s: &str) -> std::collections::HashSet<String> {
use once_cell::sync::Lazy;
use regex::Regex;
static STOP: Lazy<std::collections::HashSet<&'static str>> = Lazy::new(|| {
[
"the", "a", "an", "and", "or", "of", "to", "for", "on", "in", "is", "be", "by", "with",
"as", "that", "this", "shall", "must", "should", "will", "system", "cli",
]
.iter()
.copied()
.collect()
});
static WORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[a-z0-9]+").unwrap());
let lower = s.to_lowercase();
WORD_RE
.find_iter(&lower)
.map(|m| m.as_str().to_string())
.filter(|w| w.len() > 2 && !STOP.contains(w.as_str()))
.collect()
}
fn jaccard(a: &std::collections::HashSet<String>, b: &std::collections::HashSet<String>) -> f64 {
if a.is_empty() && b.is_empty() {
return 0.0;
}
let inter = a.intersection(b).count() as f64;
let union = a.union(b).count() as f64;
inter / union
}
pub fn conform_project(p: &Project) -> Vec<(String, Vec<Finding>)> {
let mut out = Vec::new();
let active: Vec<(&String, std::collections::HashSet<String>)> = p
.requirements
.iter()
.filter(|(_, r)| !matches!(r.status, crate::model::Status::Obsolete))
.map(|(id, r)| (id, token_set(&format!("{} {}", r.title, r.statement))))
.collect();
for (id, r) in &p.requirements {
let mut findings = conform_requirement(r);
if matches!(r.status, crate::model::Status::Obsolete) {
findings.retain(|f| f.error);
}
if !matches!(r.status, crate::model::Status::Obsolete) {
let my_tokens: Option<&std::collections::HashSet<String>> = active
.iter()
.find_map(|(aid, ts)| if *aid == id { Some(ts) } else { None });
if let Some(my) = my_tokens {
for (other_id, other_tokens) in &active {
if *other_id == id {
continue;
}
if id.as_str() > other_id.as_str() {
continue;
}
let sim = jaccard(my, other_tokens);
if sim >= DUP_INTENT_THRESHOLD {
findings.push(Finding::warn(
"REQ-V-0020",
"statement",
format!(
"duplicate-intent: {} overlaps {} at {:.0}% similarity",
id,
other_id,
sim * 100.0
),
));
}
}
}
}
for link in &r.links {
if !p.requirements.contains_key(&link.target) {
findings.push(Finding::err(
"REQ-V-0016",
"links",
format!("link target {} does not exist", link.target),
));
} else if link.target == r.id {
findings.push(Finding::err(
"REQ-V-0017",
"links",
"self-link is not allowed",
));
}
let evidence_expected = matches!(r.status, Status::Implemented | Status::Verified);
if matches!(link.kind, crate::model::LinkKind::Verifies)
&& r.tests.is_empty()
&& evidence_expected
{
findings.push(Finding::warn(
"REQ-V-0019",
"links",
format!(
"verifies → {} but {} has no test records — attach evidence with `req test record {} --result pass --notes \"...\"` or `req verify {} --by inspection --notes \"...\"`",
link.target, r.id, r.id, r.id
),
));
}
}
if matches!(
r.status,
Status::Approved | Status::Implemented | Status::Verified
) && r.acceptance.is_empty()
&& matches!(r.kind, Kind::Functional)
{
findings.push(Finding::err(
"REQ-V-0018",
"status",
"cannot be approved/implemented/verified without acceptance criteria",
));
}
if matches!(r.status, Status::Verified) {
if let Some(latest) = r.tests.last() {
if matches!(latest.outcome, crate::model::TestOutcome::Fail) {
findings.push(Finding::warn(
"REQ-V-0024",
"status",
"verified status but latest test record is Fail — re-record on a fix, or move status back with `req update <id> --status implemented --reason \"...\"`",
));
}
}
let dossier_ok = r.verification.as_ref().map(|v| v.passed()).unwrap_or(false);
if !dossier_ok && !p.req_is_verification_exempt(r) {
findings.push(Finding::err(
"REQ-V-0032",
"verification",
format!(
"{} is Verified but has no passing verification dossier — run `req verification plan {} ...` → analysis → test → conclude, or tag it `{}`",
r.id,
r.id,
crate::model::DEFAULT_VERIFICATION_EXEMPT_TAG
),
));
}
}
if !findings.is_empty() {
out.push((id.clone(), findings));
}
}
if let Ok(cmd) = std::env::var("REQ_CONFORM_LLM_CMD") {
let trimmed = cmd.trim().to_string();
if !trimmed.is_empty() {
let concurrency: usize = std::env::var("REQ_CONFORM_LLM_CONCURRENCY")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &usize| *n >= 1)
.unwrap_or(1);
let work: Vec<(String, String)> = p
.requirements
.iter()
.filter(|(_, r)| !matches!(r.status, crate::model::Status::Obsolete))
.map(|(id, r)| {
let payload = serde_json::json!({
"id": id,
"title": r.title,
"statement": r.statement,
"rationale": r.rationale,
})
.to_string();
(id.clone(), payload)
})
.collect();
type LlmJob = std::thread::JoinHandle<(String, Result<(bool, String), String>)>;
let mut llm_findings: Vec<(String, Finding)> = Vec::new();
for chunk in work.chunks(concurrency.max(1)) {
let mut handles: Vec<LlmJob> = Vec::new();
for (id, payload) in chunk {
let id = id.clone();
let payload = payload.clone();
let cmd = trimmed.clone();
handles.push(std::thread::spawn(move || {
let outcome = run_llm_hook(&cmd, &payload);
(id, outcome)
}));
}
for h in handles {
if let Ok((id, outcome)) = h.join() {
match outcome {
Ok((true, _)) => continue,
Ok((false, message)) => llm_findings.push((
id,
Finding::warn(
"REQ-V-0023",
"statement",
format!("LLM hook flagged: {}", message),
),
)),
Err(e) => llm_findings.push((
id,
Finding::warn(
"REQ-V-0023",
"statement",
format!("LLM hook unavailable: {}", e),
),
)),
}
}
}
}
llm_findings.sort_by(|a, b| a.0.cmp(&b.0));
for (id, finding) in llm_findings {
if let Some((_, existing)) = out.iter_mut().find(|(rid, _)| *rid == id) {
existing.push(finding);
} else {
out.push((id, vec![finding]));
}
}
}
}
for kind in [
crate::model::LinkKind::Parent,
crate::model::LinkKind::DependsOn,
crate::model::LinkKind::Refines,
crate::model::LinkKind::Verifies,
] {
let cycles = find_cycles(p, kind);
for cycle in cycles {
let owner = cycle
.iter()
.min()
.cloned()
.unwrap_or_else(|| cycle[0].clone());
let path = cycle.join(" -> ");
let finding = Finding::err(
"REQ-V-0021",
"links",
format!("{} cycle: {} -> {}", kind.as_str(), path, cycle[0]),
);
if let Some((_, existing)) = out.iter_mut().find(|(rid, _)| *rid == owner) {
existing.push(finding);
} else {
out.push((owner, vec![finding]));
}
}
}
for (id, findings) in conform_safety(p) {
if let Some((_, existing)) = out.iter_mut().find(|(rid, _)| *rid == id) {
existing.extend(findings);
} else {
out.push((id, findings));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
pub fn conform_safety(p: &Project) -> Vec<(String, Vec<Finding>)> {
let mut out: Vec<(String, Vec<Finding>)> = Vec::new();
let mut push = |id: &str, f: Finding| {
if let Some((_, v)) = out.iter_mut().find(|(rid, _)| rid == id) {
v.push(f);
} else {
out.push((id.to_string(), vec![f]));
}
};
for (id, h) in &p.hazards {
if matches!(h.status, HazardStatus::Obsolete) {
continue;
}
if h.harm.trim().is_empty() {
push(
id,
Finding::err(
"REQ-V-0025",
"harm",
"hazard has no harm narrative — describe the potential harm in plain words (e.g. \"an operator's hand could be severed\")",
),
);
}
let assessed_or_beyond = !matches!(h.status, HazardStatus::Identified);
if assessed_or_beyond && !h.is_assessed() {
push(
id,
Finding::err(
"REQ-V-0026",
"risk",
format!(
"{} is {} but has not been fully risk-assessed — set all four C/F/P/W parameters via `req hazard assess {}`",
id,
h.status.as_str(),
id
),
),
);
}
let mitigated_or_beyond =
matches!(h.status, HazardStatus::Mitigated | HazardStatus::Verified);
if mitigated_or_beyond {
let has_live_mitigation = p.safety_functions.values().any(|sf| {
!matches!(sf.status, SafetyFunctionStatus::Obsolete)
&& sf
.links
.iter()
.any(|l| l.kind == LinkKind::Mitigates && l.target == *id)
});
if !has_live_mitigation {
push(
id,
Finding::err(
"REQ-V-0027",
"links",
format!(
"{} is {} but no live safety function mitigates it — add one with `req sf add --mitigates {}` or step the status back",
id,
h.status.as_str(),
id
),
),
);
}
}
}
for (id, sf) in &p.safety_functions {
if matches!(sf.status, SafetyFunctionStatus::Obsolete) {
continue;
}
for l in &sf.links {
if l.kind == LinkKind::Mitigates && !p.hazards.contains_key(&l.target) {
push(
id,
Finding::err(
"REQ-V-0028",
"links",
format!("mitigates link target {} does not exist", l.target),
),
);
}
}
let allocated_or_beyond = !matches!(sf.status, SafetyFunctionStatus::Proposed);
let mitigates_any = sf.links.iter().any(|l| l.kind == LinkKind::Mitigates);
if allocated_or_beyond && !mitigates_any {
push(
id,
Finding::err(
"REQ-V-0029",
"links",
format!(
"{} is {} but mitigates no hazard — link it with `req sf mitigate {} HAZ-NNNN`",
id,
sf.status.as_str(),
id
),
),
);
}
}
for (id, sr) in &p.safety_requirements {
if matches!(sr.status, Status::Obsolete) {
continue;
}
if crate::commands::provenance::sr_awaiting_cosign(sr) {
push(
id,
Finding::warn(
"REQ-V-0038",
"verification",
format!(
"{} has a genuine verification dossier and is awaiting a human co-sign — a person must run `req verification confirm {}` to promote it to Verified",
id, id
),
),
);
}
let shim = Requirement {
id: sr.id.clone(),
title: sr.title.clone(),
statement: sr.statement.clone(),
rationale: sr.rationale.clone(),
acceptance: sr.acceptance.clone(),
kind: Kind::Functional,
priority: sr.priority,
status: sr.status,
tags: Vec::new(),
links: Vec::new(),
created: sr.created,
updated: sr.updated,
history: Vec::new(),
tests: sr.tests.clone(),
verification: None,
extra: Default::default(),
};
for f in conform_requirement(&shim) {
push(id, f);
}
for l in &sr.links {
if l.kind == LinkKind::Realizes && !p.safety_functions.contains_key(&l.target) {
push(
id,
Finding::err(
"REQ-V-0028",
"links",
format!("realizes link target {} does not exist", l.target),
),
);
}
}
if matches!(sr.status, Status::Verified) {
let genuine =
crate::commands::verification::classify(sr.verification.as_ref(), None, id)
.is_genuine();
if !genuine {
let exempt = sr.verification.as_ref().map(|v| v.exempt).unwrap_or(false);
let why = if exempt {
"rests on an audited exemption, which safety requirements may not use"
} else {
"has no passing verification dossier"
};
push(
id,
Finding::err(
"REQ-V-0033",
"verification",
format!(
"{} is Verified but {} — safety requirements need a genuine dossier; run `req verification plan {} ...` → analysis → test → conclude --promote",
id, why, id
),
),
);
}
let human_confirmed = sr
.verification
.as_ref()
.map(|v| v.human_confirmation.is_some())
.unwrap_or(false);
if genuine && !human_confirmed {
push(
id,
Finding::err(
"REQ-V-0034",
"verification",
format!(
"{} is Verified on an agent's dossier but lacks a human confirmation of the verification result — a person must run `req verification confirm {}` to co-sign it",
id, id
),
),
);
}
let last_pass = sr
.tests
.iter()
.rev()
.find(|t| matches!(t.outcome, crate::model::TestOutcome::Pass));
match last_pass {
None => push(
id,
Finding::err(
"REQ-V-0030",
"tests",
format!(
"{} is Verified but has no passing evidence — record it with `req sreq verify {} --by automated ...`",
id, id
),
),
),
Some(t) => {
let sil = p.inherited_sil(sr);
if let (Some(current), Some(evidence)) = (sil, t.sil_at_verification) {
if current.rank() > evidence.rank() {
push(
id,
Finding::err(
"REQ-V-0036",
"verification",
format!(
"{} is Verified at {} but its evidence was justified at {} — the inherited SIL rose after verification; re-verify at the current level (`req sreq verify {} --by automated --promote`)",
id,
current.as_str(),
evidence.as_str(),
id
),
),
);
}
}
let needs_strong = sil.map(|s| s.rank() >= Sil::Sil3.rank()).unwrap_or(false);
if needs_strong && matches!(t.kind, EvidenceKind::Inspection) {
let audited = t.sil_gate_exception;
if audited {
push(
id,
Finding::warn(
"REQ-V-0031",
"tests",
format!(
"{} inherits {} and is verified by an audited inspection exception — confirm the justification holds",
id,
sil.map(|s| s.as_str()).unwrap_or("SIL3+")
),
),
);
} else {
push(
id,
Finding::err(
"REQ-V-0031",
"tests",
format!(
"{} inherits {} but its verification is inspection-only — SIL 3/4 needs automated or composition evidence (re-verify, or use --force to record an audited exception)",
id,
sil.map(|s| s.as_str()).unwrap_or("SIL3+")
),
),
);
}
}
}
}
let author = sr.history.first().map(|h| h.actor.trim().to_string());
let verifier = sr
.verification
.as_ref()
.and_then(|v| v.human_confirmation.as_ref())
.map(|c| c.actor.clone())
.or_else(|| {
sr.tests
.iter()
.rev()
.find(|t| matches!(t.outcome, crate::model::TestOutcome::Pass))
.map(|t| t.actor.clone())
})
.map(|a| a.trim().to_string());
if let (Some(a), Some(v)) = (author, verifier) {
if !a.is_empty() && a.eq_ignore_ascii_case(&v) {
push(
id,
Finding::warn(
"REQ-V-0037",
"verification",
format!(
"{} was authored and verified by the same actor ({}) — IEC 61508 wants independence of assessment; have a different competent person verify it",
id, v
),
),
);
}
}
}
}
out
}
fn run_llm_hook(cmd: &str, payload: &str) -> Result<(bool, String), String> {
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let (shell, flag): (String, String) = match std::env::var("REQ_CONFORM_LLM_SHELL") {
Ok(s) if !s.trim().is_empty() => {
let s = s.trim().to_string();
let f = if s.eq_ignore_ascii_case("cmd") {
"/C".to_string()
} else if s.eq_ignore_ascii_case("powershell") || s.eq_ignore_ascii_case("pwsh") {
"-Command".to_string()
} else {
"-c".to_string()
};
(s, f)
}
_ => {
if cfg!(windows) {
("cmd".to_string(), "/C".to_string())
} else {
("sh".to_string(), "-c".to_string())
}
}
};
let mut child = Command::new(&shell)
.args([&flag, cmd])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
format!(
"spawn `{}` (override with REQ_CONFORM_LLM_SHELL): {}",
shell, e
)
})?;
{
let mut stdin = child.stdin.take().ok_or("stdin unavailable")?;
stdin
.write_all(payload.as_bytes())
.map_err(|e| format!("write: {}", e))?;
drop(stdin);
}
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
return Err("timed out after 10s".to_string());
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(format!("wait: {}", e)),
}
}
let out = child
.wait_with_output()
.map_err(|e| format!("wait: {}", e))?;
if !out.status.success() {
return Err(format!(
"hook exited non-zero: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let body = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value =
serde_json::from_str(body.trim()).map_err(|e| format!("parse json: {}", e))?;
let ok = v["ok"].as_bool().ok_or("missing 'ok' boolean")?;
let message = v["message"].as_str().unwrap_or("").to_string();
Ok((ok, message))
}
fn find_cycles(p: &Project, kind: crate::model::LinkKind) -> Vec<Vec<String>> {
use std::collections::BTreeSet;
let mut seen: BTreeSet<Vec<String>> = BTreeSet::new();
for start in p.requirements.keys() {
let mut current = start.clone();
let mut path: Vec<String> = Vec::new();
loop {
if let Some(pos) = path.iter().position(|x| x == ¤t) {
let cycle = path[pos..].to_vec();
let mut canonical = cycle.clone();
if let Some(min_pos) = canonical
.iter()
.enumerate()
.min_by_key(|(_, v)| (*v).clone())
.map(|(i, _)| i)
{
canonical.rotate_left(min_pos);
}
seen.insert(canonical);
break;
}
path.push(current.clone());
let next = p.requirements.get(¤t).and_then(|r| {
r.links
.iter()
.find(|l| l.kind == kind)
.map(|l| l.target.clone())
});
match next {
Some(n) if p.requirements.contains_key(&n) => current = n,
_ => break,
}
}
}
seen.into_iter().collect()
}
pub fn errors_only(findings: &[Finding]) -> Vec<&Finding> {
findings.iter().filter(|f| f.error).collect()
}