use std::path::Path;
use crate::contract::schema::{ChangelogMode, Contract, HealthBadge, Maturity, Registry};
use crate::ports::{CommandRunner, Fs};
use crate::protocol::audit::{
AuditReport, Category, CommunityProfile, CoreStatus, Gap, Presence, Severity,
};
use crate::protocol::facts::Facts;
#[must_use]
pub fn audit(
repo_root: &Path,
contract: &Contract,
facts: &Facts,
fs: &dyn Fs,
cmd: &dyn CommandRunner,
) -> AuditReport {
let maturity = contract.maturity;
let mut gaps: Vec<Gap> = Vec::new();
let readme_present = probe(fs, repo_root, README_NAMES);
let license_present = probe(fs, repo_root, LICENSE_NAMES);
let ci_present = facts.has_ci;
let mut core_incomplete = false;
if !readme_present {
core_incomplete = true;
gaps.push(core_gap(
"readme",
"oss-readme",
"no README found — the project's front door is part of the gated core",
));
}
if !license_present {
core_incomplete = true;
gaps.push(core_gap(
"license",
"oss-readme",
"no LICENSE file found — a public release needs an SPDX-identified license \
(part of the gated core)",
));
}
let ci_gates_core = tier_rank(maturity) >= tier_rank(Maturity::Mvp);
if !ci_present {
if ci_gates_core {
core_incomplete = true;
gaps.push(core_gap(
"ci",
"oss-ci",
"no CI configuration found — test+lint on every PR is part of the gated \
core at mvp and above",
));
} else {
gaps.push(canon_gap(
"ci",
"oss-ci",
Presence::Absent,
"no CI configuration found — add test+lint on PR to reach mvp/publish",
));
}
}
let core_complete = if core_incomplete {
CoreStatus::Incomplete
} else {
CoreStatus::Complete
};
canon_gaps(&mut gaps, repo_root, facts, fs, maturity);
producer_gaps(&mut gaps, repo_root, contract, facts, fs, maturity);
let community_profile = community_profile(repo_root, cmd);
AuditReport {
repo_root: repo_root.display().to_string(),
maturity,
core_complete,
gaps,
community_profile,
}
}
fn canon_gaps(
gaps: &mut Vec<Gap>,
repo_root: &Path,
facts: &Facts,
fs: &dyn Fs,
maturity: Maturity,
) {
if tier_rank(maturity) >= tier_rank(Maturity::Mvp) {
canon_file_gap(
gaps,
fs,
repo_root,
"changelog",
"oss-changelog",
CHANGELOG_NAMES,
"no CHANGELOG.md — mvp+ projects keep a changelog",
);
canon_file_gap(
gaps,
fs,
repo_root,
"contributing",
"oss-contributing",
CONTRIBUTING_NAMES,
"no CONTRIBUTING guide — mvp+ projects onboard contributors",
);
canon_file_gap(
gaps,
fs,
repo_root,
"code-of-conduct",
"oss-contributing",
CODE_OF_CONDUCT_NAMES,
"no CODE_OF_CONDUCT — mvp+ projects set community expectations",
);
canon_file_gap(
gaps,
fs,
repo_root,
"security-policy",
"oss-security-policy",
SECURITY_NAMES,
"no SECURITY policy — recommended at mvp+ (required once the tool crosses a \
threat boundary)",
);
if facts.dependency_bot.is_none() {
gaps.push(canon_gap(
"dependency-bot",
"oss-ci",
Presence::Absent,
"no dependency-update bot (dependabot/renovate) configured — recommended at \
mvp+",
));
}
}
if tier_rank(maturity) >= tier_rank(Maturity::Production) {
canon_file_gap(
gaps,
fs,
repo_root,
"codeowners",
"oss-contributing",
CODEOWNERS_NAMES,
"no CODEOWNERS — recommended at production for review routing",
);
canon_file_gap(
gaps,
fs,
repo_root,
"governance",
"oss-contributing",
GOVERNANCE_NAMES,
"no GOVERNANCE.md — recommended at production",
);
canon_file_gap(
gaps,
fs,
repo_root,
"architecture",
"oss-architecture",
ARCHITECTURE_NAMES,
"no ARCHITECTURE.md — offered at production (never a readiness gate)",
);
canon_file_gap(
gaps,
fs,
repo_root,
"pre-commit",
"oss-ci",
PRE_COMMIT_NAMES,
"no pre-commit config — recommended at production",
);
}
}
fn producer_gaps(
gaps: &mut Vec<Gap>,
repo_root: &Path,
contract: &Contract,
facts: &Facts,
fs: &dyn Fs,
maturity: Maturity,
) {
if contract.changelog.mode == ChangelogMode::Fragment {
let dir = repo_root.join(&contract.changelog.fragment_dir);
if !fs.is_dir(&dir) {
gaps.push(producer_gap(
"changelog-fragment-dir",
"oss-changelog",
Presence::Absent,
format!(
"changelog.mode is 'fragment' but the fragment directory '{}' does not \
exist",
contract.changelog.fragment_dir
),
));
}
}
cross_platform_gap(gaps, contract, maturity);
let has_registry_target = contract
.targets
.iter()
.any(|t| t.registry != Registry::GhReleases);
if has_registry_target && contract.license.trim().is_empty() {
gaps.push(producer_gap(
"registry-license",
"oss-readme",
Presence::Absent,
"a registry publish target is configured but the contract declares no license \
— registries (crates.io/npm/PyPI) require an SPDX license",
));
}
let coverage_badge = contract.health_badges.contains(&HealthBadge::Coverage);
let coverage_expected =
coverage_badge || tier_rank(maturity) >= tier_rank(Maturity::Production);
if coverage_expected {
let status = workflow_mentions(repo_root, fs, COVERAGE_TOKENS);
if status != Presence::Present {
let (category, detail) = if coverage_badge {
(
Category::Producer,
"the contract enables a 'coverage' health badge but no coverage step was \
found in CI — the badge has no producer",
)
} else {
(
Category::Canon,
"no coverage step found in CI — recommended at production",
)
};
gaps.push(Gap {
id: "coverage".to_string(),
category,
severity: Severity::Recommended,
status,
member: "oss-ci".to_string(),
detail: detail.to_string(),
});
}
}
if contract.health_badges.contains(&HealthBadge::Scorecard) {
let status = workflow_mentions(repo_root, fs, SCORECARD_TOKENS);
if status != Presence::Present {
gaps.push(producer_gap(
"scorecard",
"oss-security-policy",
status,
"the contract enables a 'scorecard' health badge but no OSSF Scorecard action \
was found in CI — the badge has no producer",
));
}
}
if contract.health_badges.contains(&HealthBadge::Ci) && !facts.has_ci {
gaps.push(producer_gap(
"ci-badge-producer",
"oss-ci",
Presence::Absent,
"the contract enables a 'ci' health badge but no CI configuration was found — \
the badge has no producer",
));
}
if contract.health_badges.contains(&HealthBadge::License)
&& !probe(fs, repo_root, LICENSE_NAMES)
{
gaps.push(producer_gap(
"license-badge-producer",
"oss-readme",
Presence::Absent,
"the contract enables a 'license' health badge but no LICENSE file was found — \
the badge has no producer",
));
}
}
fn community_profile(repo_root: &Path, cmd: &dyn CommandRunner) -> CommunityProfile {
let Some(slug) = github_slug(repo_root, cmd) else {
return unchecked_profile("no GitHub 'origin' remote could be resolved");
};
let path = format!("repos/{slug}/community/profile");
let out = match cmd.run("gh", &["api", &path], repo_root) {
Ok(out) if out.status == Some(0) => out,
Ok(out) => {
let reason =
first_line(&out.stderr).unwrap_or_else(|| "gh api exited non-zero".to_string());
return unchecked_profile(&format!("gh api failed: {reason}"));
}
Err(e) => return unchecked_profile(&format!("could not run gh: {e}")),
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&out.stdout) else {
return unchecked_profile("gh api returned unparseable JSON");
};
let Some(files) = json.get("files").and_then(serde_json::Value::as_object) else {
return unchecked_profile("gh api response had no 'files' object");
};
let f = |key: &str| {
if files.get(key).is_some_and(|v| !v.is_null()) {
Presence::Present
} else {
Presence::Absent
}
};
let security = if matches!(f("security_policy"), Presence::Present) {
Presence::Present
} else {
f("security")
};
CommunityProfile {
checked: true,
unavailable_reason: None,
readme: f("readme"),
license: f("license"),
contributing: f("contributing"),
code_of_conduct: f("code_of_conduct"),
issue_template: f("issue_template"),
pull_request_template: f("pull_request_template"),
security,
}
}
fn github_slug(repo_root: &Path, cmd: &dyn CommandRunner) -> Option<String> {
let out = cmd
.run("git", &["remote", "get-url", "origin"], repo_root)
.ok()?;
if out.status != Some(0) {
return None;
}
crate::vcs::parse_github_slug(out.stdout.trim())
}
fn core_gap(id: &str, member: &str, detail: &str) -> Gap {
Gap {
id: id.to_string(),
category: Category::Core,
severity: Severity::Blocking,
status: Presence::Absent,
member: member.to_string(),
detail: detail.to_string(),
}
}
fn canon_gap(id: &str, member: &str, status: Presence, detail: &str) -> Gap {
Gap {
id: id.to_string(),
category: Category::Canon,
severity: Severity::Recommended,
status,
member: member.to_string(),
detail: detail.to_string(),
}
}
fn producer_gap(id: &str, member: &str, status: Presence, detail: impl Into<String>) -> Gap {
Gap {
id: id.to_string(),
category: Category::Producer,
severity: Severity::Recommended,
status,
member: member.to_string(),
detail: detail.into(),
}
}
fn canon_file_gap(
gaps: &mut Vec<Gap>,
fs: &dyn Fs,
repo_root: &Path,
id: &str,
member: &str,
names: &[&str],
detail: &str,
) {
if !probe(fs, repo_root, names) {
gaps.push(canon_gap(id, member, Presence::Absent, detail));
}
}
fn unchecked_profile(reason: &str) -> CommunityProfile {
CommunityProfile {
checked: false,
unavailable_reason: Some(reason.to_string()),
readme: Presence::Unknown,
license: Presence::Unknown,
contributing: Presence::Unknown,
code_of_conduct: Presence::Unknown,
issue_template: Presence::Unknown,
pull_request_template: Presence::Unknown,
security: Presence::Unknown,
}
}
const HEALTH_DIRS: &[&str] = &["", ".github", "docs"];
fn probe(fs: &dyn Fs, repo_root: &Path, names: &[&str]) -> bool {
names.iter().any(|name| {
HEALTH_DIRS.iter().any(|dir| {
let path = if dir.is_empty() {
repo_root.join(name)
} else {
repo_root.join(dir).join(name)
};
fs.is_file(&path)
})
})
}
const WORKFLOW_READ_LIMIT: usize = 1 << 20;
fn workflow_mentions(repo_root: &Path, fs: &dyn Fs, tokens: &[&str]) -> Presence {
let dir = repo_root.join(".github/workflows");
let entries = match fs.read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Presence::Absent,
Err(_) => return Presence::Unknown,
};
let mut unreadable = false;
for name in &entries {
if !matches!(
Path::new(name).extension().and_then(|e| e.to_str()),
Some("yml" | "yaml")
) {
continue;
}
let path = dir.join(name);
match fs.read(&path) {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(WORKFLOW_READ_LIMIT)])
.to_lowercase();
if tokens.iter().any(|t| text.contains(t)) {
return Presence::Present;
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => unreadable = true,
}
}
if unreadable {
Presence::Unknown
} else {
Presence::Absent
}
}
fn cross_platform_gap(gaps: &mut Vec<Gap>, contract: &Contract, maturity: Maturity) {
let production = tier_rank(maturity) >= tier_rank(Maturity::Production);
let multi = contract.distributions.len() > 1;
for (idx, dist) in contract.distributions.iter().enumerate() {
let suffix = if multi {
let key = dist.package.clone().unwrap_or_else(|| idx.to_string());
format!(":{key}")
} else {
String::new()
};
if !dist.platforms.iter().any(|t| is_linux_triple(t)) {
gaps.push(platform_gap(
&format!("distribution-linux{suffix}"),
"Linux",
production,
));
}
if !dist.platforms.iter().any(|t| is_darwin_triple(t)) {
gaps.push(platform_gap(
&format!("distribution-macos{suffix}"),
"macOS",
production,
));
}
}
}
fn platform_gap(id: &str, os: &str, production: bool) -> Gap {
let policy = if production {
"required by the cross-platform install policy: macOS AND Linux"
} else {
"cross-platform install policy: macOS AND Linux"
};
producer_gap(
id,
"oss-init",
Presence::Absent,
format!("distribution declares no {os} target — not installable on {os} ({policy})"),
)
}
fn is_linux_triple(triple: &str) -> bool {
triple.contains("-unknown-linux-")
}
fn is_darwin_triple(triple: &str) -> bool {
triple.contains("-apple-darwin")
}
fn tier_rank(m: Maturity) -> u8 {
match m {
Maturity::Spike => 0,
Maturity::Mvp => 1,
Maturity::Production => 2,
}
}
fn first_line(s: &str) -> Option<String> {
s.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
const README_NAMES: &[&str] = &["README.md", "README.rst", "README.txt", "README"];
const LICENSE_NAMES: &[&str] = &[
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"LICENCE",
"LICENCE.md",
"COPYING",
"COPYING.md",
];
const CHANGELOG_NAMES: &[&str] = &["CHANGELOG.md", "CHANGELOG", "CHANGES.md", "HISTORY.md"];
const CONTRIBUTING_NAMES: &[&str] = &["CONTRIBUTING.md", "CONTRIBUTING", "CONTRIBUTING.rst"];
const CODE_OF_CONDUCT_NAMES: &[&str] = &["CODE_OF_CONDUCT.md", "CODE_OF_CONDUCT"];
const SECURITY_NAMES: &[&str] = &["SECURITY.md", "SECURITY"];
const CODEOWNERS_NAMES: &[&str] = &["CODEOWNERS"];
const GOVERNANCE_NAMES: &[&str] = &["GOVERNANCE.md", "GOVERNANCE"];
const ARCHITECTURE_NAMES: &[&str] = &["ARCHITECTURE.md", "ARCHITECTURE"];
const PRE_COMMIT_NAMES: &[&str] = &[".pre-commit-config.yaml", ".pre-commit-config.yml"];
const COVERAGE_TOKENS: &[&str] = &[
"coverage",
"codecov",
"coveralls",
"tarpaulin",
"llvm-cov",
"grcov",
];
const SCORECARD_TOKENS: &[&str] = &[
"ossf/scorecard",
"scorecard-action",
"step-security/scorecard",
];
#[cfg(test)]
mod tests;