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 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 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 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 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 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
336fn 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 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 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 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 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
398fn 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
411fn 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
425fn 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
437fn 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
449fn 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
464fn 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
479const HEALTH_DIRS: &[&str] = &["", ".github", "docs"];
483
484fn 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
499const WORKFLOW_READ_LIMIT: usize = 1 << 20; fn 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 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 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 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
558fn tier_rank(m: Maturity) -> u8 {
560 match m {
561 Maturity::Spike => 0,
562 Maturity::Mvp => 1,
563 Maturity::Production => 2,
564 }
565}
566
567fn 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
575const 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
596const COVERAGE_TOKENS: &[&str] = &[
598 "coverage",
599 "codecov",
600 "coveralls",
601 "tarpaulin",
602 "llvm-cov",
603 "grcov",
604];
605const SCORECARD_TOKENS: &[&str] = &[
607 "ossf/scorecard",
608 "scorecard-action",
609 "step-security/scorecard",
610];
611
612#[cfg(test)]
613mod tests;