Skip to main content

wenlan_types/
lint_report.rs

1// SPDX-License-Identifier: Apache-2.0
2use super::*;
3use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
4use std::collections::BTreeSet;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct LintTotals {
8    checks: u32,
9    passed: u32,
10    findings: u32,
11    actionable_findings: u32,
12    advisory_findings: u32,
13    incomplete: u32,
14}
15impl LintTotals {
16    fn from_checks(checks: &[LintCheckResult]) -> Result<Self, LintContractError> {
17        let checks_count =
18            u32::try_from(checks.len()).map_err(|_| LintContractError::TooManyChecks)?;
19        let mut totals = Self {
20            checks: checks_count,
21            passed: 0,
22            findings: 0,
23            actionable_findings: 0,
24            advisory_findings: 0,
25            incomplete: 0,
26        };
27        for check in checks {
28            match check.outcome {
29                LintOutcome::Pass => totals.passed += 1,
30                LintOutcome::Finding => {
31                    totals.findings += 1;
32                    match check.gate_effect() {
33                        LintGateEffect::Actionable => totals.actionable_findings += 1,
34                        LintGateEffect::Advisory => totals.advisory_findings += 1,
35                    }
36                }
37                LintOutcome::NotRunPrerequisite
38                | LintOutcome::InconsistentSnapshot
39                | LintOutcome::FailedToRun => totals.incomplete += 1,
40            }
41        }
42        Ok(totals)
43    }
44    pub const fn checks(&self) -> u32 {
45        self.checks
46    }
47    pub const fn findings(&self) -> u32 {
48        self.findings
49    }
50    pub const fn actionable_findings(&self) -> u32 {
51        self.actionable_findings
52    }
53    pub const fn advisory_findings(&self) -> u32 {
54        self.advisory_findings
55    }
56    pub const fn passed(&self) -> u32 {
57        self.passed
58    }
59    pub const fn incomplete(&self) -> u32 {
60        self.incomplete
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct LintReport {
66    report_schema_version: u16,
67    check_catalog_version: u16,
68    profile: LintProfile,
69    scope: LintScope,
70    capability_context: LintCapabilityContext,
71    snapshots: LintSnapshotReceipts,
72    config_fingerprint: LintConfigFingerprint,
73    producer_receipt: LintProducerReceipt,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    agent_work: Option<LintAgentWork>,
76    checks: Vec<LintCheckResult>,
77    totals: LintTotals,
78    complete: bool,
79}
80#[derive(Deserialize)]
81struct LintReportWire {
82    report_schema_version: u16,
83    check_catalog_version: u16,
84    profile: LintProfile,
85    scope: LintScope,
86    capability_context: LintCapabilityContext,
87    snapshots: LintSnapshotReceipts,
88    config_fingerprint: LintConfigFingerprint,
89    producer_receipt: LintProducerReceipt,
90    #[serde(default)]
91    agent_work: Option<LintAgentWork>,
92    checks: Vec<LintCheckResult>,
93    totals: LintTotals,
94    complete: bool,
95}
96impl LintReport {
97    pub fn try_new(
98        scope: LintScope,
99        capability_context: LintCapabilityContext,
100        snapshots: LintSnapshotReceipts,
101        config_fingerprint: LintConfigFingerprint,
102        producer_receipt: LintProducerReceipt,
103        checks: Vec<LintCheckResult>,
104    ) -> Result<Self, LintContractError> {
105        Self::try_new_for_profile(
106            LintProfile::General,
107            scope,
108            capability_context,
109            snapshots,
110            config_fingerprint,
111            producer_receipt,
112            checks,
113        )
114    }
115
116    pub fn try_new_for_profile(
117        profile: LintProfile,
118        scope: LintScope,
119        capability_context: LintCapabilityContext,
120        snapshots: LintSnapshotReceipts,
121        config_fingerprint: LintConfigFingerprint,
122        producer_receipt: LintProducerReceipt,
123        checks: Vec<LintCheckResult>,
124    ) -> Result<Self, LintContractError> {
125        Self::try_new_for_profile_with_agent_work(
126            profile,
127            scope,
128            capability_context,
129            snapshots,
130            config_fingerprint,
131            producer_receipt,
132            checks,
133            None,
134        )
135    }
136
137    #[allow(clippy::too_many_arguments)]
138    pub fn try_new_for_profile_with_agent_work(
139        profile: LintProfile,
140        scope: LintScope,
141        capability_context: LintCapabilityContext,
142        snapshots: LintSnapshotReceipts,
143        config_fingerprint: LintConfigFingerprint,
144        producer_receipt: LintProducerReceipt,
145        mut checks: Vec<LintCheckResult>,
146        agent_work: Option<LintAgentWork>,
147    ) -> Result<Self, LintContractError> {
148        if profile == LintProfile::General && agent_work.is_some() {
149            return Err(LintContractError::InvalidAgentWork);
150        }
151        checks.sort_by(|left, right| left.check_id().cmp(right.check_id()));
152        let totals = LintTotals::from_checks(&checks)?;
153        let complete = checks.iter().all(|check| check.outcome.is_complete());
154        Ok(Self {
155            report_schema_version: LINT_REPORT_SCHEMA_VERSION,
156            check_catalog_version: LINT_CHECK_CATALOG_VERSION,
157            profile,
158            scope,
159            capability_context,
160            snapshots,
161            config_fingerprint,
162            producer_receipt,
163            agent_work,
164            checks,
165            totals,
166            complete,
167        })
168    }
169    pub const fn complete(&self) -> bool {
170        self.complete
171    }
172    pub const fn profile(&self) -> LintProfile {
173        self.profile
174    }
175    pub const fn totals(&self) -> &LintTotals {
176        &self.totals
177    }
178    pub fn checks(&self) -> &[LintCheckResult] {
179        &self.checks
180    }
181    pub fn scope(&self) -> &LintScope {
182        &self.scope
183    }
184    pub const fn capability_context(&self) -> LintCapabilityContext {
185        self.capability_context
186    }
187    pub fn snapshots(&self) -> &LintSnapshotReceipts {
188        &self.snapshots
189    }
190    pub fn config_fingerprint(&self) -> &LintConfigFingerprint {
191        &self.config_fingerprint
192    }
193    pub fn producer_receipt(&self) -> &LintProducerReceipt {
194        &self.producer_receipt
195    }
196    pub fn agent_work(&self) -> Option<&LintAgentWork> {
197        self.agent_work.as_ref()
198    }
199}
200impl<'de> Deserialize<'de> for LintReport {
201    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
202    where
203        D: Deserializer<'de>,
204    {
205        let wire = LintReportWire::deserialize(deserializer)?;
206        if wire.report_schema_version != LINT_REPORT_SCHEMA_VERSION {
207            return Err(D::Error::custom(LintContractError::UnsupportedReportSchema));
208        }
209        if wire.check_catalog_version != LINT_CHECK_CATALOG_VERSION {
210            return Err(D::Error::custom(LintContractError::UnsupportedCheckCatalog));
211        }
212        let expected_checks = match wire.profile {
213            LintProfile::General => LINT_GENERAL_CHECK_COUNT,
214            LintProfile::Deep => LINT_DEEP_CHECK_COUNT,
215        };
216        let unique_ids = wire
217            .checks
218            .iter()
219            .map(LintCheckResult::check_id)
220            .collect::<BTreeSet<_>>();
221        if wire.checks.len() != expected_checks || unique_ids.len() != wire.checks.len() {
222            return Err(D::Error::custom(LintContractError::InvalidCatalogShape));
223        }
224        if wire.checks.iter().any(|check| {
225            canonical_gate_effect(wire.profile, check.check_id()) != Some(check.gate_effect())
226        }) {
227            return Err(D::Error::custom(LintContractError::InvalidCatalogShape));
228        }
229        let report = Self::try_new_for_profile_with_agent_work(
230            wire.profile,
231            wire.scope,
232            wire.capability_context,
233            wire.snapshots,
234            wire.config_fingerprint,
235            wire.producer_receipt,
236            wire.checks,
237            wire.agent_work,
238        )
239        .map_err(D::Error::custom)?;
240        if report.totals != wire.totals {
241            return Err(D::Error::custom(LintContractError::InvalidTotals));
242        }
243        if report.complete != wire.complete {
244            return Err(D::Error::custom(LintContractError::InvalidCompleteness));
245        }
246        Ok(report)
247    }
248}
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct LintQuery {
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub profile: Option<LintProfile>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub space: Option<String>,
255}
256
257impl LintQuery {
258    pub const fn new(profile: Option<LintProfile>, space: Option<String>) -> Self {
259        Self { profile, space }
260    }
261
262    pub fn applied_profile(&self) -> LintProfile {
263        self.profile.unwrap_or_default()
264    }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct LintRequestQuery {
269    #[serde(flatten)]
270    lint: LintQuery,
271    #[serde(default, skip_serializing_if = "is_false")]
272    external_egress: bool,
273    #[serde(default, skip_serializing_if = "is_false")]
274    agent_assist: bool,
275}
276
277impl LintRequestQuery {
278    pub const fn new(lint: LintQuery, external_egress: bool, agent_assist: bool) -> Self {
279        Self {
280            lint,
281            external_egress,
282            agent_assist,
283        }
284    }
285
286    pub const fn lint(&self) -> &LintQuery {
287        &self.lint
288    }
289
290    pub const fn external_egress(&self) -> bool {
291        self.external_egress
292    }
293
294    pub const fn agent_assist(&self) -> bool {
295        self.agent_assist
296    }
297}
298
299const fn is_false(value: &bool) -> bool {
300    !*value
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304pub struct LintErrorResponse {
305    error: String,
306}
307
308impl LintErrorResponse {
309    pub fn new(error: impl Into<String>) -> Self {
310        Self {
311            error: error.into(),
312        }
313    }
314
315    pub fn error(&self) -> &str {
316        &self.error
317    }
318}
319
320impl LintReport {
321    /// The report as the text `wenlan lint` prints and the MCP `lint` tool
322    /// returns: totals, per-group counts, then every finding, advisory and
323    /// incomplete check with its recommendation and coverage. The typed
324    /// report stays the contract; this is what a person or an agent reads.
325    pub fn render_text(&self) -> String {
326        let totals = self.totals();
327        let mut groups = [(0_u32, 0_u32, 0_u32); 7];
328        for check in self.checks() {
329            let Some(group) = LintCheckGroup::for_check_id(check.check_id()) else {
330                continue;
331            };
332            let counts = &mut groups[group_index(group)];
333            counts.0 += 1;
334            match check.outcome() {
335                LintOutcome::Pass => {}
336                LintOutcome::Finding => counts.1 += 1,
337                LintOutcome::NotRunPrerequisite
338                | LintOutcome::InconsistentSnapshot
339                | LintOutcome::FailedToRun => counts.2 += 1,
340            }
341        }
342        let mut output = format!(
343            "Lint: {} checks, {} passed, {} actionable findings, {} advisor{}, {} incomplete\nGroups:\n",
344            totals.checks(),
345            totals.passed(),
346            totals.actionable_findings(),
347            totals.advisory_findings(),
348            if totals.advisory_findings() == 1 { "y" } else { "ies" },
349            totals.incomplete()
350        );
351        for group in LintCheckGroup::ALL {
352            let (checks, findings, incomplete) = groups[group_index(group)];
353            if checks == 0 {
354                continue;
355            }
356            output.push_str(&format!(
357                "  {}: {checks} check{}, {findings} findings, {incomplete} incomplete\n",
358                group.as_str(),
359                if checks == 1 { "" } else { "s" }
360            ));
361        }
362        if let Some(work) = self.agent_work() {
363            output.push_str(&format!(
364                "Agent work: {} bounded records; digest={}\n",
365                work.records().len(),
366                work.work_digest().as_str()
367            ));
368        }
369        append_findings(&mut output, "Findings", self, LintGateEffect::Actionable);
370        append_findings(&mut output, "Advisories", self, LintGateEffect::Advisory);
371        // A check that passed but still carries advice is waiting on the owner, not
372        // broken — e.g. a graph channel with no model source chosen. It is not a
373        // finding and does not change the exit code, but the reader still has to see
374        // the one thing they can do about it.
375        output.push_str("Waiting on you");
376        let waiting: Vec<_> = self
377            .checks()
378            .iter()
379            .filter(|check| check.outcome() == LintOutcome::Pass && check.action_code().is_some())
380            .collect();
381        append_selected(&mut output, &waiting);
382        output.push_str("Incomplete");
383        let incomplete: Vec<_> = self
384            .checks()
385            .iter()
386            .filter(|check| !matches!(check.outcome(), LintOutcome::Pass | LintOutcome::Finding))
387            .collect();
388        append_selected(&mut output, &incomplete);
389        output
390    }
391}
392
393fn append_findings(
394    output: &mut String,
395    label: &str,
396    report: &LintReport,
397    gate_effect: LintGateEffect,
398) {
399    output.push_str(label);
400    let selected: Vec<_> = report
401        .checks()
402        .iter()
403        .filter(|check| {
404            check.outcome() == LintOutcome::Finding && check.gate_effect() == gate_effect
405        })
406        .collect();
407    append_selected(output, &selected);
408}
409
410fn append_selected(output: &mut String, checks: &[&LintCheckResult]) {
411    if checks.is_empty() {
412        output.push_str(": none\n");
413        return;
414    }
415    output.push_str(&format!(" ({}):\n", checks.len()));
416    for check in checks {
417        let summary = summary_name(check.summary_code());
418        let code_suffix = match (check.recommendation_code(), check.action_code()) {
419            (Some(recommendation), _) => {
420                format!("; recommendation: {}", recommendation_name(recommendation))
421            }
422            (None, Some(action)) => format!("; action: {}", action_name(action)),
423            (None, None) => String::new(),
424        };
425        output.push_str(&format!("  {}: {summary}{code_suffix}\n", check.check_id()));
426        // The codes above are stable identifiers for scripts. This line is the
427        // same thing in plain English for the person reading the terminal.
428        output.push_str(&format!(
429            "    {}{}\n",
430            check.summary_code().meaning(),
431            match (check.recommendation_code(), check.action_code()) {
432                (Some(recommendation), _) => format!(" {}", recommendation.action()),
433                (None, Some(action)) => format!(" {}", action.action()),
434                (None, None) => String::new(),
435            }
436        ));
437        let affected = check.metrics().iter().find_map(|metric| {
438            if metric.code() == LintMetricCode::AffectedRecords {
439                match metric.value() {
440                    LintMetricValue::Count { value } => Some(*value),
441                    LintMetricValue::Boolean { .. } | LintMetricValue::CatalogCode { .. } => None,
442                }
443            } else {
444                None
445            }
446        });
447        let mut evidence_items = check
448            .evidence()
449            .iter()
450            .take(8)
451            .map(evidence_name)
452            .collect::<Vec<_>>();
453        if check.evidence().len() > evidence_items.len() {
454            evidence_items.push(format!(
455                "+{}_more",
456                check.evidence().len() - evidence_items.len()
457            ));
458        }
459        let evidence = evidence_items.join(",");
460        output.push_str(&format!(
461            "    affected={}; evaluated={}/{}; evidence={}; truncated={}\n",
462            affected.map_or_else(|| "unknown".to_string(), |value| value.to_string()),
463            check.coverage().evaluated(),
464            check.coverage().denominator(),
465            if evidence.is_empty() {
466                "none"
467            } else {
468                &evidence
469            },
470            check.coverage().truncated(),
471        ));
472    }
473}
474
475const fn group_index(group: LintCheckGroup) -> usize {
476    match group {
477        LintCheckGroup::Identity => 0,
478        LintCheckGroup::KnowledgeGraph => 1,
479        LintCheckGroup::Memories => 2,
480        LintCheckGroup::Operations => 3,
481        LintCheckGroup::Pages => 4,
482        LintCheckGroup::Runtime => 5,
483        LintCheckGroup::Serving => 6,
484    }
485}
486
487fn evidence_name(evidence: &LintEvidenceRef) -> String {
488    match evidence {
489        LintEvidenceRef::OpaqueId { opaque_id } => format!("opaque:{}", opaque_id.ordinal()),
490        LintEvidenceRef::OpaqueDigest { opaque_digest } => {
491            format!("opaque-digest:{}", opaque_digest.as_str())
492        }
493        LintEvidenceRef::ReasonCode { reason_code } => {
494            format!("reason:{}", reason_name(*reason_code))
495        }
496        LintEvidenceRef::SafeRootRelativePath {
497            safe_root_relative_path,
498        } => format!("path:{safe_root_relative_path:?}"),
499        LintEvidenceRef::SemanticFinding { finding } => format!(
500            "semantic:{}:{:?}:{:?}:{}:{:?}",
501            finding.candidate_id().ordinal(),
502            finding.proposed_action(),
503            finding.reason_code(),
504            finding.confidence_basis_points(),
505            finding.provider_route(),
506        ),
507    }
508}
509
510const fn reason_name(reason: LintReasonCode) -> &'static str {
511    match reason {
512        LintReasonCode::MissingArtifact => "missing_artifact",
513        LintReasonCode::InvalidCatalogState => "invalid_catalog_state",
514        LintReasonCode::ExpectedEmptySubstrate => "expected_empty_substrate",
515        LintReasonCode::InvalidSourceConfiguration => "invalid_source_configuration",
516        LintReasonCode::TerminalOperationFailure => "terminal_operation_failure",
517        LintReasonCode::ExpiredRetry => "expired_retry",
518        LintReasonCode::InvalidOperationState => "invalid_operation_state",
519        LintReasonCode::DurableNoProgress => "durable_no_progress",
520        LintReasonCode::SemanticProviderUnavailable => "semantic_provider_unavailable",
521        LintReasonCode::InsufficientSemanticEvidence => "insufficient_semantic_evidence",
522        LintReasonCode::SemanticExecutionFailure => "semantic_execution_failure",
523        LintReasonCode::SemanticAgentAdjudicationRequired => "semantic_agent_adjudication_required",
524        LintReasonCode::SemanticAgentWorkStale => "semantic_agent_work_stale",
525        LintReasonCode::SemanticAgentSubmissionInvalid => "semantic_agent_submission_invalid",
526        LintReasonCode::SemanticCandidateGenerationFailure => {
527            "semantic_candidate_generation_failure"
528        }
529        LintReasonCode::SemanticPopulationIncomplete => "semantic_population_incomplete",
530        LintReasonCode::SemanticDisagreementUnresolved => "semantic_disagreement_unresolved",
531        LintReasonCode::SemanticSecondJudgeRequired => "semantic_second_judge_required",
532    }
533}
534
535const fn recommendation_name(recommendation: LintRecommendationCode) -> &'static str {
536    match recommendation {
537        LintRecommendationCode::ReviewFinding => "review_finding",
538        LintRecommendationCode::RestorePrerequisite => "restore_prerequisite",
539        LintRecommendationCode::RerunAfterSnapshotStabilizes => "rerun_after_snapshot_stabilizes",
540        LintRecommendationCode::InspectRuntime => "inspect_runtime",
541    }
542}
543
544const fn action_name(action: LintActionCode) -> &'static str {
545    match action {
546        LintActionCode::ChooseModelSource => "choose_model_source",
547    }
548}
549
550const fn summary_name(summary: LintSummaryCode) -> &'static str {
551    match summary {
552        LintSummaryCode::CheckPassed => "check_passed",
553        LintSummaryCode::FindingDetected => "finding_detected",
554        LintSummaryCode::PrerequisiteUnavailable => "prerequisite_unavailable",
555        LintSummaryCode::SnapshotInconsistent => "snapshot_inconsistent",
556        LintSummaryCode::ExecutionFailed => "execution_failed",
557        LintSummaryCode::ExpectedEmpty => "expected_empty",
558    }
559}