diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index fdf47a2..f6f074f 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -8,6 +8,7 @@ on: - 'internal/**' - 'tests/**' - 'benches/**' + - 'cli/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/core.yml' @@ -19,6 +20,7 @@ on: - 'internal/**' - 'tests/**' - 'benches/**' + - 'cli/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/core.yml' @@ -59,3 +61,17 @@ jobs: - name: Build library run: cargo build + + - name: Run hygiene check (cli) + run: cargo run -p cougr-cli -- check --path . + + - name: Run verified check (canonical examples) + run: cargo run -p cougr-cli -- check --path . --verified --canonical-only --output verified.json + + - name: Upload verified badge report + if: always() + uses: actions/upload-artifact@v4 + with: + name: verified-badge-report + path: verified.json + retention-days: 30 diff --git a/Cargo.lock b/Cargo.lock index a760b54..7343307 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -73,6 +82,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.3.2" @@ -396,8 +411,12 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "cougr-cli" version = "1.1.0" dependencies = [ + "anyhow", "clap", + "regex", "rust-embed", + "serde", + "serde_json", "tempfile", ] @@ -1316,6 +1335,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a9be1f6..2bb3354 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -21,6 +21,10 @@ clap = { version = "4.6", features = ["derive"] } # `debug-embed` keeps the templates compiled into the binary in debug builds # too, so `cougr new` behaves identically for contributors and end users. rust-embed = { version = "8.12", features = ["debug-embed"] } +regex = "1" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [dev-dependencies] tempfile = "3" diff --git a/cli/src/check.rs b/cli/src/check.rs new file mode 100644 index 0000000..006c434 --- /dev/null +++ b/cli/src/check.rs @@ -0,0 +1,280 @@ +//! Hygiene check logic for `cougr check`. +//! +//! Mirrors the checks in `scripts/verify_hygiene.sh` and +//! `scripts/enforce_hygiene.sh`, ported to Rust for cross-platform +//! operation with no external script dependencies (shells out only for +//! `git ls-files` and `cargo metadata`, which are required regardless). + +use crate::context::{example_dir, CheckContext}; +use anyhow::{Context, Result}; +use regex::Regex; +use std::fs; +use std::path::Path; +use std::process::{exit, Command}; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Run hygiene checks. +pub fn run(ctx: &CheckContext) -> Result<()> { + println!("=== Cougr Hygiene Check ==="); + println!("Repository root : {}", ctx.repo_root.display()); + if ctx.examples.len() == 1 { + println!("Checking example: {}", ctx.examples[0].name); + } else { + println!("Checking : {} examples", ctx.examples.len()); + } + println!(); + + let mut failures: Vec = Vec::new(); + + // Root-level checks always run — tracked artifacts and root .gitignore + // issues would fail CI regardless of which example is being checked. + + // Check 1 — root .gitignore must NOT ignore Cargo.lock + check_root_gitignore_cargo_lock(&ctx.repo_root, &mut failures); + + // Check 2 & 3 — no tracked build artifacts (git ls-files) + check_tracked_artifacts(&ctx.repo_root, &mut failures); + + // Per-example checks + for ex in &ctx.examples { + let dir = example_dir(&ctx.repo_root, &ex.name); + + // Check 4 — no hardcoded contract IDs in README + check_readme_contract_ids(&dir, &ex.name, &mut failures); + + // Check 5 — .gitignore exists and has target/ + check_example_gitignore(&dir, &ex.name, &mut failures); + + // Check 6 — .gitignore must NOT ignore Cargo.lock + check_example_gitignore_cargo_lock(&dir, &ex.name, &mut failures); + + // Check 7 — Cargo.toml has description + check_cargo_toml_description(&dir, &ex.name, &mut failures); + + // Check 8 — cargo metadata --no-deps + check_cargo_metadata(&dir, &ex.name, &mut failures); + } + + // Report + if failures.is_empty() { + println!("=== ALL CHECKS PASSED ==="); + Ok(()) + } else { + eprintln!(); + eprintln!("=== {} CHECK(S) FAILED ===", failures.len()); + for f in &failures { + eprintln!(" FAIL: {}", f); + } + exit(1); + } +} + +// --------------------------------------------------------------------------- +// Individual checks +// --------------------------------------------------------------------------- + +/// Check 1: root `.gitignore` must NOT ignore `Cargo.lock`. +fn check_root_gitignore_cargo_lock(repo_root: &Path, failures: &mut Vec) { + let gitignore = repo_root.join(".gitignore"); + match fs::read_to_string(&gitignore) { + Ok(contents) => { + let re = Regex::new(r"(?m)^Cargo\.lock$").unwrap(); + if re.is_match(&contents) { + failures.push( + "root .gitignore must not ignore Cargo.lock (examples are applications)" + .to_string(), + ); + } + } + Err(e) => { + failures.push(format!("cannot read root .gitignore: {}", e)); + } + } +} + +/// Checks 2 & 3: no tracked `target/` directories or `.wasm` files in examples/. +fn check_tracked_artifacts(repo_root: &Path, failures: &mut Vec) { + for (pattern, label) in &[ + ("examples/**/target/**", "target/"), + ("examples/**/*.wasm", ".wasm"), + ] { + match run_git_ls_files(repo_root, pattern) { + Ok(output) => { + let trimmed = output.trim(); + if !trimmed.is_empty() { + failures.push(format!( + "tracked {} artifacts found:\n{}", + label, + indent_lines(trimmed, " ") + )); + } + } + Err(e) => { + failures.push(format!( + "failed to check for tracked {} artifacts: {}", + label, e + )); + } + } + } +} + +/// Check 4: no hardcoded contract IDs (`C[A-Z2-7]{55}`) in example READMEs. +pub fn check_readme_contract_ids(example_dir: &Path, name: &str, failures: &mut Vec) { + let readme = example_dir.join("README.md"); + if !readme.is_file() { + return; + } + + match fs::read_to_string(&readme) { + Ok(contents) => { + let re = Regex::new(r"C[A-Z2-7]{55}").unwrap(); + if re.is_match(&contents) { + failures.push(format!( + "hardcoded contract ID(s) in examples/{}/README.md", + name + )); + } + } + Err(e) => { + failures.push(format!("cannot read examples/{}/README.md: {}", name, e)); + } + } +} + +/// Check 5: each example must have a `.gitignore` containing `target/`. +pub fn check_example_gitignore(example_dir: &Path, name: &str, failures: &mut Vec) { + let gitignore = example_dir.join(".gitignore"); + if !gitignore.is_file() { + failures.push(format!("examples/{}: missing .gitignore", name)); + return; + } + + match fs::read_to_string(&gitignore) { + Ok(contents) => { + let re = Regex::new(r"(?m)^target/").unwrap(); + if !re.is_match(&contents) { + failures.push(format!( + "examples/{}: .gitignore does not ignore target/", + name + )); + } + } + Err(e) => { + failures.push(format!("examples/{}: cannot read .gitignore: {}", name, e)); + } + } +} + +/// Check 6: example `.gitignore` must NOT ignore `Cargo.lock`. +fn check_example_gitignore_cargo_lock(example_dir: &Path, name: &str, failures: &mut Vec) { + let gitignore = example_dir.join(".gitignore"); + if !gitignore.is_file() { + return; // already flagged by check_example_gitignore + } + + match fs::read_to_string(&gitignore) { + Ok(contents) => { + let re = Regex::new(r"(?m)^Cargo\.lock$").unwrap(); + if re.is_match(&contents) { + failures.push(format!( + "examples/{}: .gitignore must not ignore Cargo.lock (examples are applications)", + name, + )); + } + } + Err(e) => { + failures.push(format!("examples/{}: cannot read .gitignore: {}", name, e)); + } + } +} + +/// Check 7: `Cargo.toml` must have a non-empty `description` field. +pub fn check_cargo_toml_description(example_dir: &Path, name: &str, failures: &mut Vec) { + let cargo_toml = example_dir.join("Cargo.toml"); + if !cargo_toml.is_file() { + failures.push(format!("examples/{}: missing Cargo.toml", name)); + return; + } + + match fs::read_to_string(&cargo_toml) { + Ok(contents) => { + let re = Regex::new(r#"(?m)^description\s*=\s*"([^"]*)""#).unwrap(); + if let Some(caps) = re.captures(&contents) { + let desc = &caps[1]; + if desc.trim().is_empty() { + failures.push(format!( + "examples/{}: Cargo.toml description field is empty", + name + )); + } + } else { + failures.push(format!( + "examples/{}: Cargo.toml is missing a description field", + name + )); + } + } + Err(e) => { + failures.push(format!("examples/{}: cannot read Cargo.toml: {}", name, e)); + } + } +} + +/// Check 8: `cargo metadata --no-deps` must succeed. +pub fn check_cargo_metadata(example_dir: &Path, name: &str, failures: &mut Vec) { + let output = Command::new("cargo") + .args(["metadata", "--no-deps", "--format-version", "1"]) + .current_dir(example_dir) + .output(); + + match output { + Ok(out) => { + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + failures.push(format!( + "examples/{}: cargo metadata --no-deps FAILED:\n{}", + name, + indent_lines(stderr.trim(), " ") + )); + } + } + Err(e) => { + failures.push(format!( + "examples/{}: cannot run cargo metadata: {}", + name, e + )); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Run `git ls-files ` from `cwd` and return stdout. +pub fn run_git_ls_files(cwd: &Path, pattern: &str) -> Result { + let output = Command::new("git") + .args(["ls-files", pattern]) + .current_dir(cwd) + .output() + .context("failed to run git ls-files")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git ls-files failed: {}", stderr.trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Indent every line by `prefix`. +pub fn indent_lines(s: &str, prefix: &str) -> String { + s.lines() + .map(|l| format!("{}{}", prefix, l)) + .collect::>() + .join("\n") +} diff --git a/cli/src/context.rs b/cli/src/context.rs new file mode 100644 index 0000000..59a2ca3 --- /dev/null +++ b/cli/src/context.rs @@ -0,0 +1,156 @@ +//! Shared context resolution for `cougr check` and `cougr check --verified`. +//! +//! Determines the repository root and which examples to check, supporting +//! auto-detection from cwd, explicit `--path`, and `--example` flags. + +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Metadata for a single example discovered under `examples/`. +#[derive(Clone, Debug)] +pub struct Example { + pub name: String, +} + +/// The resolved context for a check run. +pub struct CheckContext { + pub repo_root: PathBuf, + pub examples: Vec, +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/// Determine the repo root and which examples to check. +pub fn resolve( + cwd: &Path, + explicit_root: Option<&str>, + single_example: Option<&str>, +) -> Result { + // 1. Determine repo root + let repo_root = if let Some(r) = explicit_root { + let p = PathBuf::from(r); + p.canonicalize() + .context(format!("explicit --path does not exist: {}", r))? + } else { + find_repo_root(cwd)? + }; + + // 2. Determine examples to check + let examples = if let Some(name) = single_example { + let example_dir = repo_root.join("examples").join(name); + if !example_dir.is_dir() { + anyhow::bail!("example '{}' not found at {}", name, example_dir.display()); + } + vec![Example { + name: name.to_string(), + }] + } else if explicit_root.is_none() && is_inside_example_dir(cwd) { + // Auto-detect: if cwd is inside examples//, just check that one. + // Only auto-detect when no explicit --path was given. + let name = cwd + .file_name() + .and_then(|n| n.to_str()) + .map(String::from) + .context("cannot determine example name from current directory")?; + vec![Example { name }] + } else { + discover_examples(&repo_root)? + }; + + Ok(CheckContext { + repo_root, + examples, + }) +} + +/// Return the absolute path to an example's directory. +pub fn example_dir(repo_root: &Path, name: &str) -> PathBuf { + repo_root.join("examples").join(name) +} + +/// Return the 10 currently-canonical example names per EXAMPLE_STANDARD.md §7. +pub fn canonical_example_names() -> &'static [&'static str] { + &[ + "spawn_and_move", + "tic_tac_toe", + "session_arena", + "hidden_hand", + "fog_explorer", + "dice_duel", + "blind_auction", + "snake", + "battleship", + "guild_arena", + ] +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Walk upward from `cwd` looking for a directory that contains both +/// `Cargo.toml` and an `examples/` subdirectory (the repo root). +fn find_repo_root(cwd: &Path) -> Result { + let mut current = cwd.to_path_buf(); + loop { + if current.join("Cargo.toml").is_file() && current.join("examples").is_dir() { + return Ok(current); + } + if !current.pop() { + anyhow::bail!( + "could not find repo root (no Cargo.toml + examples/ found above {:?}). \ + Use --path to specify the repo root explicitly.", + cwd + ); + } + } +} + +/// True when `cwd` is inside an `examples//` directory. +fn is_inside_example_dir(cwd: &Path) -> bool { + if let Some(parent) = cwd.parent() { + parent.file_name().map(|n| n == "examples").unwrap_or(false) + } else { + false + } +} + +/// Discover all example directories under `examples/` that contain a Cargo.toml. +fn discover_examples(repo_root: &Path) -> Result> { + let examples_dir = repo_root.join("examples"); + let mut examples = Vec::new(); + + if !examples_dir.is_dir() { + anyhow::bail!( + "examples/ directory not found at {}", + examples_dir.display() + ); + } + + for entry in fs::read_dir(&examples_dir).context("cannot read examples/ directory")? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let dir_name = entry.file_name(); + if let Some(name) = dir_name.to_str() { + if entry.path().join("Cargo.toml").is_file() { + examples.push(Example { + name: name.to_string(), + }); + } + } + } + } + + if examples.is_empty() { + anyhow::bail!("no examples found under {}", examples_dir.display()); + } + + Ok(examples) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 0e1e8fb..c52c9f3 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,16 +1,24 @@ //! `cougr` — command-line tooling for the Cougr ECS framework. //! -//! Currently exposes a single command, [`cougr new`](commands::new), which -//! scaffolds a Soroban game contract wired to `cougr-core` from one of four -//! embedded templates. +//! Currently exposes two commands: +//! +//! - [`cougr new`] — scaffold a Soroban game contract wired to `cougr-core` from +//! one of four embedded templates. +//! - [`cougr check`] — run repository hygiene checks against `examples/`, or +//! with `--verified`, the full canonical-quality checklist for the +//! "Cougr Verified" badge. +mod check; mod commands; +mod context; mod error; mod name; mod template; +mod verify; use std::process::ExitCode; +use anyhow::Result; use clap::{Parser, Subcommand}; use crate::error::CliError; @@ -30,6 +38,44 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Run repository hygiene checks on examples/. + /// + /// Auto-detects whether run from repo root (checks all examples) + /// or an individual example directory (checks one example). + /// + /// With --verified, runs the full canonical-quality checklist from + /// EXAMPLE_STANDARD.md and produces pass/fail data suitable for the + /// "Cougr Verified" badge in the showcase. + Check { + /// Explicit path to the repository root. + #[arg(short, long)] + path: Option, + + /// Check a single example by name (e.g. "snake"). Requires --path or repo-root cwd. + #[arg(short, long)] + example: Option, + + /// Run the full canonical-quality checklist for the "Cougr Verified" badge. + #[arg(long)] + verified: bool, + + /// Output results as JSON (for machine consumption by the showcase generator). + #[arg(long)] + json: bool, + + /// Also run heavy build-validation checks (cargo test, stellar contract build). + #[arg(long)] + full: bool, + + /// Only check the 10 canonical examples (per EXAMPLE_STANDARD.md §7). + #[arg(long)] + canonical_only: bool, + + /// Write verified badge results to a JSON file (for showcase/gallery consumption). + #[arg(short = 'o', long)] + output: Option, + }, + /// Create a new Cougr game contract crate. New { /// Name of the project. Becomes the crate name and the directory name. @@ -48,26 +94,53 @@ enum Command { fn main() -> ExitCode { let cli = Cli::parse(); - let result = match cli.command { + let result: Result<()> = match cli.command { Command::New { name, template, path, - } => commands::new::run(&name, template, path.as_deref()), + } => commands::new::run(&name, template, path.as_deref()).map_err(anyhow::Error::from), + + Command::Check { + path, + example, + verified, + json, + full, + canonical_only, + output, + } => (|| -> Result<()> { + let cwd = std::env::current_dir()?; + let ctx = context::resolve(&cwd, path.as_deref(), example.as_deref())?; + + if verified { + verify::run( + &ctx, + json || output.is_some(), + full, + canonical_only, + output.as_deref(), + )?; + } else { + check::run(&ctx)?; + } + Ok(()) + })(), }; match result { Ok(()) => ExitCode::SUCCESS, Err(err) => { - report(&err); + eprintln!("error: {err}"); + + // Show hint for known error types + if let Some(cli_err) = err.downcast_ref::() { + if let Some(hint) = cli_err.hint() { + eprintln!(" help: {hint}"); + } + } + ExitCode::FAILURE } } } - -fn report(err: &CliError) { - eprintln!("error: {err}"); - if let Some(hint) = err.hint() { - eprintln!(" help: {hint}"); - } -} diff --git a/cli/src/verify.rs b/cli/src/verify.rs new file mode 100644 index 0000000..3989452 --- /dev/null +++ b/cli/src/verify.rs @@ -0,0 +1,823 @@ +//! Canonical-quality verification for the "Cougr Verified" badge. +//! +//! Evaluates an example against every criterion in EXAMPLE_STANDARD.md's +//! canonical-vs-transitional quality checklist (§Quality Checklist). +//! +//! The base hygiene checks from `check.rs` are a subset; this module adds +//! the full canonical-quality assessment required for the verified badge. +//! +//! Part of #258: produces structured pass/fail data that the showcase/ +//! example gallery generator consumes via `--output`, CI uploads the +//! resulting `verified.json` as a build artifact. + +use crate::check::{ + check_cargo_metadata, check_cargo_toml_description, check_example_gitignore, + check_readme_contract_ids, indent_lines, run_git_ls_files, +}; +use crate::context::{canonical_example_names, example_dir, CheckContext}; +use anyhow::{Context, Result}; +use regex::Regex; +use serde::Serialize; +use std::fs; +use std::path::Path; +use std::process::{exit, Command}; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Run verified-quality checks and return structured results. +/// +/// When `output_path` is provided, writes the JSON report to that file +/// (for consumption by the showcase/gallery generator). The JSON is also +/// printed to stdout when `json` is true or when writing to a file. +pub fn run( + ctx: &CheckContext, + json: bool, + run_build: bool, + canonical_only: bool, + output_path: Option<&str>, +) -> Result<()> { + // Filter to canonical examples if requested + let target_examples: Vec<&crate::context::Example> = if canonical_only { + let canonicals = canonical_example_names(); + ctx.examples + .iter() + .filter(|ex| canonicals.contains(&ex.name.as_str())) + .collect() + } else { + ctx.examples.iter().collect() + }; + + if target_examples.is_empty() { + anyhow::bail!("no examples to check (canonical-only filter may exclude all)"); + } + + // Root-level checks run once (global, not per-example) + let root_criteria = check_root_hygiene(&ctx.repo_root); + + let mut results: Vec = Vec::new(); + + for ex in &target_examples { + let dir = example_dir(&ctx.repo_root, &ex.name); + let mut criteria: Vec = Vec::new(); + + // Root-level hygiene (shared across all examples) + criteria.extend(root_criteria.clone()); + + // Per-example hygiene + check_hygiene(&dir, &ex.name, &mut criteria); + + // Dependencies + check_dependencies(&dir, &mut criteria); + + // Module structure + check_module_structure(&dir, &mut criteria); + + // README completeness + check_readme_sections(&dir, &mut criteria); + + // Test coverage + check_test_coverage(&dir, &mut criteria); + + // Classification + check_classification(&dir, &mut criteria); + + // Cargo.lock committed + check_cargo_lock_committed(&ctx.repo_root, &ex.name, &mut criteria); + + // Build validation (heavy, optional) + if run_build { + check_cargo_test(&dir, &mut criteria); + check_stellar_build(&dir, &mut criteria); + } + + let all_pass = criteria.iter().all(|c| c.pass); + let unmet = if all_pass { + vec![] + } else { + criteria + .iter() + .filter(|c| !c.pass) + .map(|c| c.id.clone()) + .collect() + }; + results.push(ExampleResult { + example: ex.name.clone(), + verified: all_pass, + criteria, + unmet, + }); + } + + // Output: serialize once, then decide print vs file vs both + let json_out: Option = if json || output_path.is_some() { + Some(serde_json::to_string_pretty(&results).context("failed to serialize JSON output")?) + } else { + None + }; + + if let Some(ref out) = json_out { + println!("{}", out); + } + if let Some(path) = output_path { + if let Some(ref out) = json_out { + fs::write(path, out) + .context(format!("failed to write verified badge report to {}", path))?; + eprintln!("Verified badge report written to {}", path); + } + } + if json_out.is_none() { + print_human_summary(&results); + } + + // Exit code + let all_verified = results.iter().all(|r| r.verified); + if !all_verified { + exit(1); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Output types (serializable for JSON) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ExampleResult { + example: String, + verified: bool, + criteria: Vec, + unmet: Vec, +} + +#[derive(Serialize, Clone)] +struct Criterion { + id: String, + label: String, + pass: bool, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, +} + +impl Criterion { + fn new(id: &str, label: &str, pass: bool, detail: Option) -> Self { + Criterion { + id: id.to_string(), + label: label.to_string(), + pass, + detail, + } + } +} + +// --------------------------------------------------------------------------- +// Human-readable output +// --------------------------------------------------------------------------- + +fn print_human_summary(results: &[ExampleResult]) { + println!("=== Cougr Verified Check ==="); + println!(); + + for r in results { + let icon = if r.verified { "✓" } else { "✗" }; + println!(" {} {}", icon, r.example); + + if r.verified { + continue; + } + + for c in &r.criteria { + if !c.pass { + println!(" ✗ {} — {}", c.label, c.detail.as_deref().unwrap_or("")); + } + } + } + + println!(); + let passed = results.iter().filter(|r| r.verified).count(); + let total = results.len(); + println!("=== {}/{} VERIFIED ===", passed, total); + + if passed != total { + println!(); + println!("Unmet criteria per example:"); + for r in results { + if !r.verified { + println!(" {}: {}", r.example, r.unmet.join(", ")); + } + } + } +} + +// --------------------------------------------------------------------------- +// Root-level hygiene (tracked artifacts, root .gitignore) +// --------------------------------------------------------------------------- + +/// Returns root-level criteria (same for every example — global, not per-example). +fn check_root_hygiene(repo_root: &Path) -> Vec { + let mut criteria = Vec::new(); + + // Tracked target/ artifacts + match run_git_ls_files(repo_root, "examples/**/target/**") { + Ok(output) => { + let clean = output.trim().is_empty(); + criteria.push(Criterion::new( + "hygiene_no_target_artifacts", + "Hygiene: no tracked target/ artifacts", + clean, + if clean { + None + } else { + Some("tracked target/ artifacts found in git".into()) + }, + )); + } + Err(e) => { + criteria.push(Criterion::new( + "hygiene_no_target_artifacts", + "Hygiene: no tracked target/ artifacts", + false, + Some(format!("could not check: {}", e)), + )); + } + } + + // Tracked .wasm artifacts + match run_git_ls_files(repo_root, "examples/**/*.wasm") { + Ok(output) => { + let clean = output.trim().is_empty(); + criteria.push(Criterion::new( + "hygiene_no_wasm_artifacts", + "Hygiene: no tracked .wasm artifacts", + clean, + if clean { + None + } else { + Some("tracked .wasm artifacts found in git".into()) + }, + )); + } + Err(e) => { + criteria.push(Criterion::new( + "hygiene_no_wasm_artifacts", + "Hygiene: no tracked .wasm artifacts", + false, + Some(format!("could not check: {}", e)), + )); + } + } + + // Root .gitignore must NOT ignore Cargo.lock + let gitignore = repo_root.join(".gitignore"); + if let Ok(contents) = fs::read_to_string(&gitignore) { + let re = Regex::new(r"(?m)^Cargo\.lock$").expect("valid regex"); + let cargo_lock_ignored = re.is_match(&contents); + criteria.push(Criterion::new( + "hygiene_root_gitignore", + "Hygiene: root .gitignore does not ignore Cargo.lock", + !cargo_lock_ignored, + if cargo_lock_ignored { + Some("root .gitignore ignores Cargo.lock".into()) + } else { + None + }, + )); + } + + criteria +} + +// --------------------------------------------------------------------------- +// Hygiene checks (reuse base check logic, adapted for criterion format) +// --------------------------------------------------------------------------- + +fn check_hygiene(dir: &Path, name: &str, criteria: &mut Vec) { + let mut failures: Vec = Vec::new(); + + // No hardcoded contract IDs in README + check_readme_contract_ids(dir, name, &mut failures); + criteria.push(Criterion::new( + "readme_no_contract_ids", + "README: no hardcoded contract IDs", + failures.is_empty(), + failures.first().cloned(), + )); + failures.clear(); + + // .gitignore exists and has target/ + check_example_gitignore(dir, name, &mut failures); + criteria.push(Criterion::new( + "gitignore_exists", + ".gitignore: exists and ignores target/", + failures.is_empty(), + failures.first().cloned(), + )); + failures.clear(); + + // .gitignore must NOT ignore Cargo.lock + check_gitignore_cargo_lock(dir, name, &mut failures); + criteria.push(Criterion::new( + "gitignore_no_cargo_lock", + ".gitignore: does not ignore Cargo.lock", + failures.is_empty(), + failures.first().cloned(), + )); + failures.clear(); + + // Cargo.toml has description + check_cargo_toml_description(dir, name, &mut failures); + criteria.push(Criterion::new( + "cargo_toml_description", + "Cargo.toml: has non-empty description", + failures.is_empty(), + failures.first().cloned(), + )); + failures.clear(); + + // cargo metadata passes + check_cargo_metadata(dir, name, &mut failures); + criteria.push(Criterion::new( + "cargo_metadata", + "cargo metadata --no-deps passes", + failures.is_empty(), + failures.first().cloned(), + )); +} + +/// Check that example `.gitignore` does NOT contain `Cargo.lock`. +fn check_gitignore_cargo_lock(dir: &Path, name: &str, failures: &mut Vec) { + let gitignore = dir.join(".gitignore"); + match fs::read_to_string(&gitignore) { + Ok(contents) => { + let re = Regex::new(r"(?m)^Cargo\.lock$").expect("valid regex"); + if re.is_match(&contents) { + failures.push(format!( + "examples/{}: .gitignore must not ignore Cargo.lock (examples are applications)", + name, + )); + } + } + Err(e) => { + failures.push(format!("examples/{}: cannot read .gitignore: {}", name, e)); + } + } +} + +// --------------------------------------------------------------------------- +// Dependencies (§1) +// --------------------------------------------------------------------------- + +fn check_dependencies(dir: &Path, criteria: &mut Vec) { + let cargo_toml = dir.join("Cargo.toml"); + let contents = match fs::read_to_string(&cargo_toml) { + Ok(c) => c, + Err(e) => { + criteria.push(Criterion::new( + "deps_no_path", + "Dependencies: no unannotated path dependency on cougr-core", + false, + Some(format!("cannot read Cargo.toml: {}", e)), + )); + return; + } + }; + + // Check for path dependency on cougr-core + let path_re = Regex::new(r#"cougr-core\s*=\s*\{[^}]*path\s*="#).expect("valid regex"); + if !path_re.is_match(&contents) { + criteria.push(Criterion::new( + "deps_no_path", + "Dependencies: uses published cougr-core version (not path dep)", + true, + None, + )); + } else { + let annotated = has_path_dep_annotation(&contents); + criteria.push(Criterion::new( + "deps_no_path", + "Dependencies: path dep is annotated per §1.1", + annotated, + if annotated { + None + } else { + Some("path dependency on cougr-core without annotation comment".into()) + }, + )); + } + + // Check for wildcard version specifiers ("*") + let wildcard_re = Regex::new(r#"(?m)^\w[\w-]*\s*=\s*"[*]""#).expect("valid regex"); + let has_wildcard = wildcard_re.is_match(&contents); + criteria.push(Criterion::new( + "deps_no_wildcard", + "Cargo.toml: no wildcard version specifiers", + !has_wildcard, + if has_wildcard { + Some("wildcard version (*) found in dependency".into()) + } else { + None + }, + )); +} + +fn has_path_dep_annotation(contents: &str) -> bool { + let annotation_re = + Regex::new(r"(?m)^#\s*path dep\s*[—\-]\s*pending cougr-core").expect("valid regex"); + annotation_re.is_match(contents) +} + +// --------------------------------------------------------------------------- +// Module structure (§3) +// --------------------------------------------------------------------------- + +fn check_module_structure(dir: &Path, criteria: &mut Vec) { + let src = dir.join("src"); + + let has_components = src.join("components.rs").is_file(); + criteria.push(Criterion::new( + "module_components", + "Module: src/components.rs exists", + has_components, + if has_components { + None + } else { + Some("missing src/components.rs".into()) + }, + )); + + let has_systems = src.join("systems.rs").is_file(); + criteria.push(Criterion::new( + "module_systems", + "Module: src/systems.rs exists", + has_systems, + if has_systems { + None + } else { + Some("missing src/systems.rs".into()) + }, + )); + + // Heuristic: if components.rs exists, lib.rs should not contain impl_component! + if has_components { + check_lib_rs_separation(&src.join("lib.rs"), criteria); + } +} + +fn check_lib_rs_separation(lib: &Path, criteria: &mut Vec) { + match fs::read_to_string(lib) { + Ok(contents) => { + let component_re = Regex::new(r"impl_component!\s*\(").expect("valid regex"); + let has_component_macro = component_re.is_match(&contents); + + let system_re = Regex::new(r"(?m)^pub\s+fn\s+\w+_system\s*\(").expect("valid regex"); + let has_system_fn = system_re.is_match(&contents); + + let clean = !has_component_macro && !has_system_fn; + let mut detail = Vec::new(); + if has_component_macro { + detail.push("impl_component! in lib.rs (should be in components.rs)"); + } + if has_system_fn { + detail.push("system functions in lib.rs (should be in systems.rs)"); + } + criteria.push(Criterion::new( + "module_lib_separation", + "Module: lib.rs separation (no components/systems inline)", + clean, + if clean { None } else { Some(detail.join("; ")) }, + )); + } + Err(_) => { + criteria.push(Criterion::new( + "module_lib_separation", + "Module: lib.rs separation", + false, + Some("cannot read lib.rs".into()), + )); + } + } +} + +// --------------------------------------------------------------------------- +// README completeness (§4) +// --------------------------------------------------------------------------- + +fn check_readme_sections(dir: &Path, criteria: &mut Vec) { + let readme = dir.join("README.md"); + let contents = match fs::read_to_string(&readme) { + Ok(c) => c, + Err(_) => { + criteria.push(Criterion::new( + "readme_sections", + "README: all 8 required sections present", + false, + Some("README.md missing or unreadable".into()), + )); + return; + } + }; + + let required: &[(&str, &[&str])] = &[ + ("purpose", &["## Purpose", "## Purpose and pattern"]), + ( + "api", + &["## Public contract API", "## Contract API", "## API"], + ), + ( + "architecture", + &["## Architecture", "## Architecture overview"], + ), + ("storage", &["## Storage", "## Storage model"]), + ( + "gameplay", + &["## Main gameplay flow", "## Gameplay", "## Gameplay flow"], + ), + ("cougr_apis", &["## Cougr APIs", "## Cougr APIs used"]), + ( + "build", + &[ + "## Build", + "## Build and test", + "## Build and test commands", + ], + ), + ("limitations", &["## Known limitations", "## Limitations"]), + ]; + + let mut missing: Vec = Vec::new(); + for (id, headers) in required { + let found = headers.iter().any(|h| { + let escaped = regex::escape(h); + let re = Regex::new(&format!("(?m)^{}", escaped)).expect("valid regex"); + re.is_match(&contents) + }); + if !found { + if *id == "build" + && (contents.contains("cargo test") || contents.contains("stellar contract build")) + { + continue; + } + missing.push(id.to_string()); + } + } + + criteria.push(Criterion::new( + "readme_sections", + "README: all required sections present", + missing.is_empty(), + if missing.is_empty() { + None + } else { + Some(format!("missing section(s): {}", missing.join(", "))) + }, + )); +} + +// --------------------------------------------------------------------------- +// Test coverage (§5) +// --------------------------------------------------------------------------- + +fn check_test_coverage(dir: &Path, criteria: &mut Vec) { + let src = dir.join("src"); + + let has_tests = src.join("test.rs").is_file() + || src.join("tests.rs").is_file() + || src.join("sandbox_tests.rs").is_file() + || has_test_attr_in_lib(&src.join("lib.rs")); + + criteria.push(Criterion::new( + "tests_exist", + "Tests: test module or file present", + has_tests, + if has_tests { + None + } else { + Some( + "no test file found (test.rs, tests.rs, sandbox_tests.rs, or #[test] in lib.rs)" + .into(), + ) + }, + )); + + let test_count = count_tests(&src); + let enough_tests = test_count >= 3; + criteria.push(Criterion::new( + "tests_count", + "Tests: at least 3 test functions", + enough_tests, + if enough_tests { + Some(format!("{} tests found", test_count)) + } else { + Some(format!( + "only {} test(s) found (expect at least 3)", + test_count + )) + }, + )); + + let uses_testutils = check_testutils_usage(&src); + criteria.push(Criterion::new( + "tests_testutils", + "Tests: uses soroban-sdk testutils", + uses_testutils, + if uses_testutils { + None + } else { + Some("no soroban-sdk testutils import found in test files".into()) + }, + )); +} + +fn has_test_attr_in_lib(lib: &Path) -> bool { + match fs::read_to_string(lib) { + Ok(contents) => contents.contains("#[test]"), + Err(_) => false, + } +} + +fn count_tests(src: &Path) -> usize { + let test_files = ["test.rs", "tests.rs", "sandbox_tests.rs", "lib.rs"]; + let test_re = Regex::new(r"#\[test\]").expect("valid regex"); + let mut count = 0; + + for fname in &test_files { + let p = src.join(fname); + if let Ok(contents) = fs::read_to_string(&p) { + count += test_re.find_iter(&contents).count(); + } + } + count +} + +fn check_testutils_usage(src: &Path) -> bool { + let test_files = ["test.rs", "tests.rs", "sandbox_tests.rs", "lib.rs"]; + let testutils_re = Regex::new(r"soroban_sdk.*testutils|testutils").expect("valid regex"); + + for fname in &test_files { + let p = src.join(fname); + if let Ok(contents) = fs::read_to_string(&p) { + if testutils_re.is_match(&contents) { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Classification (§7) +// --------------------------------------------------------------------------- + +fn check_classification(dir: &Path, criteria: &mut Vec) { + let readme = dir.join("README.md"); + let contents = match fs::read_to_string(&readme) { + Ok(c) => c, + Err(_) => { + criteria.push(Criterion::new( + "classification", + "Classification: marked as canonical or transitional", + false, + Some("README.md missing".into()), + )); + return; + } + }; + + let is_canonical = contents.contains("Canonical example") + || contents.contains("**Canonical") + || contents.contains("canonical example"); + let is_transitional = contents.contains("Transitional example") + || contents.contains("**Transitional") + || contents.contains("transitional example"); + + if is_canonical || is_transitional { + let marker = if is_canonical { + "canonical" + } else { + "transitional" + }; + criteria.push(Criterion::new( + "classification", + "Classification: marked as canonical or transitional", + true, + Some(format!("marked as {}", marker)), + )); + } else { + criteria.push(Criterion::new( + "classification", + "Classification: marked as canonical or transitional", + false, + Some("no 'Canonical example' or 'Transitional example' marker found in README".into()), + )); + } +} + +// --------------------------------------------------------------------------- +// Cargo.lock committed +// --------------------------------------------------------------------------- + +fn check_cargo_lock_committed(repo_root: &Path, name: &str, criteria: &mut Vec) { + let lock_path = format!("examples/{}/Cargo.lock", name); + match run_git_ls_files(repo_root, &lock_path) { + Ok(output) => { + let committed = !output.trim().is_empty(); + criteria.push(Criterion::new( + "cargo_lock_committed", + "Cargo.lock is committed", + committed, + if committed { + None + } else { + Some("Cargo.lock is not tracked by git".into()) + }, + )); + } + Err(e) => { + criteria.push(Criterion::new( + "cargo_lock_committed", + "Cargo.lock is committed", + false, + Some(format!("could not check: {}", e)), + )); + } + } +} + +// --------------------------------------------------------------------------- +// Build validation (heavy, opt-in via --full) +// --------------------------------------------------------------------------- + +fn check_cargo_test(dir: &Path, criteria: &mut Vec) { + let output = Command::new("cargo") + .args(["test"]) + .current_dir(dir) + .output(); + + match output { + Ok(out) => { + let pass = out.status.success(); + criteria.push(Criterion::new( + "build_cargo_test", + "Build: cargo test passes", + pass, + if pass { + None + } else { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + Some(format!( + "cargo test failed:\n{}", + indent_lines(&format!("{}\n{}", stdout.trim(), stderr.trim()), " ") + )) + }, + )); + } + Err(e) => { + criteria.push(Criterion::new( + "build_cargo_test", + "Build: cargo test passes", + false, + Some(format!("could not run cargo test: {}", e)), + )); + } + } +} + +fn check_stellar_build(dir: &Path, criteria: &mut Vec) { + let output = Command::new("stellar") + .args(["contract", "build"]) + .current_dir(dir) + .output(); + + match output { + Ok(out) => { + let pass = out.status.success(); + criteria.push(Criterion::new( + "build_stellar", + "Build: stellar contract build passes", + pass, + if pass { + None + } else { + let stderr = String::from_utf8_lossy(&out.stderr); + Some(format!( + "stellar contract build failed:\n{}", + indent_lines(stderr.trim(), " ") + )) + }, + )); + } + Err(e) => { + criteria.push(Criterion::new( + "build_stellar", + "Build: stellar contract build passes", + false, + Some(format!( + "could not run stellar: {} (is stellar-cli installed?)", + e + )), + )); + } + } +}