Skip to main content

omnist/ops/
lint.rs

1//! Non-destructive structural diagnostics for a schema. Ported from
2//! `~/dev/omnist/omnist/ops/lint.py`.
3//!
4//! `Schema::validate` checks a *document* against a schema; `lint` checks
5//! the *schema itself* for structural problems that parse fine but mean
6//! parts of the schema can never do anything. It **reports, never
7//! mutates** -- `prune`/`normalize` are the transforms that fix these
8//! issues; `lint` only diagnoses them.
9//!
10//! Four checks:
11//!
12//! * `lint.unsatisfiable-record` (`warning`) -- a reachable record no finite
13//!   document can match (e.g. a mandatory ref cycle). Reuses
14//!   [`super::prune::satisfiable_set`] (its complement), intersected with
15//!   reachable.
16//! * `lint.unreachable-record` (`warning`) -- a record defined in the env but not
17//!   reachable from root by following any ref. A plain reachability walk
18//!   (no pruning): every `Ref`-typed field is followed regardless of
19//!   cardinality.
20//! * `lint.duplicate-record` (`warning`) -- two or more structurally identical
21//!   records under different names. Reuses
22//!   [`super::minimize::equivalence_classes`] on the *raw* schema, so
23//!   duplicates are reported as authored.
24//! * `lint.any-field` (`info`) -- an inventory of every `any`-typed field, so a
25//!   human can audit the schema's deliberate openings. Advisory only; never
26//!   affects a caller's exit code on its own.
27
28use indexmap::IndexSet;
29
30use crate::schema::{FieldType, Schema};
31
32use super::minimize::equivalence_classes;
33use super::prune::satisfiable_set;
34
35/// One structural diagnostic. `code` is a stable machine-readable
36/// identifier (`lint.unsatisfiable-record`, `lint.unreachable-record`,
37/// `lint.duplicate-record`, `lint.any-field`); `severity` is `warning` or `info`;
38/// `location` is a record name (or `Record.label` for `lint.any-field`);
39/// `message` is a human-readable, actionable description.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LintFinding {
42    /// Stable machine-readable diagnostic code.
43    pub code: &'static str,
44    /// Severity level (`"warning"` or `"info"`).
45    pub severity: &'static str,
46    /// Schema location (e.g. record or field name) of the finding.
47    pub location: String,
48    /// Human-readable explanation of the finding.
49    pub message: String,
50}
51
52/// Record names reachable from `s`'s root by a plain walk following every
53/// `Ref`-typed field -- no pruning, cardinality ignored. A record reachable
54/// only via an optional or unsatisfiable field still counts as referenced.
55fn reachable(s: &Schema) -> IndexSet<String> {
56    let mut seen: IndexSet<String> = IndexSet::new();
57    let mut stack = vec![s.root().name.clone()];
58    while let Some(name) = stack.pop() {
59        if seen.contains(&name) {
60            continue;
61        }
62        // Every name pushed onto `stack` is either `s.root().name` or a
63        // `Ref` target found on an already-visited record's fields --
64        // `Schema::new`'s `check_refs` guarantees both always resolve
65        // within `s.env()`, so `.get` here can never miss. A fallible
66        // `if let ... else { continue }` here would be dead code `cargo
67        // llvm-cov` correctly flags as unreachable (see oml.rs's
68        // `scan_number` for the same pattern), so it's replaced with an
69        // `expect` documenting the invariant instead.
70        let rec = s
71            .env()
72            .get(&name)
73            .expect("Schema's own invariant: every Ref target resolves within its env");
74        seen.insert(name.clone());
75        for f in rec.fields() {
76            if let FieldType::Ref(r) = &f.ty {
77                stack.push(r.name.clone());
78            }
79        }
80    }
81    seen
82}
83
84/// Structural diagnostics for `s` -- see the module doc comment for the
85/// checks. Returns findings sorted deterministically by `(code, location)`.
86/// Never mutates `s`.
87pub fn lint(s: &Schema) -> Vec<LintFinding> {
88    let mut findings: Vec<LintFinding> = Vec::new();
89
90    let reach = reachable(s);
91    let sat = satisfiable_set(s);
92
93    // unsatisfiable-record: reachable but not satisfiable. Iteration order
94    // here doesn't matter for determinism -- the final `.sort_by` below is
95    // what makes the output canonical, matching the Python reference's own
96    // set-difference-then-sort shape.
97    for name in &reach {
98        if !sat.contains(name) {
99            findings.push(LintFinding {
100                code: "lint.unsatisfiable-record",
101                severity: "warning",
102                location: name.clone(),
103                message: format!(
104                    "record {name:?} is reachable but unsatisfiable -- no finite document \
105                     can match it (e.g. a mandatory ref cycle)"
106                ),
107            });
108        }
109    }
110
111    // unreachable-record: defined in env but not reachable from root.
112    for name in s.env().keys() {
113        if !reach.contains(name) {
114            findings.push(LintFinding {
115                code: "lint.unreachable-record",
116                severity: "warning",
117                location: name.clone(),
118                message: format!(
119                    "record {name:?} is defined but never reachable from the root; drop it \
120                     with `schema prune`"
121                ),
122            });
123        }
124    }
125
126    // duplicate-record: structurally identical records under different
127    // names.
128    for block in equivalence_classes(s) {
129        if block.len() > 1 {
130            let mut group = block.clone();
131            group.sort();
132            let location = group.join(", ");
133            let keep = group[0].clone();
134            let others: Vec<String> = group[1..].iter().map(|n| format!("{n:?}")).collect();
135            findings.push(LintFinding {
136                code: "lint.duplicate-record",
137                severity: "warning",
138                location,
139                message: format!(
140                    "records {} are structurally identical to {keep:?}; merge them with \
141                     `schema normalize`",
142                    others.join(", ")
143                ),
144            });
145        }
146    }
147
148    // any-field: inventory of every any-typed field, sorted by record name
149    // to match the input's declared env order deterministically before the
150    // final canonical sort below.
151    for name in s.env().keys() {
152        let rec = s.env().get(name).expect("name comes from s.env's own keys");
153        for f in rec.fields() {
154            if matches!(f.ty, FieldType::Any) {
155                findings.push(LintFinding {
156                    code: "lint.any-field",
157                    severity: "info",
158                    location: format!("{name}.{}", f.label),
159                    message: format!(
160                        "field {:?} of record {name:?} is typed `any` (accepts any value \
161                         unchecked)",
162                        f.label
163                    ),
164                });
165            }
166        }
167    }
168
169    // Canonical ordering -- codepoint (byte-wise) comparison via `str`'s
170    // default `Ord`, never locale-aware. See `tests.rs`'s omnist-ts#56
171    // regression test.
172    findings.sort_by(|a, b| (a.code, &a.location).cmp(&(b.code, &b.location)));
173    findings
174}