From e7ca9ea72a60d763da2c5d95b2b7356ef2967716 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Thu, 30 Jul 2026 08:13:07 -0400 Subject: [PATCH] Add the coverage and shape layer for storied srd verify Every vendored file under a *_Each/ directory must be claimed by exactly one corpus entry's source field; other vendored files may be claimed at most once. Every kind's entry count must meet its floor (300 spells, 200 monsters, 200 magic items, exactly 12 classes, 15 feats, 5 core chapters), so a half-built corpus fails loudly instead of quietly passing. README.md must carry the WotC attribution paragraph verbatim from the vendored Legal.md and a link to the oldmanumby source repo; meta.yaml must parse and pin both sources. This completes the verifier: schema, fidelity, coverage, and shape together are the full format contract described in plans/0002-srd.md. verify() is now the complete function both the CLI and task 6's corpus build will call. Adds the Layer test fixture (one entry per kind, its matching fake vendored source, README, and meta.yaml) and an end-to-end test per kind of mistake the brief calls out: a dropped, added, or changed body word; a bad school; an unknown key; a slug mismatch or collision; a type/directory mismatch; a missing source; a double-claimed vendored file; a count floor breach; a frontmatter/body field drift; and a stat-region word missing from frontmatter. Not wired into pre-commit or the CLI yet; that lands in a follow-up commit once the corpus itself exists to gate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017jBcx24HfGr66ZAJnYf1fR --- src/srd/verify/coverage.rs | 271 ++++++++++++++++++++++++++ src/srd/verify/fixtures.rs | 135 ++++++++++++- src/srd/verify/integration.rs | 229 ++++++++++++++++++++++ src/srd/verify/mod.rs | 58 ++++-- src/srd/verify/readme_meta.rs | 355 ++++++++++++++++++++++++++++++++++ 5 files changed, 1031 insertions(+), 17 deletions(-) create mode 100644 src/srd/verify/coverage.rs create mode 100644 src/srd/verify/integration.rs create mode 100644 src/srd/verify/readme_meta.rs diff --git a/src/srd/verify/coverage.rs b/src/srd/verify/coverage.rs new file mode 100644 index 0000000..ef9a76d --- /dev/null +++ b/src/srd/verify/coverage.rs @@ -0,0 +1,271 @@ +//! Checks that every vendored file the corpus should draw from is used +//! exactly once, and that each kind has enough entries to be a real +//! corpus rather than a stub. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::Failure; +use super::kind::Kind; + +const VENDORED_ROOT: &str = "sources/dnd.srd.5.2.1"; + +/// The minimum number of entries each kind's directory must hold, so an +/// empty or half-built corpus fails loudly instead of quietly passing. +fn floor(kind: Kind) -> usize { + match kind { + Kind::Spell => 300, + Kind::Monster => 200, + Kind::MagicItem => 200, + Kind::Class => 12, + Kind::Feat => 15, + Kind::Core => 5, + } +} + +/// Walks `layer_root`'s vendored source tree and checks its claims: +/// every file under a `*_Each/` directory must be claimed by exactly one +/// corpus source, and no other vendored file may be claimed more than +/// once. `claims` is every corpus file's `source` field, layer-relative. +pub fn check_claims(layer_root: &Path, claims: &[PathBuf]) -> Vec { + let mut claim_counts: HashMap = HashMap::new(); + for claim in claims { + *claim_counts.entry(claim.clone()).or_insert(0) += 1; + } + + let mut failures = Vec::new(); + let vendored_root = layer_root.join(VENDORED_ROOT); + for vendored_path in walk_files(&vendored_root) { + let layer_relative = PathBuf::from(VENDORED_ROOT).join( + vendored_path + .strip_prefix(&vendored_root) + .unwrap_or(&vendored_path), + ); + let count = claim_counts.get(&layer_relative).copied().unwrap_or(0); + if is_under_each_dir(&vendored_path, &vendored_root) { + match count { + 1 => {} + 0 => failures.push(Failure::new( + &layer_relative, + "vendored file is not claimed by any corpus entry", + )), + n => failures.push(Failure::new( + &layer_relative, + format!( + "vendored file is claimed by {n} corpus entries, exactly one is allowed" + ), + )), + } + } else if count > 1 { + failures.push(Failure::new( + &layer_relative, + format!( + "vendored file is claimed by {count} corpus entries, at most one is allowed" + ), + )); + } + } + failures.sort_by(|a, b| a.path.cmp(&b.path)); + failures +} + +/// Reports whether `path` (inside `vendored_root`) has an ancestor +/// directory, up to `vendored_root`, whose name ends in `_Each`. +fn is_under_each_dir(path: &Path, vendored_root: &Path) -> bool { + path.strip_prefix(vendored_root) + .into_iter() + .flat_map(|relative| relative.parent()) + .flat_map(|parent| parent.components()) + .any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| name.ends_with("_Each")) + }) +} + +fn walk_files(dir: &Path) -> Vec { + let mut files = Vec::new(); + let Ok(entries) = fs::read_dir(dir) else { + return files; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + files.extend(walk_files(&path)); + } else { + files.push(path); + } + } + files +} + +/// Checks every kind's entry count against its floor, appending one +/// failure per kind that falls short. +pub fn check_counts(counts: &HashMap, failures: &mut Vec) { + for kind in Kind::ALL { + let count = counts.get(&kind).copied().unwrap_or(0); + let required = floor(kind); + let meets_floor = if kind == Kind::Class { + count == required + } else { + count >= required + }; + if !meets_floor { + let comparison = if kind == Kind::Class { + "exactly" + } else { + "at least" + }; + failures.push(Failure::new( + kind.dir_name(), + format!("has {count} entries, needs {comparison} {required}"), + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; + + fn each_file(layer_root: &Path, relative: &str) -> PathBuf { + let path = layer_root.join(VENDORED_ROOT).join(relative); + write_file(&path, "content"); + PathBuf::from(VENDORED_ROOT).join(relative) + } + + #[test] + fn check_claims_passes_when_every_each_file_is_claimed_once() { + let layer_root = unique_temp_dir(); + let source = each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[source]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_reports_an_unclaimed_each_file() { + let layer_root = unique_temp_dir(); + each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[]); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("not claimed")); + } + + #[test] + fn check_claims_reports_a_double_claimed_each_file() { + let layer_root = unique_temp_dir(); + let source = each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[source.clone(), source]); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("2 corpus entries")); + assert!(failures[0].message.contains("exactly one")); + } + + #[test] + fn check_claims_sorts_multiple_failures_by_path() { + let layer_root = unique_temp_dir(); + each_file(&layer_root, "07_Spells/Spells_Each/Wish.md"); + each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[]); + + let paths: Vec<_> = failures.iter().map(|f| f.path.clone()).collect(); + let mut sorted_paths = paths.clone(); + sorted_paths.sort(); + assert_eq!(paths, sorted_paths); + assert_eq!(failures.len(), 2); + } + + #[test] + fn check_claims_tolerates_a_missing_vendored_tree() { + let layer_root = unique_temp_dir(); + + let failures = check_claims(&layer_root, &[]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_allows_an_unclaimed_non_each_file() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(VENDORED_ROOT).join("Legal.md"), "content"); + + let failures = check_claims(&layer_root, &[]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_allows_a_non_each_file_claimed_once() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(VENDORED_ROOT).join("Legal.md"), "content"); + let source = PathBuf::from(VENDORED_ROOT).join("Legal.md"); + + let failures = check_claims(&layer_root, &[source]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_rejects_a_non_each_file_claimed_twice() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(VENDORED_ROOT).join("Legal.md"), "content"); + let source = PathBuf::from(VENDORED_ROOT).join("Legal.md"); + + let failures = check_claims(&layer_root, &[source.clone(), source]); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("at most one")); + } + + #[test] + fn check_counts_passes_when_every_floor_is_met() { + let counts = HashMap::from([ + (Kind::Spell, 300), + (Kind::Monster, 200), + (Kind::MagicItem, 200), + (Kind::Class, 12), + (Kind::Feat, 15), + (Kind::Core, 5), + ]); + let mut failures = Vec::new(); + + check_counts(&counts, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_counts_reports_a_floor_breach() { + let counts = HashMap::from([(Kind::Spell, 2)]); + let mut failures = Vec::new(); + + check_counts(&counts, &mut failures); + + assert!(failures.iter().any(|f| f.path == Path::new("spells") + && f.message.contains("has 2 entries, needs at least 300"))); + } + + #[test] + fn check_counts_requires_exactly_twelve_classes() { + let counts = HashMap::from([(Kind::Class, 13)]); + let mut failures = Vec::new(); + + check_counts(&counts, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("needs exactly 12")) + ); + } +} diff --git a/src/srd/verify/fixtures.rs b/src/srd/verify/fixtures.rs index d5d47c2..f83ef86 100644 --- a/src/srd/verify/fixtures.rs +++ b/src/srd/verify/fixtures.rs @@ -1,5 +1,6 @@ -//! Shared test fixtures for the verify module: a fresh temp directory and -//! a small file-writing helper for synthetic corpus layers. +//! Shared test fixtures for the verify module: a fresh temp directory, a +//! small file-writing helper, and a builder for a small synthetic corpus +//! layer with matching fake vendored sources. use std::fs; use std::path::{Path, PathBuf}; @@ -25,3 +26,133 @@ pub fn write_file(path: &Path, contents: &str) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, contents).unwrap(); } + +const ATTRIBUTION: &str = "This work includes material from the System Reference Document 5.2.1 (\"SRD 5.2.1\") by Wizards of the Coast LLC, available at https://www.dndbeyond.com/srd."; +const SOURCE_REPO_URL: &str = "https://github.com/oldmanumby/dnd.srd.5.2.1"; + +pub const FIREBALL_SOURCE: &str = "sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"; +pub const GOBLIN_SOURCE: &str = "sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/Goblin_Warrior.md"; +pub const AMULET_SOURCE: &str = + "sources/dnd.srd.5.2.1/10_Magic_Items/Magic_Items_Each/Amulet_of_Health.md"; +pub const BARD_SOURCE: &str = + "sources/dnd.srd.5.2.1/03_Character_Classes/Character_Classes_Each/Bard.md"; +pub const ALERT_SOURCE: &str = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Alert.md"; +pub const CORE_SOURCE: &str = "sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md"; + +const FIREBALL_BODY: &str = "# Fireball\n\n*Level 3 Evocation (Sorcerer, Wizard)*\n\n**Casting Time:** Action\n\n**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n\nA bright streak flashes from you.\n"; + +const GOBLIN_SOURCE_TEXT: &str = "# Goblin Warrior\n\n*Small Fey (Goblinoid), Chaotic Neutral*\n\n**AC** 15\n\n**HP** 10 (3d6)\n\n**Speed** 30 ft.\n\n| | MOD | SAVE | | MOD | SAVE | | MOD | SAVE |\n| :- | :- | :- | :- | :- | :- | :- | :- | :- |\n| **Str 8** | -1 | -1 | **Dex 15** | +2 | +2 | **Con 10** | +0 | +0 |\n| **Int 10** | +0 | +0 | **Wis 8** | -1 | -1 | **Cha 8** | -1 | -1 |\n\n**CR** 1/4\n\n## Actions\n\n***Scimitar.*** *Melee Attack Roll:* +4, reach 5 ft.\n"; + +const AMULET_SOURCE_TEXT: &str = "# Amulet of Health\n\n*Wondrous Item, Rare (Requires Attunement)*\n\nYour Constitution is 19 while you wear this amulet.\n"; +const AMULET_FIELD_LINES: &str = + "\n**Category:** Wondrous Item\n\n**Rarity:** Rare\n\n**Attunement:** Requires Attunement\n"; + +const BARD_BODY: &str = "# Bard\n\nBards are charming performers.\n"; +const ALERT_BODY: &str = "# Alert\n\nYou gain the following benefits.\n"; +const CORE_BODY: &str = "# Playing the Game\n\nThe three pillars of play are exploration, social interaction, and combat.\n"; + +/// A small valid corpus layer: one entry per kind, its matching fake +/// vendored source, and a README and meta.yaml that satisfy the +/// attribution and pin checks. Every kind's entry count is under its real +/// floor (the corpus is tiny by design), so `verify()` on an unmodified +/// layer reports one count-floor failure per kind and nothing else; +/// tests that care about a different, single failure should filter those +/// out rather than assert an empty failure list. +pub struct Layer { + pub root: PathBuf, +} + +impl Layer { + pub fn valid() -> Self { + let layer = Self { + root: unique_temp_dir(), + }; + + layer.write(FIREBALL_SOURCE, FIREBALL_BODY); + layer.write( + "spells/fireball.md", + &format!( + "---\nname: Fireball\ntype: spell\nsource: {FIREBALL_SOURCE}\nlevel: 3\nschool: evocation\nclasses: [sorcerer, wizard]\ncasting_time: Action\nrange: 150 feet\ncomponents: V, S, M\nduration: Instantaneous\n---\n{FIREBALL_BODY}" + ), + ); + + layer.write(GOBLIN_SOURCE, GOBLIN_SOURCE_TEXT); + let goblin_body = super::stat_block::source_outside_region(GOBLIN_SOURCE_TEXT); + layer.write( + "monsters/goblin-warrior.md", + &format!( + "---\nname: Goblin Warrior\ntype: monster\nsource: {GOBLIN_SOURCE}\nsize: Small\ncreature_type: Fey (Goblinoid)\nalignment: Chaotic Neutral\nac: '15'\nhp: 10 (3d6)\nspeed: 30 ft.\ncr: 1/4\nabilities:\n str: {{score: 8, mod: -1, save: -1}}\n dex: {{score: 15, mod: 2, save: 2}}\n con: {{score: 10, mod: 0, save: 0}}\n int: {{score: 10, mod: 0, save: 0}}\n wis: {{score: 8, mod: -1, save: -1}}\n cha: {{score: 8, mod: -1, save: -1}}\n---\n{goblin_body}" + ), + ); + + layer.write(AMULET_SOURCE, AMULET_SOURCE_TEXT); + layer.write( + "magic-items/amulet-of-health.md", + &format!( + "---\nname: Amulet of Health\ntype: magic-item\nsource: {AMULET_SOURCE}\ncategory: Wondrous Item\nrarity: rare\nattunement: true\n---\n{AMULET_SOURCE_TEXT}{AMULET_FIELD_LINES}" + ), + ); + + layer.write(BARD_SOURCE, BARD_BODY); + layer.write( + "classes/bard.md", + &format!("---\nname: Bard\ntype: class\nsource: {BARD_SOURCE}\n---\n{BARD_BODY}"), + ); + + layer.write(ALERT_SOURCE, ALERT_BODY); + layer.write( + "feats/alert.md", + &format!("---\nname: Alert\ntype: feat\nsource: {ALERT_SOURCE}\n---\n{ALERT_BODY}"), + ); + + layer.write(CORE_SOURCE, CORE_BODY); + layer.write( + "core/playing-the-game.md", + &format!( + "---\nname: Playing the Game\ntype: core\nsource: {CORE_SOURCE}\n---\n{CORE_BODY}" + ), + ); + + layer.write( + "sources/dnd.srd.5.2.1/Legal.md", + &format!("# Legal Information\n\n{ATTRIBUTION}\n\nOther legal text.\n"), + ); + layer.write( + "README.md", + &format!("# SRD 5.2\n\n{ATTRIBUTION}\n\nVendored from {SOURCE_REPO_URL}.\n"), + ); + layer.write( + "meta.yaml", + "version: 5.2.1\nsources:\n - path: sources/SRD_CC_v5.2.1.pdf\n sha256: abc\n - path: sources/dnd.srd.5.2.1/\n commit: deadbeef\n", + ); + + layer + } + + pub fn path(&self, relative: &str) -> PathBuf { + self.root.join(relative) + } + + pub fn write(&self, relative: &str, contents: &str) { + write_file(&self.path(relative), contents); + } + + pub fn read(&self, relative: &str) -> String { + fs::read_to_string(self.path(relative)).unwrap() + } + + pub fn remove(&self, relative: &str) { + fs::remove_file(self.path(relative)).unwrap(); + } + + /// Replaces the first occurrence of `from` with `to` in the file at + /// `relative`, for tests that mutate one known detail of a fixture. + pub fn replace(&self, relative: &str, from: &str, to: &str) { + let contents = self.read(relative); + assert!( + contents.contains(from), + "fixture {relative} does not contain {from:?} to replace" + ); + self.write(relative, &contents.replacen(from, to, 1)); + } +} diff --git a/src/srd/verify/integration.rs b/src/srd/verify/integration.rs new file mode 100644 index 0000000..dfadf4b --- /dev/null +++ b/src/srd/verify/integration.rs @@ -0,0 +1,229 @@ +//! End-to-end tests of `verify()` against the synthetic fixture layer: +//! one test per kind of mistake the brief calls out, each applied as a +//! single, targeted mutation to an otherwise-valid layer. +//! +//! The fixture layer is intentionally tiny (one entry per kind), so it +//! never meets the real count floors (300 spells, and so on); every test +//! here sees those floor-breach failures alongside whatever the test +//! itself is checking. `extra_failures` filters them out so each test can +//! assert on the failure it actually cares about. + +#![cfg(test)] + +use super::fixtures::{FIREBALL_SOURCE, Layer}; +use super::{Failure, verify}; + +fn extra_failures(layer: &Layer) -> Vec { + verify(&layer.root) + .failures + .into_iter() + .filter(|failure| { + !failure.message.contains("needs at least") + && !failure.message.contains("needs exactly") + }) + .collect() +} + +#[test] +fn a_valid_layer_only_reports_count_floor_breaches() { + let layer = Layer::valid(); + + let report = verify(&layer.root); + + assert_eq!(extra_failures(&layer), vec![]); + assert_eq!(report.failures.len(), 6); + assert!(report.failures.iter().all(|f| f.message.contains("needs"))); +} + +#[test] +fn a_dropped_body_word_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "A bright streak flashes from you.\n", + "A bright flashes from you.\n", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("streak")); +} + +#[test] +fn an_added_body_word_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "A bright streak flashes", + "A bright red streak flashes", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("red")); +} + +#[test] +fn a_changed_body_number_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "**Range:** 150 feet", + "**Range:** 200 feet", + ); + + let failures = extra_failures(&layer); + + assert!( + failures + .iter() + .any(|f| f.message.contains("150") && f.message.contains("200")) + ); +} + +#[test] +fn a_bad_school_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "school: evocation", + "school: astrology", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("school")); +} + +#[test] +fn an_unknown_frontmatter_key_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "level: 3", + "level: 3\ncomponent_pouch: true", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!( + failures[0] + .message + .contains("unknown frontmatter key 'component_pouch'") + ); +} + +#[test] +fn a_slug_mismatch_is_reported() { + let layer = Layer::valid(); + let contents = layer.read("spells/fireball.md"); + layer.remove("spells/fireball.md"); + layer.write("spells/not-fireball.md", &contents); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("does not match the slug")); +} + +#[test] +fn a_slug_collision_is_reported() { + let layer = Layer::valid(); + let light_feat_source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Light.md"; + let light_core_source = "sources/dnd.srd.5.2.1/01_Playing_The_Game/Light.md"; + layer.write(light_feat_source, "# Light\n\nA cheap source of light.\n"); + layer.write( + "feats/light.md", + &format!("---\nname: Light\ntype: feat\nsource: {light_feat_source}\n---\n# Light\n\nA cheap source of light.\n"), + ); + layer.write( + light_core_source, + "# Light\n\nRules for light and vision.\n", + ); + layer.write( + "core/light.md", + &format!("---\nname: Light\ntype: core\nsource: {light_core_source}\n---\n# Light\n\nRules for light and vision.\n"), + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 2); + assert!(failures.iter().all(|f| f.message.contains("collides with"))); +} + +#[test] +fn a_type_directory_mismatch_is_reported() { + let layer = Layer::valid(); + layer.replace("spells/fireball.md", "type: spell", "type: monster"); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("does not match its directory")); +} + +#[test] +fn a_missing_source_is_reported() { + let layer = Layer::valid(); + layer.remove(FIREBALL_SOURCE); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("does not exist")); +} + +#[test] +fn a_double_claimed_each_file_is_reported() { + let layer = Layer::valid(); + let goblin = layer.read("monsters/goblin-warrior.md"); + layer.write( + "monsters/goblin-warrior-2.md", + &goblin.replacen("Goblin Warrior", "Goblin Warrior 2", 1), + ); + + let failures = extra_failures(&layer); + + assert!( + failures + .iter() + .any(|f| f.message.contains("2 corpus entries") && f.message.contains("exactly one")) + ); +} + +#[test] +fn a_frontmatter_body_field_drift_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "casting_time: Action", + "casting_time: Bonus Action", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("Bonus Action")); +} + +#[test] +fn a_stat_region_word_missing_from_frontmatter_is_reported() { + let layer = Layer::valid(); + layer.replace( + "monsters/goblin-warrior.md", + "speed: 30 ft.", + "speed: 35 ft.", + ); + + let failures = extra_failures(&layer); + + assert!( + failures + .iter() + .any(|f| f.message.contains("stat block has words")) + ); +} diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs index 1b4340f..d55364d 100644 --- a/src/srd/verify/mod.rs +++ b/src/srd/verify/mod.rs @@ -3,24 +3,28 @@ //! corpus format contract: whatever it accepts is legal, whatever it //! rejects is not. //! -//! This adds the fidelity layer to the schema layer: every corpus file's -//! body matches its vendored source as a normalized word stream (or, for -//! monsters, matches outside the stat-block region, with the region -//! checked against frontmatter instead), and spells and magic items keep -//! their frontmatter and body field lines from drifting apart. Coverage -//! of the vendored tree and corpus shape (entry-count floors, README, -//! meta.yaml) are a separate layer built on top of this one. - +//! This adds the coverage and shape layer to the schema and fidelity +//! layers: every vendored file under a `*_Each/` directory is claimed by +//! exactly one corpus entry, no other vendored file is claimed more than +//! once, every kind meets its entry-count floor, and the layer root's +//! README.md and meta.yaml carry what CC-BY-4.0 and the pinned sources +//! require. This is the complete verifier: `verify` is what both the CLI +//! and task 6's corpus build run. + +mod coverage; mod diff; mod fidelity; mod field_lines; #[cfg(test)] mod fixtures; +#[cfg(test)] +mod integration; mod kind; mod layer; mod magic_item; mod monster; mod normalize; +mod readme_meta; mod schema; mod slug; mod spell; @@ -80,15 +84,15 @@ impl Report { } } -/// Verifies the corpus layer rooted at `layer_root`'s schema and fidelity: -/// every file parses, its frontmatter matches its kind, its slug is -/// well-formed and collision-free, and its body matches its vendored -/// source. Returns every failure found rather than stopping at the first. +/// Verifies the corpus layer rooted at `layer_root` against every layer of +/// the format contract: schema, fidelity, coverage, and shape. Returns +/// every failure found rather than stopping at the first. pub fn verify(layer_root: &Path) -> Report { let (files, mut failures) = layer::discover(layer_root); let mut counts: HashMap = HashMap::new(); let mut names: Vec<(PathBuf, String)> = Vec::new(); + let mut claims: Vec = Vec::new(); for file in &files { *counts.entry(file.kind).or_insert(0) += 1; @@ -97,6 +101,7 @@ pub fn verify(layer_root: &Path) -> Report { continue; }; names.push((file.path.clone(), universal.name.clone())); + claims.push(universal.source.clone()); let spell_fields = (file.kind == Kind::Spell) .then(|| spell::validate(file, &mut failures)) @@ -122,6 +127,10 @@ pub fn verify(layer_root: &Path) -> Report { } failures.extend(schema::check_slug_collisions(&names)); + failures.extend(coverage::check_claims(layer_root, &claims)); + coverage::check_counts(&counts, &mut failures); + readme_meta::check_readme(layer_root, &mut failures); + readme_meta::check_meta(layer_root, &mut failures); Report { failures, counts } } @@ -151,13 +160,24 @@ mod tests { } #[test] - fn verify_on_an_empty_layer_finds_nothing() { + fn verify_on_an_empty_layer_finds_no_entries_but_reports_shape_failures() { let layer_root = unique_temp_dir(); let report = verify(&layer_root); - assert_eq!(report.failures, vec![]); assert!(report.counts.is_empty()); + assert!( + report + .failures + .iter() + .any(|f| f.message.contains("needs at least")) + ); + assert!( + report + .failures + .iter() + .any(|f| f.path == Path::new("README.md")) + ); } #[test] @@ -242,7 +262,15 @@ mod tests { let report = verify(&layer_root); - assert_eq!(report.failures, vec![]); + // The layer is otherwise empty, so shape checks (count floors, + // README, meta.yaml) still fail; this only confirms the feat + // entry itself adds no schema or fidelity failure of its own. + assert!( + !report + .failures + .iter() + .any(|f| f.path == Path::new("feats/alert.md")) + ); } #[test] diff --git a/src/srd/verify/readme_meta.rs b/src/srd/verify/readme_meta.rs new file mode 100644 index 0000000..ade69a0 --- /dev/null +++ b/src/srd/verify/readme_meta.rs @@ -0,0 +1,355 @@ +//! Checks the layer root's `README.md` and `meta.yaml`: the attribution +//! CC-BY-4.0 requires, a link back to the vendored source repo, and a +//! `meta.yaml` that actually records what it's pinned to. + +use std::fs; +use std::path::Path; + +use serde_yaml_ng::Value; + +use super::Failure; + +const README_PATH: &str = "README.md"; +const META_PATH: &str = "meta.yaml"; +const LEGAL_PATH: &str = "sources/dnd.srd.5.2.1/Legal.md"; +const ATTRIBUTION_ANCHOR: &str = "This work includes material from the System Reference Document"; +const SOURCE_REPO_URL: &str = "https://github.com/oldmanumby/dnd.srd.5.2.1"; + +/// Checks `README.md` for the exact WotC attribution paragraph (read live +/// from the vendored `Legal.md`, not hardcoded here, since a test layer's +/// `Legal.md` is a fixture rather than the real one) and a link to the +/// vendored source repo. +pub fn check_readme(layer_root: &Path, failures: &mut Vec) { + let readme_path = layer_root.join(README_PATH); + let Ok(readme) = fs::read_to_string(&readme_path) else { + failures.push(Failure::new( + README_PATH, + "does not exist or cannot be read", + )); + return; + }; + + match attribution_paragraph(layer_root) { + Ok(attribution) => { + if !normalize_whitespace(&readme).contains(&normalize_whitespace(&attribution)) { + failures.push(Failure::new( + README_PATH, + "does not contain the WotC attribution paragraph from Legal.md", + )); + } + } + Err(message) => failures.push(Failure::new(README_PATH, message)), + } + + if !readme.contains(SOURCE_REPO_URL) { + failures.push(Failure::new( + README_PATH, + format!("does not link to the vendored source repo ({SOURCE_REPO_URL})"), + )); + } +} + +/// The paragraph in the vendored `Legal.md` that opens with the WotC +/// attribution anchor sentence, blank-line-delimited. +fn attribution_paragraph(layer_root: &Path) -> Result { + let legal_path = layer_root.join(LEGAL_PATH); + let legal = fs::read_to_string(&legal_path) + .map_err(|error| format!("cannot read {LEGAL_PATH}: {error}"))?; + legal + .split("\n\n") + .find(|paragraph| paragraph.trim_start().starts_with(ATTRIBUTION_ANCHOR)) + .map(str::to_string) + .ok_or_else(|| format!("{LEGAL_PATH} has no attribution paragraph to check against")) +} + +fn normalize_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// Checks `meta.yaml` parses and records a version and both source pins +/// (the PDF's `sha256` and the markdown source's `commit`). +pub fn check_meta(layer_root: &Path, failures: &mut Vec) { + let meta_path = layer_root.join(META_PATH); + let Ok(contents) = fs::read_to_string(&meta_path) else { + failures.push(Failure::new(META_PATH, "does not exist or cannot be read")); + return; + }; + let value: Value = match serde_yaml_ng::from_str(&contents) { + Ok(value) => value, + Err(error) => { + failures.push(Failure::new(META_PATH, format!("invalid YAML: {error}"))); + return; + } + }; + let Some(mapping) = value.as_mapping() else { + failures.push(Failure::new(META_PATH, "must be a YAML mapping")); + return; + }; + + if !matches!(mapping.get("version"), Some(Value::String(version)) if !version.trim().is_empty()) + { + failures.push(Failure::new(META_PATH, "missing a non-empty 'version'")); + } + + let Some(Value::Sequence(sources)) = mapping.get("sources") else { + failures.push(Failure::new(META_PATH, "missing a 'sources' list")); + return; + }; + let has_pdf_pin = sources + .iter() + .any(|source| source.as_mapping().and_then(|m| m.get("sha256")).is_some()); + let has_text_pin = sources + .iter() + .any(|source| source.as_mapping().and_then(|m| m.get("commit")).is_some()); + if !has_pdf_pin { + failures.push(Failure::new( + META_PATH, + "no source pins the PDF with a 'sha256'", + )); + } + if !has_text_pin { + failures.push(Failure::new( + META_PATH, + "no source pins the markdown text with a 'commit'", + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; + + const ATTRIBUTION: &str = "This work includes material from the System Reference Document 5.2.1 (\"SRD 5.2.1\") by Wizards of the Coast LLC, available at https://www.dndbeyond.com/srd."; + + fn layer_with_legal(attribution: &str) -> std::path::PathBuf { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(LEGAL_PATH), + &format!("# Legal Information\n\n{attribution}\n\nOther text.\n"), + ); + layer_root + } + + #[test] + fn check_readme_passes_with_attribution_and_link() { + let layer_root = layer_with_legal(ATTRIBUTION); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\n{ATTRIBUTION}\n\nSee {SOURCE_REPO_URL}.\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_readme_tolerates_rewrapped_attribution_whitespace() { + let layer_root = layer_with_legal(ATTRIBUTION); + let rewrapped = ATTRIBUTION.replace(' ', "\n"); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\n{rewrapped}\n\nSee {SOURCE_REPO_URL}.\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_readme_reports_a_missing_file() { + let layer_root = unique_temp_dir(); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not exist")) + ); + } + + #[test] + fn check_readme_reports_a_missing_attribution() { + let layer_root = layer_with_legal(ATTRIBUTION); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\nSee {SOURCE_REPO_URL}.\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("attribution paragraph")) + ); + } + + #[test] + fn check_readme_reports_a_missing_source_link() { + let layer_root = layer_with_legal(ATTRIBUTION); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\n{ATTRIBUTION}\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("does not link"))); + } + + #[test] + fn check_readme_reports_an_unreadable_legal_file() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(README_PATH), "# SRD\n"); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("cannot read"))); + } + + #[test] + fn check_readme_reports_a_legal_file_without_an_attribution_paragraph() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(LEGAL_PATH), + "# Legal\n\nNo attribution here.\n", + ); + write_file(&layer_root.join(README_PATH), "# SRD\n"); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("no attribution paragraph")) + ); + } + + fn valid_meta_yaml() -> &'static str { + "version: 5.2.1\n\ + sources:\n\ + \x20 - path: sources/SRD_CC_v5.2.1.pdf\n\ + \x20 sha256: abc\n\ + \x20 - path: sources/dnd.srd.5.2.1/\n\ + \x20 commit: deadbeef" + } + + #[test] + fn check_meta_passes_for_a_well_formed_file() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), valid_meta_yaml()); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_meta_reports_a_missing_file() { + let layer_root = unique_temp_dir(); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not exist")) + ); + } + + #[test] + fn check_meta_reports_invalid_yaml() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), "not: [valid"); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("invalid YAML"))); + } + + #[test] + fn check_meta_reports_a_non_mapping_document() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), "just a string"); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("must be a YAML mapping")) + ); + } + + #[test] + fn check_meta_reports_a_missing_version() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(META_PATH), + "sources:\n - path: a\n sha256: abc\n - path: b\n commit: deadbeef", + ); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("version"))); + } + + #[test] + fn check_meta_reports_a_missing_sources_list() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), "version: 5.2.1"); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("'sources' list")) + ); + } + + #[test] + fn check_meta_reports_a_missing_pdf_pin() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(META_PATH), + "version: 5.2.1\nsources:\n - path: sources/dnd.srd.5.2.1/\n commit: deadbeef", + ); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("sha256"))); + } + + #[test] + fn check_meta_reports_a_missing_text_pin() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(META_PATH), + "version: 5.2.1\nsources:\n - path: sources/SRD_CC_v5.2.1.pdf\n sha256: abc", + ); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("commit"))); + } +} -- 2.51.2