From e667a32ff7e9e71c2b04b225cd0f399a7bd1b6c6 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 15 May 2026 11:34:54 +0000 Subject: [PATCH] feat(grasp-audit): wire GRASP-06 suite into CLI with skipped-state reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Grasp06Tests::run_all as a suite-level aggregator over the existing GRASP-06 tests, plus --spec grasp06 in the audit CLI (and inclusion in --spec all). Behaviour is gated once on NIP-11 supported_grasps: - Discovery gate (404-when-not-advertised) always runs. - On-contract tests run only when NIP-11 advertises "GRASP-06". - Otherwise on-contract tests are emitted as skipped TestResults so they appear in the report without failing the suite. NIP-11 fetch errors are treated conservatively as "not advertised" — the discovery gate is the right place to surface relay-level issues rather than the on-contract tests. Skipped state on TestResult --------------------------- Adds `skipped: bool` and `skip_reason: Option` to TestResult, plus a `.skip(reason)` builder. Skipped tests also have `passed = true` so `all_passed()` stays clean, but they render in grey with a "(skip: )" suffix and are tallied separately ("1 passed, 0 failed, 2 skipped (of 3 total)") in the summary line. `passed_count()` now means "ran and passed" (excludes skipped); `skipped_count()` is the new helper. GRASP-06 report renderer ------------------------ The existing AuditResult::print_report walks GRASP_01_REQUIREMENTS and parses only "GRASP-01:" spec_refs, so GRASP-06 results would have been counted in the totals but invisible in the section view. Instead of generalising the GRASP-01 renderer, GRASP-06 owns its own Grasp06Tests::print_report that walks the new GRASP_06_REQUIREMENTS table and renders an analogous block. The CLI calls the right renderer for each spec family — --spec all prints the GRASP-01 block first, then the GRASP-06 block underneath. Two renderers is small duplication for now. When GRASP-02/05 land the right move is a SpecMeta trait + one generic walker; premature with only two specs. GRASP_06_REQUIREMENTS table --------------------------- Adds the spec-requirements scaffolding (table, sections, parse helpers) covering all 9 audit-tracked GRASP-06 invariants: 4 from 06.md MUSTs, 2 audit-derived (NIP-11 discovery gate, both directions), 2 design-doc derived (cross-service mirror, no reverse mirror). Audit-derived and design-doc requirements use synthetic line numbers (negative or 100+) to sort distinctly from real spec lines. Behaviour verified end-to-end ----------------------------- Smoke-tested against a stock ngit-grasp serve (GRASP-06 not advertised): Spec coverage: 3/9 requirements tested (33.3%) Test results: 1 passed, 0 failed, 2 skipped (of 3 total) ✅ All tests passed! (exit 0) cargo build, cargo clippy --all-targets -D warnings, cargo fmt --check, cargo test --lib all clean. The existing TDD-red integration tests in tests/grasp06_pr_hosting.rs remain red as designed — they assert the "on" contract against a relay started with the feature flag, and Phase 9 of the implementation plan hasn't landed yet. --- grasp-audit/src/bin/grasp-audit.rs | 39 ++- grasp-audit/src/result.rs | 111 +++++++- grasp-audit/src/specs/grasp06/mod.rs | 247 ++++++++++++++++++ .../src/specs/grasp06/spec_requirements.rs | 180 ++++++++++++- grasp-audit/src/specs/mod.rs | 4 +- 5 files changed, 561 insertions(+), 20 deletions(-) diff --git a/grasp-audit/src/bin/grasp-audit.rs b/grasp-audit/src/bin/grasp-audit.rs index ab835e7..2a9124c 100644 --- a/grasp-audit/src/bin/grasp-audit.rs +++ b/grasp-audit/src/bin/grasp-audit.rs @@ -57,7 +57,7 @@ enum Commands { #[arg(short, long, default_value = "shared")] mode: String, - /// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, git-filter, push-auth, repo-creation, purgatory, all) + /// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, git-filter, push-auth, repo-creation, purgatory, grasp06, all) #[arg(short, long, default_value = "all")] spec: String, @@ -252,6 +252,21 @@ async fn main() -> Result<()> { println!("Running purgatory tests...\n"); specs::PurgatoryTests::run_all(&client).await } + "grasp06" => { + println!("Running GRASP-06 tests...\n"); + // GRASP-06 has its own report renderer (sections differ + // from GRASP-01). Print it directly and short-circuit the + // shared print_report path below. + let grasp06_results = specs::Grasp06Tests::run_all(&client).await; + specs::Grasp06Tests::print_report(&grasp06_results); + if !grasp06_results.all_passed() { + println!("❌ Some tests failed"); + std::process::exit(1); + } else { + println!("✅ All tests passed!"); + } + return Ok(()); + } "all" => { println!("Running all tests...\n"); let mut all_results = AuditResult::new("All GRASP-01 Tests"); @@ -306,12 +321,30 @@ async fn main() -> Result<()> { let push_results = specs::PushAuthorizationTests::run_all(&client, &relay_domain).await; all_results.merge(push_results); + // GRASP-06 tests live in their own spec family with their + // own report renderer. Print the GRASP-01 block first + // (via the default `print_report` below), then the + // GRASP-06 block separately. + println!(" → GRASP-06 tests..."); + let grasp06_results = specs::Grasp06Tests::run_all(&client).await; + println!(); - all_results + all_results.print_report(); + specs::Grasp06Tests::print_report(&grasp06_results); + + let combined_ok = + all_results.all_passed() && grasp06_results.all_passed(); + if !combined_ok { + println!("❌ Some tests failed"); + std::process::exit(1); + } else { + println!("✅ All tests passed!"); + } + return Ok(()); } _ => { return Err(anyhow!( - "Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'git-filter', 'push-auth', 'repo-creation', 'purgatory', or 'all'", + "Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'git-filter', 'push-auth', 'repo-creation', 'purgatory', 'grasp06', or 'all'", spec )) } diff --git a/grasp-audit/src/result.rs b/grasp-audit/src/result.rs index 6584c62..a7a0b27 100644 --- a/grasp-audit/src/result.rs +++ b/grasp-audit/src/result.rs @@ -16,6 +16,7 @@ const RED: &str = "\x1b[1;91m"; // Bold bright red - ANSI standard for high visi const YELLOW: &str = "\x1b[33m"; const BLUE: &str = "\x1b[34m"; const CYAN: &str = "\x1b[36m"; +const GREY: &str = "\x1b[90m"; // Bright black / grey — used for skipped tests const RESET: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; @@ -68,6 +69,15 @@ pub struct TestResult { pub spec_ref: String, pub requirement: String, pub passed: bool, + /// True if the test was skipped (e.g. precondition not met). + /// + /// Skipped tests also have `passed = true` so they do not cause + /// [`AuditResult::all_passed`] to fail, but they are reported separately + /// with a grey marker so it is obvious which assertions actually ran. + pub skipped: bool, + /// Human-readable reason for the skip, displayed next to the test name + /// in the report. Only meaningful when `skipped == true`. + pub skip_reason: Option, pub error: Option, pub duration: Duration, } @@ -87,6 +97,8 @@ impl TestResult { spec_ref: spec_ref.spec_ref_string().to_string(), requirement: requirement.to_string(), passed: false, + skipped: false, + skip_reason: None, error: None, duration: Duration::default(), } @@ -126,6 +138,19 @@ impl TestResult { self.error = Some(error.into()); self } + + /// Mark this test as skipped with a human-readable reason. + /// + /// Skipped tests also have `passed = true` so they don't cause + /// [`AuditResult::all_passed`] to return false, but they are rendered + /// distinctly (grey, with the reason) in audit reports. + pub fn skip(mut self, reason: impl Into) -> Self { + self.skipped = true; + self.passed = true; + self.skip_reason = Some(reason.into()); + self.error = None; + self + } } /// Collection of test results for a spec @@ -154,14 +179,17 @@ impl AuditResult { self.results.extend(other.results); } - /// Check if all tests passed + /// Check if all tests passed (skipped tests count as passed) pub fn all_passed(&self) -> bool { self.results.iter().all(|r| r.passed) } - /// Get count of passed tests + /// Get count of tests that ran and passed (excludes skipped) pub fn passed_count(&self) -> usize { - self.results.iter().filter(|r| r.passed).count() + self.results + .iter() + .filter(|r| r.passed && !r.skipped) + .count() } /// Get count of failed tests @@ -169,6 +197,11 @@ impl AuditResult { self.results.iter().filter(|r| !r.passed).count() } + /// Get count of skipped tests + pub fn skipped_count(&self) -> usize { + self.results.iter().filter(|r| r.skipped).count() + } + /// Get total count of tests pub fn total_count(&self) -> usize { self.results.len() @@ -221,12 +254,22 @@ impl AuditResult { if let Some(tests) = tests_by_line.get(&req.line) { tested_requirements += 1; for test in tests { - let (color, status) = if test.passed { + let (color, status) = if test.skipped { + (GREY, "⏭") + } else if test.passed { (GREEN, "✓") } else { (RED, "✗") }; - println!(" {}{} {}{}", color, status, test.name, RESET); + if test.skipped { + let reason = test.skip_reason.as_deref().unwrap_or("skipped"); + println!( + " {}{} {} (skip: {}){}", + color, status, test.name, reason, RESET + ); + } else { + println!(" {}{} {}{}", color, status, test.name, RESET); + } if let Some(error) = &test.error { // Truncate long errors @@ -252,6 +295,8 @@ impl AuditResult { // Summary statistics let passed = self.passed_count(); + let failed = self.failed_count(); + let skipped = self.skipped_count(); let total_tests = self.total_count(); let spec_coverage = if total_requirements > 0 { @@ -266,9 +311,9 @@ impl AuditResult { 0.0 }; - let summary_color = if passed == total_tests && tested_requirements == total_requirements { + let summary_color = if failed == 0 && tested_requirements == total_requirements { GREEN - } else if passed == total_tests { + } else if failed == 0 { YELLOW } else { RED @@ -278,10 +323,17 @@ impl AuditResult { "{}Spec coverage: {}/{} requirements tested ({:.1}%){}", summary_color, tested_requirements, total_requirements, spec_coverage, RESET ); - println!( - "{}Test results: {}/{} tests passed ({:.1}%){}", - summary_color, passed, total_tests, pass_rate, RESET - ); + if skipped > 0 { + println!( + "{}Test results: {}/{} tests passed ({:.1}%), {} failed, {} skipped{}", + summary_color, passed, total_tests, pass_rate, failed, skipped, RESET + ); + } else { + println!( + "{}Test results: {}/{} tests passed ({:.1}%){}", + summary_color, passed, total_tests, pass_rate, RESET + ); + } println!( "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", BOLD, RESET @@ -350,9 +402,46 @@ mod tests { assert_eq!(audit.total_count(), 2); assert_eq!(audit.passed_count(), 1); assert_eq!(audit.failed_count(), 1); + assert_eq!(audit.skipped_count(), 0); assert!(!audit.all_passed()); } + #[tokio::test] + async fn test_result_skip() { + let result = TestResult::new( + "test_skipped", + SpecRef::NostrRelayNip01Compliant, + "Test requirement", + ) + .skip("precondition not met"); + + assert!(result.skipped); + assert!(result.passed, "skipped tests must also count as passed"); + assert_eq!(result.skip_reason.as_deref(), Some("precondition not met")); + assert!(result.error.is_none()); + } + + #[test] + fn test_audit_result_with_skipped() { + let mut audit = AuditResult::new("Test Spec"); + + audit.add(TestResult::new("test1", SpecRef::NostrRelayNip01Compliant, "Test 1").pass()); + audit.add( + TestResult::new( + "test2", + SpecRef::NostrRelayRejectMissingCloneRelays, + "Test 2", + ) + .skip("precondition not met"), + ); + + assert_eq!(audit.total_count(), 2); + assert_eq!(audit.passed_count(), 1, "skipped not counted as passed"); + assert_eq!(audit.failed_count(), 0); + assert_eq!(audit.skipped_count(), 1); + assert!(audit.all_passed(), "skipped tests must not fail the suite"); + } + #[test] fn test_parse_spec_lines_single() { assert_eq!(parse_spec_lines("GRASP-01:nostr-relay:7"), vec![7]); diff --git a/grasp-audit/src/specs/grasp06/mod.rs b/grasp-audit/src/specs/grasp06/mod.rs index 159c37d..43f3e2c 100644 --- a/grasp-audit/src/specs/grasp06/mod.rs +++ b/grasp-audit/src/specs/grasp06/mod.rs @@ -16,6 +16,22 @@ //! [`fixtures`] holds non-Event prerequisites shared across the suite //! (currently the NIP-11 document). New checks needing the doc should reuse //! [`fixtures::advertises_grasp`] rather than re-fetching. +//! +//! ## Suite-level orchestration: [`Grasp06Tests`] +//! +//! Use [`Grasp06Tests::run_all`] to run the full GRASP-06 suite. It +//! transparently handles the NIP-11 discovery gate: +//! +//! - The "off-contract" discovery gate test always runs. +//! - If NIP-11 advertises `GRASP-06`, the "on-contract" tests run normally. +//! - If NIP-11 does not advertise it (or the NIP-11 fetch fails), the +//! on-contract tests are emitted as **skipped** results — visible in the +//! audit report in grey, with a reason — so the suite never accidentally +//! reports a red failure for a feature the relay never claimed to support. +//! +//! [`Grasp06Tests::print_report`] renders the GRASP-06-specific report block. +//! The CLI prints it separately from the GRASP-01 block (when running +//! `--spec all`), so each spec family gets its own header and section walk. pub mod fixtures; pub mod nip11; @@ -25,3 +41,234 @@ pub mod spec_requirements; pub use nip11::Nip11Tests; pub use prs_endpoint::PrsEndpointTests; pub use spec_requirements::{SpecRef, GRASP_06_COMMIT_ID}; + +use crate::{AuditClient, AuditResult, TestContext, TestResult}; +use spec_requirements::{get_sections, parse_spec_line, GRASP_06_REQUIREMENTS}; +use std::collections::BTreeMap; + +// ANSI colour codes — duplicated locally (same constants as result.rs) rather +// than re-exported, to keep the renderer self-contained. +const GREEN: &str = "\x1b[1;92m"; +const RED: &str = "\x1b[1;91m"; +const YELLOW: &str = "\x1b[33m"; +const BLUE: &str = "\x1b[34m"; +const CYAN: &str = "\x1b[36m"; +const GREY: &str = "\x1b[90m"; +const RESET: &str = "\x1b[0m"; +const BOLD: &str = "\x1b[1m"; + +/// Aggregator for the GRASP-06 audit test suite. +/// +/// See [module docs](self) for the gating model. +pub struct Grasp06Tests; + +impl Grasp06Tests { + /// Run the full GRASP-06 audit suite against `client`. + /// + /// Always runs the NIP-11 discovery-gate test. Branches once on the + /// relay's NIP-11 `supported_grasps` field: + /// + /// - If `GRASP-06` is advertised → runs the on-contract tests. + /// - If not (or NIP-11 fetch fails) → emits skipped placeholders for the + /// on-contract tests so the report still shows what wasn't run. + pub async fn run_all(client: &AuditClient) -> AuditResult { + let mut results = AuditResult::new("GRASP-06 Compliance Tests"); + + // (1) Discovery gate — always runs. Trivially passes when GRASP-06 + // IS advertised (precondition not met); enforces /prs/ -> 404 + // when it isn't. + results.add( + PrsEndpointTests::test_prs_namespace_404_when_grasp06_not_advertised(client).await, + ); + + // (2) Branch once. NIP-11 fetch failures are treated conservatively + // as "not advertised": we cannot confirm the feature is enabled. + let ctx = TestContext::new(client); + let advertised = fixtures::advertises_grasp(&ctx, "GRASP-06") + .await + .unwrap_or(false); + + if advertised { + // (3a) On-contract tests run with real assertions. + results.add(Nip11Tests::test_nip11_advertises_grasp_06_when_enabled(client).await); + results + .add(PrsEndpointTests::test_prs_fetch_unknown_path_serves_empty_repo(client).await); + } else { + // (3b) Visible skipped stubs — same SpecRef and requirement text + // as the real tests, so they show up in the report under the + // same spec lines but rendered grey with a reason. + let reason = "GRASP-06 not advertised in NIP-11"; + results.add( + TestResult::new( + "nip11_advertises_grasp_06_when_enabled", + SpecRef::Grasp06AdvertisedWhenEnabled, + "NIP-11 supported_grasps MUST include 'GRASP-06' when feature is enabled", + ) + .skip(reason), + ); + results.add( + TestResult::new( + "prs_fetch_unknown_path_serves_empty_repo", + SpecRef::Grasp06FetchEmptyRepo, + "MUST serve empty bare repo on fetch for any well-formed /prs/ path \ + until refs/nostr/ has been accepted", + ) + .skip(reason), + ); + } + + results + } + + /// Print the GRASP-06 audit report block for `results`. + /// + /// Walks the [`GRASP_06_REQUIREMENTS`] table, groups tests by spec line, + /// and renders each section. Skipped tests are shown in grey with their + /// reason; passes in green; failures in red. + /// + /// Use this instead of [`AuditResult::print_report`] when the results + /// come from [`Self::run_all`] — the default `print_report` is + /// GRASP-01-specific and would not display GRASP-06 sections. + pub fn print_report(results: &AuditResult) { + println!(); + println!( + "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", + BOLD, RESET + ); + println!("{}GRASP-06 Compliance Report{}", BOLD, RESET); + println!( + "Source: github.com/DanConwayDev/grasp/blob/main/06.md (commit: {})", + GRASP_06_COMMIT_ID + ); + println!( + "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", + BOLD, RESET + ); + + // Group tests by spec line, ignoring any non-GRASP-06 spec refs. + let mut tests_by_line: BTreeMap> = BTreeMap::new(); + let mut unknown_refs: Vec<&TestResult> = Vec::new(); + for r in &results.results { + match parse_spec_line(&r.spec_ref) { + Some(line) => tests_by_line.entry(line).or_default().push(r), + None => unknown_refs.push(r), + } + } + + let mut tested_requirements = 0usize; + let total_requirements = GRASP_06_REQUIREMENTS.len(); + + for section in get_sections() { + println!(); + println!("{}{}## {}{}", CYAN, BOLD, section, RESET); + + for req in GRASP_06_REQUIREMENTS + .iter() + .filter(|r| r.section == section) + { + println!(); + println!("{}📘 {}{}", BLUE, req.text, RESET); + + if let Some(tests) = tests_by_line.get(&req.line) { + tested_requirements += 1; + for test in tests { + render_test(test); + } + } else { + println!(" {}⚠️ No Tests Implemented{}", YELLOW, RESET); + } + } + } + + // Surface any tests whose spec_ref didn't match a known GRASP-06 line + // — usually a programming error in a new test. + if !unknown_refs.is_empty() { + println!(); + println!( + "{}{}## Uncategorised Tests (spec_ref not in GRASP_06_REQUIREMENTS){}", + CYAN, BOLD, RESET + ); + for t in unknown_refs { + render_test(t); + } + } + + println!(); + println!( + "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", + BOLD, RESET + ); + + let passed = results.passed_count(); + let failed = results.failed_count(); + let skipped = results.skipped_count(); + let total_tests = results.total_count(); + + let spec_coverage = if total_requirements > 0 { + (tested_requirements as f64 / total_requirements as f64) * 100.0 + } else { + 0.0 + }; + + let summary_color = if failed == 0 && tested_requirements == total_requirements { + GREEN + } else if failed == 0 { + YELLOW + } else { + RED + }; + + println!( + "{}Spec coverage: {}/{} requirements tested ({:.1}%){}", + summary_color, tested_requirements, total_requirements, spec_coverage, RESET + ); + if skipped > 0 { + println!( + "{}Test results: {} passed, {} failed, {} skipped (of {} total){}", + summary_color, passed, failed, skipped, total_tests, RESET + ); + } else { + let pass_rate = if total_tests > 0 { + (passed as f64 / total_tests as f64) * 100.0 + } else { + 0.0 + }; + println!( + "{}Test results: {}/{} tests passed ({:.1}%){}", + summary_color, passed, total_tests, pass_rate, RESET + ); + } + println!( + "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", + BOLD, RESET + ); + println!(); + } +} + +fn render_test(test: &TestResult) { + let (color, status) = if test.skipped { + (GREY, "⏭") + } else if test.passed { + (GREEN, "✓") + } else { + (RED, "✗") + }; + if test.skipped { + let reason = test.skip_reason.as_deref().unwrap_or("skipped"); + println!( + " {}{} {} (skip: {}){}", + color, status, test.name, reason, RESET + ); + } else { + println!(" {}{} {}{}", color, status, test.name, RESET); + } + if let Some(error) = &test.error { + let truncated = if error.len() > 100 { + format!("{}...", &error[..100]) + } else { + error.clone() + }; + println!(" {}Error: {}{}", RED, truncated, RESET); + } +} diff --git a/grasp-audit/src/specs/grasp06/spec_requirements.rs b/grasp-audit/src/specs/grasp06/spec_requirements.rs index 344a919..312cb71 100644 --- a/grasp-audit/src/specs/grasp06/spec_requirements.rs +++ b/grasp-audit/src/specs/grasp06/spec_requirements.rs @@ -5,6 +5,8 @@ //! //! This is the single source of truth for spec text displayed in audit reports. +use crate::specs::grasp01::RequirementLevel; + /// GRASP spec repository commit ID this version is based on. /// /// Update this when bumping to a newer spec revision so reports indicate @@ -48,19 +50,31 @@ pub enum SpecRef { Grasp06NoReverseMirror, } +/// Synthetic "line numbers" used for the audit report grouping. +/// +/// The two audit-derived requirements (`Grasp06NotAdvertised404` and +/// `Grasp06AdvertisedWhenEnabled`) follow from the spec but do not map to a +/// single spec line. They are grouped under the "NIP-11 Discovery" section +/// with negative synthetic line numbers (`-1`, `-2`) so they sort to the top +/// of the report and remain visually distinct from real spec lines. +mod synthetic_lines { + pub const NOT_ADVERTISED_404: i32 = -1; + pub const ADVERTISED_WHEN_ENABLED: i32 = -2; +} + impl SpecRef { /// Get the spec reference string in format "GRASP-06:section:line". pub fn spec_ref_string(self) -> &'static str { match self { - SpecRef::Grasp06NotAdvertised404 => "GRASP-06:audit:nip11-gate", - SpecRef::Grasp06AdvertisedWhenEnabled => "GRASP-06:nip11:when-enabled", + SpecRef::Grasp06NotAdvertised404 => "GRASP-06:nip-11-discovery:-1", + SpecRef::Grasp06AdvertisedWhenEnabled => "GRASP-06:nip-11-discovery:-2", SpecRef::Grasp06FetchEmptyRepo => "GRASP-06:git-http:13", SpecRef::Grasp06AcceptRefsNostrPush => "GRASP-06:git-http:15", SpecRef::Grasp06RejectNonNostrRefs => "GRASP-06:git-http:15", SpecRef::Grasp06RelaxAcceptPrEvent => "GRASP-06:event-acceptance:21", SpecRef::Grasp06RelaxRequiresCloneTag => "GRASP-06:event-acceptance:23", - SpecRef::Grasp06MirrorToAnnouncedRepo => "GRASP-06:design:mirror-forward", - SpecRef::Grasp06NoReverseMirror => "GRASP-06:design:mirror-no-reverse", + SpecRef::Grasp06MirrorToAnnouncedRepo => "GRASP-06:mirror:design", + SpecRef::Grasp06NoReverseMirror => "GRASP-06:mirror:design", } } } @@ -70,3 +84,161 @@ impl crate::result::SpecRefStr for SpecRef { SpecRef::spec_ref_string(*self) } } + +/// A single GRASP-06 specification requirement. +/// +/// Mirrors the GRASP-01 `SpecRequirement` shape but uses `i32` for `line` +/// so audit-derived requirements can use negative synthetic line numbers +/// without conflicting with real spec lines. +#[derive(Debug, Clone)] +pub struct SpecRequirement { + pub spec_ref: SpecRef, + pub line: i32, + pub section: &'static str, + pub text: &'static str, + pub level: RequirementLevel, +} + +/// All GRASP-06 specification requirements (and audit-derived invariants). +pub const GRASP_06_REQUIREMENTS: &[SpecRequirement] = &[ + // NIP-11 Discovery (audit-derived: bridges NIP-11 capability advertisement + // with /prs/ endpoint availability — clients must be able to rely on NIP-11 + // alone for capability discovery). + SpecRequirement { + spec_ref: SpecRef::Grasp06NotAdvertised404, + line: synthetic_lines::NOT_ADVERTISED_404, + section: "NIP-11 Discovery", + text: "When NIP-11 supported_grasps does NOT include 'GRASP-06', the /prs//.git namespace MUST return 404 (audit-derived from NIP-11 capability discovery semantics)", + level: RequirementLevel::Must, + }, + SpecRequirement { + spec_ref: SpecRef::Grasp06AdvertisedWhenEnabled, + line: synthetic_lines::ADVERTISED_WHEN_ENABLED, + section: "NIP-11 Discovery", + text: "When the relay is configured with GRASP-06 enabled, NIP-11 supported_grasps MUST include 'GRASP-06' so clients can discover the capability (implementation plan Phase 9)", + level: RequirementLevel::Must, + }, + // Git Smart HTTP Service + SpecRequirement { + spec_ref: SpecRef::Grasp06FetchEmptyRepo, + line: 13, + section: "Git Smart HTTP Service", + text: "MUST respond to upload-pack requests for any well-formed path as if serving an empty bare repository until at least one `refs/nostr/` has been accepted for that path.", + level: RequirementLevel::Must, + }, + SpecRequirement { + spec_ref: SpecRef::Grasp06AcceptRefsNostrPush, + line: 15, + section: "Git Smart HTTP Service", + text: "MUST accept pushes to `refs/nostr/`. MAY reject based on size, SPAM prevention, allowlists, pre-payment, PoW, or similar policy.", + level: RequirementLevel::Must, + }, + SpecRequirement { + spec_ref: SpecRef::Grasp06RejectNonNostrRefs, + line: 15, + section: "Git Smart HTTP Service", + text: "MUST reject pushes to any other ref namespace (only `refs/nostr/` is accepted).", + level: RequirementLevel::Must, + }, + // Event Acceptance + SpecRequirement { + spec_ref: SpecRef::Grasp06RelaxAcceptPrEvent, + line: 21, + section: "Event Acceptance", + text: "MUST accept PRs and PR Updates that would otherwise be rejected under GRASP-01 for not referencing an accepted repository announcement, provided the event has an `a` tag of the form `30617::` AND a `clone` tag naming this service's /prs//.git endpoint.", + level: RequirementLevel::Must, + }, + SpecRequirement { + spec_ref: SpecRef::Grasp06RelaxRequiresCloneTag, + line: 23, + section: "Event Acceptance", + text: "The relaxation applies ONLY when the event's `clone` tag names this relay's /prs/ endpoint. PR events without a matching clone tag remain subject to GRASP-01 rejection.", + level: RequirementLevel::Must, + }, + // Cross-service mirror (design-doc derived — not in the spec, but follows + // from the design choice that PR refs at /prs/ become discoverable at the + // announced repo on the same relay). + SpecRequirement { + spec_ref: SpecRef::Grasp06MirrorToAnnouncedRepo, + line: 100, // synthetic — design-doc requirement, no spec line + section: "Cross-Service Mirror", + text: "Refs accepted via /prs/ MUST be mirrored into any matching accepted-announcement repos on this relay (design-doc: docs/explanation/grasp-06-contributor-pr-submission.md \"Cross-service mirror\").", + level: RequirementLevel::Must, + }, + SpecRequirement { + spec_ref: SpecRef::Grasp06NoReverseMirror, + line: 101, // synthetic + section: "Cross-Service Mirror", + text: "The reverse direction MUST NOT mirror: a push to the standard //.git endpoint must not appear under /prs/.", + level: RequirementLevel::Must, + }, +]; + +/// Parse line number from a "GRASP-06:section:line" spec_ref string. +/// +/// Returns `None` if the spec_ref is not a GRASP-06 ref or the line component +/// can't be parsed as an integer. +pub fn parse_spec_line(spec_ref: &str) -> Option { + if !spec_ref.starts_with("GRASP-06:") { + return None; + } + let parts: Vec<&str> = spec_ref.split(':').collect(); + if parts.len() < 3 { + return None; + } + parts.last().and_then(|s| s.parse::().ok()) +} + +/// Get all unique section names in the order they first appear. +pub fn get_sections() -> Vec<&'static str> { + let mut sections = Vec::new(); + for req in GRASP_06_REQUIREMENTS { + if !sections.contains(&req.section) { + sections.push(req.section); + } + } + sections +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spec_ref_unique() { + let mut refs = std::collections::HashSet::new(); + for req in GRASP_06_REQUIREMENTS { + assert!( + refs.insert(req.spec_ref), + "Duplicate SpecRef found: {:?}", + req.spec_ref + ); + } + } + + #[test] + fn test_parse_spec_line_real() { + assert_eq!(parse_spec_line("GRASP-06:git-http:13"), Some(13)); + assert_eq!(parse_spec_line("GRASP-06:git-http:15"), Some(15)); + } + + #[test] + fn test_parse_spec_line_synthetic() { + assert_eq!(parse_spec_line("GRASP-06:nip-11-discovery:-1"), Some(-1)); + } + + #[test] + fn test_parse_spec_line_non_grasp06() { + assert_eq!(parse_spec_line("GRASP-01:nostr-relay:7"), None); + assert_eq!(parse_spec_line("NIP-01:basic:1"), None); + } + + #[test] + fn test_sections_order() { + let sections = get_sections(); + assert_eq!(sections[0], "NIP-11 Discovery"); + assert!(sections.contains(&"Git Smart HTTP Service")); + assert!(sections.contains(&"Event Acceptance")); + assert!(sections.contains(&"Cross-Service Mirror")); + } +} diff --git a/grasp-audit/src/specs/mod.rs b/grasp-audit/src/specs/mod.rs index 574f4cd..18a6d9e 100644 --- a/grasp-audit/src/specs/mod.rs +++ b/grasp-audit/src/specs/mod.rs @@ -11,5 +11,5 @@ pub use grasp01::{ Nip11DocumentTests, PurgatoryTests, PushAuthorizationTests, RepositoryCreationTests, }; -// Re-export test structs from grasp06 module -pub use grasp06::{Nip11Tests, PrsEndpointTests}; +// Re-export GRASP-06 test structs +pub use grasp06::{Grasp06Tests, Nip11Tests, PrsEndpointTests};