ossctl_core/protocol/audit.rs
1//! Public wire DTO for the readiness gap-report (`ossctl audit`).
2//!
3//! The versioned surface the `/oss-readiness` skill wraps: `ossctl audit` scores
4//! a repo against the **gated core** (README + LICENSE + CI), the **tier-scaled
5//! canon** (recommended artifacts scaled to the facts' maturity), the
6//! **producer-existence** obligations the contract declares (a `fragment`
7//! changelog needs its dir, a `coverage`/`scorecard` badge needs its CI
8//! producer, a registry target needs an SPDX license), and the **GitHub
9//! community standards** (`gh api …/community/profile`). It is **read-only** —
10//! nothing here nor in [`crate::audit`] ever writes the repo (ADR-0001 §3).
11//!
12//! Consumers read this document under the CLI's canonical `data` envelope:
13//! `{schema_version, data: <this shape>, warnings}` — the same envelope every
14//! `ossctl --json` command shares (`crate::SCHEMA_VERSION` versions that wire
15//! envelope). Like the facts report, the gap-report is *derived*, never
16//! authored, so it has no document version of its own; the envelope's
17//! `schema_version` is the single version consumers gate on.
18//!
19//! The report **reuses** [`Maturity`] from the canonical contract model rather
20//! than restating its wire strings: the audit and the contract must agree on
21//! `"mvp"` down to the byte, and sharing the one enum makes that agreement
22//! structural instead of coincidental.
23//!
24//! ## The `unknown` discipline
25//!
26//! Every check distinguishes **checked-and-absent** from **could-not-check**.
27//! Filesystem probes are always determinate ([`Presence::Present`] /
28//! [`Presence::Absent`]). A GitHub-API or registry lookup that *fails* yields
29//! [`Presence::Unknown`], never `Absent` — an outage must never be read as "the
30//! artifact is missing" (issue: registry/GH-API failure ⇒ `unknown`, never
31//! `false`).
32
33use serde::Serialize;
34
35use crate::contract::schema::Maturity;
36
37/// Tri-state presence of one checked artifact.
38///
39/// `Unknown` is reserved for a check that *could not be performed* (a failed
40/// `gh api` call, an unresolved remote) — it is never a synonym for `Absent`,
41/// which means "checked, and the artifact is not there".
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "lowercase")]
44pub enum Presence {
45 /// The artifact was found.
46 Present,
47 /// The artifact was looked for and is not there.
48 Absent,
49 /// The check could not be completed (outage / unresolved input).
50 Unknown,
51}
52
53impl Presence {
54 /// The wire string for this value — the single source of truth the
55 /// `Serialize` derive (`rename_all = "lowercase"`) also emits, so text and
56 /// JSON never drift.
57 #[must_use]
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::Present => "present",
61 Self::Absent => "absent",
62 Self::Unknown => "unknown",
63 }
64 }
65}
66
67/// Whether the tier-scaled **gated core** is complete.
68///
69/// The gated core is README + LICENSE + CI, but the CI leg only gates at `mvp`
70/// and above: a `spike` is not being published, so it is gated on README +
71/// LICENSE alone and CI is reported as a (non-blocking) gap toward `mvp`
72/// (design §4). `Unknown` is a forward-compatible third state; today every core
73/// leg is a determinate filesystem probe, so the core resolves to
74/// `Complete`/`Incomplete`.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "lowercase")]
77pub enum CoreStatus {
78 /// Every applicable core artifact is present.
79 Complete,
80 /// At least one applicable core artifact is absent.
81 Incomplete,
82 /// A core leg could not be determined (never today; reserved).
83 Unknown,
84}
85
86impl CoreStatus {
87 /// The wire string for this value (matches the `Serialize` derive).
88 #[must_use]
89 pub fn as_str(self) -> &'static str {
90 match self {
91 Self::Complete => "complete",
92 Self::Incomplete => "incomplete",
93 Self::Unknown => "unknown",
94 }
95 }
96}
97
98/// Which scoring axis a gap comes from.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100#[serde(rename_all = "lowercase")]
101pub enum Category {
102 /// The gated core (README + LICENSE + CI) — the publish gate.
103 Core,
104 /// The tier-scaled recommended set (the "canon"): offered, never blocking.
105 Canon,
106 /// A producer obligation the contract declared (fragment dir, coverage /
107 /// scorecard CI step, registry ⇒ SPDX license). The normalizer does **not**
108 /// hard-fail on these (advisory-producer decision); the audit reports them.
109 Producer,
110}
111
112impl Category {
113 /// The wire string for this value (matches the `Serialize` derive).
114 #[must_use]
115 pub fn as_str(self) -> &'static str {
116 match self {
117 Self::Core => "core",
118 Self::Canon => "canon",
119 Self::Producer => "producer",
120 }
121 }
122}
123
124/// How much a gap matters — its gating weight.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
126#[serde(rename_all = "lowercase")]
127pub enum Severity {
128 /// Part of the gated core at this tier: blocks a responsible release.
129 Blocking,
130 /// Tier-scaled canon or a producer obligation: offered, never blocks.
131 Recommended,
132}
133
134impl Severity {
135 /// The wire string for this value (matches the `Serialize` derive).
136 #[must_use]
137 pub fn as_str(self) -> &'static str {
138 match self {
139 Self::Blocking => "blocking",
140 Self::Recommended => "recommended",
141 }
142 }
143}
144
145/// One unmet readiness obligation — an artifact that is absent (or could not be
146/// checked) but is expected at this maturity tier (or required by the contract).
147///
148/// The report lists only actual gaps; a satisfied check produces no entry. Each
149/// gap names the `/oss-*` member skill that closes it, so `/oss-readiness` can
150/// sequence the fixes highest-severity first.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct Gap {
153 /// Stable slug for the missing artifact (`readme`, `license`, `ci`,
154 /// `changelog`, `coverage`, …) — a caller keys off this, not the prose.
155 pub id: String,
156 /// The scoring axis this gap comes from.
157 pub category: Category,
158 /// The gating weight (only [`Category::Core`] gaps are ever `Blocking`).
159 pub severity: Severity,
160 /// Presence of the artifact — [`Presence::Absent`] or [`Presence::Unknown`]
161 /// (a `Present` artifact is never a gap).
162 pub status: Presence,
163 /// The `/oss-*` member skill that closes this gap (no leading slash), e.g.
164 /// `oss-readme`, `oss-ci`, `oss-changelog`.
165 pub member: String,
166 /// Human-readable explanation of what is missing and why it is expected.
167 pub detail: String,
168}
169
170/// GitHub's own community-standards view of the repo — the parsed
171/// `gh api repos/<owner>/<repo>/community/profile` `files` block.
172///
173/// Supplementary evidence alongside the filesystem-derived gaps: GitHub also
174/// recognizes health files under `.github/` and `docs/`, so a `Present` here
175/// can corroborate a file the root-level probe did not see. When the lookup
176/// could not run ([`Self::checked`] is `false`), every field is
177/// [`Presence::Unknown`] — the outage is never read as "absent".
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct CommunityProfile {
180 /// Whether the read-only `gh api …/community/profile` call succeeded. When
181 /// `false`, every field below is [`Presence::Unknown`].
182 pub checked: bool,
183 /// A short reason when [`Self::checked`] is `false` (no GitHub remote, `gh`
184 /// unavailable, API error), else `null`.
185 pub unavailable_reason: Option<String>,
186 /// Whether GitHub sees a README.
187 pub readme: Presence,
188 /// Whether GitHub sees a LICENSE.
189 pub license: Presence,
190 /// Whether GitHub sees a CONTRIBUTING file.
191 pub contributing: Presence,
192 /// Whether GitHub sees a code of conduct.
193 pub code_of_conduct: Presence,
194 /// Whether GitHub sees an issue template.
195 pub issue_template: Presence,
196 /// Whether GitHub sees a pull-request template.
197 pub pull_request_template: Presence,
198 /// Whether GitHub sees a SECURITY policy.
199 pub security: Presence,
200}
201
202/// The readiness gap-report — a read-only score of the repo against the gated
203/// core, the tier-scaled canon, the contract's producer obligations, and the
204/// GitHub community standards.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206pub struct AuditReport {
207 /// The canonicalized repository root that was scored.
208 pub repo_root: String,
209 /// The maturity tier the scoring scaled to (the contract's `maturity`).
210 pub maturity: Maturity,
211 /// Whether the tier-scaled gated core is complete — the publish gate the
212 /// orchestrator reads to decide bootstrap-vs-cut.
213 pub core_complete: CoreStatus,
214 /// Every unmet obligation, in stable emit order (core first, then canon,
215 /// then producer). Empty when the repo is fully ready at its tier.
216 pub gaps: Vec<Gap>,
217 /// GitHub's community-standards view (supplementary evidence).
218 pub community_profile: CommunityProfile,
219}