pmcp_workbook_runtime/finding.rs
1//! The located, collect-all lint-finding types (DIA-02 finding shape).
2//!
3//! RELOCATED into `workbook-runtime` (Phase 11, Plan 05): the runtime executor's
4//! `run()` returns a `Box<LintFinding>` on a dependency cycle, so the finding
5//! types must live on the umya-free runtime side. `workbook-compiler` re-exports
6//! these from `pmcp_workbook_runtime` so its `dialect::{LintFinding, LintReport,
7//! Severity}` surface (and every `crate::dialect::*` consumer) is unchanged.
8//!
9//! A [`LintFinding`] is the linter's atomic output unit: a `severity` tier, a
10//! stable slash-namespaced `rule` id, a `sheet` + optional `cell` LOCATION, a
11//! human `message`, and BA-actionable `repair` text. A [`LintReport`] is the
12//! collect-all aggregate: the linter never stops at the first problem — it
13//! accumulates EVERY finding and answers [`LintReport::has_errors`] as the
14//! conformance gate (D-05: only `Error` severity blocks; `Warning`/`Info` do
15//! not).
16//!
17//! These three derive `serde::Serialize` + `serde::Deserialize` +
18//! `schemars::JsonSchema` because they serialize to (and round-trip back from)
19//! the lint-report artifact + snapshot that Phases 8–11 and the BA consume
20//! (`Deserialize` added per D-08 so a served `LintReport` JSON parses back into
21//! the typed struct).
22
23use serde::{Deserialize, Serialize};
24
25/// The severity tier of a [`LintFinding`]. Only [`Severity::Error`] gates
26/// conformance ([`LintReport::has_errors`]); `Warning`/`Info` are advisory (D-05).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
28#[serde(rename_all = "lowercase")]
29pub enum Severity {
30 /// A dialect violation that BLOCKS conformance (e.g. an out-of-whitelist
31 /// function, a macro-bearing workbook, an array formula).
32 Error,
33 /// An advisory dialect concern that does NOT block (e.g. a hidden row).
34 Warning,
35 /// Informational signal surfaced for the BA but not a violation.
36 Info,
37}
38
39/// A single located dialect finding (DIA-02). The `rule` is a stable
40/// slash-namespaced id (e.g. `"whitelist/unsupported-fn"`,
41/// `"structure/hidden-sheet"`, `"manifest/role-conflict"`); `repair` carries
42/// BA-actionable fix text so a non-engineer can act without a round-trip.
43#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
44pub struct LintFinding {
45 /// The conformance-gating tier (only `Error` blocks; D-05).
46 pub severity: Severity,
47 /// Stable slash-namespaced rule id (`<namespace>/<kebab-rule>`).
48 pub rule: String,
49 /// The sheet the finding is located on (e.g. `"1_Inputs"`).
50 pub sheet: String,
51 /// The optional cell address within `sheet` (e.g. `"E6"`); `None` for a
52 /// sheet- or workbook-level finding.
53 pub cell: Option<String>,
54 /// Human-readable description of what was found.
55 pub message: String,
56 /// BA-actionable repair text describing how to fix the finding.
57 pub repair: String,
58}
59
60impl LintFinding {
61 /// Construct a located finding. `cell` is `None` for sheet/workbook-level
62 /// findings.
63 pub fn new(
64 severity: Severity,
65 rule: impl Into<String>,
66 sheet: impl Into<String>,
67 cell: Option<String>,
68 message: impl Into<String>,
69 repair: impl Into<String>,
70 ) -> Self {
71 Self {
72 severity,
73 rule: rule.into(),
74 sheet: sheet.into(),
75 cell,
76 message: message.into(),
77 repair: repair.into(),
78 }
79 }
80}
81
82/// The collect-all aggregate of every [`LintFinding`] from one lint pass
83/// (mirrors `CatalogError::Load(Vec<_>)`). The linter accumulates into one
84/// report; [`LintReport::has_errors`] is the conformance gate (D-05).
85#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
86pub struct LintReport {
87 /// EVERY finding from the pass, in discovery order.
88 pub findings: Vec<LintFinding>,
89}
90
91impl LintReport {
92 /// An empty report ready to accumulate findings.
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 /// Append a single finding.
98 pub fn push(&mut self, finding: LintFinding) {
99 self.findings.push(finding);
100 }
101
102 /// Append every finding from an iterator (so independent passes fold into
103 /// one report).
104 pub fn extend(&mut self, findings: impl IntoIterator<Item = LintFinding>) {
105 self.findings.extend(findings);
106 }
107
108 /// The conformance gate (D-05): `true` iff any finding is `Error` severity.
109 /// `Warning`/`Info` findings do NOT block conformance.
110 pub fn has_errors(&self) -> bool {
111 self.findings.iter().any(|f| f.severity == Severity::Error)
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn has_errors_gates_on_error_severity_only() {
121 let mut report = LintReport::new();
122 report.push(LintFinding::new(
123 Severity::Error,
124 "whitelist/unsupported-fn",
125 "1_Inputs",
126 Some("A2".to_string()),
127 "OFFSET is not in the dialect whitelist",
128 "Replace OFFSET with an INDEX/MATCH lookup",
129 ));
130 report.push(LintFinding::new(
131 Severity::Warning,
132 "structure/hidden-row",
133 "1_Inputs",
134 None,
135 "row 7 is hidden",
136 "Unhide the row or document why it is hidden",
137 ));
138 assert!(report.has_errors(), "an Error finding must trip has_errors");
139 assert_eq!(report.findings.len(), 2);
140 }
141
142 #[test]
143 fn has_errors_false_when_only_warnings_and_info() {
144 let mut report = LintReport::new();
145 report.extend([
146 LintFinding::new(
147 Severity::Warning,
148 "structure/hidden-row",
149 "1_Inputs",
150 None,
151 "row 7 is hidden",
152 "Unhide the row",
153 ),
154 LintFinding::new(
155 Severity::Info,
156 "structure/note",
157 "0_Guide",
158 None,
159 "guide legend present",
160 "No action required",
161 ),
162 ]);
163 assert!(
164 !report.has_errors(),
165 "warnings and info alone must NOT trip has_errors (D-05)"
166 );
167 }
168
169 #[test]
170 fn lint_finding_serializes_with_repair_field() {
171 let finding = LintFinding::new(
172 Severity::Error,
173 "structure/external-link",
174 "1_Inputs",
175 Some("E6".to_string()),
176 "external link reference [1]Sheet1 found",
177 "Inline the referenced value; the dialect forbids external links",
178 );
179 let json = serde_json::to_value(&finding).expect("serialize finding");
180 assert_eq!(
181 json["repair"],
182 "Inline the referenced value; the dialect forbids external links"
183 );
184 assert_eq!(json["severity"], "error");
185 assert_eq!(json["rule"], "structure/external-link");
186 assert_eq!(json["cell"], "E6");
187 }
188
189 #[test]
190 fn lint_report_round_trips_through_json() {
191 let mut report = LintReport::new();
192 report.push(LintFinding::new(
193 Severity::Error,
194 "whitelist/unsupported-fn",
195 "1_Inputs",
196 Some("A2".into()),
197 "msg",
198 "repair",
199 ));
200 let back: LintReport =
201 serde_json::from_value(serde_json::to_value(&report).unwrap()).unwrap();
202 assert_eq!(back.findings.len(), 1);
203 assert!(back.has_errors());
204 }
205}