Skip to main content

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}