Skip to content

Commit a91c93e

Browse files
feat(robot-repo-automaton): canonical RSR skeleton generator (rsr-template-repo#48) (#160)
Adds a `skeleton` subcommand making robot-repo-automaton the single source of truth for the RSR required-files set, so rsr-template-repo can be *derived* from it (and a drift-CI there can fail on divergence) instead of hand-maintained. Closes the stronger structural fix that rsr-template-repo#45/#47 deferred. - `skeleton emit <dir>` — write the canonical required-files set - `skeleton check <dir>` — verify a repo matches; non-zero exit + per -file MISSING/DRIFTED report on drift (CRLF/final-newline tolerant) Source of truth = the **post-#47 canonical layout**, NOT the prose in standards `REQUIRED-FILES.md`, which is stale: it still lists root `*.scm` + `Mustfile`, predating the estate `.scm`->`.a2ml` migration and the `.machine_readable/6a2/` layout. The 11 canonical artefacts are embedded under `templates/skeleton/` (byte-identical to current rsr-template-repo, so the drift check passes on day one). Updating REQUIRED-FILES.md to match is a separate standards-repo change, out of scope here (and standards is under concurrent-session churn). `.editorconfig` / `.tool-versions` are force-added: the canonical RSR `.gitignore` itself ignores them (machine-local in real repos), but the embedded copies are generator *data* and must be tracked. Tests: 4 unit tests (emit/check roundtrip, missing+differs detection, eol-normalisation); `skeleton check` against live rsr-template-repo origin/main = clean. Full crate builds. Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 20530da commit a91c93e

14 files changed

Lines changed: 1638 additions & 0 deletions

File tree

robot-repo-automaton/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ pub mod registry_guard;
6262
pub mod fleet;
6363
pub mod github;
6464
pub mod hooks;
65+
pub mod skeleton;
6566

6667
pub use catalog::ErrorCatalog;
6768
pub use confidence::{ConfidenceLevel, FixDecision, ProposedFix, ThresholdConfig};

robot-repo-automaton/src/main.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
//! 3. **ScanOrg**: High-concurrency bulk auditing across a GitHub organization.
1313
//! 4. **Hooks**: Lifecycle management for Git pre-commit and pre-push filters.
1414
//! 5. **Catalog**: Inspection of the authoritative error database (ERROR-CATALOG.scm).
15+
//! 6. **Skeleton**: Emit/verify the canonical RSR required-files set; the
16+
//! single source of truth `rsr-template-repo` is derived from
17+
//! (rsr-template-repo#48).
1518
1619
use clap::{Parser, Subcommand};
1720
use robot_repo_automaton::prelude::*;
@@ -102,6 +105,28 @@ enum Commands {
102105
#[arg(short, long)]
103106
severity: Option<String>,
104107
},
108+
109+
/// Generate or verify the canonical RSR skeleton (rsr-template-repo#48)
110+
Skeleton {
111+
#[command(subcommand)]
112+
action: SkeletonAction,
113+
},
114+
}
115+
116+
#[derive(Subcommand, Debug)]
117+
enum SkeletonAction {
118+
/// Write the canonical RSR required-files set into a directory
119+
Emit {
120+
/// Target directory (created if absent)
121+
#[arg(default_value = ".")]
122+
out: PathBuf,
123+
},
124+
/// Verify a repo's required files match canonical; non-zero exit on drift
125+
Check {
126+
/// Repository root to verify
127+
#[arg(default_value = ".")]
128+
repo: PathBuf,
129+
},
105130
}
106131

