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}