Skip to main content

cpm_planner/
lint.rs

1//! `plan.lint`: static checks over a [`PlanGraph`] that never submit it.
2//!
3//! Every submit-time rule of the planner is reported here as a finding
4//! instead of an error, plus advisory checks (rationale, redundancy,
5//! milestone coverage). Lint never panics on a malformed graph and its
6//! output is deterministic.
7
8use std::collections::{HashMap, HashSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::graph::{prerequisite_ids, reachability};
13use crate::plan::{
14    Deliverable, FINISH_ID, FileMode, MAX_DELIVERABLES, PlanGraph, PrerequisiteKind, START_ID,
15};
16
17/// How serious a finding is. Orders errors first.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Severity {
21    Error,
22    Warning,
23    Info,
24}
25
26/// One lint result.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct LintFinding {
29    /// Stable machine-readable code, e.g. `CYCLE`.
30    pub code: String,
31    pub severity: Severity,
32    pub message: String,
33    /// Deliverables involved (loop order for `CYCLE`).
34    pub ids: Vec<String>,
35    /// File path, for file findings.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub path: Option<String>,
38}
39
40/// All findings for a graph. `clean` means no error or warning findings.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct LintReport {
43    pub clean: bool,
44    pub findings: Vec<LintFinding>,
45}
46
47fn finding(code: &str, severity: Severity, message: String, ids: Vec<String>) -> LintFinding {
48    LintFinding {
49        code: code.to_string(),
50        severity,
51        message,
52        ids,
53        path: None,
54    }
55}
56
57fn has_legacy_rationale(d: &Deliverable, prereq: &str) -> bool {
58    d.metadata
59        .get("consumes")
60        .and_then(|c| c.get(prereq))
61        .is_some_and(serde_json::Value::is_string)
62}
63
64/// Back edges found by a DFS along prerequisite links, one loop per back
65/// edge. Each loop starts and ends at the same id.
66fn find_cycles(graph: &PlanGraph, known: &HashSet<&str>) -> Vec<Vec<String>> {
67    #[derive(Clone, Copy, PartialEq)]
68    enum Mark {
69        Active,
70        Done,
71    }
72    let mut by_id: HashMap<&str, &Deliverable> = HashMap::new();
73    for d in &graph.deliverables {
74        by_id.entry(d.id.as_str()).or_insert(d);
75    }
76    let mut marks: HashMap<&str, Mark> = HashMap::new();
77    let mut cycles = Vec::new();
78    for root in &graph.deliverables {
79        if marks.contains_key(root.id.as_str()) {
80            continue;
81        }
82        // Iterative DFS: (node, index of next prerequisite to visit).
83        let mut stack: Vec<(&str, usize)> = vec![(root.id.as_str(), 0)];
84        marks.insert(root.id.as_str(), Mark::Active);
85        while let Some(&mut (node, ref mut next)) = stack.last_mut() {
86            let prereqs: Vec<&str> = by_id.get(node).map_or_else(Vec::new, |d| {
87                prerequisite_ids(d).filter(|p| known.contains(p)).collect()
88            });
89            if let Some(&p) = prereqs.get(*next) {
90                *next += 1;
91                match marks.get(p) {
92                    None => {
93                        marks.insert(p, Mark::Active);
94                        stack.push((p, 0));
95                    }
96                    Some(Mark::Active) => {
97                        if let Some(pos) = stack.iter().position(|(n, _)| *n == p) {
98                            let mut ids: Vec<String> =
99                                stack[pos..].iter().map(|(n, _)| (*n).to_string()).collect();
100                            ids.push(p.to_string());
101                            cycles.push(ids);
102                        }
103                    }
104                    Some(Mark::Done) => {}
105                }
106            } else {
107                marks.insert(node, Mark::Done);
108                stack.pop();
109            }
110        }
111    }
112    cycles
113}
114
115/// Fixed-width set of node indices, one bit per node.
116struct BitSet {
117    words: Vec<u64>,
118}
119
120impl BitSet {
121    fn new(n: usize) -> Self {
122        Self {
123            words: vec![0; n.div_ceil(64)],
124        }
125    }
126
127    fn insert(&mut self, i: usize) {
128        self.words[i / 64] |= 1 << (i % 64);
129    }
130
131    fn union_with(&mut self, other: &Self) {
132        for (a, b) in self.words.iter_mut().zip(&other.words) {
133            *a |= *b;
134        }
135    }
136
137    fn intersects(&self, other: &Self) -> bool {
138        self.words.iter().zip(&other.words).any(|(a, b)| a & b != 0)
139    }
140}
141
142/// `REDUNDANT_EDGE` and `FEEDS_NO_MILESTONE` for an acyclic graph with
143/// unique, known ids. Descendant sets are bitsets filled in reverse
144/// topological order, so the cost is O(V + E * V / 64).
145fn structure_findings(graph: &PlanGraph) -> Vec<LintFinding> {
146    let n = graph.deliverables.len();
147    let index: HashMap<&str, usize> = graph
148        .deliverables
149        .iter()
150        .enumerate()
151        .map(|(i, d)| (d.id.as_str(), i))
152        .collect();
153    // Distinct prerequisite indices per node, in declaration order.
154    let preds: Vec<Vec<usize>> = graph
155        .deliverables
156        .iter()
157        .map(|d| {
158            let mut ps: Vec<usize> = Vec::new();
159            for p in prerequisite_ids(d) {
160                if let Some(&pi) = index.get(p)
161                    && !ps.contains(&pi)
162                {
163                    ps.push(pi);
164                }
165            }
166            ps
167        })
168        .collect();
169    let mut succs: Vec<Vec<usize>> = vec![Vec::new(); n];
170    for (i, ps) in preds.iter().enumerate() {
171        for &p in ps {
172            succs[p].push(i);
173        }
174    }
175    let mut indeg: Vec<usize> = preds.iter().map(Vec::len).collect();
176    let mut order: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
177    let mut head = 0;
178    while head < order.len() {
179        let u = order[head];
180        head += 1;
181        for &v in &succs[u] {
182            indeg[v] -= 1;
183            if indeg[v] == 0 {
184                order.push(v);
185            }
186        }
187    }
188    if order.len() != n {
189        // Callers only pass acyclic graphs; never report on a bad order.
190        return Vec::new();
191    }
192
193    let milestone: Vec<bool> = graph
194        .deliverables
195        .iter()
196        .map(Deliverable::is_milestone)
197        .collect();
198    let mut desc: Vec<BitSet> = (0..n).map(|_| BitSet::new(n)).collect();
199    let mut feeds = vec![false; n];
200    for &i in order.iter().rev() {
201        let mut set = BitSet::new(n);
202        for &s in &succs[i] {
203            set.insert(s);
204            set.union_with(&desc[s]);
205            feeds[i] |= milestone[s] || feeds[s];
206        }
207        desc[i] = set;
208    }
209
210    let mut out = Vec::new();
211    for (i, d) in graph.deliverables.iter().enumerate() {
212        if preds[i].len() > 1 {
213            let mut prereq_set = BitSet::new(n);
214            for &p in &preds[i] {
215                prereq_set.insert(p);
216            }
217            for &p in &preds[i] {
218                // A DAG node is never its own descendant, so any hit is
219                // another prerequisite reachable from `p`.
220                if desc[p].intersects(&prereq_set) {
221                    let pid = &graph.deliverables[p].id;
222                    out.push(finding(
223                        "REDUNDANT_EDGE",
224                        Severity::Warning,
225                        format!(
226                            "'{}' <- '{pid}' is redundant: '{pid}' is already an ancestor of another prerequisite",
227                            d.id
228                        ),
229                        vec![d.id.clone(), pid.clone()],
230                    ));
231                }
232            }
233        }
234    }
235    if milestone.iter().any(|&m| m) {
236        for (i, d) in graph.deliverables.iter().enumerate() {
237            if !milestone[i] && !feeds[i] {
238                out.push(finding(
239                    "FEEDS_NO_MILESTONE",
240                    Severity::Warning,
241                    format!("'{}' is neither a milestone nor an ancestor of one", d.id),
242                    vec![d.id.clone()],
243                ));
244            }
245        }
246    }
247    out
248}
249
250/// Lint `graph`. Never errors or panics.
251pub fn lint(graph: &PlanGraph) -> LintReport {
252    use Severity::{Error, Info, Warning};
253    let mut out: Vec<LintFinding> = Vec::new();
254
255    // Size: an oversized graph gets one finding and no further (quadratic)
256    // analysis. Same limit and message as submit.
257    if graph.deliverables.len() > MAX_DELIVERABLES {
258        out.push(finding(
259            "TOO_MANY_DELIVERABLES",
260            Error,
261            format!(
262                "plan has {} deliverables; maximum is {MAX_DELIVERABLES}",
263                graph.deliverables.len()
264            ),
265            Vec::new(),
266        ));
267        return LintReport {
268            clean: false,
269            findings: out,
270        };
271    }
272
273    // Identity.
274    let mut seen: HashSet<&str> = HashSet::new();
275    let mut dup_reported: HashSet<&str> = HashSet::new();
276    for d in &graph.deliverables {
277        if d.id == START_ID || d.id == FINISH_ID {
278            out.push(finding(
279                "RESERVED_ID",
280                Error,
281                format!("deliverable id '{}' is reserved", d.id),
282                vec![d.id.clone()],
283            ));
284        }
285        if !seen.insert(d.id.as_str()) && dup_reported.insert(d.id.as_str()) {
286            out.push(finding(
287                "DUPLICATE_ID",
288                Error,
289                format!("duplicate deliverable id '{}'", d.id),
290                vec![d.id.clone()],
291            ));
292        }
293    }
294    let duplicated = !dup_reported.is_empty();
295    let known: HashSet<&str> = seen;
296
297    // Values: same rules and messages as submit.
298    for problem in crate::graph::value_problems(graph) {
299        out.push(finding(
300            "INVALID_VALUE",
301            Error,
302            problem.message,
303            problem.ids,
304        ));
305    }
306
307    // References and cycles.
308    let mut unknown = false;
309    for d in &graph.deliverables {
310        for p in prerequisite_ids(d) {
311            if !known.contains(p) {
312                unknown = true;
313                out.push(finding(
314                    "UNKNOWN_PREREQUISITE",
315                    Error,
316                    format!(
317                        "prerequisite '{p}' for deliverable '{}' does not exist",
318                        d.id
319                    ),
320                    vec![d.id.clone(), p.to_string()],
321                ));
322            }
323        }
324    }
325    let cycles = find_cycles(graph, &known);
326    let cyclic = !cycles.is_empty();
327    for ids in cycles {
328        out.push(finding(
329            "CYCLE",
330            Error,
331            format!("prerequisite cycle: {}", ids.join(" -> ")),
332            ids,
333        ));
334    }
335
336    // Shared files: same rule as submit. Reachability is only computed when
337    // a shared path actually needs an ordering check.
338    let mut reach: Option<HashMap<String, HashSet<String>>> = None;
339    let mut claimants: HashMap<&std::path::Path, Vec<(&str, FileMode)>> = HashMap::new();
340    let mut path_order: Vec<&std::path::Path> = Vec::new();
341    for d in &graph.deliverables {
342        for f in &d.owned_files {
343            claimants
344                .entry(f.path())
345                .or_insert_with(|| {
346                    path_order.push(f.path());
347                    Vec::new()
348                })
349                .push((d.id.as_str(), f.mode()));
350        }
351    }
352    for path in path_order {
353        let list = &claimants[path];
354        for (i, &(x, xm)) in list.iter().enumerate() {
355            for &(y, ym) in &list[i + 1..] {
356                if x == y || (xm == FileMode::Append && ym == FileMode::Append) {
357                    continue;
358                }
359                let reach = reach.get_or_insert_with(|| reachability(graph));
360                let ordered = reach.get(x).is_some_and(|r| r.contains(y))
361                    || reach.get(y).is_some_and(|r| r.contains(x));
362                if !ordered {
363                    let mut f = finding(
364                        "UNORDERED_FILE_OVERLAP",
365                        Error,
366                        format!(
367                            "file '{}' is claimed by '{x}' and '{y}', which are not ordered by prerequisites",
368                            path.display()
369                        ),
370                        vec![x.to_string(), y.to_string()],
371                    );
372                    f.path = Some(path.display().to_string());
373                    out.push(f);
374                }
375            }
376        }
377    }
378
379    // Edge quality.
380    let by_id: HashMap<&str, &Deliverable> = {
381        let mut m = HashMap::new();
382        for d in &graph.deliverables {
383            m.entry(d.id.as_str()).or_insert(d);
384        }
385        m
386    };
387    for d in &graph.deliverables {
388        for p in &d.prerequisites {
389            if p.consumes().is_none() && !has_legacy_rationale(d, p.id()) {
390                out.push(finding(
391                    "NO_RATIONALE",
392                    Warning,
393                    format!(
394                        "'{}' depends on '{}' without saying what it consumes",
395                        d.id,
396                        p.id()
397                    ),
398                    vec![d.id.clone(), p.id().to_string()],
399                ));
400            }
401            if p.kind() == Some(PrerequisiteKind::Interface)
402                && let Some(target) = by_id.get(p.id())
403                && target
404                    .metadata
405                    .get("contract")
406                    .and_then(serde_json::Value::as_bool)
407                    != Some(true)
408            {
409                out.push(finding(
410                    "INTERFACE_EDGE_NOT_CONTRACT",
411                    Warning,
412                    format!(
413                        "'{}' has an interface edge to '{}', which is not marked metadata.contract = true",
414                        d.id,
415                        p.id()
416                    ),
417                    vec![d.id.clone(), p.id().to_string()],
418                ));
419            }
420        }
421    }
422
423    // Structure checks need a well-formed DAG with unique ids.
424    if !cyclic && !unknown && !duplicated {
425        out.extend(structure_findings(graph));
426    }
427
428    if !graph.deliverables.iter().any(Deliverable::is_milestone) {
429        out.push(finding(
430            "NO_MILESTONE",
431            Info,
432            "the graph defines no milestone".to_string(),
433            Vec::new(),
434        ));
435    }
436    for d in graph.deliverables.iter().filter(|d| !d.is_milestone()) {
437        if d.metadata.get("artifact").is_none() {
438            out.push(finding(
439                "NO_ARTIFACT",
440                Info,
441                format!("'{}' declares no metadata.artifact", d.id),
442                vec![d.id.clone()],
443            ));
444        }
445    }
446
447    out.sort_by(|a, b| {
448        (a.severity, &a.code, &a.ids, &a.path, &a.message)
449            .cmp(&(b.severity, &b.code, &b.ids, &b.path, &b.message))
450    });
451    out.dedup();
452    let clean = out.iter().all(|f| f.severity == Info);
453    LintReport {
454        clean,
455        findings: out,
456    }
457}
458
459/// Append `finding` to `report`, restoring the deterministic finding order
460/// and recomputing `clean` ("clean means no error or warning finding").
461/// Callers outside `lint` (e.g. `plan.lint` on a stored plan) use this to
462/// add a finding without duplicating the ordering rule.
463pub fn push_finding(report: &mut LintReport, finding: LintFinding) {
464    report.findings.push(finding);
465    report.findings.sort_by(|a, b| {
466        (a.severity, &a.code, &a.ids, &a.path, &a.message)
467            .cmp(&(b.severity, &b.code, &b.ids, &b.path, &b.message))
468    });
469    report.findings.dedup();
470    report.clean = report.findings.iter().all(|f| f.severity == Severity::Info);
471}