107132
#[derive(Subcommand, Debug)]
@@ -173,6 +198,48 @@ async fn main() -> anyhow::Result<()> {
173198
} => cmd_scan_org(config.as_ref(), &org, &format, concurrency, cli.dry_run).await,
174199
Commands::Hooks { action } => cmd_hooks(config.as_ref(), action).await,
175200
Commands::Catalog { path, severity } => cmd_catalog(&path, severity.as_deref()),
201+
Commands::Skeleton { action } => cmd_skeleton(action),
202+
}
203+
}
204+
205+
/// Emit or verify the canonical RSR skeleton (rsr-template-repo#48).
206+
///
207+
/// `Check` exits non-zero on drift so it can gate `rsr-template-repo`'s CI:
208+
/// the template is *derived* from this generator, never hand-maintained.
209+
fn cmd_skeleton(action: SkeletonAction) -> anyhow::Result<()> {
210+
use robot_repo_automaton::skeleton;
211+
match action {
212+
SkeletonAction::Emit { out } => {
213+
skeleton::emit(&out)?;
214+
println!(
215+
"wrote {} canonical RSR skeleton file(s) to {}",
216+
skeleton::SKELETON.len(),
217+
out.display()
218+
);
219+
Ok(())
220+
}
221+
SkeletonAction::Check { repo } => {
222+
let drift = skeleton::check(&repo)?;
223+
if drift.is_empty() {
224+
println!(
225+
"OK: all {} required files match the canonical skeleton",
226+
skeleton::SKELETON.len()
227+
);
228+
Ok(())
229+
} else {
230+
for d in &drift {
231+
match d {
232+
skeleton::Drift::Missing(f) => error!("MISSING {f}"),
233+
skeleton::Drift::Differs(f) => error!("DRIFTED {f}"),
234+
}
235+
}
236+
anyhow::bail!(
237+
"{} required file(s) drifted from the canonical skeleton \
238+
(regenerate with `robot-repo-automaton skeleton emit`)",
239+
drift.len()
240+
)
241+
}
242+
}
176243
}
177244
}
178245

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
//! Canonical RSR skeleton generator (hyperpolymath/rsr-template-repo#48).
3+
//!
4+
//! `robot-repo-automaton` is the single source of truth for the RSR
5+
//! *required-files set*. `rsr-template-repo` is **derived** from this: its
6+
//! drift-CI runs `robot-repo-automaton skeleton check .` and fails if the
7+
//! checked-in template diverges from [`SKELETON`]. This makes hand-maintenance
8+
//! of the template structurally impossible to drift (#45/#47 closed the
9+
//! immediate sanitisation; this closes the stronger structural fix #48).
10+
//!
11+
//! Source of truth = the **post-#47 canonical layout**, not the prose in
12+
//! `standards` `REQUIRED-FILES.md` (which is stale: it still lists root
13+
//! `*.scm` + `Mustfile`, predating the estate-wide `.scm`→`.a2ml` migration
14+
//! and the `.machine_readable/6a2/` layout). Updating that doc to match is a
15+
//! separate `standards`-repo change, deliberately out of scope here.
16+
17+
use anyhow::{Context, Result};
18+
use std::path::Path;
19+
20+
/// `(repo-relative path, canonical content)`, embedded at compile time so the
21+
/// generator is hermetic and reproducible. The embedded copies under
22+
/// `templates/skeleton/` are themselves the canonical artefacts; on day one
23+
/// they are byte-identical to the post-#47 `rsr-template-repo`, so the drift
24+
/// check passes immediately and any future divergence fails CI.
25+
pub const SKELETON: &[(&str, &str)] = &[
26+
(
27+
".gitignore",
28+
include_str!("../templates/skeleton/.gitignore"),
29+
),
30+
(
31+
".gitattributes",
32+
include_str!("../templates/skeleton/.gitattributes"),
33+
),
34+
(
35+
".editorconfig",
36+
include_str!("../templates/skeleton/.editorconfig"),
37+
),
38+
(
39+
".tool-versions",
40+
include_str!("../templates/skeleton/.tool-versions"),
41+
),
42+
("Justfile", include_str!("../templates/skeleton/Justfile")),
43+
(
44+
".machine_readable/6a2/META.a2ml",
45+
include_str!("../templates/skeleton/.machine_readable/6a2/META.a2ml"),
46+
),
47+
(
48+
".machine_readable/6a2/STATE.a2ml",
49+
include_str!("../templates/skeleton/.machine_readable/6a2/STATE.a2ml"),
50+
),
51+
(
52+
".machine_readable/6a2/ECOSYSTEM.a2ml",
53+
include_str!("../templates/skeleton/.machine_readable/6a2/ECOSYSTEM.a2ml"),
54+
),
55+
(
56+
".machine_readable/6a2/PLAYBOOK.a2ml",
57+
include_str!("../templates/skeleton/.machine_readable/6a2/PLAYBOOK.a2ml"),
58+
),
59+
(
60+
".machine_readable/6a2/AGENTIC.a2ml",
61+
include_str!("../templates/skeleton/.machine_readable/6a2/AGENTIC.a2ml"),
62+
),
63+
(
64+
".machine_readable/6a2/NEUROSYM.a2ml",
65+
include_str!("../templates/skeleton/.machine_readable/6a2/NEUROSYM.a2ml"),
66+
),
67+
];
68+
69+
/// Write the canonical skeleton into `out`, creating parent directories.
70+
pub fn emit(out: &Path) -> Result<()> {
71+
for (rel, content) in SKELETON {
72+
let dst = out.join(rel);
73+
if let Some(parent) = dst.parent() {
74+
std::fs::create_dir_all(parent)
75+
.with_context(|| format!("creating {}", parent.display()))?;
76+
}
77+
std::fs::write(&dst, content).with_context(|| format!("writing {}", dst.display()))?;
78+
}
79+
Ok(())
80+
}
81+
82+
/// A single required file that has drifted from canonical.
83+
#[derive(Debug, PartialEq, Eq)]
84+
pub enum Drift {
85+
/// Required file absent in the target repo.
86+
Missing(String),
87+
/// Required file present but content differs from canonical.
88+
Differs(String),
89+
}
90+
91+
/// Compare `repo`'s required files against [`SKELETON`]. Empty result == in
92+
/// sync. Comparison ignores only CRLF and trailing-final-newline noise so the
93+
/// check is robust across platforms/editors without being lax about content.
94+
pub fn check(repo: &Path) -> Result<Vec<Drift>> {
95+
let mut drift = Vec::new();
96+
for (rel, content) in SKELETON {
97+
let path = repo.join(rel);
98+
match std::fs::read_to_string(&path) {
99+
Err(_) => drift.push(Drift::Missing((*rel).to_string())),
100+
Ok(actual) => {
101+
if normalize(&actual) != normalize(content) {
102+
drift.push(Drift::Differs((*rel).to_string()));
103+
}
104+
}
105+
}
106+
}
107+
Ok(drift)
108+
}
109+
110+
fn normalize(s: &str) -> String {
111+
let mut t = s.replace("\r\n", "\n");
112+
while t.ends_with('\n') {
113+
t.pop();
114+
}
115+
t
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
122+
#[test]
123+
fn skeleton_is_non_empty_and_well_formed() {
124+
assert!(SKELETON.len() >= 11, "expected the full RSR required set");
125+
for (rel, content) in SKELETON {
126+
assert!(!rel.is_empty(), "empty skeleton path");
127+
assert!(!rel.starts_with('/'), "{rel} must be repo-relative");
128+
assert!(!content.is_empty(), "{rel} canonical content is empty");
129+
}
130+
}
131+
132+
#[test]
133+
fn emit_then_check_roundtrips_clean() {
134+
let dir = tempfile::tempdir().unwrap();
135+
emit(dir.path()).unwrap();
136+
let drift = check(dir.path()).unwrap();
137+
assert!(drift.is_empty(), "freshly emitted skeleton drifted: {drift:?}");
138+
}
139+
140+
#[test]
141+
fn check_flags_missing_and_differing() {
142+
let dir = tempfile::tempdir().unwrap();
143+
emit(dir.path()).unwrap();
144+
// Mutate one file and delete another.
145+
std::fs::write(dir.path().join(".tool-versions"), "tampered\n").unwrap();
146+
std::fs::remove_file(dir.path().join(".gitignore")).unwrap();
147+
let drift = check(dir.path()).unwrap();
148+
assert!(drift.contains(&Drift::Differs(".tool-versions".into())));
149+
assert!(drift.contains(&Drift::Missing(".gitignore".into())));
150+
}
151+
152+
#[test]
153+
fn normalize_ignores_only_eol_noise() {
154+
assert_eq!(normalize("a\r\nb\n\n"), normalize("a\nb"));
155+
assert_ne!(normalize("a b"), normalize("a b"));
156+
}
157+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# RSR-template-repo - Editor Configuration
2+
# https://editorconfig.org
3+
4+
root = true
5+
6+
[*]
7+
charset = utf-8
8+
end_of_line = lf
9+
indent_size = 2
10+
indent_style = space
11+
insert_final_newline = true
12+
trim_trailing_whitespace = true
13+
14+
[*.md]
15+
trim_trailing_whitespace = false
16+
17+
[*.adoc]
18+
trim_trailing_whitespace = false
19+
20+
[*.rs]
21+
indent_size = 4
22+
23+
[*.ex]
24+
indent_size = 2
25+
26+
[*.exs]
27+
indent_size = 2
28+
29+
[*.zig]
30+
indent_size = 4
31+
32+
[*.ada]
33+
indent_size = 3
34+
35+
[*.adb]
36+
indent_size = 3
37+
38+
[*.ads]
39+
indent_size = 3
40+
41+
[*.hs]
42+
indent_size = 2
43+
44+
[*.res]
45+
indent_size = 2
46+
47+
[*.resi]
48+
indent_size = 2
49+
50+
[*.ncl]
51+
indent_size = 2
52+
53+
[*.rkt]
54+
indent_size = 2
55+
56+
[*.scm]
57+
indent_size = 2
58+
59+
[*.nix]
60+
indent_size = 2
61+
62+
[Justfile]
63+
indent_style = space
64+
indent_size = 4
65+
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# RSR-compliant .gitattributes
3+
4+
* text=auto eol=lf
5+
6+
# Source
7+
*.rs text eol=lf diff=rust
8+
*.ex text eol=lf diff=elixir
9+
*.exs text eol=lf diff=elixir
10+
*.jl text eol=lf
11+
*.res text eol=lf
12+
*.resi text eol=lf
13+
*.ada text eol=lf diff=ada
14+
*.adb text eol=lf diff=ada
15+
*.ads text eol=lf diff=ada
16+
*.hs text eol=lf
17+
*.chpl text eol=lf
18+
*.scm text eol=lf
19+
*.a2ml text eol=lf linguist-language=TOML
20+
*.ncl text eol=lf
21+
*.nix text eol=lf
22+
23+
# Docs
24+
*.md text eol=lf diff=markdown
25+
*.adoc text eol=lf
26+
*.txt text eol=lf
27+
28+
# Data
29+
*.json text eol=lf
30+
*.yaml text eol=lf
31+
*.yml text eol=lf
32+
*.toml text eol=lf
33+
*.jsonl text eol=lf
34+
35+
# Generated / large-corpora hygiene (estate guardrail)
36+
# Keep large data corpora and generated emit out of language stats and
37+
# collapsed in diffs so they don't drown review or skew the language bar.
38+
# (No git-lfs: this template does not configure lfs, so we do not introduce
39+
# it here — gitignore + linguist attributes only.)
40+
*.jsonl linguist-generated=true
41+
*.res.js linguist-generated=true
42+
*.cmj linguist-generated=true
43+
*.cmi linguist-generated=true
44+
*.cmt linguist-generated=true
45+
*.cmti linguist-generated=true
46+
47+
# Config
48+
.gitignore text eol=lf
49+
.gitattributes text eol=lf
50+
Justfile text eol=lf
51+
Makefile text eol=lf
52+
Containerfile text eol=lf
53+
54+
# Scripts
55+
*.sh text eol=lf
56+
57+
# Binary
58+
*.png binary
59+
*.jpg binary
60+
*.gif binary
61+
*.pdf binary
62+
*.woff2 binary
63+
*.zip binary
64+
*.gz binary
65+
66+
# Lock files
67+
Cargo.lock text eol=lf -diff
68+
flake.lock text eol=lf -diff

0 commit comments

Comments
 (0)