mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 07:28:23 +00:00
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:
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user