Skip to main content

rac_engine/
watchkeeper.rs

1//! Watchkeeper report assembly (`decided.services.watchkeeper`): resolve the
2//! base and head of a comparison — each an existing directory or a git
3//! revision materialized through `revisions` — load both states, compare,
4//! run intent analysis, and derive the deterministic review verdict.
5//! `to_dict` (rendered in `output.rs`) is the stable JSON contract
6//! (ADR-007, schema_version "1").
7
8use std::path::Path;
9
10use crate::compare::{compare_states, load_state, RepositoryComparison};
11use crate::intent::{
12    analyze_intent, IntentFinding, ACCEPTANCE_CRITERIA_REMOVED, CONSTRAINT_REMOVED,
13    CONSTRAINT_WEAKENED, SEVERITY_WARNING, SPECIFICITY_REGRESSION, SUCCESS_MEASURES_REMOVED,
14};
15use crate::pycompat::{py_abspath, py_relpath};
16use crate::revisions::{materialize_revision, repository_root, MaterializedRevision, RevisionError};
17
18// Recommendation reason codes (part of the JSON contract, ADR-007).
19pub const REASON_VALIDATION_REGRESSION: &str = "validation_regression";
20pub const REASON_BROKEN_RELATIONSHIP: &str = "broken_relationship";
21
22/// Findings that recommend review on their own. Ambiguity, unlinked scope,
23/// and relationship impact inform but never recommend (v0.12.2 contract).
24pub const RECOMMENDING_FINDINGS: [&str; 5] = [
25    SPECIFICITY_REGRESSION,
26    CONSTRAINT_WEAKENED,
27    CONSTRAINT_REMOVED,
28    ACCEPTANCE_CRITERIA_REMOVED,
29    SUCCESS_MEASURES_REMOVED,
30];
31
32pub fn is_recommending(code: &str) -> bool {
33    RECOMMENDING_FINDINGS.contains(&code)
34}
35
36/// Core-owned reason sentences, one per code: consumers render these, they
37/// do not compose their own.
38fn reason_text(code: &str) -> &'static str {
39    match code {
40        REASON_VALIDATION_REGRESSION => "One or more artifacts became invalid.",
41        REASON_BROKEN_RELATIONSHIP => "One or more relationship references broke.",
42        SPECIFICITY_REGRESSION => "A measurable requirement became vague.",
43        CONSTRAINT_WEAKENED => "A mandatory requirement was weakened.",
44        CONSTRAINT_REMOVED => "A requirement with mandatory wording was removed.",
45        ACCEPTANCE_CRITERIA_REMOVED => "An acceptance criteria section was removed.",
46        SUCCESS_MEASURES_REMOVED => "A success measures section was removed.",
47        _ => "",
48    }
49}
50
51/// One deterministic reason human review is recommended.
52#[derive(Debug, Clone)]
53pub struct ReviewRecommendation {
54    pub code: &'static str,
55    pub reason: &'static str,
56}
57
58/// One product knowledge review: base state, head state, what changed.
59pub struct WatchkeeperReport {
60    pub directory: String,
61    pub base: String, // base label: revision name or directory path
62    pub head: String, // head label: revision name or directory path (working tree)
63    pub comparison: RepositoryComparison,
64    pub findings: Vec<IntentFinding>,
65    pub recommendations: Vec<ReviewRecommendation>,
66}
67
68impl WatchkeeperReport {
69    pub fn review_recommended(&self) -> bool {
70        !self.recommendations.is_empty()
71    }
72
73    pub fn has_warnings(&self) -> bool {
74        self.findings
75            .iter()
76            .any(|f| f.severity == SEVERITY_WARNING)
77    }
78}
79
80/// The deterministic finding/delta -> reason mapping (v0.12.2): validation
81/// regressions, broken relationships, then finding-driven reasons in
82/// finding order, deduplicated by code.
83pub fn derive_recommendations(
84    comparison: &RepositoryComparison,
85    findings: &[IntentFinding],
86) -> Vec<ReviewRecommendation> {
87    let mut codes: Vec<&'static str> = Vec::new();
88    if !comparison.validation.newly_invalid.is_empty() {
89        codes.push(REASON_VALIDATION_REGRESSION);
90    }
91    if !comparison.relationships.new_issues.is_empty() {
92        codes.push(REASON_BROKEN_RELATIONSHIP);
93    }
94    for finding in findings {
95        if is_recommending(finding.code) && !codes.contains(&finding.code) {
96            codes.push(finding.code);
97        }
98    }
99    codes
100        .into_iter()
101        .map(|code| ReviewRecommendation {
102            code,
103            reason: reason_text(code),
104        })
105        .collect()
106}
107
108/// A directory for one comparison side: `reference` itself when it names an
109/// existing directory, or a materialization of it as a git revision.
110fn resolve_side(
111    guards: &mut Vec<MaterializedRevision>,
112    directory: &str,
113    reference: &str,
114) -> Result<String, RevisionError> {
115    if Path::new(reference).is_dir() {
116        return Ok(reference.to_string());
117    }
118    let root = repository_root(directory)?;
119    let subpath = py_relpath(&py_abspath(directory), &root);
120    let materialized = materialize_revision(&root, reference, &subpath)?;
121    let corpus = materialized.corpus.to_string_lossy().into_owned();
122    guards.push(materialized);
123    Ok(corpus)
124}
125
126/// `build_watchkeeper_report(directory, base=..., head=...)` — compare the
127/// corpus at `directory` between `base` and `head` (`None` head = the
128/// working tree at `directory`).
129pub fn build_watchkeeper_report(
130    directory: &str,
131    base: &str,
132    head: Option<&str>,
133) -> Result<WatchkeeperReport, RevisionError> {
134    let head_label = head.unwrap_or(directory);
135    let mut guards: Vec<MaterializedRevision> = Vec::new();
136    let base_dir = resolve_side(&mut guards, directory, base)?;
137    let head_dir = match head {
138        Some(reference) => resolve_side(&mut guards, directory, reference)?,
139        None => directory.to_string(),
140    };
141    let base_state = load_state(&base_dir, base);
142    let head_state = load_state(&head_dir, head_label);
143    let comparison = compare_states(base_state, head_state);
144    let findings = analyze_intent(&comparison);
145    drop(guards); // materialized revisions are removed here, like ExitStack
146    let recommendations = derive_recommendations(&comparison, &findings);
147    Ok(WatchkeeperReport {
148        directory: directory.to_string(),
149        base: base.to_string(),
150        head: head_label.to_string(),
151        comparison,
152        findings,
153        recommendations,
154    })
155}