1use 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#[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 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 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 canon_gaps(&mut gaps, repo_root, facts, fs, maturity);
105
106 producer_gaps(&mut gaps, repo_root, contract, facts, fs, maturity);
110
111 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
123fn canon_gaps(
127 gaps: &mut Vec<Gap>,
128 repo_root: &Path,
129 facts: &Facts,
130 fs: &dyn Fs,
131 maturity: Maturity,
132) {
133 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 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
224fn 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 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 cross_platform_gap(gaps, contract, maturity);
252
253 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 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 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 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 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
339fn 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 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 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 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 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
401fn 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
414fn 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
428fn 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
440fn 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
452fn 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
467fn 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
482const HEALTH_DIRS: &[&str] = &["", ".github", "docs"];
486
487fn 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
502const WORKFLOW_READ_LIMIT: usize = 1 << 20; fn 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 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 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 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
561fn 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 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
606fn 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
622fn is_linux_triple(triple: &str) -> bool {
627 triple.contains("-unknown-linux-")
628}
629
630fn is_darwin_triple(triple: &str) -> bool {
633 triple.contains("-apple-darwin")
634}
635
636fn tier_rank(m: Maturity) -> u8 {
638 match m {
639 Maturity::Spike => 0,
640 Maturity::Mvp => 1,
641 Maturity::Production => 2,
642 }
643}
644
645fn 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
653const 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
674const COVERAGE_TOKENS: &[&str] = &[
676 "coverage",
677 "codecov",
678 "coveralls",
679 "tarpaulin",
680 "llvm-cov",
681 "grcov",
682];
683const SCORECARD_TOKENS: &[&str] = &[
685 "ossf/scorecard",
686 "scorecard-action",
687 "step-security/scorecard",
688];
689
690#[cfg(test)]
691mod tests;