Skip to main content

comindly_core/
checks.rs

1//! Compiled constraint checks: `forbid [<edge-kind>] from <sel> to <sel>` and `forbid dep <pkg> [<ver>]`,
2//! where a selector is `<glob over repo/path>[::<glob over name>]`.
3
4use std::collections::HashMap;
5
6use anyhow::{bail, Context, Result};
7use globset::{Glob, GlobMatcher};
8use serde::Serialize;
9
10use crate::ledger::{Entry, Kind, Status, Trust};
11use crate::manifests::PackageRow;
12use crate::model::{Edge, EdgeKind, Symbol};
13
14#[derive(Debug, Clone)]
15pub struct Selector {
16    path: GlobMatcher,
17    name: Option<GlobMatcher>,
18}
19
20impl Selector {
21    fn parse(s: &str) -> Result<Selector> {
22        let (path, name) = match s.split_once("::") {
23            Some((p, n)) => (p, Some(glob(n)?)),
24            None => (s, None),
25        };
26        Ok(Selector {
27            path: glob(path)?,
28            name,
29        })
30    }
31
32    fn matches(&self, s: &Symbol) -> bool {
33        self.path.is_match(format!("{}/{}", s.repo.0, s.file_path))
34            && self.name.as_ref().is_none_or(|n| n.is_match(&s.name))
35    }
36}
37
38#[derive(Debug, Clone)]
39pub enum Rule {
40    Edge {
41        kind: Option<EdgeKind>,
42        from: Selector,
43        to: Selector,
44    },
45    Dep {
46        name: GlobMatcher,
47        version: Option<GlobMatcher>,
48    },
49}
50
51/// One place where the code breaks a rule.
52#[derive(Debug, Clone, Serialize)]
53pub struct Violation {
54    pub repo: String,
55    pub path: String,
56    pub line: u32,
57    pub what: String,
58}
59
60/// A checked constraint and what it found.
61#[derive(Debug, Clone, Serialize)]
62pub struct Finding {
63    pub record: String,
64    pub title: String,
65    pub rule: String,
66    pub severity: String,
67    pub violations: Vec<Violation>,
68}
69
70fn glob(s: &str) -> Result<GlobMatcher> {
71    Ok(Glob::new(s)
72        .with_context(|| format!("bad glob `{s}`"))?
73        .compile_matcher())
74}
75
76fn edge_kind(s: &str) -> Option<EdgeKind> {
77    use EdgeKind::*;
78    [
79        Contains,
80        Imports,
81        Calls,
82        Instantiates,
83        Inherits,
84        Implements,
85        References,
86        Defines,
87        Uses,
88        ParticipatesIn,
89        Serves,
90        Consumes,
91        Publishes,
92        Subscribes,
93        Reads,
94        Writes,
95    ]
96    .into_iter()
97    .find(|k| k.as_str() == s)
98}
99
100impl Rule {
101    pub fn parse(text: &str) -> Result<Rule> {
102        let w: Vec<&str> = text.split_whitespace().collect();
103        match w.as_slice() {
104            ["forbid", "dep", name] => Ok(Rule::Dep {
105                name: glob(name)?,
106                version: None,
107            }),
108            ["forbid", "dep", name, v] => Ok(Rule::Dep {
109                name: glob(name)?,
110                version: Some(glob(v.trim_start_matches(['=', '^', '~']))?),
111            }),
112            ["forbid", "from", a, "to", b] => Ok(Rule::Edge {
113                kind: None,
114                from: Selector::parse(a)?,
115                to: Selector::parse(b)?,
116            }),
117            ["forbid", k, "from", a, "to", b] => Ok(Rule::Edge {
118                kind: Some(edge_kind(k).with_context(|| format!("unknown edge kind `{k}`"))?),
119                from: Selector::parse(a)?,
120                to: Selector::parse(b)?,
121            }),
122            _ => bail!(
123                "expected `forbid [<edge-kind>] from <glob>[::<name>] to <glob>[::<name>]` \
124                 or `forbid dep <package> [<version>]`"
125            ),
126        }
127    }
128
129    pub fn violations(
130        &self,
131        by_id: &HashMap<String, &Symbol>,
132        edges: &[Edge],
133        packages: &[PackageRow],
134    ) -> Vec<Violation> {
135        let mut out = Vec::new();
136        match self {
137            Rule::Edge { kind, from, to } => {
138                for e in edges {
139                    let wanted = match kind {
140                        Some(k) => &e.kind == k,
141                        None => e.kind != EdgeKind::Contains,
142                    };
143                    if !wanted {
144                        continue;
145                    }
146                    let (Some(s), Some(d)) =
147                        (by_id.get(&e.src.render()), by_id.get(&e.dst.render()))
148                    else {
149                        continue;
150                    };
151                    if from.matches(s) && to.matches(d) {
152                        out.push(Violation {
153                            repo: s.repo.0.clone(),
154                            path: s.file_path.clone(),
155                            line: s.range.start.line,
156                            what: if d.file_path.is_empty() {
157                                format!("`{}` {} `{}`", s.name, e.kind, d.name)
158                            } else {
159                                format!(
160                                    "`{}` {} `{}` ({}/{})",
161                                    s.name, e.kind, d.name, d.repo.0, d.file_path
162                                )
163                            },
164                        });
165                    }
166                }
167            }
168            Rule::Dep { name, version } => {
169                for p in packages
170                    .iter()
171                    .filter(|p| !p.publishes && name.is_match(&p.name))
172                {
173                    let hit = match version {
174                        None => true,
175                        Some(v) => [&p.locked, &p.version].into_iter().flatten().any(|x| {
176                            v.is_match(x.trim_start_matches(['=', '^', '~', '>', '<', ' ']))
177                        }),
178                    };
179                    if hit {
180                        out.push(Violation {
181                            repo: p.repo.clone(),
182                            path: p.manifest.clone(),
183                            line: 0,
184                            what: format!(
185                                "depends on {} {}",
186                                p.name,
187                                p.locked.as_deref().or(p.version.as_deref()).unwrap_or("*")
188                            ),
189                        });
190                    }
191                }
192            }
193        }
194        out.sort_by(|a, b| (&a.repo, &a.path, a.line).cmp(&(&b.repo, &b.path, b.line)));
195        out.dedup_by(|a, b| a.repo == b.repo && a.path == b.path && a.what == b.what);
196        out
197    }
198}
199
200/// Live attempts anchored to a dependency version (`dep=<pkg> <version>`) whose condition no
201/// longer holds: the package is still used, but no manifest matches that version any more.
202pub fn revivals<'e>(entries: &'e [Entry], packages: &[PackageRow]) -> Vec<(&'e Entry, String)> {
203    let mut out = Vec::new();
204    for e in entries {
205        if e.record.kind != Kind::Attempt
206            || matches!(e.status, Status::Retracted | Status::Superseded)
207        {
208            continue;
209        }
210        let Some(dep) = e.record.fields.get("dep").and_then(|v| v.as_str()) else {
211            continue;
212        };
213        let Ok(Rule::Dep {
214            name,
215            version: Some(v),
216        }) = Rule::parse(&format!("forbid dep {dep}"))
217        else {
218            continue;
219        };
220        let pinned = Rule::Dep {
221            name: name.clone(),
222            version: Some(v),
223        };
224        let none = HashMap::new();
225        let used = Rule::Dep {
226            name,
227            version: None,
228        }
229        .violations(&none, &[], packages);
230        if !used.is_empty() && pinned.violations(&none, &[], packages).is_empty() {
231            let now: std::collections::BTreeSet<&str> = used
232                .iter()
233                .filter_map(|u| u.what.rsplit(' ').next())
234                .collect();
235            out.push((
236                e,
237                format!(
238                    "failed under `{dep}`; now on {}",
239                    now.into_iter().collect::<Vec<_>>().join(", ")
240                ),
241            ));
242        }
243    }
244    out
245}
246
247/// The rule text of a record, if it is a live constraint with a check.
248pub fn rule_of(e: &Entry) -> Option<&str> {
249    if e.record.kind != Kind::Constraint
250        || matches!(e.status, Status::Retracted | Status::Superseded)
251    {
252        return None;
253    }
254    e.record.fields.get("check")?.as_str()
255}
256
257/// Evaluate every live constraint's check; unparsable rules are reported as one violation.
258pub fn run(
259    entries: &[Entry],
260    symbols: &[Symbol],
261    edges: &[Edge],
262    packages: &[PackageRow],
263) -> Vec<Finding> {
264    let by_id: HashMap<String, &Symbol> = symbols.iter().map(|s| (s.id.render(), s)).collect();
265    entries
266        .iter()
267        .filter_map(|e| {
268            let rule = rule_of(e)?;
269            let violations = match Rule::parse(rule) {
270                Ok(r) => r.violations(&by_id, edges, packages),
271                Err(err) => vec![Violation {
272                    repo: String::new(),
273                    path: String::new(),
274                    line: 0,
275                    what: format!("rule does not parse: {err:#}"),
276                }],
277            };
278            Some(Finding {
279                record: e.record.id.clone(),
280                title: e.record.title.clone(),
281                rule: rule.to_string(),
282                severity: e
283                    .record
284                    .fields
285                    .get("severity")
286                    .and_then(|v| v.as_str())
287                    .unwrap_or("must")
288                    .to_string(),
289                violations,
290            })
291        })
292        .collect()
293}
294
295/// Constraints whose check passes on the current code are enforced (T4).
296pub fn promote(entries: &mut [Entry], findings: &[Finding]) {
297    for e in entries.iter_mut() {
298        if findings
299            .iter()
300            .any(|f| f.record == e.record.id && f.violations.is_empty())
301        {
302            e.trust = Trust::T4;
303        }
304    }
305}
306
307/// Markdown summary; `only` limits violations to these repo-relative files (`repo/path`).
308pub fn markdown(findings: &[Finding], only: Option<&[String]>) -> String {
309    let mut o = String::new();
310    for f in findings {
311        let shown: Vec<&Violation> = f
312            .violations
313            .iter()
314            .filter(|v| only.is_none_or(|o| o.contains(&format!("{}/{}", v.repo, v.path))))
315            .collect();
316        let state = if f.violations.is_empty() {
317            "passes".to_string()
318        } else {
319            format!("{} violation(s)", f.violations.len())
320        };
321        if only.is_some() && shown.is_empty() {
322            continue;
323        }
324        o.push_str(&format!(
325            "- [{} · {state}] {} — `{}` · id `{}`\n",
326            f.severity,
327            f.title,
328            f.rule,
329            &f.record[..f.record.len().min(13)]
330        ));
331        for v in shown.iter().take(20) {
332            let at = if v.line > 0 {
333                format!(":{}", v.line)
334            } else {
335                String::new()
336            };
337            o.push_str(&format!("  - {}/{}{at} {}\n", v.repo, v.path, v.what));
338        }
339        if shown.len() > 20 {
340            o.push_str(&format!("  - … {} more\n", shown.len() - 20));
341        }
342    }
343    o
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn parses_rules() {
352        assert!(Rule::parse("forbid calls from svc-a/handlers/** to */db/*.py::raw_*").is_ok());
353        assert!(Rule::parse("forbid from a/** to b/**").is_ok());
354        assert!(Rule::parse("forbid dep lance =1.0.1").is_ok());
355        assert!(Rule::parse("forbid frobs from a to b").is_err());
356        assert!(Rule::parse("allow everything").is_err());
357    }
358
359    #[test]
360    fn edge_rule_matches_path_and_name() {
361        use crate::model::{GlobalSymbolId, Language, Position, Range, RepoId, SymbolKind};
362        let at = Position { line: 1, column: 0 };
363        let sym = |repo: &str, path: &str, name: &str| Symbol {
364            id: GlobalSymbolId {
365                scheme: "comindly-treesitter".into(),
366                package_manager: ".".into(),
367                package: repo.into(),
368                version: ".".into(),
369                descriptor: format!("{path}/{name}()."),
370            },
371            name: name.into(),
372            kind: SymbolKind::Function,
373            language: Language::Python,
374            repo: RepoId(repo.into()),
375            file_path: path.into(),
376            range: Range { start: at, end: at },
377            signature: None,
378            docstring: None,
379            fingerprint: None,
380        };
381        let a = sym("svc", "app/handlers/user.py", "get_user");
382        let b = sym("svc", "app/db/raw.py", "raw_query");
383        let edge = Edge {
384            src: a.id.clone(),
385            dst: b.id.clone(),
386            kind: EdgeKind::Calls,
387            confidence: 1.0,
388            cross_repo: false,
389        };
390        let by_id: HashMap<String, &Symbol> =
391            [&a, &b].into_iter().map(|s| (s.id.render(), s)).collect();
392        let hit = |r: &str| {
393            Rule::parse(r)
394                .unwrap()
395                .violations(&by_id, std::slice::from_ref(&edge), &[])
396                .len()
397        };
398        assert_eq!(hit("forbid calls from svc/app/handlers/** to **::raw_*"), 1);
399        assert_eq!(hit("forbid from svc/app/handlers/** to svc/app/db/*"), 1);
400        assert_eq!(hit("forbid imports from svc/** to svc/**"), 0);
401        assert_eq!(hit("forbid calls from svc/app/db/** to **"), 0);
402    }
403
404    #[test]
405    fn revival_when_pinned_version_is_gone() {
406        use crate::ledger::{Anchor, AuthorKind, Provenance, Record};
407        let row = |l: &str| PackageRow {
408            repo: "r".into(),
409            ecosystem: "cargo".into(),
410            name: "lance".into(),
411            publishes: false,
412            version: Some(l.into()),
413            locked: Some(l.into()),
414            manifest: "Cargo.toml".into(),
415        };
416        let mut rec = Record::new(
417            Kind::Attempt,
418            "lance fragment reuse",
419            Vec::<Anchor>::new(),
420            Provenance {
421                author: AuthorKind::Human,
422                actor: "a".into(),
423                source: "t".into(),
424                agent: None,
425                session: None,
426                name: None,
427                teams: Vec::new(),
428            },
429        );
430        rec.fields.insert("dep".into(), "lance =1.0.1".into());
431        let e = vec![Entry {
432            record: rec,
433            status: Status::Active,
434            trust: Trust::T3,
435            current: Vec::new(),
436            related: Vec::new(),
437            verified: None,
438            by: String::new(),
439            history: Vec::new(),
440            retired: None,
441            outside: Vec::new(),
442            vouched: Vec::new(),
443            alike: None,
444        }];
445        assert!(revivals(&e, &[row("1.0.1")]).is_empty());
446        assert_eq!(revivals(&e, &[row("1.3.0")]).len(), 1);
447        assert!(revivals(&e, &[]).is_empty());
448    }
449
450    #[test]
451    fn dep_rule_matches_locked_or_declared() {
452        let row = |v: &str, l: Option<&str>| PackageRow {
453            repo: "r".into(),
454            ecosystem: "cargo".into(),
455            name: "lance".into(),
456            publishes: false,
457            version: Some(v.into()),
458            locked: l.map(Into::into),
459            manifest: "Cargo.toml".into(),
460        };
461        let r = Rule::parse("forbid dep lance =1.0.1").unwrap();
462        let none = HashMap::new();
463        assert_eq!(r.violations(&none, &[], &[row("=1.0.1", None)]).len(), 1);
464        assert_eq!(
465            r.violations(&none, &[], &[row("1.0", Some("1.0.1"))]).len(),
466            1
467        );
468        assert!(r
469            .violations(&none, &[], &[row("1.3", Some("1.3.2"))])
470            .is_empty());
471    }
472}