1use 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct LintFinding {
29 pub code: String,
31 pub severity: Severity,
32 pub message: String,
33 pub ids: Vec<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub path: Option<String>,
38}
39
40#[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
64fn 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 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
115struct 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
142fn 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 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 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 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
250pub fn lint(graph: &PlanGraph) -> LintReport {
252 use Severity::{Error, Info, Warning};
253 let mut out: Vec<LintFinding> = Vec::new();
254
255 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 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 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 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 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 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 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
459pub 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}