Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions catalog/laws.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ id = "repo-contract"
title = "Repo contract law"
description = "Active repos must resolve to a typed devctl contract whose archetype, posture, commands, release evidence, and artifact classes match observed repo reality."
maturity = "pilot"

[[laws]]
id = "doctrine-quartet"
title = "Doctrine quartet law"
description = "Every real repo must carry the contract quartet — NORTH_STAR.md, ANCHOR.md, AGENTS.md, CLAUDE.md — so workspace standards sprinkle down per repo and cannot drift silently. Missing quartet files are findings with file:line evidence at the repo anchor."
maturity = "pilot"
89 changes: 87 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ use std::time::{SystemTime, UNIX_EPOCH};

const SCHEMA_VERSION: &str = "0.1.0";
const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION");
const V0_LAWS: [&str; 5] = [
const V0_LAWS: [&str; 6] = [
"cloudflare-mutation",
"token",
"command-verification",
"release-proof",
"artifact-boundary",
"doctrine-quartet",
];
const CONTRACT_LAW: &str = "repo-contract";

Expand Down Expand Up @@ -408,6 +409,8 @@ pub struct GitInfo {

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DocsInfo {
pub north_star: Option<Utf8PathBuf>,
pub anchor: Option<Utf8PathBuf>,
pub agents: Option<Utf8PathBuf>,
pub claude: Option<Utf8PathBuf>,
pub readme: Option<Utf8PathBuf>,
Expand Down Expand Up @@ -1599,6 +1602,8 @@ fn infer_status(path: &Utf8Path) -> RepoStatus {

fn scan_docs(path: &Utf8Path) -> DocsInfo {
DocsInfo {
north_star: exists_path(path, "NORTH_STAR.md"),
anchor: exists_path(path, "ANCHOR.md"),
agents: exists_path(path, "AGENTS.md"),
claude: exists_path(path, "CLAUDE.md"),
readme: exists_path(path, "README.md"),
Expand Down Expand Up @@ -1834,6 +1839,7 @@ fn audit_repos(
}
audit_cloudflare(repo, &mut findings)?;
audit_tokens(repo, &mut findings)?;
audit_doctrine_quartet(repo, &mut findings)?;
audit_command_verification(repo, &mut findings)?;
audit_release(repo, &mut findings)?;
audit_artifacts(repo, &mut findings)?;
Expand Down Expand Up @@ -1883,6 +1889,7 @@ fn scanner_requirement_id(finding: &Finding) -> &'static str {
"cloudflare-governance" => "cloudflare-mutation-lane-classified",
"cloudflare-token-law" => "cloudflare-parent-token-contained",
"secret-file-permissions" => "secret-file-permissions-private",
"doctrine-quartet" => "contract-quartet-present",
"verification-contract" => "canonical-verification-command-present",
"script-ownership" => "script-state-classified",
"release-proof" => "release-lane-proof-bound",
Expand All @@ -1900,6 +1907,9 @@ fn scanner_expected(finding: &Finding) -> &'static str {
"Parent Cloudflare tokens remain in allowlisted control-plane or rotation contexts"
}
"secret-file-permissions" => "Secret-bearing env files are not group/world readable",
"doctrine-quartet" => {
"Every real repo carries the NORTH_STAR/ANCHOR/AGENTS/CLAUDE contract quartet"
}
"verification-contract" => "Active repos declare a canonical verification surface",
"script-ownership" => {
"Check/verify scripts have a gated/recovery/transitive/retired classification"
Expand Down Expand Up @@ -2406,6 +2416,45 @@ fn audit_tokens(repo: &RepoRecord, findings: &mut Vec<Finding>) -> DevResult<()>
Ok(())
}

fn audit_doctrine_quartet(repo: &RepoRecord, findings: &mut Vec<Finding>) -> DevResult<()> {
if matches!(repo.status, RepoStatus::Template | RepoStatus::Unknown) {
return Ok(());
}
let quartet: [(&str, &Option<Utf8PathBuf>); 4] = [
("NORTH_STAR.md", &repo.docs.north_star),
("ANCHOR.md", &repo.docs.anchor),
("AGENTS.md", &repo.docs.agents),
("CLAUDE.md", &repo.docs.claude),
];
for (name, present) in quartet {
if present.is_some() {
continue;
}
let message = format!("Repo is missing contract quartet file {name}");
let evidence = format!(
"{name} not found at repo root; workspace doctrine expects the full \
NORTH_STAR/ANCHOR/AGENTS/CLAUDE quartet"
);
let recommendation = format!(
"Author {name} from live repo truth so workspace standards sprinkle down \
and stay enforceable."
);
findings.push(Finding::new(
"doctrine-quartet",
Severity::P2,
repo,
repo_anchor(repo),
None,
&message,
&evidence,
&recommendation,
Confidence::High,
"doctrine-quartet",
));
}
Ok(())
}

fn audit_command_verification(repo: &RepoRecord, findings: &mut Vec<Finding>) -> DevResult<()> {
if matches!(
repo.status,
Expand Down Expand Up @@ -3255,7 +3304,9 @@ fn print_repo_human(repo: &RepoRecord) {
println!("path: {}", repo.path);
println!("status: {}", repo.status);
println!(
"docs: AGENTS={} CLAUDE={} README={} SECURITY={}",
"docs: NORTH_STAR={} ANCHOR={} AGENTS={} CLAUDE={} README={} SECURITY={}",
repo.docs.north_star.is_some(),
repo.docs.anchor.is_some(),
repo.docs.agents.is_some(),
repo.docs.claude.is_some(),
repo.docs.readme.is_some(),
Expand Down Expand Up @@ -3364,6 +3415,40 @@ mod tests {
);
}

#[test]
fn missing_quartet_files_produce_doctrine_findings() {
let fixture = TestRepo::new("doctrine-repo");
fixture.write("AGENTS.md", "active repo\n");
fixture.write("Cargo.toml", "[package]\nname = \"doctrine-repo\"\n");
let messages: Vec<String> = fixture
.audit()
.into_iter()
.filter(|finding| finding.law == "doctrine-quartet")
.map(|finding| finding.message)
.collect();
// AGENTS.md is present, so only the other three quartet files are findings.
assert!(messages.iter().any(|m| m.contains("NORTH_STAR.md")));
assert!(messages.iter().any(|m| m.contains("ANCHOR.md")));
assert!(messages.iter().any(|m| m.contains("CLAUDE.md")));
assert!(!messages.iter().any(|m| m.contains("AGENTS.md")));
}

#[test]
fn complete_quartet_produces_no_doctrine_finding() {
let fixture = TestRepo::new("complete-repo");
fixture.write("NORTH_STAR.md", "north\n");
fixture.write("ANCHOR.md", "anchor\n");
fixture.write("AGENTS.md", "agents\n");
fixture.write("CLAUDE.md", "claude\n");
fixture.write("Cargo.toml", "[package]\nname = \"complete-repo\"\n");
assert!(
!fixture
.audit()
.iter()
.any(|finding| finding.law == "doctrine-quartet")
);
}

#[test]
fn inventory_json_has_schema_version() {
let fixture = TestWorkspace::new();
Expand Down