From a8133510c6d8bd6f8061665ed708b10ddb68f697 Mon Sep 17 00:00:00 2001 From: Rhodibot canon update Date: Fri, 18 Sep 2026 12:31:39 +0000 Subject: [PATCH] fix(rhodibot): stop demanding filenames the estate abandoned The checker's required-file table predated the Markdown/TXT -> AsciiDoc migration (#486, 2026-08) and the canonical-location changes after it, so it demanded filenames the estate had deliberately moved away from. Measured against 269 estate repositories on 2026-09-18: wants LICENSE.txt 0/269 have it (LICENSE: 269/269) wants CONTRIBUTING.md 25/269 (CONTRIBUTING.adoc: 255) wants CODE_OF_CONDUCT.md 15/269 (CODE_OF_CONDUCT.adoc: 240) wants SECURITY.md 35/269 (SECURITY.adoc: 250) wants lowercase justfile 7/269 (Justfile: 267) wants .machine_readable/STATE.scm 1/269 (.a2ml spelling: 13) A bot whose stated job is "structural checks, directory layout" was therefore telling essentially every repository to reintroduce the pre-migration filenames. Anyone acting on that - a maintainer, or the automation downstream - reverses the AsciiDoc migration. Required files now name the current convention, with the superseded spellings accepted as alternates so a rename is never reported as a regression. CheckDef gains `alternates`, and `check_path_exists()` tries the canonical path first and only falls back to alternates when it is absent, so the common case costs one request exactly as before. The Guile Scheme state files never landed (1/269, and 13/269 under the .a2ml spelling), so they drop to Recommended/Required at strict and above, and 0-AI-MANIFEST.a2ml - which 261/269 repositories do carry - becomes the machine-readable check that carries the weight. www/ awareness Adds www/.well-known/security.txt (canonical since #53) and a banned pattern for the legacy root .well-known/. Both are advisory at minimal and standard while the stage-5 migration window is open; the banned pattern's severity can be raised once the sweep has landed, which is what will stop the migration regressing. Two defects found while making the change * is_fixable() advertised four files as auto-fixable that robot-repo-automaton has no template for. get_template_content() falls through to String::new() for anything it does not recognise, so the automaton would open a PR creating those files EMPTY - and inbox-steward auto-merges PRs that pass CI. is_fixable() now matches the template arms exactly. * The automaton had no arm for SECURITY.adoc, so the renamed check would also have produced an empty file. Added, sharing SECURITY.tmpl. Rule IDs RSR-001..015 are unchanged, and the old spellings still map to them, so existing reports and .rsr.toml overrides keep resolving. Tests: rhodibot 50 passed (was 45 passing / 3 failing), including two new regression tests for the alternates path and the www/ check. Automaton 114 passed. --- bots/rhodibot/src/fleet.rs | 57 ++++++--- bots/rhodibot/src/rsr.rs | 144 +++++++++++++++++++--- bots/rhodibot/src/webhook.rs | 20 ++-- bots/rhodibot/tests/integration_tests.rs | 146 ++++++++++++++++++----- robot-repo-automaton/src/fixer.rs | 6 +- 5 files changed, 298 insertions(+), 75 deletions(-) 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(), } }