feat(grasp-audit): wire GRASP-06 suite into CLI with skipped-state reporting

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<String>` 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:
<reason>)" 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.
This commit is contained in:
DanConwayDev
2026-05-15 11:34:54 +00:00
parent 28719f7882
commit e667a32ff7
5 changed files with 561 additions and 20 deletions
+36 -3
View File
@@ -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
))
}
+100 -11
View File
@@ -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<String>,
pub error: Option<String>,
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<String>) -> 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]);
+247
View File
@@ -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/<event-id> 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<i32, Vec<&TestResult>> = 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);
}
}
@@ -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/<npub>/<id>.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/<event-id>` 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/<event-id>`. 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/<event-id>` 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:<pubkey>:<identifier>` AND a `clone` tag naming this service's /prs/<signer-npub>/<identifier>.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 /<npub>/<id>.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<i32> {
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::<i32>().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"));
}
}
+2 -2
View File
@@ -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};