Skip to main content

allow_core/
actionable_diagnostic.rs

1//! One versioned, source-located, task-aware diagnostic / missing-obligation /
2//! typed-action semantic kernel (#2188, step 1 of its recommended sequence).
3//!
4//! Every cargo-allow output surface — human text, canonical JSON, worklist,
5//! SARIF, a future LSP, and agent packets — must agree on the same semantic
6//! finding and repair objects. Building each renderer over its own ad-hoc shape
7//! lets editor behavior disagree with CLI, CI, and SARIF. This module owns the
8//! semantics; renderers only project them.
9//!
10//! ## Claim boundary
11//!
12//! This slice defines the typed kernel and its canonical identity/fingerprint
13//! plus Rust fixtures. It deliberately keeps four dimensions independent —
14//! severity (user impact), posture (how the rule gates), confidence (how sure
15//! the judgment is), and result class (finding vs. stale vs. not-proven vs.
16//! unsupported vs. instrument failure) — so an instrument crash is never a
17//! repository defect and an advisory recommendation is never an automatic
18//! blocking rule. Projection parity across the concrete renderers, JSON schema
19//! wiring, preview/apply of safe edits, and surface migration are deferred
20//! follow-ups; this kernel neither renders nor applies anything.
21
22use crate::fingerprint::sha256_v1_bytes;
23
24/// Semantic schema/generation tag for the diagnostic kernel.
25pub const DIAGNOSTIC_KERNEL_SCHEMA: &str = "cargo-allow.diagnostic-kernel.v1";
26
27/// Likely defect / user impact of a diagnostic. Independent of [`RulePosture`]:
28/// a high-severity judgment recommendation is not automatically blocking.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub enum DiagnosticSeverity {
31    Info,
32    Low,
33    Medium,
34    High,
35    Critical,
36}
37
38impl DiagnosticSeverity {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Info => "info",
42            Self::Low => "low",
43            Self::Medium => "medium",
44            Self::High => "high",
45            Self::Critical => "critical",
46        }
47    }
48}
49
50/// How the rule gates, independent of severity. `Shadow` observes without
51/// affecting exit posture; `Blocking` is a deterministic gate.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum RulePosture {
54    Informational,
55    Advisory,
56    Shadow,
57    Blocking,
58}
59
60impl RulePosture {
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Informational => "informational",
64            Self::Advisory => "advisory",
65            Self::Shadow => "shadow",
66            Self::Blocking => "blocking",
67        }
68    }
69
70    /// Whether this posture contributes to a blocking (non-zero) outcome.
71    pub fn is_blocking(self) -> bool {
72        matches!(self, Self::Blocking)
73    }
74}
75
76/// How sure the judgment is, independent of severity and posture.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78pub enum DiagnosticConfidence {
79    Exact,
80    High,
81    Bounded,
82    Uncertain,
83    Unavailable,
84}
85
86impl DiagnosticConfidence {
87    pub fn as_str(self) -> &'static str {
88        match self {
89            Self::Exact => "exact",
90            Self::High => "high",
91            Self::Bounded => "bounded",
92            Self::Uncertain => "uncertain",
93            Self::Unavailable => "unavailable",
94        }
95    }
96}
97
98/// What kind of result this is. An instrument crash is not a repository defect,
99/// and unsupported capability is not a clean pass — each stays distinct.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum DiagnosticResultClass {
102    Finding,
103    Stale,
104    NotProven,
105    Unsupported,
106    InstrumentFailure,
107}
108
109impl DiagnosticResultClass {
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Self::Finding => "finding",
113            Self::Stale => "stale",
114            Self::NotProven => "not_proven",
115            Self::Unsupported => "unsupported",
116            Self::InstrumentFailure => "instrument_failure",
117        }
118    }
119
120    /// Whether the result reflects a repository condition (finding/stale/
121    /// not-proven) rather than a tool-side failure (unsupported/instrument).
122    pub fn is_repository_condition(self) -> bool {
123        matches!(self, Self::Finding | Self::Stale | Self::NotProven)
124    }
125
126    /// Whether the result reflects a tool-side limitation (unsupported
127    /// capability or instrument failure) rather than a repository condition.
128    pub fn result_class_is_tool_side(self) -> bool {
129        !self.is_repository_condition()
130    }
131}
132
133/// Column-offset encoding for a source position. Made explicit so an LSP
134/// (UTF-16) and CLI (UTF-8) never silently disagree on a column number.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub enum SourceEncoding {
137    Utf8,
138    Utf16,
139}
140
141impl SourceEncoding {
142    pub fn as_str(self) -> &'static str {
143        match self {
144            Self::Utf8 => "utf8",
145            Self::Utf16 => "utf16",
146        }
147    }
148}
149
150/// Whether line/column offsets are zero- or one-based. Explicit so conversions
151/// are contractual rather than assumed.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
153pub enum PositionBase {
154    Zero,
155    One,
156}
157
158impl PositionBase {
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::Zero => "zero_based",
162            Self::One => "one_based",
163        }
164    }
165}
166
167/// Whether the source is authored or a generated artifact. A generated location
168/// carries different repair semantics (regenerate vs. edit).
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub enum SourceProvenance {
171    Authored,
172    Generated,
173}
174
175impl SourceProvenance {
176    pub fn as_str(self) -> &'static str {
177        match self {
178            Self::Authored => "authored",
179            Self::Generated => "generated",
180        }
181    }
182}
183
184/// A line/column position. `column == None` is an explicit line-only (degraded)
185/// location rather than a fabricated precise column.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
187pub struct SourcePosition {
188    pub line: u32,
189    pub column: Option<u32>,
190}
191
192impl SourcePosition {
193    pub fn line_only(line: u32) -> Self {
194        Self { line, column: None }
195    }
196
197    pub fn precise(line: u32, column: u32) -> Self {
198        Self {
199            line,
200            column: Some(column),
201        }
202    }
203
204    pub fn is_precise(&self) -> bool {
205        self.column.is_some()
206    }
207}
208
209/// An exact source location. A `None` range with a path is a file-level
210/// location; a `Some` start with a line-only [`SourcePosition`] is an explicit
211/// degraded result, not a precise range pretending to be one.
212#[derive(Debug, Clone, PartialEq, Eq, Hash)]
213pub struct SourceRange {
214    /// Repository-relative, normalized (forward-slash) path.
215    pub path: String,
216    pub start: Option<SourcePosition>,
217    pub end: Option<SourcePosition>,
218    pub encoding: SourceEncoding,
219    pub base: PositionBase,
220    pub provenance: SourceProvenance,
221    /// Source-content identity for stale-action rejection, when known.
222    pub content_identity: Option<String>,
223}
224
225impl SourceRange {
226    /// A file-level location with no precise range.
227    pub fn file(path: impl Into<String>) -> Self {
228        Self {
229            path: path.into(),
230            start: None,
231            end: None,
232            encoding: SourceEncoding::Utf8,
233            base: PositionBase::One,
234            provenance: SourceProvenance::Authored,
235            content_identity: None,
236        }
237    }
238
239    pub fn with_span(mut self, start: SourcePosition, end: SourcePosition) -> Self {
240        self.start = Some(start);
241        self.end = Some(end);
242        self
243    }
244
245    pub fn with_provenance(mut self, provenance: SourceProvenance) -> Self {
246        self.provenance = provenance;
247        self
248    }
249
250    pub fn with_content_identity(mut self, identity: impl Into<String>) -> Self {
251        self.content_identity = Some(identity.into());
252        self
253    }
254
255    /// Whether the location carries a precise start position (vs. file/line-only).
256    pub fn is_precise(&self) -> bool {
257        self.start.is_some_and(|start| start.is_precise())
258    }
259}
260
261/// The typed role a related location plays relative to the primary diagnostic.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263pub enum RelatedRole {
264    Requirement,
265    ImplementationSeam,
266    TestSubject,
267    Receipt,
268    Definition,
269    Reference,
270}
271
272impl RelatedRole {
273    pub fn as_str(self) -> &'static str {
274        match self {
275            Self::Requirement => "requirement",
276            Self::ImplementationSeam => "implementation_seam",
277            Self::TestSubject => "test_subject",
278            Self::Receipt => "receipt",
279            Self::Definition => "definition",
280            Self::Reference => "reference",
281        }
282    }
283}
284
285/// A typed related location (e.g. the requirement, seam, test subject, or
286/// receipt connected to this diagnostic).
287#[derive(Debug, Clone, PartialEq, Eq, Hash)]
288pub struct RelatedLocation {
289    pub role: RelatedRole,
290    pub range: SourceRange,
291    pub note: Option<String>,
292}
293
294/// The closed vocabulary of why a diagnostic's obligation is unmet. Each names
295/// what remains unproven after structural repair.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
297pub enum MissingObligation {
298    NormativeRequirementMissing,
299    ImplementationSliceMissingOrStale,
300    ImplementationSeamOwnerMissing,
301    EvidencePurposeMissing,
302    EvidenceSubjectMissingOrAmbiguous,
303    NegativeDiscriminatorMissing,
304    ProofCommandMissingOrIncompatible,
305    ExternalReceiptMissingOrStale,
306    ReceiptSubjectsMissing,
307    SpecCodeTestAtomicityBroken,
308    AuthorityMissingOrContradictory,
309    GeneratedArtifactStale,
310    PackOrAdapterDrift,
311    UnsupportedCapability,
312    RepositoryDecisionRequired,
313}
314
315impl MissingObligation {
316    pub fn as_str(self) -> &'static str {
317        match self {
318            Self::NormativeRequirementMissing => "normative_requirement_missing",
319            Self::ImplementationSliceMissingOrStale => "implementation_slice_missing_or_stale",
320            Self::ImplementationSeamOwnerMissing => "implementation_seam_owner_missing",
321            Self::EvidencePurposeMissing => "evidence_purpose_missing",
322            Self::EvidenceSubjectMissingOrAmbiguous => "evidence_subject_missing_or_ambiguous",
323            Self::NegativeDiscriminatorMissing => "negative_discriminator_missing",
324            Self::ProofCommandMissingOrIncompatible => "proof_command_missing_or_incompatible",
325            Self::ExternalReceiptMissingOrStale => "external_receipt_missing_or_stale",
326            Self::ReceiptSubjectsMissing => "receipt_subjects_missing",
327            Self::SpecCodeTestAtomicityBroken => "spec_code_test_atomicity_broken",
328            Self::AuthorityMissingOrContradictory => "authority_missing_or_contradictory",
329            Self::GeneratedArtifactStale => "generated_artifact_stale",
330            Self::PackOrAdapterDrift => "pack_or_adapter_drift",
331            Self::UnsupportedCapability => "unsupported_capability",
332            Self::RepositoryDecisionRequired => "repository_decision_required",
333        }
334    }
335}
336
337/// The closed vocabulary of what the next action is. Only deterministic,
338/// non-inventive changes may be [`ActionApplicability::Automatic`].
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
340pub enum ActionKind {
341    AutomaticSafeEdit,
342    PreviewableWorkspaceEdit,
343    GenerateOwnedArtifact,
344    RunCargoAllowCommand,
345    OpenOrNavigate,
346    RefreshOrReissue,
347    ChooseBetweenAuthorities,
348    RequestRepositoryDecision,
349    PerformExternalAction,
350    DeferWithTypedReason,
351    SuppressOrExemptUnderPolicy,
352    NoSafeActionKnown,
353}
354
355impl ActionKind {
356    pub fn as_str(self) -> &'static str {
357        match self {
358            Self::AutomaticSafeEdit => "automatic_safe_edit",
359            Self::PreviewableWorkspaceEdit => "previewable_workspace_edit",
360            Self::GenerateOwnedArtifact => "generate_owned_artifact",
361            Self::RunCargoAllowCommand => "run_cargo_allow_command",
362            Self::OpenOrNavigate => "open_or_navigate",
363            Self::RefreshOrReissue => "refresh_or_reissue",
364            Self::ChooseBetweenAuthorities => "choose_between_authorities",
365            Self::RequestRepositoryDecision => "request_repository_decision",
366            Self::PerformExternalAction => "perform_external_action",
367            Self::DeferWithTypedReason => "defer_with_typed_reason",
368            Self::SuppressOrExemptUnderPolicy => "suppress_or_exempt_under_policy",
369            Self::NoSafeActionKnown => "no_safe_action_known",
370        }
371    }
372
373    /// Whether this kind changes source (a text edit, an owned-artifact
374    /// generate/refresh, or writing a policy exception) versus navigating,
375    /// deciding, deferring, or invoking an external/command action.
376    pub fn mutates_source(self) -> bool {
377        matches!(
378            self,
379            Self::AutomaticSafeEdit
380                | Self::PreviewableWorkspaceEdit
381                | Self::GenerateOwnedArtifact
382                | Self::RefreshOrReissue
383                | Self::SuppressOrExemptUnderPolicy
384        )
385    }
386
387    /// Whether this kind may ever be applied *automatically* (without review).
388    /// The issue permits only deterministic, non-inventive changes to be
389    /// automatic: a safe edit, generating an owned artifact, or refreshing one
390    /// from unchanged canonical inputs. `SuppressOrExemptUnderPolicy` is
391    /// inventive — it creates an exception — and must never be automatic; a
392    /// `PreviewableWorkspaceEdit` requires confirmation by definition; running a
393    /// command or performing an external action is never a silent auto-apply.
394    pub fn may_be_automatic(self) -> bool {
395        matches!(
396            self,
397            Self::AutomaticSafeEdit | Self::GenerateOwnedArtifact | Self::RefreshOrReissue
398        )
399    }
400}
401
402/// How an action may be applied.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
404pub enum ActionApplicability {
405    /// A deterministic, non-inventive change that may be applied without review.
406    Automatic,
407    /// A previewable edit that requires operator confirmation.
408    Preview,
409    /// A manual step (navigation, decision, external action).
410    Manual,
411    /// No safe application is currently possible.
412    Unavailable,
413}
414
415impl ActionApplicability {
416    pub fn as_str(self) -> &'static str {
417        match self {
418            Self::Automatic => "automatic",
419            Self::Preview => "preview",
420            Self::Manual => "manual",
421            Self::Unavailable => "unavailable",
422        }
423    }
424}
425
426/// The proof a caller should rerun after applying an action.
427#[derive(Debug, Clone, PartialEq, Eq, Hash)]
428pub struct RequiredProof {
429    /// Authoritative program plus ordered argv; consumers must not shell-split.
430    pub command_argv: Vec<String>,
431    pub description: Option<String>,
432}
433
434/// One typed next action bound to a diagnostic.
435#[derive(Debug, Clone, PartialEq, Eq, Hash)]
436pub struct CargoAllowActionV1 {
437    pub id: String,
438    pub kind: ActionKind,
439    pub applicability: ActionApplicability,
440    /// Preconditions that must hold before the action applies.
441    pub preconditions: Vec<String>,
442    /// What part of the source the action touches (e.g. a path, a range, a
443    /// policy entry). `None` for actions that mutate nothing (navigation,
444    /// decision, external).
445    pub mutation_scope: Option<String>,
446    pub expected_effect: String,
447    /// How to undo the action, when it mutates source.
448    pub rollback: Option<String>,
449    pub required_proof: Option<RequiredProof>,
450    /// What remains unproven / claimed after the action (its claim boundary).
451    pub residual_claim: Vec<String>,
452}
453
454impl CargoAllowActionV1 {
455    /// A minimal navigation action for a location — never mutates source.
456    pub fn navigate(id: impl Into<String>, expected_effect: impl Into<String>) -> Self {
457        Self {
458            id: id.into(),
459            kind: ActionKind::OpenOrNavigate,
460            applicability: ActionApplicability::Manual,
461            preconditions: Vec::new(),
462            mutation_scope: None,
463            expected_effect: expected_effect.into(),
464            rollback: None,
465            required_proof: None,
466            residual_claim: Vec::new(),
467        }
468    }
469
470    /// Invariant: only kinds that [`ActionKind::may_be_automatic`] may carry
471    /// [`ActionApplicability::Automatic`]. A navigation, decision, external,
472    /// preview, or exception-creating action can never be automatic.
473    pub fn applicability_is_coherent(&self) -> bool {
474        if self.applicability == ActionApplicability::Automatic {
475            return self.kind.may_be_automatic();
476        }
477        true
478    }
479}
480
481/// Whether a diagnostic batch covers the intended scope or was bounded by
482/// partial data / an instrument limit.
483#[derive(Debug, Clone, PartialEq, Eq, Hash)]
484pub struct PartialDataBoundary {
485    pub complete: bool,
486    pub reasons: Vec<String>,
487}
488
489impl PartialDataBoundary {
490    pub fn complete() -> Self {
491        Self {
492            complete: true,
493            reasons: Vec::new(),
494        }
495    }
496
497    pub fn partial(reasons: Vec<String>) -> Self {
498        Self {
499            complete: false,
500            reasons,
501        }
502    }
503}
504
505/// One semantic diagnostic. Its four judgment dimensions are independent fields;
506/// none is derived from another.
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct CargoAllowDiagnosticV1 {
509    pub rule_id: String,
510    pub rule_generation: u32,
511    /// Stable subject key identifying what the diagnostic is about.
512    pub subject_key: String,
513    pub severity: DiagnosticSeverity,
514    pub posture: RulePosture,
515    pub confidence: DiagnosticConfidence,
516    pub result_class: DiagnosticResultClass,
517    pub primary_location: SourceRange,
518    pub related: Vec<RelatedLocation>,
519    pub missing_obligation: Option<MissingObligation>,
520    /// The repository/source basis this diagnostic was computed against, for
521    /// stale-input rejection at preview/apply.
522    pub snapshot_identity: String,
523    pub message: String,
524    pub actions: Vec<CargoAllowActionV1>,
525}
526
527impl CargoAllowDiagnosticV1 {
528    /// A deterministic identity fingerprint. It binds the semantic *identity* of
529    /// the diagnostic — rule, generation, subject, primary location, missing
530    /// obligation, result class, and snapshot basis — so it survives output
531    /// format changes and message/action wording, but changes when the semantic
532    /// subject, rule, or snapshot changes.
533    pub fn fingerprint(&self) -> String {
534        let mut canonical = Vec::new();
535        push_field(&mut canonical, "cargo-allow.diagnostic-id.v1");
536        push_field(&mut canonical, &self.rule_id);
537        push_field(&mut canonical, &self.rule_generation.to_string());
538        push_field(&mut canonical, &self.subject_key);
539        push_field(&mut canonical, self.result_class.as_str());
540        push_field(
541            &mut canonical,
542            self.missing_obligation
543                .map(MissingObligation::as_str)
544                .unwrap_or(""),
545        );
546        push_field(&mut canonical, &self.primary_location.path);
547        push_field(
548            &mut canonical,
549            &location_position_key(self.primary_location.start),
550        );
551        push_field(
552            &mut canonical,
553            &location_position_key(self.primary_location.end),
554        );
555        // Encoding and position base are part of the physical location's
556        // meaning: the same numeric offsets under UTF-8/one-based vs.
557        // UTF-16/zero-based address different characters, so they must bind.
558        push_field(&mut canonical, self.primary_location.encoding.as_str());
559        push_field(&mut canonical, self.primary_location.base.as_str());
560        // Provenance binds too: an authored and a generated location at the same
561        // path/range carry different repair semantics (edit vs. regenerate), so
562        // they must not collapse to one identity in a worklist/cache/dedupe.
563        push_field(&mut canonical, self.primary_location.provenance.as_str());
564        push_field(&mut canonical, &self.snapshot_identity);
565        sha256_v1_bytes(&canonical)
566    }
567
568    /// Whether every action's applicability is coherent with its kind.
569    pub fn actions_are_coherent(&self) -> bool {
570        self.actions
571            .iter()
572            .all(CargoAllowActionV1::applicability_is_coherent)
573    }
574}
575
576/// A versioned batch of diagnostics computed against one repository snapshot.
577#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct CargoAllowDiagnosticBatchV1 {
579    pub schema: &'static str,
580    pub snapshot_identity: String,
581    pub diagnostics: Vec<CargoAllowDiagnosticV1>,
582    pub partial_data: PartialDataBoundary,
583}
584
585impl CargoAllowDiagnosticBatchV1 {
586    pub fn new(snapshot_identity: impl Into<String>) -> Self {
587        Self {
588            schema: DIAGNOSTIC_KERNEL_SCHEMA,
589            snapshot_identity: snapshot_identity.into(),
590            diagnostics: Vec::new(),
591            partial_data: PartialDataBoundary::complete(),
592        }
593    }
594
595    pub fn with_diagnostic(mut self, diagnostic: CargoAllowDiagnosticV1) -> Self {
596        self.diagnostics.push(diagnostic);
597        self
598    }
599
600    pub fn with_partial_data(mut self, partial_data: PartialDataBoundary) -> Self {
601        self.partial_data = partial_data;
602        self
603    }
604
605    /// Deterministic fingerprints for every diagnostic, in order.
606    pub fn diagnostic_fingerprints(&self) -> Vec<String> {
607        self.diagnostics
608            .iter()
609            .map(CargoAllowDiagnosticV1::fingerprint)
610            .collect()
611    }
612}
613
614fn location_position_key(position: Option<SourcePosition>) -> String {
615    match position {
616        None => String::new(),
617        Some(position) => match position.column {
618            Some(column) => format!("{}:{column}", position.line),
619            None => format!("{}:", position.line),
620        },
621    }
622}
623
624/// Length-prefixed canonical field encoding so no field boundary is ambiguous.
625fn push_field(output: &mut Vec<u8>, value: &str) {
626    output.extend_from_slice(&(value.len() as u64).to_be_bytes());
627    output.extend_from_slice(value.as_bytes());
628}
629
630#[cfg(test)]
631#[path = "actionable_diagnostic_tests.rs"]
632mod tests;