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 declared binary distribution must cover Linux (cross-platform policy).
251    cross_platform_gap(gaps, contract, maturity);
252
253    // A registry target requires an SPDX license configured in the contract.
254    let has_registry_target = contract
255        .targets
256        .iter()
257        .any(|t| t.registry != Registry::GhReleases);
258    if has_registry_target && contract.license.trim().is_empty() {
259        gaps.push(producer_gap(
260            "registry-license",
261            "oss-readme",
262            Presence::Absent,
263            "a registry publish target is configured but the contract declares no license \
264             — registries (crates.io/npm/PyPI) require an SPDX license",
265        ));
266    }
267
268    // A `coverage` badge needs a coverage step in CI. Also recommended at
269    // production even without the badge; emit at most one coverage gap. A
270    // workflow-read failure surfaces as `Unknown`, never a false `Absent`.
271    let coverage_badge = contract.health_badges.contains(&HealthBadge::Coverage);
272    let coverage_expected =
273        coverage_badge || tier_rank(maturity) >= tier_rank(Maturity::Production);
274    if coverage_expected {
275        let status = workflow_mentions(repo_root, fs, COVERAGE_TOKENS);
276        if status != Presence::Present {
277            let (category, detail) = if coverage_badge {
278                (
279                    Category::Producer,
280                    "the contract enables a 'coverage' health badge but no coverage step was \
281                     found in CI — the badge has no producer",
282                )
283            } else {
284                (
285                    Category::Canon,
286                    "no coverage step found in CI — recommended at production",
287                )
288            };
289            gaps.push(Gap {
290                id: "coverage".to_string(),
291                category,
292                severity: Severity::Recommended,
293                status,
294                member: "oss-ci".to_string(),
295                detail: detail.to_string(),
296            });
297        }
298    }
299
300    // A `scorecard` badge needs the OSSF Scorecard action wired in CI.
301    if contract.health_badges.contains(&HealthBadge::Scorecard) {
302        let status = workflow_mentions(repo_root, fs, SCORECARD_TOKENS);
303        if status != Presence::Present {
304            gaps.push(producer_gap(
305                "scorecard",
306                "oss-security-policy",
307                status,
308                "the contract enables a 'scorecard' health badge but no OSSF Scorecard action \
309                 was found in CI — the badge has no producer",
310            ));
311        }
312    }
313
314    // A `ci` badge needs CI to actually exist.
315    if contract.health_badges.contains(&HealthBadge::Ci) && !facts.has_ci {
316        gaps.push(producer_gap(
317            "ci-badge-producer",
318            "oss-ci",
319            Presence::Absent,
320            "the contract enables a 'ci' health badge but no CI configuration was found — \
321             the badge has no producer",
322        ));
323    }
324
325    // A `license` badge needs a LICENSE file.
326    if contract.health_badges.contains(&HealthBadge::License)
327        && !probe(fs, repo_root, LICENSE_NAMES)
328    {
329        gaps.push(producer_gap(
330            "license-badge-producer",
331            "oss-readme",
332            Presence::Absent,
333            "the contract enables a 'license' health badge but no LICENSE file was found — \
334             the badge has no producer",
335        ));
336    }
337}
338
339/// Query GitHub's community-standards profile for the repo (read-only).
340///
341/// Resolves `owner/repo` from the `origin` remote, then runs
342/// `gh api repos/<owner>/<repo>/community/profile`. Any failure along the way
343/// (no GitHub remote, `gh` missing, non-zero exit, unparseable JSON) degrades to
344/// an unchecked profile with every field [`Presence::Unknown`] — never `Absent`.
345fn community_profile(repo_root: &Path, cmd: &dyn CommandRunner) -> CommunityProfile {
346    let Some(slug) = github_slug(repo_root, cmd) else {
347        return unchecked_profile("no GitHub 'origin' remote could be resolved");
348    };
349    let path = format!("repos/{slug}/community/profile");
350    let out = match cmd.run("gh", &["api", &path], repo_root) {
351        Ok(out) if out.status == Some(0) => out,
352        Ok(out) => {
353            // A 404 (private/absent repo) or any non-zero exit is "could not
354            // check", not "the files are absent".
355            let reason =
356                first_line(&out.stderr).unwrap_or_else(|| "gh api exited non-zero".to_string());
357            return unchecked_profile(&format!("gh api failed: {reason}"));
358        }
359        Err(e) => return unchecked_profile(&format!("could not run gh: {e}")),
360    };
361    let Ok(json) = serde_json::from_str::<serde_json::Value>(&out.stdout) else {
362        return unchecked_profile("gh api returned unparseable JSON");
363    };
364    // The response MUST carry a `files` object. Anything else (a `{}`, a
365    // `{"message": "..."}` error body that still exited 0 through a proxy, a
366    // renamed schema) is "could not check" ⇒ every field unknown, never a blanket
367    // `Absent` — the outage discipline (issue: failure ⇒ unknown, never false).
368    let Some(files) = json.get("files").and_then(serde_json::Value::as_object) else {
369        return unchecked_profile("gh api response had no 'files' object");
370    };
371    // A recognized health file is a non-null object under its key; a `null` (or
372    // absent) key means checked-and-absent.
373    let f = |key: &str| {
374        if files.get(key).is_some_and(|v| !v.is_null()) {
375            Presence::Present
376        } else {
377            Presence::Absent
378        }
379    };
380    // GitHub has named the security-policy field `security_policy` and (in some
381    // API versions) `security`; accept either so a present SECURITY.md is not
382    // misreported absent.
383    let security = if matches!(f("security_policy"), Presence::Present) {
384        Presence::Present
385    } else {
386        f("security")
387    };
388    CommunityProfile {
389        checked: true,
390        unavailable_reason: None,
391        readme: f("readme"),
392        license: f("license"),
393        contributing: f("contributing"),
394        code_of_conduct: f("code_of_conduct"),
395        issue_template: f("issue_template"),
396        pull_request_template: f("pull_request_template"),
397        security,
398    }
399}
400
401/// Resolve a `owner/repo` GitHub slug from the `origin` remote, or `None` when
402/// there is no GitHub remote (or `git` failed). Parsing lives in [`crate::vcs`]
403/// (shared with the release coordinator).
404fn github_slug(repo_root: &Path, cmd: &dyn CommandRunner) -> Option<String> {
405    let out = cmd
406        .run("git", &["remote", "get-url", "origin"], repo_root)
407        .ok()?;
408    if out.status != Some(0) {
409        return None;
410    }
411    crate::vcs::parse_github_slug(out.stdout.trim())
412}
413
414// ── Small builders ───────────────────────────────────────────────────────────
415
416/// A blocking core gap (README/LICENSE/CI at the gate).
417fn core_gap(id: &str, member: &str, detail: &str) -> Gap {
418    Gap {
419        id: id.to_string(),
420        category: Category::Core,
421        severity: Severity::Blocking,
422        status: Presence::Absent,
423        member: member.to_string(),
424        detail: detail.to_string(),
425    }
426}
427
428/// A recommended canon gap.
429fn canon_gap(id: &str, member: &str, status: Presence, detail: &str) -> Gap {
430    Gap {
431        id: id.to_string(),
432        category: Category::Canon,
433        severity: Severity::Recommended,
434        status,
435        member: member.to_string(),
436        detail: detail.to_string(),
437    }
438}
439
440/// A recommended producer-existence gap.
441fn producer_gap(id: &str, member: &str, status: Presence, detail: impl Into<String>) -> Gap {
442    Gap {
443        id: id.to_string(),
444        category: Category::Producer,
445        severity: Severity::Recommended,
446        status,
447        member: member.to_string(),
448        detail: detail.into(),
449    }
450}
451
452/// Push a canon gap for a missing file (probing the standard locations).
453fn canon_file_gap(
454    gaps: &mut Vec<Gap>,
455    fs: &dyn Fs,
456    repo_root: &Path,
457    id: &str,
458    member: &str,
459    names: &[&str],
460    detail: &str,
461) {
462    if !probe(fs, repo_root, names) {
463        gaps.push(canon_gap(id, member, Presence::Absent, detail));
464    }
465}
466
467/// An unchecked community profile — every field `Unknown`, with a reason.
468fn unchecked_profile(reason: &str) -> CommunityProfile {
469    CommunityProfile {
470        checked: false,
471        unavailable_reason: Some(reason.to_string()),
472        readme: Presence::Unknown,
473        license: Presence::Unknown,
474        contributing: Presence::Unknown,
475        code_of_conduct: Presence::Unknown,
476        issue_template: Presence::Unknown,
477        pull_request_template: Presence::Unknown,
478        security: Presence::Unknown,
479    }
480}
481
482// ── Filesystem probes ────────────────────────────────────────────────────────
483
484/// The directories GitHub (and this audit) recognize community/health files in.
485const HEALTH_DIRS: &[&str] = &["", ".github", "docs"];
486
487/// Whether any of `names` exists as a regular file in a recognized health
488/// directory (`.`, `.github`, `docs`).
489fn probe(fs: &dyn Fs, repo_root: &Path, names: &[&str]) -> bool {
490    names.iter().any(|name| {
491        HEALTH_DIRS.iter().any(|dir| {
492            let path = if dir.is_empty() {
493                repo_root.join(name)
494            } else {
495                repo_root.join(dir).join(name)
496            };
497            fs.is_file(&path)
498        })
499    })
500}
501
502/// Cap on a single workflow file read — a workflow this large is not real, and
503/// an unbounded read would let a pathological file stall the audit.
504const WORKFLOW_READ_LIMIT: usize = 1 << 20; // 1 MiB
505
506/// Probe `.github/workflows` for any YAML file mentioning one of `tokens`
507/// (case-insensitively) — the read-only producer probe for a coverage/scorecard
508/// step. Tri-state, honoring the outage discipline:
509///
510/// - [`Presence::Present`] — a readable workflow contains a token.
511/// - [`Presence::Absent`] — the directory is genuinely missing, or every YAML
512///   workflow was read and none matched.
513/// - [`Presence::Unknown`] — the directory or a workflow file could not be read
514///   (permission/I/O error), so "no producer" cannot be asserted.
515///
516/// This is a substring heuristic, not a YAML parse: a token inside a comment
517/// (`# TODO: add coverage`) can false-positive and an unusual action name can
518/// false-negative. That is a deliberate, documented limitation — the audit only
519/// *reports* the gap (never mutates), and a coarse producer signal is enough to
520/// tell an agent to look; a full workflow-graph parse is out of scope here.
521fn workflow_mentions(repo_root: &Path, fs: &dyn Fs, tokens: &[&str]) -> Presence {
522    let dir = repo_root.join(".github/workflows");
523    let entries = match fs.read_dir(&dir) {
524        Ok(entries) => entries,
525        // A missing directory is "checked, no producer"; any other read error
526        // (permission, I/O) is "could not check".
527        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Presence::Absent,
528        Err(_) => return Presence::Unknown,
529    };
530    let mut unreadable = false;
531    for name in &entries {
532        // Only real workflow files (`.yml`/`.yaml`) — not a stray `README.bak`.
533        if !matches!(
534            Path::new(name).extension().and_then(|e| e.to_str()),
535            Some("yml" | "yaml")
536        ) {
537            continue;
538        }
539        let path = dir.join(name);
540        match fs.read(&path) {
541            Ok(bytes) => {
542                let text = String::from_utf8_lossy(&bytes[..bytes.len().min(WORKFLOW_READ_LIMIT)])
543                    .to_lowercase();
544                if tokens.iter().any(|t| text.contains(t)) {
545                    return Presence::Present;
546                }
547            }
548            // A file that vanished mid-scan is fine to skip; a genuine read
549            // failure means we cannot be sure the producer is absent.
550            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
551            Err(_) => unreadable = true,
552        }
553    }
554    if unreadable {
555        Presence::Unknown
556    } else {
557        Presence::Absent
558    }
559}
560
561/// Emit the cross-platform gap(s) when a declared binary distribution omits an
562/// OS the "installs on macOS AND Linux" policy requires — self-checked. The two
563/// OSes are checked *independently*: a distribution missing both yields two gaps
564/// (`distribution-linux` + `distribution-macos`), one missing yields one.
565///
566/// The normalizer defaults an OMITTED `platforms` to the cross-platform set
567/// (macOS + Linux) and rejects an explicit empty list, so a set missing either
568/// OS is only ever an *explicit* author choice; a registry-only repo has no
569/// `distribution` block and is never flagged. Because the check reads the
570/// contract's declared target set (not built artifacts), the wording says
571/// "declares", not "builds". Severity stays [`Severity::Recommended`] (the audit
572/// reserves [`Severity::Blocking`] for the gated core; the same idiom the
573/// `security-policy` gap uses), but the wording escalates at production, where a
574/// one-OS release is a hard gap.
575fn cross_platform_gap(gaps: &mut Vec<Gap>, contract: &Contract, maturity: Maturity) {
576    let production = tier_rank(maturity) >= tier_rank(Maturity::Production);
577    let multi = contract.distributions.len() > 1;
578    for (idx, dist) in contract.distributions.iter().enumerate() {
579        // Keep the gap ids bare (`distribution-linux`/`distribution-macos`) for the
580        // single-distribution case — byte-identical audit output — and disambiguate
581        // per package for a monorepo so two distributions missing the same OS do not
582        // collide on one id.
583        let suffix = if multi {
584            let key = dist.package.clone().unwrap_or_else(|| idx.to_string());
585            format!(":{key}")
586        } else {
587            String::new()
588        };
589        if !dist.platforms.iter().any(|t| is_linux_triple(t)) {
590            gaps.push(platform_gap(
591                &format!("distribution-linux{suffix}"),
592                "Linux",
593                production,
594            ));
595        }
596        if !dist.platforms.iter().any(|t| is_darwin_triple(t)) {
597            gaps.push(platform_gap(
598                &format!("distribution-macos{suffix}"),
599                "macOS",
600                production,
601            ));
602        }
603    }
604}
605
606/// Build one cross-platform gap for a missing `os` ("Linux"/"macOS"), escalating
607/// the wording at `production`.
608fn platform_gap(id: &str, os: &str, production: bool) -> Gap {
609    let policy = if production {
610        "required by the cross-platform install policy: macOS AND Linux"
611    } else {
612        "cross-platform install policy: macOS AND Linux"
613    };
614    producer_gap(
615        id,
616        "oss-init",
617        Presence::Absent,
618        format!("distribution declares no {os} target — not installable on {os} ({policy})"),
619    )
620}
621
622/// Whether `triple` is a desktop-Linux target-triple (`*-unknown-linux-*`, musl
623/// or gnu). Rust's std desktop-Linux triples are always spelled `-unknown-linux-`,
624/// so this reliably includes gnu/musl while excluding `*-linux-android` (Android
625/// is not a desktop-Linux install target) and non-Linux OSes.
626fn is_linux_triple(triple: &str) -> bool {
627    triple.contains("-unknown-linux-")
628}
629
630/// Whether `triple` is a macOS target-triple (`*-apple-darwin`). Excludes
631/// `*-apple-ios`/`*-apple-tvos` etc., which are not macOS install targets.
632fn is_darwin_triple(triple: &str) -> bool {
633    triple.contains("-apple-darwin")
634}
635
636/// Maturity as a monotone rank for tier comparisons (`spike < mvp < production`).
637fn tier_rank(m: Maturity) -> u8 {
638    match m {
639        Maturity::Spike => 0,
640        Maturity::Mvp => 1,
641        Maturity::Production => 2,
642    }
643}
644
645/// The first non-empty line of `s`, trimmed.
646fn first_line(s: &str) -> Option<String> {
647    s.lines()
648        .map(str::trim)
649        .find(|l| !l.is_empty())
650        .map(str::to_string)
651}
652
653// ── Artifact name tables (probed across the health directories) ──────────────
654
655const README_NAMES: &[&str] = &["README.md", "README.rst", "README.txt", "README"];
656const LICENSE_NAMES: &[&str] = &[
657    "LICENSE",
658    "LICENSE.md",
659    "LICENSE.txt",
660    "LICENCE",
661    "LICENCE.md",
662    "COPYING",
663    "COPYING.md",
664];
665const CHANGELOG_NAMES: &[&str] = &["CHANGELOG.md", "CHANGELOG", "CHANGES.md", "HISTORY.md"];
666const CONTRIBUTING_NAMES: &[&str] = &["CONTRIBUTING.md", "CONTRIBUTING", "CONTRIBUTING.rst"];
667const CODE_OF_CONDUCT_NAMES: &[&str] = &["CODE_OF_CONDUCT.md", "CODE_OF_CONDUCT"];
668const SECURITY_NAMES: &[&str] = &["SECURITY.md", "SECURITY"];
669const CODEOWNERS_NAMES: &[&str] = &["CODEOWNERS"];
670const GOVERNANCE_NAMES: &[&str] = &["GOVERNANCE.md", "GOVERNANCE"];
671const ARCHITECTURE_NAMES: &[&str] = &["ARCHITECTURE.md", "ARCHITECTURE"];
672const PRE_COMMIT_NAMES: &[&str] = &[".pre-commit-config.yaml", ".pre-commit-config.yml"];
673
674/// CI tokens that indicate a coverage step (case-insensitive substring match).
675const COVERAGE_TOKENS: &[&str] = &[
676    "coverage",
677    "codecov",
678    "coveralls",
679    "tarpaulin",
680    "llvm-cov",
681    "grcov",
682];
683/// CI tokens that indicate the OSSF Scorecard action.
684const SCORECARD_TOKENS: &[&str] = &[
685    "ossf/scorecard",
686    "scorecard-action",
687    "step-security/scorecard",
688];
689
690#[cfg(test)]
691mod tests;