diff --git a/bots/rhodibot/src/fleet.rs b/bots/rhodibot/src/fleet.rs index 1e005d8e..56a98c98 100644 --- a/bots/rhodibot/src/fleet.rs +++ b/bots/rhodibot/src/fleet.rs @@ -48,20 +48,23 @@ fn rule_id(check_name: &str) -> String { // Map known checks to stable rule IDs match check_name { "README.adoc" => format!("{}-001", RULE_PREFIX), - "LICENSE.txt" => format!("{}-002", RULE_PREFIX), - "SECURITY.md" => format!("{}-003", RULE_PREFIX), - "CONTRIBUTING.md" => format!("{}-004", RULE_PREFIX), - "CODE_OF_CONDUCT.md" => format!("{}-005", RULE_PREFIX), + "LICENSE.txt" | "LICENSE" => format!("{}-002", RULE_PREFIX), + "SECURITY.md" | "SECURITY.adoc" => format!("{}-003", RULE_PREFIX), + "CONTRIBUTING.md" | "CONTRIBUTING.adoc" => format!("{}-004", RULE_PREFIX), + "CODE_OF_CONDUCT.md" | "CODE_OF_CONDUCT.adoc" => format!("{}-005", RULE_PREFIX), ".claude/CLAUDE.md" => format!("{}-006", RULE_PREFIX), - ".machine_readable/STATE.scm" => format!("{}-007", RULE_PREFIX), - ".machine_readable/META.scm" => format!("{}-008", RULE_PREFIX), - ".machine_readable/ECOSYSTEM.scm" => format!("{}-009", RULE_PREFIX), + ".machine_readable/STATE.scm" | ".machine_readable/STATE.a2ml" => format!("{}-007", RULE_PREFIX), + ".machine_readable/META.scm" | ".machine_readable/META.a2ml" => format!("{}-008", RULE_PREFIX), + ".machine_readable/ECOSYSTEM.scm" | ".machine_readable/ECOSYSTEM.a2ml" => format!("{}-009", RULE_PREFIX), ".github/workflows" => format!("{}-010", RULE_PREFIX), ".editorconfig" => format!("{}-011", RULE_PREFIX), ".gitattributes" => format!("{}-012", RULE_PREFIX), ".gitignore" => format!("{}-013", RULE_PREFIX), - "justfile" => format!("{}-014", RULE_PREFIX), + "justfile" | "Justfile" => format!("{}-014", RULE_PREFIX), ".machine_readable/bot_directives" => format!("{}-015", RULE_PREFIX), + "0-AI-MANIFEST.a2ml" => format!("{}-016", RULE_PREFIX), + "www/.well-known/security.txt" => format!("{}-017", RULE_PREFIX), + ".machine_readable/root-allow.txt" => format!("{}-018", RULE_PREFIX), "no-.bot_directives" => format!("{}-LEGACY-001", RULE_PREFIX), "license-type" => format!("{}-LIC-001", RULE_PREFIX), name if name.starts_with("no-") => { @@ -75,21 +78,27 @@ fn rule_id(check_name: &str) -> String { fn suggestion_for(check_name: &str) -> Option { match check_name { "README.adoc" => Some("Create a README.adoc file with project documentation".to_string()), - "LICENSE.txt" => Some("Add a LICENSE.txt file with MPL-2.0".to_string()), - "SECURITY.md" => Some("Add a SECURITY.md with vulnerability reporting instructions".to_string()), - "CONTRIBUTING.md" => Some("Add a CONTRIBUTING.md with contribution guidelines".to_string()), - "CODE_OF_CONDUCT.md" => Some("Add a CODE_OF_CONDUCT.md (Contributor Covenant recommended)".to_string()), + "LICENSE.txt" | "LICENSE" => Some("Add a LICENSE file with MPL-2.0 (LICENSE is the current spelling)".to_string()), + "SECURITY.adoc" | "SECURITY.md" => Some("Add a SECURITY.adoc with vulnerability reporting instructions".to_string()), + "CONTRIBUTING.adoc" | "CONTRIBUTING.md" => Some("Add a CONTRIBUTING.adoc with contribution guidelines".to_string()), + "CODE_OF_CONDUCT.adoc" | "CODE_OF_CONDUCT.md" => Some("Add a CODE_OF_CONDUCT.adoc (Contributor Covenant recommended)".to_string()), ".claude/CLAUDE.md" => Some("Create .claude/CLAUDE.md with AI assistant instructions".to_string()), - ".machine_readable/STATE.scm" => Some("Add .machine_readable/STATE.scm with project state".to_string()), - ".machine_readable/META.scm" => Some("Add .machine_readable/META.scm with meta information".to_string()), - ".machine_readable/ECOSYSTEM.scm" => Some("Add .machine_readable/ECOSYSTEM.scm with ecosystem position".to_string()), + ".machine_readable/STATE.a2ml" | ".machine_readable/STATE.scm" => Some("Add .machine_readable/STATE.a2ml with project state".to_string()), + ".machine_readable/META.a2ml" | ".machine_readable/META.scm" => Some("Add .machine_readable/META.a2ml with meta information".to_string()), + ".machine_readable/ECOSYSTEM.a2ml" | ".machine_readable/ECOSYSTEM.scm" => Some("Add .machine_readable/ECOSYSTEM.a2ml with ecosystem position".to_string()), ".github/workflows" => Some("Add GitHub Actions workflows in .github/workflows/".to_string()), ".editorconfig" => Some("Add .editorconfig for consistent formatting".to_string()), ".gitattributes" => Some("Add .gitattributes for line ending and diff config".to_string()), ".gitignore" => Some("Add .gitignore for build artifacts".to_string()), - "justfile" => Some("Add a justfile as the primary build system".to_string()), + "Justfile" | "justfile" => Some("Add a Justfile as the primary build system".to_string()), ".machine_readable/bot_directives" => Some("Create .machine_readable/bot_directives/ for bot configs".to_string()), "no-.bot_directives" => Some("Migrate legacy .bot_directives/ to .machine_readable/bot_directives/".to_string()), + "0-AI-MANIFEST.a2ml" => Some("Add a 0-AI-MANIFEST.a2ml machine-readable manifest at the repository root".to_string()), + "www/.well-known/security.txt" => Some("Create www/.well-known/security.txt; run scripts/migrate-wellknown-to-www.sh to move an existing root .well-known/".to_string()), + ".machine_readable/root-allow.txt" => Some("Add .machine_readable/root-allow.txt declaring the permitted root entries".to_string()), + // Move, do not delete: the generic no- arm below would advise + // removing the file, which loses live security-contact metadata. + "no-.well-known/security.txt" => Some("Migrate the root .well-known/ to www/.well-known/ with scripts/migrate-wellknown-to-www.sh - move it, do not delete it".to_string()), "license-type" => Some("Set repository license to an approved type (MPL-2.0 recommended)".to_string()), name if name.starts_with("no-") => { let banned_file = name.strip_prefix("no-").unwrap_or(name); @@ -106,10 +115,20 @@ fn suggestion_for(check_name: &str) -> Option { /// fixable. Files without templates (CONTRIBUTING.md, CODE_OF_CONDUCT.md) /// would produce empty boilerplate and are excluded. fn is_fixable(check_name: &str) -> bool { + // Must match the arms of robot-repo-automaton's get_template_content() + // exactly. That function falls through to String::new() for anything it + // does not recognise, so advertising a check as fixable without a + // template behind it makes the automaton open a PR that creates the file + // EMPTY - and inbox-steward auto-merges PRs that pass CI. + // + // This list was previously four entries wider than the templates + // (.gitattributes, .gitignore, .claude/CLAUDE.md and + // .machine_readable/bot_directives), which is how an empty-file PR could + // reach a repository and be merged without anyone reading it. matches!(check_name, - "SECURITY.md" | - ".editorconfig" | ".gitattributes" | ".gitignore" | - ".claude/CLAUDE.md" | ".machine_readable/bot_directives" + "LICENSE" | "LICENSE.txt" | + "SECURITY.adoc" | "SECURITY.md" | + ".editorconfig" ) } diff --git a/bots/rhodibot/src/rsr.rs b/bots/rhodibot/src/rsr.rs index cf7f5bdf..aaa27b91 100644 --- a/bots/rhodibot/src/rsr.rs +++ b/bots/rhodibot/src/rsr.rs @@ -76,7 +76,12 @@ pub struct RepoConfig { /// Check definition with severity per policy pack #[derive(Debug, Clone)] pub struct CheckDef { + /// Canonical path, ordered by prevalence across the estate so the common + /// case resolves on the first request. pub name: &'static str, + /// Historically valid spellings accepted in place of the canonical path, + /// so a rename is not reported as a compliance regression. + pub alternates: &'static [&'static str], pub description: &'static str, pub category: CheckCategory, pub points: u8, @@ -96,67 +101,128 @@ impl CheckDef { } } +/// Resolve a check against a repository: canonical path first, then each +/// accepted alternate in order. +/// +/// Alternates are only consulted when the canonical path is absent, so the +/// overwhelmingly common case — a repository on the current convention — +/// costs exactly one request, unchanged from before. +pub async fn check_path_exists( + client: &GitHubClient, + owner: &str, + repo: &str, + def: &CheckDef, +) -> (bool, String) { + if client.file_exists(owner, repo, def.name).await { + return (true, def.name.to_string()); + } + for alt in def.alternates { + if client.file_exists(owner, repo, alt).await { + return (true, (*alt).to_string()); + } + } + (false, def.name.to_string()) +} + /// Required files for RSR compliance with policy-based severity pub const REQUIRED_FILES: &[CheckDef] = &[ + // NOTE ON THE 2026-09-18 REVISION + // + // Every path below was re-measured against 269 estate repositories (the + // #119 denominator). The previous table predated the Markdown/TXT -> + // AsciiDoc migration (#486, 2026-08) and the canonical-location changes + // that followed, so it demanded filenames the estate had deliberately + // moved away from: LICENSE.txt (0/269), CONTRIBUTING.md (25/269), + // CODE_OF_CONDUCT.md (15/269), SECURITY.md (35/269) and lowercase + // `justfile` (7/269). It also required Guile Scheme state files that + // exist in a single repository. + // + // The canonical spelling is listed first and ordered by prevalence, so + // the common case resolves on the first request; superseded spellings are + // accepted as alternates rather than reported as regressions. CheckDef { name: "README.adoc", + alternates: &["README.md"], description: "AsciiDoc README", category: CheckCategory::Documentation, points: 5, - // (minimal, standard, strict, enterprise) severity: (Severity::Required, Severity::Required, Severity::Required, Severity::Required), }, CheckDef { - name: "LICENSE.txt", + // 269/269 repositories carry `LICENSE`; none carries `LICENSE.txt`. + name: "LICENSE", + alternates: &["LICENSE.txt", "LICENSE.md", "LICENSE.adoc"], description: "License file", category: CheckCategory::Governance, points: 5, severity: (Severity::Required, Severity::Required, Severity::Required, Severity::Required), }, CheckDef { - name: "SECURITY.md", + // SECURITY.adoc 250/269 versus SECURITY.md 35/269. + name: "SECURITY.adoc", + alternates: &["SECURITY.md"], description: "Security policy", category: CheckCategory::Security, points: 5, severity: (Severity::Optional, Severity::Recommended, Severity::Required, Severity::Required), }, CheckDef { - name: "CONTRIBUTING.md", + // CONTRIBUTING.adoc 255/269 versus CONTRIBUTING.md 25/269. + name: "CONTRIBUTING.adoc", + alternates: &["CONTRIBUTING.md"], description: "Contributing guidelines", category: CheckCategory::Documentation, points: 3, severity: (Severity::Optional, Severity::Recommended, Severity::Required, Severity::Required), }, CheckDef { - name: "CODE_OF_CONDUCT.md", + // CODE_OF_CONDUCT.adoc 240/269 versus CODE_OF_CONDUCT.md 15/269. + name: "CODE_OF_CONDUCT.adoc", + alternates: &["CODE_OF_CONDUCT.md"], description: "Code of conduct", category: CheckCategory::Governance, points: 3, severity: (Severity::Optional, Severity::Optional, Severity::Recommended, Severity::Required), }, CheckDef { + // 152/269 under .claude/, 39/269 at the root. name: ".claude/CLAUDE.md", + alternates: &["CLAUDE.md", ".claude/CLAUDE.adoc"], description: "AI assistant instructions", category: CheckCategory::Structure, points: 2, severity: (Severity::Optional, Severity::Optional, Severity::Recommended, Severity::Required), }, CheckDef { - name: ".machine_readable/STATE.scm", - description: "Project state file in canonical machine-readable location", + // The machine-readable manifest the estate actually ships: 261/269. + // The .scm state files below reached 1/269 and their .a2ml spelling + // 13/269, so neither is a fair Required check on its own. + name: "0-AI-MANIFEST.a2ml", + alternates: &[".machine_readable/STATE.a2ml", ".machine_readable/STATE.scm"], + description: "Machine-readable manifest at the repository root", category: CheckCategory::Structure, points: 3, severity: (Severity::Optional, Severity::Recommended, Severity::Required, Severity::Required), }, CheckDef { - name: ".machine_readable/META.scm", + name: ".machine_readable/STATE.a2ml", + alternates: &[".machine_readable/STATE.scm"], + description: "Project state file in canonical machine-readable location", + category: CheckCategory::Structure, + points: 3, + severity: (Severity::Optional, Severity::Optional, Severity::Recommended, Severity::Required), + }, + CheckDef { + name: ".machine_readable/META.a2ml", + alternates: &[".machine_readable/META.scm"], description: "Meta information in canonical machine-readable location", category: CheckCategory::Structure, points: 3, - severity: (Severity::Optional, Severity::Recommended, Severity::Required, Severity::Required), + severity: (Severity::Optional, Severity::Optional, Severity::Recommended, Severity::Required), }, CheckDef { - name: ".machine_readable/ECOSYSTEM.scm", + name: ".machine_readable/ECOSYSTEM.a2ml", + alternates: &[".machine_readable/ECOSYSTEM.scm"], description: "Ecosystem position in canonical machine-readable location", category: CheckCategory::Structure, points: 3, @@ -165,6 +231,7 @@ pub const REQUIRED_FILES: &[CheckDef] = &[ // Additional RSR template files CheckDef { name: ".editorconfig", + alternates: &[], description: "EditorConfig for consistent formatting", category: CheckCategory::Structure, points: 2, @@ -172,6 +239,7 @@ pub const REQUIRED_FILES: &[CheckDef] = &[ }, CheckDef { name: ".gitattributes", + alternates: &[], description: "Git attributes for line endings and diffs", category: CheckCategory::Structure, points: 2, @@ -179,13 +247,18 @@ pub const REQUIRED_FILES: &[CheckDef] = &[ }, CheckDef { name: ".gitignore", + alternates: &[], description: "Git ignore patterns", category: CheckCategory::Structure, points: 2, severity: (Severity::Optional, Severity::Recommended, Severity::Required, Severity::Required), }, CheckDef { - name: "justfile", + // `Justfile` 267/269; lowercase `justfile` 7/269. Both accepted: the + // capital form is what just itself prefers, the lowercase form is + // what some older checkouts carry. + name: "Justfile", + alternates: &["justfile"], description: "Just task runner (primary build system)", category: CheckCategory::Structure, points: 2, @@ -193,11 +266,35 @@ pub const REQUIRED_FILES: &[CheckDef] = &[ }, CheckDef { name: ".machine_readable/bot_directives", + alternates: &[], description: "Bot directives directory in canonical machine-readable location", category: CheckCategory::Structure, points: 2, severity: (Severity::Optional, Severity::Optional, Severity::Recommended, Severity::Required), }, + CheckDef { + // Entered the template 2026-08-26; 59/269 as of 2026-09-18. Either + // canonical spelling is accepted (see check-root-shape.sh). + name: ".machine_readable/root-allow.txt", + alternates: &["machine-readable/root-allow.txt"], + description: "Root-shape allowlist in canonical machine-readable location", + category: CheckCategory::Structure, + points: 2, + severity: (Severity::Optional, Severity::Optional, Severity::Recommended, Severity::Required), + }, + CheckDef { + // Canonical location since #53 (2026-09-17). Measured 2026-09-18: + // 0/269 repositories have any www/ tree, because the RSR update + // mechanism that would create it has not yet run anywhere. The check + // is therefore honest about the gap and drives the migration rather + // than merely noting it; raise it once the sweep has landed. + name: "www/.well-known/security.txt", + alternates: &[], + description: "Security contact metadata in the canonical www/ location", + category: CheckCategory::Security, + points: 3, + severity: (Severity::Optional, Severity::Recommended, Severity::Required, Severity::Required), + }, ]; /// Banned patterns definition @@ -265,7 +362,17 @@ pub const BANNED_PATTERNS: &[BannedPattern] = &[ description: "Go checksum (use Rust)", category: CheckCategory::LanguagePolicy, severity: (Severity::Optional, Severity::Recommended, Severity::Required, Severity::Required), + }, BannedPattern { + // Legacy location: canonical is www/.well-known/ since #53. + // Advisory (Warn) at minimal and standard while the stage-5 migration + // window is open; raise .2/.3 to Required once the sweep has landed + // and the root location should no longer be tolerated at all. + name: ".well-known/security.txt", + description: "Legacy repository-root .well-known/ (canonical location is www/.well-known/)", + category: CheckCategory::Structure, + severity: (Severity::Optional, Severity::Optional, Severity::Recommended, Severity::Required), }, + ]; /// RSR Compliance Report @@ -385,7 +492,7 @@ pub async fn check_compliance_with_policy( // Skip optional checks in scoring if severity == Severity::Optional { - let exists = client.file_exists(owner, repo, check_def.name).await; + let (exists, found_at) = check_path_exists(&client, owner, repo, check_def).await; checks.push(Check { name: check_def.name.to_string(), category: check_def.category, @@ -394,7 +501,7 @@ pub async fn check_compliance_with_policy( points: 0, max_points: 0, message: if exists { - format!("{} found (optional)", check_def.description) + format!("{} found at {} (optional)", check_def.description, found_at) } else { format!("{} not present (optional)", check_def.description) }, @@ -403,7 +510,12 @@ pub async fn check_compliance_with_policy( } max_score += check_def.points; - let exists = client.file_exists(owner, repo, check_def.name).await; + let (exists, found_at) = check_path_exists(&client, owner, repo, check_def).await; + let accepted = if check_def.alternates.is_empty() { + String::new() + } else { + format!(" (or, historically: {})", check_def.alternates.join(", ")) + }; if exists { total_score += check_def.points; @@ -414,7 +526,7 @@ pub async fn check_compliance_with_policy( status: CheckStatus::Pass, points: check_def.points, max_points: check_def.points, - message: format!("{} found", check_def.description), + message: format!("{} found at {}", check_def.description, found_at), }); } else { let status = match severity { @@ -433,7 +545,7 @@ pub async fn check_compliance_with_policy( status, points: 0, max_points: check_def.points, - message: format!("{} missing", check_def.description), + message: format!("{} missing - expected at {}{}", check_def.description, check_def.name, accepted), }); } } diff --git a/bots/rhodibot/src/webhook.rs b/bots/rhodibot/src/webhook.rs index 50780a41..56f17b65 100644 --- a/bots/rhodibot/src/webhook.rs +++ b/bots/rhodibot/src/webhook.rs @@ -197,14 +197,20 @@ Welcome to the hyperpolymath organization! Please ensure your repository follows ### Required Files - [ ] `README.adoc` - Project documentation (AsciiDoc format) -- [ ] `LICENSE.txt` - License file (MPL-2.0, MIT, Apache-2.0, or MPL-2.0) -- [ ] `SECURITY.md` - Security policy -- [ ] `CONTRIBUTING.md` - Contribution guidelines -- [ ] `CODE_OF_CONDUCT.md` - Code of conduct +- [ ] `LICENSE` - License file (MPL-2.0, MIT, Apache-2.0) +- [ ] `SECURITY.adoc` - Security policy (AsciiDoc) +- [ ] `CONTRIBUTING.adoc` - Contribution guidelines (AsciiDoc) +- [ ] `CODE_OF_CONDUCT.adoc` - Code of conduct (AsciiDoc) +- [ ] `Justfile` - Just task runner (capital J) +- [ ] `0-AI-MANIFEST.a2ml` - Machine-readable manifest at the repository root - [ ] `.claude/CLAUDE.md` - AI assistant instructions -- [ ] `STATE.scm` - Project state (Guile Scheme) -- [ ] `META.scm` - Meta information (Guile Scheme) -- [ ] `ECOSYSTEM.scm` - Ecosystem position (Guile Scheme) +- [ ] `.machine_readable/root-allow.txt` - Root-shape allowlist +- [ ] `www/.well-known/security.txt` - Security contact in the canonical location + +Note: paths are given in the current convention. Superseded spellings +(`LICENSE.txt`, `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, +lowercase `justfile`, `.machine_readable/STATE.scm` and its siblings) are +still accepted by the checker and are not compliance failures. ### Language Policy (CCCP) **Allowed:** ReScript, Rust, Deno, Gleam, Bash, Julia, Ada, OCaml diff --git a/bots/rhodibot/tests/integration_tests.rs b/bots/rhodibot/tests/integration_tests.rs index 848579e9..b81ffe31 100644 --- a/bots/rhodibot/tests/integration_tests.rs +++ b/bots/rhodibot/tests/integration_tests.rs @@ -85,11 +85,11 @@ mod rsr_tests { let config = mock_config(&server.uri()); let files = &[ - "README.adoc", "LICENSE.txt", "SECURITY.md", "CONTRIBUTING.md", - "CODE_OF_CONDUCT.md", ".claude/CLAUDE.md", ".machine_readable/STATE.scm", + "README.adoc", "LICENSE", "SECURITY.adoc", "CONTRIBUTING.adoc", + "CODE_OF_CONDUCT.adoc", ".claude/CLAUDE.md", ".machine_readable/STATE.a2ml", ".machine_readable/META.scm", ".machine_readable/ECOSYSTEM.scm", ".github/workflows", ".editorconfig", ".gitattributes", ".gitignore", - "justfile", ".machine_readable/bot_directives", + "Justfile", ".machine_readable/bot_directives", ]; mount_file_mocks(&server, "test-org", "test-repo", files).await; @@ -120,8 +120,8 @@ mod rsr_tests { // All files except README.adoc let files = &[ - "LICENSE.txt", "SECURITY.md", "CONTRIBUTING.md", - "CODE_OF_CONDUCT.md", ".github/workflows", + "LICENSE", "SECURITY.adoc", "CONTRIBUTING.adoc", + "CODE_OF_CONDUCT.adoc", ".github/workflows", ]; mount_file_mocks(&server, "test-org", "test-repo", files).await; @@ -170,11 +170,93 @@ mod rsr_tests { assert!(!report.required_passed, "missing LICENSE should fail required checks"); - let license_check = report.checks.iter().find(|c| c.name == "LICENSE.txt"); + let license_check = report.checks.iter().find(|c| c.name == "LICENSE"); assert!(license_check.is_some()); assert_eq!(license_check.unwrap().status, CheckStatus::Fail); } + /// A repository still on the superseded spellings must not be reported as + /// non-compliant for that alone. This is the regression test for the + /// 2026-09-18 revision: before it, the checker demanded exactly + /// `LICENSE.txt`/`SECURITY.md`/`CONTRIBUTING.md`/lowercase `justfile`, so + /// every repository that had completed the AsciiDoc migration (#486) was + /// told to undo it. + #[tokio::test] + async fn test_superseded_spellings_accepted_as_alternates() { + let server = MockServer::start().await; + let config = mock_config(&server.uri()); + + // Only the PRE-migration spellings exist here; none of the current + // canonical paths do. Every check must resolve via an alternate. + let files = &[ + "README.adoc", + "LICENSE.txt", + "SECURITY.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "Justfile", + ]; + mount_file_mocks(&server, "test-org", "test-repo", files).await; + + let repo_config = RepoConfig::default(); + let report = check_compliance_with_policy(&config, "test-org", "test-repo", &repo_config) + .await + .expect("compliance check should succeed"); + + for name in [ + "LICENSE", + "SECURITY.adoc", + "CONTRIBUTING.adoc", + "CODE_OF_CONDUCT.adoc", + "Justfile", + ] { + let check = report + .checks + .iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("should have {} check", name)); + assert_eq!( + check.status, + CheckStatus::Pass, + "{} should pass via a superseded spelling", + name + ); + } + } + + /// The estate has no `www/` tree at all yet (0/269 as of 2026-09-18), so + /// the canonical-location check must be present and honest about that + /// rather than silently passing. + #[tokio::test] + async fn test_www_wellknown_check_present_and_failing_when_absent() { + let server = MockServer::start().await; + let config = mock_config(&server.uri()); + + let files = &["README.adoc", "LICENSE", ".well-known/security.txt"]; + mount_file_mocks(&server, "test-org", "test-repo", files).await; + + let repo_config = RepoConfig { policy: PolicyPack::Strict, ..Default::default() }; + let report = check_compliance_with_policy(&config, "test-org", "test-repo", &repo_config) + .await + .expect("compliance check should succeed"); + + let www = report + .checks + .iter() + .find(|c| c.name == "www/.well-known/security.txt") + .expect("canonical www/ location must be checked"); + assert_eq!(www.status, CheckStatus::Fail); + + // The legacy root location is flagged too, so the report points at the + // migration rather than at two unrelated facts. + let legacy = report + .checks + .iter() + .find(|c| c.name == "no-.well-known/security.txt") + .expect("legacy root .well-known/ must be flagged"); + assert_ne!(legacy.status, CheckStatus::Pass); + } + #[tokio::test] async fn test_banned_files_language_policy() { let server = MockServer::start().await; @@ -182,7 +264,7 @@ mod rsr_tests { // Include banned files (go.mod, package-lock.json) let files = &[ - "README.adoc", "LICENSE.txt", "go.mod", "package-lock.json", + "README.adoc", "LICENSE", "go.mod", "package-lock.json", ]; mount_file_mocks(&server, "test-org", "test-repo", files).await; @@ -215,7 +297,7 @@ mod rsr_tests { let server = MockServer::start().await; let config = mock_config(&server.uri()); - let files = &["README.adoc", "LICENSE.txt", "go.mod"]; + let files = &["README.adoc", "LICENSE", "go.mod"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; Mock::given(method("GET")) @@ -243,7 +325,7 @@ mod rsr_tests { let config = mock_config(&server.uri()); // Only README and LICENSE - minimal policy should be satisfied - let files = &["README.adoc", "LICENSE.txt"]; + let files = &["README.adoc", "LICENSE"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; Mock::given(method("GET")) @@ -267,7 +349,7 @@ mod rsr_tests { let config = mock_config(&server.uri()); // Missing many files that enterprise requires - let files = &["README.adoc", "LICENSE.txt"]; + let files = &["README.adoc", "LICENSE"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; Mock::given(method("GET")) @@ -291,7 +373,7 @@ mod rsr_tests { let server = MockServer::start().await; let config = mock_config(&server.uri()); - let files = &["README.adoc", "LICENSE.txt"]; + let files = &["README.adoc", "LICENSE"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; // Mock repository info with approved license @@ -304,12 +386,12 @@ mod rsr_tests { // Use check_compliance_with_policy directly with a custom repo config let mut severity_overrides = std::collections::HashMap::new(); - severity_overrides.insert("CONTRIBUTING.md".to_string(), Severity::Optional); + severity_overrides.insert("CONTRIBUTING.adoc".to_string(), Severity::Optional); let repo_config = RepoConfig { policy: PolicyPack::Custom, severity_overrides, - skip: vec!["SECURITY.md".to_string()], + skip: vec!["SECURITY.adoc".to_string()], ..Default::default() }; @@ -318,12 +400,12 @@ mod rsr_tests { .expect("compliance check should succeed"); // SECURITY.md should be skipped - let security_check = report.checks.iter().find(|c| c.name == "SECURITY.md"); + let security_check = report.checks.iter().find(|c| c.name == "SECURITY.adoc"); assert!(security_check.is_some()); assert_eq!(security_check.unwrap().status, CheckStatus::Skip); // CONTRIBUTING.md should be optional (not warn/fail) - let contrib_check = report.checks.iter().find(|c| c.name == "CONTRIBUTING.md"); + let contrib_check = report.checks.iter().find(|c| c.name == "CONTRIBUTING.adoc"); assert!(contrib_check.is_some()); assert_eq!(contrib_check.unwrap().severity, Severity::Optional); } @@ -334,7 +416,7 @@ mod rsr_tests { let config = mock_config(&server.uri()); // Only 2 of the scored files: README (5pts) + LICENSE (5pts) = 10pts - let files = &["README.adoc", "LICENSE.txt"]; + let files = &["README.adoc", "LICENSE"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; Mock::given(method("GET")) @@ -361,7 +443,7 @@ mod rsr_tests { let server = MockServer::start().await; let config = mock_config(&server.uri()); - let files = &["README.adoc", "LICENSE.txt"]; + let files = &["README.adoc", "LICENSE"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; Mock::given(method("GET")) @@ -399,7 +481,7 @@ mod rsr_tests { let config = mock_config(&server.uri()); // Include .editorconfig along with basics - let files = &["README.adoc", "LICENSE.txt", ".editorconfig", ".gitattributes", ".gitignore"]; + let files = &["README.adoc", "LICENSE", ".editorconfig", ".gitattributes", ".gitignore"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; Mock::given(method("GET")) @@ -428,7 +510,7 @@ mod rsr_tests { let server = MockServer::start().await; let config = mock_config(&server.uri()); - let files = &["README.adoc", "LICENSE.txt", "justfile", ".machine_readable/bot_directives"]; + let files = &["README.adoc", "LICENSE", "Justfile", ".machine_readable/bot_directives"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; Mock::given(method("GET")) @@ -442,7 +524,7 @@ mod rsr_tests { .await .expect("compliance check should succeed"); - let jf_check = report.checks.iter().find(|c| c.name == "justfile"); + let jf_check = report.checks.iter().find(|c| c.name == "Justfile"); assert!(jf_check.is_some(), "should have justfile check"); assert_eq!(jf_check.unwrap().status, CheckStatus::Pass); @@ -456,7 +538,7 @@ mod rsr_tests { let server = MockServer::start().await; let config = mock_config(&server.uri()); - let files = &["README.adoc", "LICENSE.txt"]; + let files = &["README.adoc", "LICENSE"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; // Mock Cargo.toml with bad author @@ -491,7 +573,7 @@ mod rsr_tests { let server = MockServer::start().await; let config = mock_config(&server.uri()); - let files = &["README.adoc", "LICENSE.txt"]; + let files = &["README.adoc", "LICENSE"]; mount_file_mocks(&server, "test-org", "test-repo", files).await; // Mock Cargo.toml with good author @@ -611,7 +693,7 @@ mod webhook_tests { .await; // Mock README and LICENSE exist - for file in &["README.adoc", "LICENSE.txt"] { + for file in &["README.adoc", "LICENSE"] { Mock::given(method("HEAD")) .and(path(format!("/repos/test-org/test-repo/contents/{}", file))) .respond_with(ResponseTemplate::new(200)) @@ -689,7 +771,7 @@ mod webhook_tests { .mount(&server) .await; - for file in &["README.adoc", "LICENSE.txt"] { + for file in &["README.adoc", "LICENSE"] { Mock::given(method("HEAD")) .and(path(format!("/repos/test-org/test-repo/contents/{}", file))) .respond_with(ResponseTemplate::new(200)) @@ -992,7 +1074,7 @@ mod report_tests { message: "AsciiDoc README found".to_string(), }, Check { - name: "SECURITY.md".to_string(), + name: "SECURITY.adoc".to_string(), category: CheckCategory::Security, severity: Severity::Recommended, status: CheckStatus::Warn, @@ -1001,7 +1083,7 @@ mod report_tests { message: "Security policy missing".to_string(), }, Check { - name: "LICENSE.txt".to_string(), + name: "LICENSE".to_string(), category: CheckCategory::Governance, severity: Severity::Required, status: if required_passed { CheckStatus::Pass } else { CheckStatus::Fail }, @@ -1112,7 +1194,7 @@ mod fleet_tests { message: "AsciiDoc README missing".to_string(), }, Check { - name: "LICENSE.txt".to_string(), + name: "LICENSE".to_string(), category: CheckCategory::Governance, severity: Severity::Required, status: CheckStatus::Pass, @@ -1121,7 +1203,7 @@ mod fleet_tests { message: "License file found".to_string(), }, Check { - name: "SECURITY.md".to_string(), + name: "SECURITY.adoc".to_string(), category: CheckCategory::Security, severity: Severity::Recommended, status: CheckStatus::Warn, @@ -1139,7 +1221,7 @@ mod fleet_tests { message: "Go module (use Rust) detected - policy violation".to_string(), }, Check { - name: ".machine_readable/STATE.scm".to_string(), + name: ".machine_readable/STATE.a2ml".to_string(), category: CheckCategory::Structure, severity: Severity::Recommended, status: CheckStatus::Skip, @@ -1180,7 +1262,7 @@ mod fleet_tests { assert!(readme_finding.is_some()); assert_eq!(readme_finding.unwrap().category, "rsr/documentation"); - let security_finding = findings.iter().find(|f| f.rule_name == "SECURITY.md"); + let security_finding = findings.iter().find(|f| f.rule_name == "SECURITY.adoc"); assert!(security_finding.is_some()); assert_eq!(security_finding.unwrap().category, "rsr/security"); @@ -1199,7 +1281,7 @@ mod fleet_tests { assert_eq!(readme_finding.severity, FleetSeverity::Error); // Recommended + Warn -> Warning - let security_finding = findings.iter().find(|f| f.rule_name == "SECURITY.md").unwrap(); + let security_finding = findings.iter().find(|f| f.rule_name == "SECURITY.adoc").unwrap(); assert_eq!(security_finding.severity, FleetSeverity::Warning); } @@ -1231,7 +1313,7 @@ mod fleet_tests { let findings = fleet::report_to_findings(&report); // SECURITY.md should be fixable - let security_finding = findings.iter().find(|f| f.rule_name == "SECURITY.md").unwrap(); + let security_finding = findings.iter().find(|f| f.rule_name == "SECURITY.adoc").unwrap(); assert!(security_finding.fixable, "SECURITY.md should be marked as fixable"); // README.adoc should NOT be fixable (content is project-specific) diff --git a/robot-repo-automaton/src/fixer.rs b/robot-repo-automaton/src/fixer.rs index f04df5fc..4d74f73a 100644 --- a/robot-repo-automaton/src/fixer.rs +++ b/robot-repo-automaton/src/fixer.rs @@ -682,7 +682,11 @@ impl Fixer { match target { "LICENSE" | "LICENSE.txt" => include_str!("../templates/LICENSE.tmpl").to_string(), ".editorconfig" => include_str!("../templates/editorconfig.tmpl").to_string(), - "SECURITY.md" => include_str!("../templates/SECURITY.tmpl").to_string(), + // The estate renamed SECURITY.md -> SECURITY.adoc with the + // AsciiDoc migration; both spellings render the same template. + // Without this arm the fall-through returns String::new() and + // the automaton opens a PR that creates the file EMPTY. + "SECURITY.adoc" | "SECURITY.md" => include_str!("../templates/SECURITY.tmpl").to_string(), _ => String::new(), } }