1use 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 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 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 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}