Skip to main content

ossctl_core/audit/
mod.rs

1//! Readiness scoring over the normalized contract + detected facts (ADR-0001 §3).
2//!
3//! [`audit`] is a read-only function of `(repo tree, contract, facts)` that
4//! produces a gap-report: the **gated core** (README + LICENSE + CI, tier-scaled
5//! so a `spike` is gated on README + LICENSE alone), the **tier-scaled canon**
6//! (recommended artifacts scaled to the contract's maturity), the
7//! **producer-existence** obligations the contract declared (a `fragment`
8//! changelog needs its dir, a `coverage`/`scorecard` badge needs its CI
9//! producer, a registry target needs an SPDX license), and the **GitHub
10//! community standards** (`gh api …/community/profile`). Feeds `ossctl audit`
11//! and the `/oss-readiness` skill.
12//!
13//! **Read-only, always.** Every probe goes through the [`Fs`] and
14//! [`CommandRunner`] ports; nothing here writes the repo. The `git remote` and
15//! `gh api` calls are read-only. A registry/GitHub lookup that *fails* yields
16//! [`Presence::Unknown`], never [`Presence::Absent`] — an outage is never read
17//! as "the artifact is missing" (issue: registry/GH-API failure ⇒ `unknown`,
18//! never `false`).
19//!
20//! The engine takes the already-normalized [`Contract`] and detected [`Facts`]
21//! by reference — it never re-parses `OSS-RELEASE.md` nor re-derives facts. The
22//! `ossctl-cli` handler runs `contract::normalize` and `facts::gather` (the same
23//! code paths behind `contract show` and `facts`) and hands their results here,
24//! so the audit, `/oss-init`, and every other member agree on maturity and the
25//! gated core down to the byte (ADR-0001 §3).
26
27use std::path::Path;
28
29use crate::contract::schema::{ChangelogMode, Contract, HealthBadge, Maturity, Registry};
30use crate::ports::{CommandRunner, Fs};
31use crate::protocol::audit::{
32    AuditReport, Category, CommunityProfile, CoreStatus, Gap, Presence, Severity,
33};
34use crate::protocol::facts::Facts;
35
36/// Score the repo at `repo_root` against its `contract` and detected `facts`.
37///
38/// Pure over its inputs and read-only over the repo: filesystem probes go
39/// through `fs`; the GitHub community-standards lookup goes through `cmd`
40/// (`git remote get-url origin` then `gh api …/community/profile`). Never
41/// mutates anything.
42#[must_use]
43pub fn audit(
44    repo_root: &Path,
45    contract: &Contract,
46    facts: &Facts,
47    fs: &dyn Fs,
48    cmd: &dyn CommandRunner,
49) -> AuditReport {
50    let maturity = contract.maturity;
51    let mut gaps: Vec<Gap> = Vec::new();
52
53    // ── Gated core (README + LICENSE always; CI at mvp+) ──
54    let readme_present = probe(fs, repo_root, README_NAMES);
55    let license_present = probe(fs, repo_root, LICENSE_NAMES);
56    let ci_present = facts.has_ci;
57
58    let mut core_incomplete = false;
59    if !readme_present {
60        core_incomplete = true;
61        gaps.push(core_gap(
62            "readme",
63            "oss-readme",
64            "no README found — the project's front door is part of the gated core",
65        ));
66    }
67    if !license_present {
68        core_incomplete = true;
69        gaps.push(core_gap(
70            "license",
71            "oss-readme",
72            "no LICENSE file found — a public release needs an SPDX-identified license \
73             (part of the gated core)",
74        ));
75    }
76    // CI only gates the core at mvp+; a spike gets CI reported as a canon gap
77    // toward mvp, not a core failure (design §4).
78    let ci_gates_core = tier_rank(maturity) >= tier_rank(Maturity::Mvp);
79    if !ci_present {
80        if ci_gates_core {
81            core_incomplete = true;
82            gaps.push(core_gap(
83                "ci",
84                "oss-ci",
85                "no CI configuration found — test+lint on every PR is part of the gated \
86                 core at mvp and above",
87            ));
88        } else {
89            gaps.push(canon_gap(
90                "ci",
91                "oss-ci",
92                Presence::Absent,
93                "no CI configuration found — add test+lint on PR to reach mvp/publish",
94            ));
95        }
96    }
97    let core_complete = if core_incomplete {
98        CoreStatus::Incomplete
99    } else {
100        CoreStatus::Complete
101    };
102
103    // ── Tier-scaled canon (recommended; never blocking) ──
104    canon_gaps(&mut gaps, repo_root, facts, fs, maturity);
105
106    // ── Producer-existence obligations declared by the contract ──
107    // The normalizer does NOT hard-fail on a missing producer (advisory-producer
108    // decision from the oss-init unit); the audit reports them as gaps.
109    producer_gaps(&mut gaps, repo_root, contract, facts, fs, maturity);
110
111    // ── GitHub community standards (read-only; failure ⇒ unknown) ──
112    let community_profile = community_profile(repo_root, cmd);
113
114    AuditReport {
115        repo_root: repo_root.display().to_string(),
116        maturity,
117        core_complete,
118        gaps,
119        community_profile,
120    }
121}
122
123/// Emit the tier-scaled canon (recommended) gaps — the artifacts a project of
124/// this maturity is expected to carry. Cumulative: each tier adds to the one
125/// below. Every canon gap is [`Severity::Recommended`], never blocking.
126fn canon_gaps(
127    gaps: &mut Vec<Gap>,
128    repo_root: &Path,
129    facts: &Facts,
130    fs: &dyn Fs,
131    maturity: Maturity,
132) {
133    // mvp adds contribution/changelog/security scaffolding + a dependency bot.
134    if tier_rank(maturity) >= tier_rank(Maturity::Mvp) {
135        canon_file_gap(
136            gaps,
137            fs,
138            repo_root,
139            "changelog",
140            "oss-changelog",
141            CHANGELOG_NAMES,
142            "no CHANGELOG.md — mvp+ projects keep a changelog",
143        );
144        canon_file_gap(
145            gaps,
146            fs,
147            repo_root,
148            "contributing",
149            "oss-contributing",
150            CONTRIBUTING_NAMES,
151            "no CONTRIBUTING guide — mvp+ projects onboard contributors",
152        );
153        canon_file_gap(
154            gaps,
155            fs,
156            repo_root,
157            "code-of-conduct",
158            "oss-contributing",
159            CODE_OF_CONDUCT_NAMES,
160            "no CODE_OF_CONDUCT — mvp+ projects set community expectations",
161        );
162        canon_file_gap(
163            gaps,
164            fs,
165            repo_root,
166            "security-policy",
167            "oss-security-policy",
168            SECURITY_NAMES,
169            "no SECURITY policy — recommended at mvp+ (required once the tool crosses a \
170             threat boundary)",
171        );
172        if facts.dependency_bot.is_none() {
173            gaps.push(canon_gap(
174                "dependency-bot",
175                "oss-ci",
176                Presence::Absent,
177                "no dependency-update bot (dependabot/renovate) configured — recommended at \
178                 mvp+",
179            ));
180        }
181    }
182
183    // production adds deeper contribution + hardening scaffolding.
184    if tier_rank(maturity) >= tier_rank(Maturity::Production) {
185        canon_file_gap(
186            gaps,
187            fs,
188            repo_root,
189            "codeowners",
190            "oss-contributing",
191            CODEOWNERS_NAMES,
192            "no CODEOWNERS — recommended at production for review routing",
193        );
194        canon_file_gap(
195            gaps,
196            fs,
197            repo_root,
198            "governance",
199            "oss-contributing",
200            GOVERNANCE_NAMES,
201            "no GOVERNANCE.md — recommended at production",
202        );
203        canon_file_gap(
204            gaps,
205            fs,
206            repo_root,
207            "architecture",
208            "oss-architecture",
209            ARCHITECTURE_NAMES,
210            "no ARCHITECTURE.md — offered at production (never a readiness gate)",
211        );
212        canon_file_gap(
213            gaps,
214            fs,
215            repo_root,
216            "pre-commit",
217            "oss-ci",
218            PRE_COMMIT_NAMES,
219            "no pre-commit config — recommended at production",
220        );
221    }
222}
223
224/// Emit the producer-existence gaps the contract's own configuration implies.
225fn producer_gaps(
226    gaps: &mut Vec<Gap>,
227    repo_root: &Path,
228    contract: &Contract,
229    facts: &Facts,
230    fs: &dyn Fs,
231    maturity: Maturity,
232) {
233    // A `fragment` changelog needs its fragment directory to exist.
234    if contract.changelog.mode == ChangelogMode::Fragment {
235        let dir = repo_root.join(&contract.changelog.fragment_dir);
236        if !fs.is_dir(&dir) {
237            gaps.push(producer_gap(
238                "changelog-fragment-dir",
239                "oss-changelog",
240                Presence::Absent,
241                format!(
242                    "changelog.mode is 'fragment' but the fragment directory '{}' does not \
243                     exist",
244                    contract.changelog.fragment_dir
245                ),
246            ));
247        }
248    }
249
250    // A registry target requires an SPDX license configured in the contract.
251    let has_registry_target = contract
252        .targets
253        .iter()
254        .any(|t| t.registry != Registry::GhReleases);
255    if has_registry_target && contract.license.trim().is_empty() {
256        gaps.push(producer_gap(
257            "registry-license",
258            "oss-readme",
259            Presence::Absent,
260            "a registry publish target is configured but the contract declares no license \
261             — registries (crates.io/npm/PyPI) require an SPDX license",
262        ));
263    }
264
265    // A `coverage` badge needs a coverage step in CI. Also recommended at
266    // production even without the badge; emit at most one coverage gap. A
267    // workflow-read failure surfaces as `Unknown`, never a false `Absent`.
268    let coverage_badge = contract.health_badges.contains(&HealthBadge::Coverage);
269    let coverage_expected =
270        coverage_badge || tier_rank(maturity) >= tier_rank(Maturity::Production);
271    if coverage_expected {
272        let status = workflow_mentions(repo_root, fs, COVERAGE_TOKENS);
273        if status != Presence::Present {
274            let (category, detail) = if coverage_badge {
275                (
276                    Category::Producer,
277                    "the contract enables a 'coverage' health badge but no coverage step was \
278                     found in CI — the badge has no producer",
279                )
280            } else {
281                (
282                    Category::Canon,
283                    "no coverage step found in CI — recommended at production",
284                )
285            };
286            gaps.push(Gap {
287                id: "coverage".to_string(),
288                category,
289                severity: Severity::Recommended,
290                status,
291                member: "oss-ci".to_string(),
292                detail: detail.to_string(),
293            });
294        }
295    }
296
297    // A `scorecard` badge needs the OSSF Scorecard action wired in CI.
298    if contract.health_badges.contains(&HealthBadge::Scorecard) {
299        let status = workflow_mentions(repo_root, fs, SCORECARD_TOKENS);
300        if status != Presence::Present {
301            gaps.push(producer_gap(
302                "scorecard",
303                "oss-security-policy",
304                status,
305                "the contract enables a 'scorecard' health badge but no OSSF Scorecard action \
306                 was found in CI — the badge has no producer",
307            ));
308        }
309    }
310
311    // A `ci` badge needs CI to actually exist.
312    if contract.health_badges.contains(&HealthBadge::Ci) && !facts.has_ci {
313        gaps.push(producer_gap(
314            "ci-badge-producer",
315            "oss-ci",
316            Presence::Absent,
317            "the contract enables a 'ci' health badge but no CI configuration was found — \
318             the badge has no producer",
319        ));
320    }
321
322    // A `license` badge needs a LICENSE file.
323    if contract.health_badges.contains(&HealthBadge::License)
324        && !probe(fs, repo_root, LICENSE_NAMES)
325    {
326        gaps.push(producer_gap(
327            "license-badge-producer",
328            "oss-readme",
329            Presence::Absent,
330            "the contract enables a 'license' health badge but no LICENSE file was found — \
331             the badge has no producer",
332        ));
333    }
334}
335
336/// Query GitHub's community-standards profile for the repo (read-only).
337///
338/// Resolves `owner/repo` from the `origin` remote, then runs
339/// `gh api repos/<owner>/<repo>/community/profile`. Any failure along the way
340/// (no GitHub remote, `gh` missing, non-zero exit, unparseable JSON) degrades to
341/// an unchecked profile with every field [`Presence::Unknown`] — never `Absent`.
342fn community_profile(repo_root: &Path, cmd: &dyn CommandRunner) -> CommunityProfile {
343    let Some(slug) = github_slug(repo_root, cmd) else {
344        return unchecked_profile("no GitHub 'origin' remote could be resolved");
345    };
346    let path = format!("repos/{slug}/community/profile");
347    let out = match cmd.run("gh", &["api", &path], repo_root) {
348        Ok(out) if out.status == Some(0) => out,
349        Ok(out) => {
350            // A 404 (private/absent repo) or any non-zero exit is "could not
351            // check", not "the files are absent".
352            let reason =
353                first_line(&out.stderr).unwrap_or_else(|| "gh api exited non-zero".to_string());
354            return unchecked_profile(&format!("gh api failed: {reason}"));
355        }
356        Err(e) => return unchecked_profile(&format!("could not run gh: {e}")),
357    };
358    let Ok(json) = serde_json::from_str::<serde_json::Value>(&out.stdout) else {
359        return unchecked_profile("gh api returned unparseable JSON");
360    };
361    // The response MUST carry a `files` object. Anything else (a `{}`, a
362    // `{"message": "..."}` error body that still exited 0 through a proxy, a
363    // renamed schema) is "could not check" ⇒ every field unknown, never a blanket
364    // `Absent` — the outage discipline (issue: failure ⇒ unknown, never false).
365    let Some(files) = json.get("files").and_then(serde_json::Value::as_object) else {
366        return unchecked_profile("gh api response had no 'files' object");
367    };
368    // A recognized health file is a non-null object under its key; a `null` (or
369    // absent) key means checked-and-absent.
370    let f = |key: &str| {
371        if files.get(key).is_some_and(|v| !v.is_null()) {
372            Presence::Present
373        } else {
374            Presence::Absent
375        }
376    };
377    // GitHub has named the security-policy field `security_policy` and (in some
378    // API versions) `security`; accept either so a present SECURITY.md is not
379    // misreported absent.
380    let security = if matches!(f("security_policy"), Presence::Present) {
381        Presence::Present
382    } else {
383        f("security")
384    };
385    CommunityProfile {
386        checked: true,
387        unavailable_reason: None,
388        readme: f("readme"),
389        license: f("license"),
390        contributing: f("contributing"),
391        code_of_conduct: f("code_of_conduct"),
392        issue_template: f("issue_template"),
393        pull_request_template: f("pull_request_template"),
394        security,
395    }
396}
397
398/// Resolve a `owner/repo` GitHub slug from the `origin` remote, or `None` when
399/// there is no GitHub remote (or `git` failed). Parsing lives in [`crate::vcs`]
400/// (shared with the release coordinator).
401fn github_slug(repo_root: &Path, cmd: &dyn CommandRunner) -> Option<String> {
402    let out = cmd
403        .run("git", &["remote", "get-url", "origin"], repo_root)
404        .ok()?;
405    if out.status != Some(0) {
406        return None;
407    }
408    crate::vcs::parse_github_slug(out.stdout.trim())
409}
410
411// ── Small builders ───────────────────────────────────────────────────────────
412
413/// A blocking core gap (README/LICENSE/CI at the gate).
414fn core_gap(id: &str, member: &str, detail: &str) -> Gap {
415    Gap {
416        id: id.to_string(),
417        category: Category::Core,
418        severity: Severity::Blocking,
419        status: Presence::Absent,
420        member: member.to_string(),
421        detail: detail.to_string(),
422    }
423}
424
425/// A recommended canon gap.
426fn canon_gap(id: &str, member: &str, status: Presence, detail: &str) -> Gap {
427    Gap {
428        id: id.to_string(),
429        category: Category::Canon,
430        severity: Severity::Recommended,
431        status,
432        member: member.to_string(),
433        detail: detail.to_string(),
434    }
435}
436
437/// A recommended producer-existence gap.
438fn producer_gap(id: &str, member: &str, status: Presence, detail: impl Into<String>) -> Gap {
439    Gap {
440        id: id.to_string(),
441        category: Category::Producer,
442        severity: Severity::Recommended,
443        status,
444        member: member.to_string(),
445        detail: detail.into(),
446    }
447}
448
449/// Push a canon gap for a missing file (probing the standard locations).
450fn canon_file_gap(
451    gaps: &mut Vec<Gap>,
452    fs: &dyn Fs,
453    repo_root: &Path,
454    id: &str,
455    member: &str,
456    names: &[&str],
457    detail: &str,
458) {
459    if !probe(fs, repo_root, names) {
460        gaps.push(canon_gap(id, member, Presence::Absent, detail));
461    }
462}
463
464/// An unchecked community profile — every field `Unknown`, with a reason.
465fn unchecked_profile(reason: &str) -> CommunityProfile {
466    CommunityProfile {
467        checked: false,
468        unavailable_reason: Some(reason.to_string()),
469        readme: Presence::Unknown,
470        license: Presence::Unknown,
471        contributing: Presence::Unknown,
472        code_of_conduct: Presence::Unknown,
473        issue_template: Presence::Unknown,
474        pull_request_template: Presence::Unknown,
475        security: Presence::Unknown,
476    }
477}
478
479// ── Filesystem probes ────────────────────────────────────────────────────────
480
481/// The directories GitHub (and this audit) recognize community/health files in.
482const HEALTH_DIRS: &[&str] = &["", ".github", "docs"];
483
484/// Whether any of `names` exists as a regular file in a recognized health
485/// directory (`.`, `.github`, `docs`).
486fn probe(fs: &dyn Fs, repo_root: &Path, names: &[&str]) -> bool {
487    names.iter().any(|name| {
488        HEALTH_DIRS.iter().any(|dir| {
489            let path = if dir.is_empty() {
490                repo_root.join(name)
491            } else {
492                repo_root.join(dir).join(name)
493            };
494            fs.is_file(&path)
495        })
496    })
497}
498
499/// Cap on a single workflow file read — a workflow this large is not real, and
500/// an unbounded read would let a pathological file stall the audit.
501const WORKFLOW_READ_LIMIT: usize = 1 << 20; // 1 MiB
502
503/// Probe `.github/workflows` for any YAML file mentioning one of `tokens`
504/// (case-insensitively) — the read-only producer probe for a coverage/scorecard
505/// step. Tri-state, honoring the outage discipline:
506///
507/// - [`Presence::Present`] — a readable workflow contains a token.
508/// - [`Presence::Absent`] — the directory is genuinely missing, or every YAML
509///   workflow was read and none matched.
510/// - [`Presence::Unknown`] — the directory or a workflow file could not be read
511///   (permission/I/O error), so "no producer" cannot be asserted.
512///
513/// This is a substring heuristic, not a YAML parse: a token inside a comment
514/// (`# TODO: add coverage`) can false-positive and an unusual action name can
515/// false-negative. That is a deliberate, documented limitation — the audit only
516/// *reports* the gap (never mutates), and a coarse producer signal is enough to
517/// tell an agent to look; a full workflow-graph parse is out of scope here.
518fn workflow_mentions(repo_root: &Path, fs: &dyn Fs, tokens: &[&str]) -> Presence {
519    let dir = repo_root.join(".github/workflows");
520    let entries = match fs.read_dir(&dir) {
521        Ok(entries) => entries,
522        // A missing directory is "checked, no producer"; any other read error
523        // (permission, I/O) is "could not check".
524        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Presence::Absent,
525        Err(_) => return Presence::Unknown,
526    };
527    let mut unreadable = false;
528    for name in &entries {
529        // Only real workflow files (`.yml`/`.yaml`) — not a stray `README.bak`.
530        if !matches!(
531            Path::new(name).extension().and_then(|e| e.to_str()),
532            Some("yml" | "yaml")
533        ) {
534            continue;
535        }
536        let path = dir.join(name);
537        match fs.read(&path) {
538            Ok(bytes) => {
539                let text = String::from_utf8_lossy(&bytes[..bytes.len().min(WORKFLOW_READ_LIMIT)])
540                    .to_lowercase();
541                if tokens.iter().any(|t| text.contains(t)) {
542                    return Presence::Present;
543                }
544            }
545            // A file that vanished mid-scan is fine to skip; a genuine read
546            // failure means we cannot be sure the producer is absent.
547            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
548            Err(_) => unreadable = true,
549        }
550    }
551    if unreadable {
552        Presence::Unknown
553    } else {
554        Presence::Absent
555    }
556}
557
558/// Maturity as a monotone rank for tier comparisons (`spike < mvp < production`).
559fn tier_rank(m: Maturity) -> u8 {
560    match m {
561        Maturity::Spike => 0,
562        Maturity::Mvp => 1,
563        Maturity::Production => 2,
564    }
565}
566
567/// The first non-empty line of `s`, trimmed.
568fn first_line(s: &str) -> Option<String> {
569    s.lines()
570        .map(str::trim)
571        .find(|l| !l.is_empty())
572        .map(str::to_string)
573}
574
575// ── Artifact name tables (probed across the health directories) ──────────────
576
577const README_NAMES: &[&str] = &["README.md", "README.rst", "README.txt", "README"];
578const LICENSE_NAMES: &[&str] = &[
579    "LICENSE",
580    "LICENSE.md",
581    "LICENSE.txt",
582    "LICENCE",
583    "LICENCE.md",
584    "COPYING",
585    "COPYING.md",
586];
587const CHANGELOG_NAMES: &[&str] = &["CHANGELOG.md", "CHANGELOG", "CHANGES.md", "HISTORY.md"];
588const CONTRIBUTING_NAMES: &[&str] = &["CONTRIBUTING.md", "CONTRIBUTING", "CONTRIBUTING.rst"];
589const CODE_OF_CONDUCT_NAMES: &[&str] = &["CODE_OF_CONDUCT.md", "CODE_OF_CONDUCT"];
590const SECURITY_NAMES: &[&str] = &["SECURITY.md", "SECURITY"];
591const CODEOWNERS_NAMES: &[&str] = &["CODEOWNERS"];
592const GOVERNANCE_NAMES: &[&str] = &["GOVERNANCE.md", "GOVERNANCE"];
593const ARCHITECTURE_NAMES: &[&str] = &["ARCHITECTURE.md", "ARCHITECTURE"];
594const PRE_COMMIT_NAMES: &[&str] = &[".pre-commit-config.yaml", ".pre-commit-config.yml"];
595
596/// CI tokens that indicate a coverage step (case-insensitive substring match).
597const COVERAGE_TOKENS: &[&str] = &[
598    "coverage",
599    "codecov",
600    "coveralls",
601    "tarpaulin",
602    "llvm-cov",
603    "grcov",
604];
605/// CI tokens that indicate the OSSF Scorecard action.
606const SCORECARD_TOKENS: &[&str] = &[
607    "ossf/scorecard",
608    "scorecard-action",
609    "step-security/scorecard",
610];
611
612#[cfg(test)]
613mod tests;