From 2b4306e040b2c900b29eeb96e8037a339a901fff Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 18 Jul 2026 06:36:35 +0700 Subject: [PATCH] feat(registryctl): catalog maintained authoring journeys Signed-off-by: Jeremi Joslin --- crates/registryctl/src/main.rs | 186 +++-- .../fixtures/project-authoring-journeys.yaml | 171 +++++ .../environments/local.yaml | 8 + .../registry-stack.yaml | 16 + crates/registryctl/tests/project_authoring.rs | 668 +++++++++++++++--- docs/site/.markdownlint-cli2.yaml | 1 + .../scripts/generate-project-starters.mjs | 341 ++++++--- .../generate-project-starters.test.mjs | 295 +++++++- .../components/ProjectWorkspaceJourneys.astro | 29 + .../content/docs/reference/registryctl.mdx | 9 + .../tutorials/author-registry-project.mdx | 2 +- .../generated/project-authoring-journeys.json | 341 +++++++++ .../src/data/generated/project-starters.json | 89 ++- 13 files changed, 1892 insertions(+), 264 deletions(-) create mode 100644 crates/registryctl/tests/fixtures/project-authoring-journeys.yaml create mode 100644 crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/environments/local.yaml create mode 100644 crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/registry-stack.yaml create mode 100644 docs/site/src/components/ProjectWorkspaceJourneys.astro create mode 100644 docs/site/src/data/generated/project-authoring-journeys.json diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 47d2ab58c..2b38fb29e 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -1278,46 +1278,8 @@ mod tests { "Relay authority: 0 source integrations, 1 records API service, 1 materialized entity definition" )); - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = temporary.path().join("notary-only-evaluation"); - std::fs::create_dir_all(project.join("environments")) - .expect("environment directory creates"); - std::fs::write( - project.join("registry-stack.yaml"), - r#"version: 1 -registry: { id: fictional-evaluation-registry } -services: - applicant-evaluation: - kind: evidence - version: 1 - purpose: application-processing - legal_basis: application-processing - consent: not_required - access: { scopes: ["evidence:application:read"] } - claims: - application-complete: - cel: "true" - value: { type: boolean } - disclosure: predicate - credential_profiles: {} -"#, - ) - .expect("project writes"); - std::fs::write( - project.join("environments/local.yaml"), - r#"version: 1 -callers: - application-service: - api_key_fingerprint: { secret: APPLICATION_SERVICE_TOKEN_HASH } - scopes: ["evidence:application:read"] -deployment: - profile: local - notary: { service: evaluation-notary } -"#, - ) - .expect("environment writes"); let notary_report = registryctl::check_registry_project(&ProjectCheckOptions { - project_directory: project, + project_directory: fixtures.join("notary-only-evaluation"), environment: "local".to_string(), explain: true, against: None, @@ -1333,35 +1295,117 @@ deployment: } #[test] - fn project_test_watch_reruns_each_maintained_starter_after_an_authored_change() { - let starters = [ - (ProjectStarter::Http, "person-record", "active-person"), - ( - ProjectStarter::Dhis2Tracker, - "health-record", - "complete-health-match", - ), - ( - ProjectStarter::OpencrvsDci, - "birth-record", - "birth-record-match", - ), - (ProjectStarter::FhirR4, "coverage", "coverage-active"), - ( - ProjectStarter::Snapshot, - "person-snapshot", - "snapshot-match", - ), - ]; - - for (starter, integration, fixture) in starters { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project_directory = temporary.path().join("registry-project"); - registryctl::init_registry_project(&ProjectInitOptions { + fn project_test_watch_reruns_each_maintained_fixture_journey_after_an_authored_change() { + fn copy_directory(source: &std::path::Path, destination: &std::path::Path) -> Result<()> { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_directory(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), target)?; + } + } + Ok(()) + } + + let manifest_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let repository_root = manifest_root.join("../.."); + let catalog: serde_yaml::Value = serde_yaml::from_slice( + &std::fs::read(manifest_root.join("tests/fixtures/project-authoring-journeys.yaml")) + .expect("project-authoring journey catalog reads"), + ) + .expect("project-authoring journey catalog parses"); + let mut journeys = Vec::new(); + for workspace in catalog["workspaces"] + .as_sequence() + .expect("catalog workspaces") + { + if !workspace["steps"] + .as_sequence() + .expect("catalog steps") + .iter() + .any(|step| step.as_str() == Some("watch")) + { + continue; + } + assert_eq!( + workspace["classification"].as_str(), + Some("maintained"), + "watch is a maintained authoring journey" + ); + let starter = workspace["starter"].as_str().map(|starter| match starter { + "http" => ProjectStarter::Http, + "dhis2-tracker" => ProjectStarter::Dhis2Tracker, + "opencrvs-dci" => ProjectStarter::OpencrvsDci, + "fhir-r4" => ProjectStarter::FhirR4, + "snapshot" => ProjectStarter::Snapshot, + other => panic!("unknown catalog starter {other}"), + }); + let id = workspace["id"].as_str().expect("watch id").to_string(); + let project_dir = workspace["project_dir"] + .as_str() + .expect("watch project directory") + .to_string(); + let source = repository_root.join( + workspace["source"] + .as_str() + .expect("catalog workspace source"), + ); + let project: serde_yaml::Value = serde_yaml::from_slice( + &std::fs::read(source.join("registry-stack.yaml")).expect("catalog project reads"), + ) + .expect("catalog project parses"); + let integrations = project["integrations"] + .as_mapping() + .expect("watch journey integrations"); + assert_eq!(integrations.len(), 1, "watch journey integration"); + let (integration, reference) = integrations.iter().next().expect("watch integration"); + let integration = integration.as_str().expect("integration id").to_string(); + let integration_file = reference["file"].as_str().expect("integration file"); + let fixture_file = workspace["focused_fixture_file"] + .as_str() + .expect("focused fixture file"); + let fixture_path = source + .join(integration_file) + .parent() + .expect("integration directory") + .join("fixtures") + .join(fixture_file); + let fixture: serde_yaml::Value = + serde_yaml::from_slice(&std::fs::read(fixture_path).expect("watch fixture reads")) + .expect("watch fixture parses"); + journeys.push(( + id, starter, - directory: project_directory.clone(), - }) - .expect("maintained starter initializes"); + source, + project_dir, + integration, + fixture["name"] + .as_str() + .expect("watch fixture name") + .to_string(), + )); + } + assert_eq!( + journeys.len(), + 9, + "every maintained fixture journey watches" + ); + + for (id, starter, source, project_dir, integration, fixture) in journeys { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project_directory = temporary.path().join(project_dir); + if let Some(starter) = starter { + registryctl::init_registry_project(&ProjectInitOptions { + starter, + directory: project_directory.clone(), + }) + .expect("maintained starter initializes"); + } else { + copy_directory(&source, &project_directory).expect("maintained non-starter copies"); + } let mut observed_runs = 0; watch_project_tests_until( @@ -1371,9 +1415,9 @@ deployment: live: false, }, ProjectTestSelection { - integration: Some(integration.to_string()), - fixture: Some(fixture.to_string()), - trace: true, + integration: Some(integration), + fixture: Some(fixture), + trace: false, }, |completed_runs, root| { observed_runs = completed_runs; @@ -1393,7 +1437,7 @@ deployment: }, ) .expect("offline watch reruns after an authored project file changes"); - assert_eq!(observed_runs, 2, "{starter:?}"); + assert_eq!(observed_runs, 2, "{id}"); } } diff --git a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml new file mode 100644 index 000000000..1ed14ab04 --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml @@ -0,0 +1,171 @@ +version: 1 +workspaces: + - id: http + label: Custom HTTP + summary: One fixed bounded HTTP request with a closed response projection. + source: crates/registryctl/assets/project-starters/bounded-http + classification: maintained + topology: combined + starter: http + project_dir: registry-project + focused_fixture_file: active.yaml + steps: [init, editor, trace, watch, test, check, build] + environment: local + check_explain: true + + - id: custom-system + label: Custom HTTP conformance workspace + summary: Combined HTTP acquisition and registry-backed evidence coverage. + source: crates/registryctl/tests/fixtures/project-authoring/custom-system + classification: maintained + topology: combined + project_dir: crates/registryctl/tests/fixtures/project-authoring/custom-system + focused_fixture_file: eligible.yaml + steps: [trace, watch, test, check, build] + environment: local + check_explain: true + + - id: dhis2-script + label: DHIS2 script conformance workspace + summary: Conformance coverage for the bounded Script adapter surface. + source: crates/registryctl/tests/fixtures/project-authoring/dhis2-script + classification: conformance-only + topology: combined + project_dir: crates/registryctl/tests/fixtures/project-authoring/dhis2-script + steps: [test, check, build] + environment: local + check_explain: true + + - id: dhis2-tracker + label: DHIS2 Tracker + summary: A product-neutral script adapter applied to a bounded DHIS2 Tracker read journey. + source: crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker + classification: maintained + topology: combined + starter: dhis2-tracker + project_dir: dhis2-project + focused_fixture_file: match.yaml + steps: [init, editor, trace, watch, test, check, build] + environment: local + check_explain: true + + - id: fhir-r4-coverage-active + label: FHIR R4 + summary: A product-neutral script adapter with bounded FHIR R4 search-set parsing. + source: crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active + classification: maintained + topology: combined + starter: fhir-r4 + project_dir: fhir-project + focused_fixture_file: match.yaml + steps: [init, editor, trace, watch, test, check, build] + environment: local + check_explain: true + + - id: nia-attribute-release + label: NIA attribute release + summary: Solmara-focused conformance coverage for a bounded Relay attribute-release profile. + source: crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release + classification: conformance-only + focus: solmara + topology: relay-only + project_dir: crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release + steps: [check, build] + environment: local + check_explain: true + + - id: opencrvs-dci + label: OpenCRVS DCI + summary: A product-neutral script adapter with the signed DCI search verification profile. + source: crates/registryctl/tests/fixtures/project-authoring/opencrvs + classification: maintained + topology: combined + starter: opencrvs-dci + project_dir: opencrvs-project + focused_fixture_file: match.yaml + steps: [init, editor, trace, watch, test, check, build] + environment: local + check_explain: true + + - id: opencrvs-country-variant + label: OpenCRVS country variant + summary: Country-owned DCI mapping coverage for match, no-match, and ambiguity. + source: crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant + classification: maintained + topology: combined + project_dir: crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant + focused_fixture_file: match.yaml + steps: [trace, watch, test, check, build] + environment: local + check_explain: true + + - id: openspp-exact + label: OpenSPP exact lookup + summary: Offline fixture evidence only, pending the owner-run holdout in GH#357. + source: crates/registryctl/tests/fixtures/project-authoring/openspp-exact + classification: maintained + topology: combined + evidence: offline-fixture-only-pending-357 + project_dir: crates/registryctl/tests/fixtures/project-authoring/openspp-exact + focused_fixture_file: match.yaml + steps: [trace, watch, test, check, build] + environment: local + check_explain: true + + - id: relay-only-materialization + label: Relay-only materialization + summary: Fixtureless Relay materialization configuration with no public records service. + source: crates/registryctl/tests/fixtures/project-authoring/relay-only-materialization + classification: maintained + topology: relay-only + project_dir: crates/registryctl/tests/fixtures/project-authoring/relay-only-materialization + steps: [check, build] + environment: local + check_explain: true + + - id: relay-only-records + label: Relay-only records API + summary: Fixtureless Relay records configuration with no Notary inputs. + source: crates/registryctl/tests/fixtures/project-authoring/relay-only-records + classification: maintained + topology: relay-only + project_dir: crates/registryctl/tests/fixtures/project-authoring/relay-only-records + steps: [check, build] + environment: local + check_explain: true + + - id: snapshot + label: Exact snapshot + summary: An exact lookup over one immutable local materialization. + source: crates/registryctl/tests/fixtures/project-authoring/snapshot-exact + classification: maintained + topology: combined + starter: snapshot + project_dir: snapshot-project + focused_fixture_file: match.yaml + steps: [init, editor, trace, watch, test, check, build] + environment: local + check_explain: true + + - id: snapshot-with-records + label: Snapshot with records API + summary: Match and no-match evidence sharing one authorized Relay materialization with records. + source: crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records + classification: maintained + topology: combined + project_dir: crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records + focused_fixture_file: match.yaml + steps: [trace, watch, test, check, build] + environment: local + check_explain: true + + - id: notary-only-evaluation + label: Notary-only evaluation + summary: Local policy evaluation only, with no credential profile, issuance, or direct source capability. + source: crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation + classification: maintained + topology: notary-only + project_dir: crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation + steps: [check, build] + environment: local + check_explain: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/environments/local.yaml new file mode 100644 index 000000000..a1138753a --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/environments/local.yaml @@ -0,0 +1,8 @@ +version: 1 +callers: + application-service: + api_key_fingerprint: { secret: APPLICATION_SERVICE_TOKEN_HASH } + scopes: ["evidence:application:read"] +deployment: + profile: local + notary: { service: evaluation-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/registry-stack.yaml new file mode 100644 index 000000000..6606494ae --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation/registry-stack.yaml @@ -0,0 +1,16 @@ +version: 1 +registry: { id: fictional-evaluation-registry } +services: + applicant-evaluation: + kind: evidence + version: 1 + purpose: application-processing + legal_basis: application-processing + consent: not_required + access: { scopes: ["evidence:application:read"] } + claims: + application-complete: + cel: "true" + value: { type: boolean } + disclosure: predicate + credential_profiles: {} diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index edec2c29a..05f9dfa60 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -11,17 +11,179 @@ use registryctl::{ ProjectBuildOptions, ProjectCheckOptions, ProjectEditorSetupOptions, ProjectInitOptions, ProjectSchemaKind, ProjectStarter, ProjectTestOptions, ProjectTestSelection, }; +use serde::Deserialize; use sha2::{Digest as _, Sha256}; const TEST_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"registryctl-test-private-key"}"#; const TEST_PUBLIC_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"registryctl-test-private-key"}"#; +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProjectAuthoringJourneyCatalog { + version: u8, + workspaces: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProjectAuthoringJourney { + id: String, + label: String, + summary: String, + source: String, + classification: String, + #[serde(default)] + focus: Option, + topology: String, + #[serde(default)] + evidence: Option, + #[serde(default)] + starter: Option, + project_dir: String, + #[serde(default)] + focused_fixture_file: Option, + steps: Vec, + environment: String, + check_explain: bool, +} + fn golden(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/project-authoring") .join(name) } +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn project_authoring_journey_catalog() -> ProjectAuthoringJourneyCatalog { + serde_yaml::from_slice( + &std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/project-authoring-journeys.yaml"), + ) + .expect("project-authoring journey catalog reads"), + ) + .expect("project-authoring journey catalog parses") +} + +fn catalog_workspace(journey: &ProjectAuthoringJourney) -> PathBuf { + repository_root().join(&journey.source) +} + +fn catalog_focused_selection(journey: &ProjectAuthoringJourney) -> (String, String) { + let workspace = catalog_workspace(journey); + let project = read_yaml(&workspace.join("registry-stack.yaml")); + let integrations = project["integrations"] + .as_mapping() + .expect("a focused catalog journey has integrations"); + assert_eq!( + integrations.len(), + 1, + "{} must derive one focused integration from its workspace", + journey.id + ); + let (integration_id, integration_reference) = integrations + .iter() + .next() + .expect("one integration reference"); + let integration_id = integration_id + .as_str() + .expect("integration id is a string") + .to_string(); + let integration_file = integration_reference["file"] + .as_str() + .expect("integration reference has a file"); + let fixture_file = journey + .focused_fixture_file + .as_deref() + .expect("focused catalog journey names a fixture file"); + let fixture_path = workspace + .join(integration_file) + .parent() + .expect("integration file has a parent") + .join("fixtures") + .join(fixture_file); + let fixture = read_yaml(&fixture_path); + let fixture_name = fixture["name"] + .as_str() + .expect("focused fixture has a name") + .to_string(); + (integration_id, fixture_name) +} + +fn catalog_has_authored_fixtures( + journey: &ProjectAuthoringJourney, + project: &serde_yaml::Value, +) -> bool { + let Some(integrations) = project["integrations"].as_mapping() else { + return false; + }; + integrations.values().any(|reference| { + let Some(file) = reference["file"].as_str() else { + return false; + }; + let fixture_directory = catalog_workspace(journey) + .join(file) + .parent() + .expect("integration file has a parent") + .join("fixtures"); + fixture_directory.is_dir() + && std::fs::read_dir(fixture_directory) + .expect("fixture directory reads") + .any(|entry| { + entry + .expect("fixture entry reads") + .path() + .extension() + .is_some_and(|extension| extension == "yaml") + }) + }) +} + +fn catalog_starter(id: &str) -> ProjectStarter { + match id { + "http" => ProjectStarter::Http, + "dhis2-tracker" => ProjectStarter::Dhis2Tracker, + "opencrvs-dci" => ProjectStarter::OpencrvsDci, + "fhir-r4" => ProjectStarter::FhirR4, + "snapshot" => ProjectStarter::Snapshot, + _ => panic!("unknown catalog starter {id}"), + } +} + +fn validate_public_starter_entries( + workspaces: &[ProjectAuthoringJourney], +) -> std::result::Result<(), String> { + let expected = BTreeSet::from([ + "dhis2-tracker", + "fhir-r4", + "http", + "opencrvs-dci", + "snapshot", + ]); + let entries = workspaces + .iter() + .filter_map(|journey| journey.starter.as_deref()) + .collect::>(); + if entries.len() != expected.len() { + return Err(format!( + "expected exactly {} starter entries, found {}", + expected.len(), + entries.len() + )); + } + let starters = entries.iter().copied().collect::>(); + if starters.len() != entries.len() { + return Err("duplicate starter entry".to_string()); + } + if starters != expected { + return Err(format!("unexpected starter entries: {starters:?}")); + } + Ok(()) +} + fn authoring_diagnostics(project: &Path) -> ProjectAuthoringDiagnostics { check_registry_project(&ProjectCheckOptions { project_directory: project.to_path_buf(), @@ -726,29 +888,406 @@ fn project_check_unsafe_inputs_are_terminal_and_value_free() { } #[test] -fn every_project_golden_passes_the_offline_journey() { - for project in [ - "custom-system", - "dhis2-tracker", - "fhir-r4-coverage-active", - "opencrvs", - "opencrvs-country-variant", - "openspp-exact", - "snapshot-exact", - "snapshot-with-records", +fn project_authoring_catalog_classifies_every_golden_and_only_five_starters() { + const GOLDEN_SOURCE_PREFIX: &str = "crates/registryctl/tests/fixtures/project-authoring/"; + const SUPPORTED_STEPS: [&str; 7] = + ["init", "editor", "trace", "watch", "test", "check", "build"]; + let catalog = project_authoring_journey_catalog(); + assert_eq!(catalog.version, 1); + + let mut ids = BTreeSet::new(); + let mut sources = BTreeSet::new(); + let mut catalog_goldens = BTreeSet::new(); + for journey in &catalog.workspaces { + assert!( + ids.insert(journey.id.as_str()), + "duplicate id {}", + journey.id + ); + assert!( + sources.insert(journey.source.as_str()), + "duplicate source {}", + journey.source + ); + assert!(!journey.label.trim().is_empty(), "{} label", journey.id); + assert!(!journey.summary.trim().is_empty(), "{} summary", journey.id); + assert!( + matches!( + journey.classification.as_str(), + "maintained" | "conformance-only" + ), + "{} classification", + journey.id + ); + assert!( + matches!( + journey.topology.as_str(), + "combined" | "relay-only" | "notary-only" + ), + "{} topology", + journey.id + ); + assert!( + !journey.project_dir.trim().is_empty(), + "{} project_dir", + journey.id + ); + assert_eq!(journey.environment, "local", "{} environment", journey.id); + assert!(journey.check_explain, "{} check explanation", journey.id); + assert!(catalog_workspace(journey).is_dir(), "{} source", journey.id); + assert!( + journey + .steps + .iter() + .all(|step| SUPPORTED_STEPS.contains(&step.as_str())), + "{} supported steps", + journey.id + ); + assert_eq!( + journey.steps.iter().collect::>().len(), + journey.steps.len(), + "{} duplicate steps", + journey.id + ); + assert!( + journey.steps.contains(&"check".to_string()), + "{} check", + journey.id + ); + assert!( + journey.steps.contains(&"build".to_string()), + "{} build", + journey.id + ); + + let project = read_yaml(&catalog_workspace(journey).join("registry-stack.yaml")); + let has_integrations = project["integrations"] + .as_mapping() + .is_some_and(|values| !values.is_empty()); + let has_entities = project["entities"] + .as_mapping() + .is_some_and(|values| !values.is_empty()); + let services = project["services"] + .as_mapping() + .expect("catalog workspace services are a mapping"); + let has_notary = services + .values() + .any(|service| service["kind"].as_str() == Some("evidence")); + let has_relay = has_integrations + || has_entities + || services + .values() + .any(|service| service["kind"].as_str() == Some("records_api")); + let derived_topology = match (has_relay, has_notary) { + (true, true) => "combined", + (true, false) => "relay-only", + (false, true) => "notary-only", + (false, false) => panic!("{} has no product topology", journey.id), + }; + assert_eq!( + journey.topology, derived_topology, + "{} topology", + journey.id + ); + + let has_authored_fixtures = catalog_has_authored_fixtures(journey, &project); + if !has_authored_fixtures { + assert_eq!( + journey.steps, + ["check", "build"], + "{} is fixtureless and must not invent test, trace, or watch journeys", + journey.id + ); + assert!( + journey.focused_fixture_file.is_none(), + "{} fixture", + journey.id + ); + } + if has_authored_fixtures && journey.classification == "maintained" { + assert!( + journey.steps.contains(&"watch".to_string()), + "{} maintained fixture journey must exercise watch", + journey.id + ); + } + if journey + .steps + .iter() + .any(|step| step == "trace" || step == "watch") + { + let (integration, fixture) = catalog_focused_selection(journey); + assert!(!integration.is_empty(), "{} integration", journey.id); + assert!(!fixture.is_empty(), "{} fixture", journey.id); + } + + if let Some(starter) = &journey.starter { + assert_eq!(journey.steps, SUPPORTED_STEPS, "{starter} starter steps"); + } else { + assert_eq!( + journey.project_dir, journey.source, + "{} non-starter commands must target the committed workspace", + journey.id + ); + assert!( + !journey.steps.contains(&"init".to_string()), + "{} non-starter cannot initialize", + journey.id + ); + } + if let Some(name) = journey.source.strip_prefix(GOLDEN_SOURCE_PREFIX) { + catalog_goldens.insert(name); + } + } + + let golden_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/project-authoring"); + let actual_goldens = std::fs::read_dir(golden_root) + .expect("golden directory reads") + .map(|entry| entry.expect("golden entry reads")) + .filter(|entry| entry.file_type().expect("golden type reads").is_dir()) + .map(|entry| { + entry + .file_name() + .into_string() + .expect("golden name is Unicode") + }) + .collect::>(); + assert_eq!( + catalog_goldens + .into_iter() + .map(str::to_string) + .collect::>(), + actual_goldens, + "adding or removing a golden requires an explicit catalog decision" + ); + validate_public_starter_entries(&catalog.workspaces) + .expect("catalog has exactly five unique public starter entries"); + + let dhis2_script = catalog + .workspaces + .iter() + .find(|journey| journey.id == "dhis2-script") + .expect("DHIS2 Script catalog entry"); + assert_eq!(dhis2_script.classification, "conformance-only"); + assert!(dhis2_script.starter.is_none()); + assert_eq!(dhis2_script.steps, ["test", "check", "build"]); + let nia = catalog + .workspaces + .iter() + .find(|journey| journey.id == "nia-attribute-release") + .expect("NIA attribute-release catalog entry"); + assert_eq!(nia.classification, "conformance-only"); + assert_eq!(nia.focus.as_deref(), Some("solmara")); + assert!(nia.starter.is_none()); + let openspp = catalog + .workspaces + .iter() + .find(|journey| journey.id == "openspp-exact") + .expect("OpenSPP catalog entry"); + assert_eq!( + openspp.evidence.as_deref(), + Some("offline-fixture-only-pending-357") + ); +} + +#[test] +fn project_authoring_catalog_rejects_a_duplicate_starter_entry() { + let mut catalog = project_authoring_journey_catalog(); + let fhir = catalog + .workspaces + .iter_mut() + .find(|journey| journey.starter.as_deref() == Some("fhir-r4")) + .expect("FHIR starter entry"); + fhir.starter = Some("http".to_string()); + + let error = validate_public_starter_entries(&catalog.workspaces) + .expect_err("a duplicate starter value must fail closed"); + assert!(error.contains("duplicate starter"), "{error}"); +} + +#[test] +fn project_authoring_catalog_rejects_fewer_than_five_starter_entries() { + let mut catalog = project_authoring_journey_catalog(); + let fhir = catalog + .workspaces + .iter_mut() + .find(|journey| journey.starter.as_deref() == Some("fhir-r4")) + .expect("FHIR starter entry"); + fhir.starter = None; + + let error = validate_public_starter_entries(&catalog.workspaces) + .expect_err("fewer than five starter entries must fail closed"); + assert!( + error.contains("expected exactly 5 starter entries"), + "{error}" + ); +} + +#[test] +fn every_cataloged_supported_project_authoring_command_is_automated() { + for journey in project_authoring_journey_catalog().workspaces { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join(&journey.project_dir); + if let Some(starter) = &journey.starter { + let report = init_registry_project(&ProjectInitOptions { + starter: catalog_starter(starter), + directory: project.clone(), + }) + .unwrap_or_else(|error| panic!("{} init failed: {error:#}", journey.id)); + assert_eq!(report.status, "initialized", "{} init", journey.id); + } else { + std::fs::create_dir_all(project.parent().expect("project path has a parent")) + .expect("project parent creates"); + copy_tree(&catalog_workspace(&journey), &project); + } + + if journey.steps.contains(&"editor".to_string()) { + let report = setup_registry_project_editor(&ProjectEditorSetupOptions { + project_directory: project.clone(), + }) + .unwrap_or_else(|error| panic!("{} editor setup failed: {error:#}", journey.id)); + assert_eq!(report.status, "configured", "{} editor", journey.id); + } + if journey.steps.contains(&"trace".to_string()) { + let (integration, fixture) = catalog_focused_selection(&journey); + let report = test_registry_project_selected( + &ProjectTestOptions { + project_directory: project.clone(), + environment: None, + live: false, + }, + &ProjectTestSelection { + integration: Some(integration), + fixture: Some(fixture.clone()), + trace: true, + }, + ) + .unwrap_or_else(|error| panic!("{} trace failed: {error:#}", journey.id)); + assert_eq!(report.status, "passed", "{} trace", journey.id); + assert!( + report + .fixtures + .iter() + .any(|result| result.fixture == fixture && result.passed), + "{} focused fixture", + journey.id + ); + } + if journey.steps.contains(&"test".to_string()) { + let report = test_registry_project(&ProjectTestOptions { + project_directory: project.clone(), + environment: None, + live: false, + }) + .unwrap_or_else(|error| panic!("{} offline test failed: {error:#}", journey.id)); + assert_eq!(report.status, "passed", "{} test", journey.id); + assert!(!report.fixtures.is_empty(), "{} fixtures", journey.id); + assert!( + report.fixtures.iter().all(|fixture| fixture.passed), + "{} fixtures", + journey.id + ); + } + + let check = check_registry_project(&ProjectCheckOptions { + project_directory: project.clone(), + environment: journey.environment.clone(), + explain: journey.check_explain, + against: None, + anchor: None, + }) + .unwrap_or_else(|error| panic!("{} check failed: {error:#}", journey.id)); + assert_eq!(check.status, "valid", "{} check", journey.id); + assert!(check.explanation.is_some(), "{} explanation", journey.id); + + let build = build_registry_project(&ProjectBuildOptions { + project_directory: project, + environment: journey.environment.clone(), + against: None, + anchor: None, + }) + .unwrap_or_else(|error| panic!("{} build failed: {error:#}", journey.id)); + assert_eq!(build.status, "built", "{} build", journey.id); + let output = PathBuf::from(build.output.expect("catalog build output")); + let relay = output.join("private/relay"); + let notary = output.join("private/notary"); + match journey.topology.as_str() { + "relay-only" => { + assert!(relay.is_dir(), "{} Relay inputs", journey.id); + assert!(!notary.exists(), "{} Notary inputs", journey.id); + } + "notary-only" => { + assert!(notary.is_dir(), "{} Notary inputs", journey.id); + assert!(!relay.exists(), "{} Relay inputs", journey.id); + } + "combined" => { + assert!(relay.is_dir(), "{} Relay inputs", journey.id); + assert!(notary.is_dir(), "{} Notary inputs", journey.id); + let notary_config = read_yaml(¬ary.join("config/notary.yaml")); + assert_eq!( + notary_config["state"]["storage"].as_str(), + Some("postgresql"), + "{} Notary correctness state", + journey.id + ); + assert!( + notary_config["evidence"]["relay"].is_mapping(), + "{} compiler-pinned Relay consultation", + journey.id + ); + let rendered = serde_yaml::to_string(¬ary_config) + .expect("generated Notary config serializes"); + assert!( + rendered.contains("contract_hash:"), + "{} compiler-pinned consultation hash", + journey.id + ); + for forbidden in ["redis:", "direct_source:", "source_credential:"] { + assert!(!rendered.contains(forbidden), "{} {forbidden}", journey.id); + } + } + _ => unreachable!("catalog topology is validated"), + } + } +} + +#[test] +fn country_variant_and_snapshot_records_keep_their_closed_outcome_sets() { + for (project, expected) in [ + ( + "opencrvs-country-variant", + [ + ("provincial-birth-match", "match"), + ("provincial-birth-no-match", "no_match"), + ("provincial-birth-ambiguous", "ambiguous"), + ] + .as_slice(), + ), + ( + "snapshot-with-records", + [ + ("snapshot-match", "match"), + ("snapshot-no-match", "no_match"), + ] + .as_slice(), + ), ] { let report = test_registry_project(&ProjectTestOptions { project_directory: golden(project), environment: None, live: false, }) - .unwrap_or_else(|error| panic!("{project} offline journey failed: {error:#}")); - assert_eq!(report.status, "passed", "{project}"); - assert!(!report.fixtures.is_empty(), "{project}"); - assert!( - report.fixtures.iter().all(|fixture| fixture.passed), - "{project}" - ); + .unwrap_or_else(|error| panic!("{project} outcome journey failed: {error:#}")); + for (fixture, outcome) in expected { + let result = report + .fixtures + .iter() + .find(|result| result.fixture == *fixture) + .unwrap_or_else(|| panic!("{project} missing {fixture}")); + assert_eq!(result.outcome.as_deref(), Some(*outcome), "{project}"); + assert!(result.passed, "{project} {fixture}"); + } } } @@ -1132,13 +1671,7 @@ fn relay_only_and_notary_only_projects_complete_their_applicable_journeys() { .expect("Relay-only project builds"); let notary_root = tempfile::tempdir().expect("Notary-only temporary directory"); - let notary = create_source_free_evaluation_project(notary_root.path()); - test_registry_project(&ProjectTestOptions { - project_directory: notary.clone(), - environment: None, - live: false, - }) - .expect("Notary-only project tests"); + let notary = copy_project("notary-only-evaluation", notary_root.path()); let check = check_registry_project(&ProjectCheckOptions { project_directory: notary.clone(), environment: "local".to_string(), @@ -2715,25 +3248,11 @@ fn strict_project_authoring_schemas_compile_and_accept_every_golden() { let integration_schema = compile("integration.schema.json"); let fixture_schema = compile("fixture.schema.json"); let entity_schema = compile("entity.schema.json"); - let mut projects = - vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/project-starters/bounded-http")]; - projects.extend( - [ - "custom-system", - "dhis2-tracker", - "dhis2-script", - "fhir-r4-coverage-active", - "opencrvs", - "opencrvs-country-variant", - "openspp-exact", - "nia-attribute-release", - "snapshot-exact", - "snapshot-with-records", - "relay-only-records", - "relay-only-materialization", - ] - .map(golden), - ); + let projects = project_authoring_journey_catalog() + .workspaces + .iter() + .map(catalog_workspace) + .collect::>(); for project in projects { validate_yaml(&project_schema, &project.join("registry-stack.yaml")); validate_yaml( @@ -4150,16 +4669,12 @@ fn records_and_snapshot_share_one_generated_materialization() { #[test] fn relay_only_and_notary_only_projects_emit_only_selected_products() { - for (project_name, present, absent, source_free_evaluation) in [ - ("relay-only-records", "relay", "notary", false), - ("notary-only-evaluation", "notary", "relay", true), + for (project_name, present, absent) in [ + ("relay-only-records", "relay", "notary"), + ("notary-only-evaluation", "notary", "relay"), ] { let temporary = tempfile::tempdir().expect("temporary directory"); - let project = if source_free_evaluation { - create_source_free_evaluation_project(temporary.path()) - } else { - copy_project(project_name, temporary.path()) - }; + let project = copy_project(project_name, temporary.path()); let build = build_registry_project(&ProjectBuildOptions { project_directory: project, environment: "local".to_string(), @@ -4881,10 +5396,22 @@ fn source_free_evaluation_without_credential_profiles_omits_issuance_and_signing .expect("evaluation-only Notary project builds without issuance"); let output = PathBuf::from(build.output.expect("build output")); let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); + assert_eq!(notary["state"]["storage"].as_str(), Some("postgresql")); + assert!(notary["evidence"].get("relay").is_none()); assert!(notary["evidence"].get("signing_keys").is_none()); assert!(notary["evidence"]["credential_profiles"] .as_mapping() .is_some_and(serde_yaml::Mapping::is_empty)); + assert!(notary.get("oid4vci").is_none()); + let rendered = serde_yaml::to_string(¬ary).expect("Notary-only config serializes"); + for forbidden in [ + "redis:", + "direct_source:", + "source_credential:", + "credential_endpoint:", + ] { + assert!(!rendered.contains(forbidden), "{forbidden}"); + } let missing_issuance_root = tempfile::tempdir().expect("temporary directory"); let missing_issuance = copy_project("custom-system", missing_issuance_root.path()); @@ -6348,44 +6875,7 @@ fn copy_project(name: &str, temporary: &Path) -> PathBuf { } fn create_source_free_evaluation_project(temporary: &Path) -> PathBuf { - let project = temporary.join("notary-only-evaluation"); - std::fs::create_dir_all(project.join("environments")) - .expect("evaluation-only project directory creates"); - let authored_project = serde_yaml::from_str( - r#"version: 1 -registry: { id: fictional-evaluation-registry } -services: - applicant-evaluation: - kind: evidence - version: 1 - purpose: application-processing - legal_basis: application-processing - consent: not_required - access: { scopes: ["evidence:application:read"] } - claims: - application-complete: - cel: "true" - value: { type: boolean } - disclosure: predicate - credential_profiles: {} -"#, - ) - .expect("evaluation-only project parses"); - write_yaml(&project.join("registry-stack.yaml"), &authored_project); - let environment = serde_yaml::from_str( - r#"version: 1 -callers: - application-service: - api_key_fingerprint: { secret: APPLICATION_SERVICE_TOKEN_HASH } - scopes: ["evidence:application:read"] -deployment: - profile: local - notary: { service: evaluation-notary } -"#, - ) - .expect("evaluation-only environment parses"); - write_yaml(&project.join("environments/local.yaml"), &environment); - project + copy_project("notary-only-evaluation", temporary) } fn add_source_free_credential_capability(project: &Path) { diff --git a/docs/site/.markdownlint-cli2.yaml b/docs/site/.markdownlint-cli2.yaml index 39da1011c..e97ca5b13 100644 --- a/docs/site/.markdownlint-cli2.yaml +++ b/docs/site/.markdownlint-cli2.yaml @@ -29,6 +29,7 @@ config: - HomeLanding - QuickstartMeta - ProjectStarterSequence + - ProjectWorkspaceJourneys - Steps globs: - "**/*.{md,mdx}" diff --git a/docs/site/scripts/generate-project-starters.mjs b/docs/site/scripts/generate-project-starters.mjs index 4b5a14c49..10bccbef6 100644 --- a/docs/site/scripts/generate-project-starters.mjs +++ b/docs/site/scripts/generate-project-starters.mjs @@ -6,113 +6,282 @@ import YAML from 'yaml'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const docsRoot = resolve(scriptDir, '..'); const defaultRepoRoot = resolve(docsRoot, '../..'); - -export const starterSources = [ - { - starter: 'http', - label: 'Custom HTTP', - workspace: 'http-project', - source: 'crates/registryctl/assets/project-starters/bounded-http', - fixtureFile: 'active.yaml', - summary: 'One fixed bounded HTTP request with a closed response projection.', - }, - { - starter: 'dhis2-tracker', - label: 'DHIS2 Tracker', - workspace: 'dhis2-project', - source: 'crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker', - fixtureFile: 'match.yaml', - summary: - 'A product-neutral script adapter applied to a bounded DHIS2 Tracker read journey.', - }, - { - starter: 'opencrvs-dci', - label: 'OpenCRVS DCI', - workspace: 'opencrvs-project', - source: 'crates/registryctl/tests/fixtures/project-authoring/opencrvs', - fixtureFile: 'match.yaml', - summary: - 'A product-neutral script adapter with the signed DCI search verification profile.', - }, - { - starter: 'fhir-r4', - label: 'FHIR R4', - workspace: 'fhir-project', - source: 'crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active', - fixtureFile: 'match.yaml', - summary: - 'A product-neutral script adapter with bounded FHIR R4 search-set parsing.', - }, - { - starter: 'snapshot', - label: 'Exact snapshot', - workspace: 'snapshot-project', - source: 'crates/registryctl/tests/fixtures/project-authoring/snapshot-exact', - fixtureFile: 'match.yaml', - summary: 'An exact lookup over one immutable local materialization.', - }, -]; +const catalogRelative = 'crates/registryctl/tests/fixtures/project-authoring-journeys.yaml'; +const goldenPrefix = 'crates/registryctl/tests/fixtures/project-authoring/'; +const supportedSteps = ['init', 'editor', 'trace', 'watch', 'test', 'check', 'build']; +const starterSteps = supportedSteps; +const publicStarterOrder = ['http', 'dhis2-tracker', 'opencrvs-dci', 'fhir-r4', 'snapshot']; +const safeCliTokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; async function readYaml(path) { return YAML.parse(await readFile(path, 'utf8')); } -export async function buildProjectStarterMatrix(repoRoot = defaultRepoRoot) { - const starters = []; +function equalValues(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} - for (const source of starterSources) { - const projectRoot = resolve(repoRoot, source.source); - const project = await readYaml(join(projectRoot, 'registry-stack.yaml')); - const integrations = Object.keys(project.integrations ?? {}); - if (integrations.length !== 1) { - throw new Error(`${source.source} must contain exactly one starter integration`); +function requireSafeCliToken(value, field) { + if (typeof value !== 'string' || !safeCliTokenPattern.test(value)) { + throw new Error(`${field} must be a safe CLI token`); + } +} + +function requireSafeProjectPath(value, field) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.startsWith('-') || + !value.split('/').every((segment) => safeCliTokenPattern.test(segment)) + ) { + throw new Error(`${field} must be a safe relative project path`); + } +} + +function validateCatalogCommandArguments(workspace) { + requireSafeProjectPath(workspace.project_dir, `${workspace.id} project_dir`); + requireSafeCliToken(workspace.environment, `${workspace.id} environment`); + if (workspace.starter !== undefined) { + requireSafeCliToken(workspace.starter, `${workspace.id} starter`); + } +} + +function deriveTopology(project, source) { + const services = Object.values(project.services ?? {}); + const hasRelay = + Object.keys(project.integrations ?? {}).length > 0 || + Object.keys(project.entities ?? {}).length > 0 || + services.some((service) => service.kind === 'records_api'); + const hasNotary = services.some((service) => service.kind === 'evidence'); + if (hasRelay && hasNotary) return 'combined'; + if (hasRelay) return 'relay-only'; + if (hasNotary) return 'notary-only'; + throw new Error(`${source} does not select a Registry Stack product`); +} + +async function deriveFocusedSelection(projectRoot, project, workspace) { + const integrations = Object.entries(project.integrations ?? {}); + if (integrations.length !== 1) { + throw new Error(`${workspace.source} must contain exactly one focused integration`); + } + const [integration, reference] = integrations[0]; + requireSafeCliToken(integration, `${workspace.id} integration id`); + const fixtureDir = join(projectRoot, dirname(reference.file), 'fixtures'); + const fixtureFiles = (await readdir(fixtureDir)).filter((name) => name.endsWith('.yaml')); + if (!fixtureFiles.includes(workspace.focused_fixture_file)) { + throw new Error( + `${workspace.source} is missing focused fixture ${workspace.focused_fixture_file}`, + ); + } + const fixture = await readYaml(join(fixtureDir, workspace.focused_fixture_file)); + if (typeof fixture.name !== 'string' || fixture.name.length === 0) { + throw new Error(`${workspace.source}/${workspace.focused_fixture_file} must be a named fixture`); + } + requireSafeCliToken(fixture.name, `${workspace.id} fixture name`); + if (workspace.starter && fixture.expect?.outcome !== 'match') { + throw new Error(`${workspace.source}/${workspace.focused_fixture_file} must be a match fixture`); + } + return { integration, fixture: fixture.name }; +} + +async function hasAuthoredFixtures(projectRoot, project) { + for (const reference of Object.values(project.integrations ?? {})) { + const fixtureDirectory = join(projectRoot, dirname(reference.file), 'fixtures'); + try { + if ((await readdir(fixtureDirectory)).some((name) => name.endsWith('.yaml'))) return true; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + return false; +} + +function buildCommands(workspace, selection) { + const commands = []; + for (const step of workspace.steps) { + switch (step) { + case 'init': + commands.push( + `registryctl init --from ${workspace.starter} --project-dir ${workspace.project_dir}`, + ); + break; + case 'editor': + commands.push(`registryctl authoring editor --project-dir ${workspace.project_dir}`); + break; + case 'trace': + commands.push( + `registryctl test --project-dir ${workspace.project_dir} --integration ${selection.integration} --fixture ${selection.fixture} --trace`, + ); + break; + case 'watch': + commands.push( + `registryctl test --project-dir ${workspace.project_dir} --integration ${selection.integration} --fixture ${selection.fixture} --watch`, + ); + break; + case 'test': + commands.push(`registryctl test --project-dir ${workspace.project_dir}`); + break; + case 'check': + commands.push( + `registryctl check --project-dir ${workspace.project_dir} --environment ${workspace.environment}${workspace.check_explain ? ' --explain' : ''}`, + ); + break; + case 'build': + commands.push( + `registryctl build --project-dir ${workspace.project_dir} --environment ${workspace.environment}`, + ); + break; + default: + throw new Error(`${workspace.id} contains unsupported step ${step}`); } + } + return commands; +} - const integration = integrations[0]; - const fixtureDir = join(projectRoot, 'integrations', integration, 'fixtures'); - const fixtureFiles = (await readdir(fixtureDir)).filter((name) => name.endsWith('.yaml')); - if (!fixtureFiles.includes(source.fixtureFile)) { - throw new Error(`${source.source} is missing focused fixture ${source.fixtureFile}`); +function selectPublicStarters(journeys) { + const starterJourneys = journeys.filter((journey) => journey.starter); + if (starterJourneys.length !== publicStarterOrder.length) { + throw new Error( + `public starter catalog must contain exactly ${publicStarterOrder.length} entries`, + ); + } + const byStarter = new Map( + starterJourneys.map((journey) => [journey.starter, journey]), + ); + if (byStarter.size !== starterJourneys.length) { + throw new Error('public starter catalog contains a duplicate starter'); + } + if ( + publicStarterOrder.some((starter) => !byStarter.has(starter)) + ) { + throw new Error( + `public starter catalog must contain exactly ${publicStarterOrder.join(', ')}`, + ); + } + return publicStarterOrder.map((starter) => byStarter.get(starter)); +} + +export async function buildProjectAuthoringJourneyMatrix(repoRoot = defaultRepoRoot) { + const catalog = await readYaml(resolve(repoRoot, catalogRelative)); + if (catalog.version !== 1 || !Array.isArray(catalog.workspaces)) { + throw new Error(`${catalogRelative} must be a version 1 workspace catalog`); + } + + const actualGoldens = new Set( + (await readdir(resolve(repoRoot, goldenPrefix), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name), + ); + const catalogGoldens = new Set(); + const ids = new Set(); + const sources = new Set(); + const journeys = []; + + for (const workspace of catalog.workspaces) { + validateCatalogCommandArguments(workspace); + if (ids.has(workspace.id)) throw new Error(`duplicate catalog id ${workspace.id}`); + if (sources.has(workspace.source)) throw new Error(`duplicate catalog source ${workspace.source}`); + ids.add(workspace.id); + sources.add(workspace.source); + if (workspace.source.startsWith(goldenPrefix)) { + catalogGoldens.add(workspace.source.slice(goldenPrefix.length)); + } + if (!['maintained', 'conformance-only'].includes(workspace.classification)) { + throw new Error(`${workspace.id} has unknown classification ${workspace.classification}`); + } + if (!Array.isArray(workspace.steps) || new Set(workspace.steps).size !== workspace.steps.length) { + throw new Error(`${workspace.id} must list each supported step once`); + } + if (!workspace.steps.every((step) => supportedSteps.includes(step))) { + throw new Error(`${workspace.id} contains an unsupported step`); + } + if (workspace.environment !== 'local' || workspace.check_explain !== true) { + throw new Error(`${workspace.id} must document check --environment local --explain`); + } + if (!workspace.steps.includes('check') || !workspace.steps.includes('build')) { + throw new Error(`${workspace.id} must support check and build`); + } + if (workspace.starter) { + if (!equalValues(workspace.steps, starterSteps)) { + throw new Error(`${workspace.id} starter must expose the canonical seven-command journey`); + } + } else if (workspace.steps.includes('init')) { + throw new Error(`${workspace.id} is not a starter and cannot emit init --from`); } - const fixture = await readYaml(join(fixtureDir, source.fixtureFile)); - if (fixture.expect?.outcome !== 'match' || typeof fixture.name !== 'string') { - throw new Error(`${source.source}/${source.fixtureFile} must be a named match fixture`); + const projectRoot = resolve(repoRoot, workspace.source); + const project = await readYaml(join(projectRoot, 'registry-stack.yaml')); + const topology = deriveTopology(project, workspace.source); + if (workspace.topology !== topology) { + throw new Error( + `${workspace.id} declares ${workspace.topology} but workspace content is ${topology}`, + ); + } + const authoredFixtures = await hasAuthoredFixtures(projectRoot, project); + if (!authoredFixtures && !equalValues(workspace.steps, ['check', 'build'])) { + throw new Error( + `${workspace.id} is fixtureless and may document only check and build`, + ); + } + if ( + authoredFixtures && + workspace.classification === 'maintained' && + !workspace.steps.includes('watch') + ) { + throw new Error(`${workspace.id} is maintained with fixtures and must document watch`); } - const projectDir = source.workspace; - const selection = `--integration ${integration} --fixture ${fixture.name}`; - starters.push({ - starter: source.starter, - label: source.label, - summary: source.summary, - source: source.source, - integration, - fixture: fixture.name, - commands: [ - `registryctl init --from ${source.starter} --project-dir ${projectDir}`, - `registryctl authoring editor --project-dir ${projectDir}`, - `registryctl test --project-dir ${projectDir} ${selection} --trace`, - `registryctl test --project-dir ${projectDir} ${selection} --watch`, - `registryctl test --project-dir ${projectDir}`, - `registryctl check --project-dir ${projectDir} --environment local --explain`, - `registryctl build --project-dir ${projectDir} --environment local`, - ], + const focused = workspace.steps.some((step) => step === 'trace' || step === 'watch') + ? await deriveFocusedSelection(projectRoot, project, workspace) + : {}; + journeys.push({ + id: workspace.id, + label: workspace.label, + summary: workspace.summary, + source: workspace.source, + classification: workspace.classification, + ...(workspace.focus ? { focus: workspace.focus } : {}), + topology, + ...(workspace.evidence ? { evidence: workspace.evidence } : {}), + ...(workspace.starter ? { starter: workspace.starter } : {}), + project_dir: workspace.project_dir, + capabilities: workspace.steps, + ...focused, + commands: buildCommands(workspace, focused), }); } - return starters; + if (!equalValues([...catalogGoldens].toSorted(), [...actualGoldens].toSorted())) { + throw new Error( + `project-authoring golden catalog drift: catalog=${[...catalogGoldens].toSorted().join(',')} actual=${[...actualGoldens].toSorted().join(',')}`, + ); + } + selectPublicStarters(journeys); + return journeys; +} + +export async function buildProjectStarterMatrix(repoRoot = defaultRepoRoot) { + return selectPublicStarters(await buildProjectAuthoringJourneyMatrix(repoRoot)); } export async function generateProjectStarterMatrix(repoRoot = defaultRepoRoot) { const outputDir = resolve(docsRoot, 'src/data/generated'); await mkdir(outputDir, { recursive: true }); - const starters = await buildProjectStarterMatrix(repoRoot); - await writeFile( - resolve(outputDir, 'project-starters.json'), - `${JSON.stringify(starters, null, 2)}\n`, + const journeys = await buildProjectAuthoringJourneyMatrix(repoRoot); + const starters = selectPublicStarters(journeys); + await Promise.all([ + writeFile( + resolve(outputDir, 'project-authoring-journeys.json'), + `${JSON.stringify(journeys, null, 2)}\n`, + ), + writeFile( + resolve(outputDir, 'project-starters.json'), + `${JSON.stringify(starters, null, 2)}\n`, + ), + ]); + console.log( + `Generated project-authoring command matrix for ${journeys.length} workspaces and ${starters.length} starters.`, ); - console.log(`Generated project starter command matrix for ${starters.length} starters.`); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/docs/site/scripts/generate-project-starters.test.mjs b/docs/site/scripts/generate-project-starters.test.mjs index d0b141c75..6289e310c 100644 --- a/docs/site/scripts/generate-project-starters.test.mjs +++ b/docs/site/scripts/generate-project-starters.test.mjs @@ -1,14 +1,61 @@ import assert from 'node:assert/strict'; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { test } from 'node:test'; -import { resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import YAML from 'yaml'; import { + buildProjectAuthoringJourneyMatrix, buildProjectStarterMatrix, - starterSources, } from './generate-project-starters.mjs'; const repoRoot = resolve(import.meta.dirname, '../../..'); +const catalogRelative = 'crates/registryctl/tests/fixtures/project-authoring-journeys.yaml'; -test('derives all advertised starter selections from committed golden workspaces', async () => { +async function withIsolatedProjectCatalog(run) { + const root = await mkdtemp(join(tmpdir(), 'registry-project-catalog-')); + try { + const catalogPath = resolve(root, catalogRelative); + const catalog = YAML.parse(await readFile(resolve(repoRoot, catalogRelative), 'utf8')); + await mkdir(dirname(catalogPath), { recursive: true }); + await writeFile(catalogPath, YAML.stringify(catalog)); + for (const source of new Set(catalog.workspaces.map((workspace) => workspace.source))) { + const destination = resolve(root, source); + await mkdir(dirname(destination), { recursive: true }); + await cp(resolve(repoRoot, source), destination, { recursive: true }); + } + await run({ root, catalog, catalogPath }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test('classifies every golden and derives topology from committed workspace content', async () => { + const journeys = await buildProjectAuthoringJourneyMatrix(repoRoot); + + assert.equal(journeys.length, 14); + assert.deepEqual( + journeys.map(({ id, classification, topology }) => ({ id, classification, topology })), + [ + { id: 'http', classification: 'maintained', topology: 'combined' }, + { id: 'custom-system', classification: 'maintained', topology: 'combined' }, + { id: 'dhis2-script', classification: 'conformance-only', topology: 'combined' }, + { id: 'dhis2-tracker', classification: 'maintained', topology: 'combined' }, + { id: 'fhir-r4-coverage-active', classification: 'maintained', topology: 'combined' }, + { id: 'nia-attribute-release', classification: 'conformance-only', topology: 'relay-only' }, + { id: 'opencrvs-dci', classification: 'maintained', topology: 'combined' }, + { id: 'opencrvs-country-variant', classification: 'maintained', topology: 'combined' }, + { id: 'openspp-exact', classification: 'maintained', topology: 'combined' }, + { id: 'relay-only-materialization', classification: 'maintained', topology: 'relay-only' }, + { id: 'relay-only-records', classification: 'maintained', topology: 'relay-only' }, + { id: 'snapshot', classification: 'maintained', topology: 'combined' }, + { id: 'snapshot-with-records', classification: 'maintained', topology: 'combined' }, + { id: 'notary-only-evaluation', classification: 'maintained', topology: 'notary-only' }, + ], + ); +}); + +test('derives all advertised starter selections from committed workspaces', async () => { const starters = await buildProjectStarterMatrix(repoRoot); assert.deepEqual( @@ -31,11 +78,20 @@ test('derives all advertised starter selections from committed golden workspaces ); }); -test('emits one canonical seven-command sequence for every starter', async () => { +test('emits one canonical seven-command sequence for exactly five starters', async () => { const starters = await buildProjectStarterMatrix(repoRoot); - assert.equal(starters.length, starterSources.length); + assert.equal(starters.length, 5); for (const starter of starters) { + assert.deepEqual(starter.capabilities, [ + 'init', + 'editor', + 'trace', + 'watch', + 'test', + 'check', + 'build', + ]); assert.equal(starter.commands.length, 7); assert.match(starter.commands[0], /^registryctl init --from /); assert.match(starter.commands[1], /^registryctl authoring editor --project-dir /); @@ -46,3 +102,232 @@ test('emits one canonical seven-command sequence for every starter', async () => assert.match(starter.commands[6], / --environment local$/); } }); + +test('non-starters never emit init and supported steps follow fixture maintenance status', async () => { + const journeys = await buildProjectAuthoringJourneyMatrix(repoRoot); + const nonStarters = journeys.filter((journey) => !journey.starter); + assert.equal(nonStarters.length, 9); + for (const journey of nonStarters) { + assert.equal(journey.commands.some((command) => command.includes(' init --from ')), false); + assert.equal(journey.project_dir, journey.source); + assert.equal( + journey.commands.every((command) => command.includes(`--project-dir ${journey.source}`)), + true, + ); + } + + for (const id of [ + 'nia-attribute-release', + 'relay-only-materialization', + 'relay-only-records', + 'notary-only-evaluation', + ]) { + const journey = journeys.find((candidate) => candidate.id === id); + assert.deepEqual(journey.capabilities, ['check', 'build']); + assert.deepEqual(journey.commands, [ + `registryctl check --project-dir ${journey.project_dir} --environment local --explain`, + `registryctl build --project-dir ${journey.project_dir} --environment local`, + ]); + } + + assert.deepEqual( + journeys + .filter((journey) => journey.capabilities.includes('watch')) + .map((journey) => journey.id), + [ + 'http', + 'custom-system', + 'dhis2-tracker', + 'fhir-r4-coverage-active', + 'opencrvs-dci', + 'opencrvs-country-variant', + 'openspp-exact', + 'snapshot', + 'snapshot-with-records', + ], + ); +}); + +test('keeps country, snapshot-records, OpenSPP, and conformance decisions explicit', async () => { + const journeys = await buildProjectAuthoringJourneyMatrix(repoRoot); + const byId = Object.fromEntries(journeys.map((journey) => [journey.id, journey])); + + assert.deepEqual( + { + integration: byId['opencrvs-country-variant'].integration, + fixture: byId['opencrvs-country-variant'].fixture, + source: byId['opencrvs-country-variant'].source, + }, + { + integration: 'birth-record', + fixture: 'provincial-birth-match', + source: + 'crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant', + }, + ); + assert.deepEqual( + { + integration: byId['snapshot-with-records'].integration, + fixture: byId['snapshot-with-records'].fixture, + source: byId['snapshot-with-records'].source, + }, + { + integration: 'person-snapshot', + fixture: 'snapshot-match', + source: 'crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records', + }, + ); + assert.equal(byId['openspp-exact'].evidence, 'offline-fixture-only-pending-357'); + assert.equal(byId['dhis2-script'].classification, 'conformance-only'); + assert.equal(byId['dhis2-script'].starter, undefined); + assert.deepEqual(byId['dhis2-script'].capabilities, ['test', 'check', 'build']); + assert.equal(byId['nia-attribute-release'].focus, 'solmara'); + assert.equal(byId['nia-attribute-release'].starter, undefined); +}); + +test('rejects unsafe catalog-derived command arguments before generation', async () => { + await withIsolatedProjectCatalog(async ({ root, catalog, catalogPath }) => { + const workspace = catalog.workspaces.find((candidate) => candidate.id === 'http'); + for (const { field, value, error } of [ + { + field: 'project_dir', + value: 'registry-project --live', + error: /http project_dir must be a safe relative project path/, + }, + { + field: 'project_dir', + value: 'registry-project/../escape', + error: /http project_dir must be a safe relative project path/, + }, + { + field: 'project_dir', + value: 'registry-project;touch-pwned', + error: /http project_dir must be a safe relative project path/, + }, + { + field: 'project_dir', + value: '/tmp/registry-project', + error: /http project_dir must be a safe relative project path/, + }, + { + field: 'project_dir', + value: '--registry-project', + error: /http project_dir must be a safe relative project path/, + }, + { + field: 'starter', + value: '--help', + error: /http starter must be a safe CLI token/, + }, + { + field: 'environment', + value: 'local$(touch-pwned)', + error: /http environment must be a safe CLI token/, + }, + ]) { + const original = workspace[field]; + workspace[field] = value; + await writeFile(catalogPath, YAML.stringify(catalog)); + await assert.rejects(buildProjectAuthoringJourneyMatrix(root), error); + workspace[field] = original; + } + }); +}); + +test('rejects unsafe workspace and fixture command arguments before generation', async () => { + await withIsolatedProjectCatalog(async ({ root, catalog }) => { + const workspace = catalog.workspaces.find((candidate) => candidate.id === 'http'); + const projectPath = resolve(root, workspace.source, 'registry-stack.yaml'); + const projectText = await readFile(projectPath, 'utf8'); + const project = YAML.parse(projectText); + const integrationReference = project.integrations['person-record']; + + for (const integration of ['--help', 'person-record;touch-pwned', '../person-record']) { + project.integrations = { [integration]: integrationReference }; + await writeFile(projectPath, YAML.stringify(project)); + await assert.rejects( + buildProjectAuthoringJourneyMatrix(root), + /http integration id must be a safe CLI token/, + ); + } + await writeFile(projectPath, projectText); + + const fixturePath = resolve( + root, + workspace.source, + dirname(integrationReference.file), + 'fixtures', + workspace.focused_fixture_file, + ); + const fixtureText = await readFile(fixturePath, 'utf8'); + const fixture = YAML.parse(fixtureText); + for (const name of ['active-person --watch', 'active-person;touch-pwned', '../active-person']) { + fixture.name = name; + await writeFile(fixturePath, YAML.stringify(fixture)); + await assert.rejects( + buildProjectAuthoringJourneyMatrix(root), + /http fixture name must be a safe CLI token/, + ); + } + }); +}); + +test('rejects duplicate starter entries instead of collapsing them', async () => { + await withIsolatedProjectCatalog(async ({ root, catalog, catalogPath }) => { + const fhir = catalog.workspaces.find((workspace) => workspace.starter === 'fhir-r4'); + fhir.starter = 'http'; + await writeFile(catalogPath, YAML.stringify(catalog)); + + await assert.rejects( + buildProjectAuthoringJourneyMatrix(root), + /public starter catalog contains a duplicate starter/, + ); + }); +}); + +test('rejects a catalog with fewer than five starter entries', async () => { + await withIsolatedProjectCatalog(async ({ root, catalog, catalogPath }) => { + const fhir = catalog.workspaces.find((workspace) => workspace.starter === 'fhir-r4'); + delete fhir.starter; + fhir.steps = fhir.steps.filter((step) => step !== 'init'); + await writeFile(catalogPath, YAML.stringify(catalog)); + + await assert.rejects( + buildProjectAuthoringJourneyMatrix(root), + /public starter catalog must contain exactly 5 entries/, + ); + }); +}); + +test('publishes the authoring tutorial with the catalog-backed HTTP command sequence', async () => { + const [starters, tutorial, astroConfig] = await Promise.all([ + buildProjectStarterMatrix(repoRoot), + readFile( + resolve(repoRoot, 'docs/site/src/content/docs/tutorials/author-registry-project.mdx'), + 'utf8', + ), + readFile(resolve(repoRoot, 'docs/site/astro.config.mjs'), 'utf8'), + ]); + const normalizedTutorial = tutorial.replaceAll(/\\\n\s*/g, '').replaceAll(/\s+/g, ' '); + const http = starters.find((starter) => starter.starter === 'http'); + + assert.match(tutorial, /^status: current$/m); + assert.doesNotMatch(tutorial, /^draft: true$/m); + for (const command of http.commands) { + assert.equal( + normalizedTutorial.includes(command), + true, + `authoring tutorial must document catalog command: ${command}`, + ); + } + assert.equal( + astroConfig.match(/slug: 'tutorials\/author-registry-project'/g)?.length, + 1, + 'authoring tutorial has one author-path placement', + ); + const integrations = astroConfig.slice( + astroConfig.indexOf("label: 'Integrations'"), + astroConfig.indexOf("label: 'Concepts'"), + ); + assert.match(integrations, /slug: 'tutorials\/author-registry-project'/); +}); diff --git a/docs/site/src/components/ProjectWorkspaceJourneys.astro b/docs/site/src/components/ProjectWorkspaceJourneys.astro new file mode 100644 index 000000000..7c89f5319 --- /dev/null +++ b/docs/site/src/components/ProjectWorkspaceJourneys.astro @@ -0,0 +1,29 @@ +--- +import journeys from '../data/generated/project-authoring-journeys.json'; + +const nonStarters = journeys.filter((journey) => !journey.starter); +--- + +{ + nonStarters.map((journey) => ( +
+

{journey.label}

+

{journey.summary}

+
+
Classification
+
{journey.classification === 'conformance-only' ? 'Conformance only' : 'Maintained'}
+
Topology
+
{journey.topology}
+
Authored source
+
{journey.source}
+
+
{journey.commands.join('\n')}
+
+ )) +} + +

+ Generated from crates/registryctl/tests/fixtures/project-authoring-journeys.yaml and + its committed workspaces. Run these commands from the repository root. Non-starter entries target + their displayed authored source directly and do not support init --from. +

diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index 829a24ee7..46f9b0c4a 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -12,6 +12,7 @@ standards_referenced: [] --- import ProjectStarterSequence from '../../../components/ProjectStarterSequence.astro'; +import ProjectWorkspaceJourneys from '../../../components/ProjectWorkspaceJourneys.astro'; `registryctl` is the local adopter command-line tool for Registry Stack. It creates a local project, starts and stops the generated services, and runs validation and smoke checks against them. @@ -98,6 +99,14 @@ documentation copy. +### Maintained and conformance workspaces + +The following workspaces are checked-in authoring evidence, not additional public starters. +Their command lists contain only the workflow steps supported by each workspace. +OpenSPP remains offline-fixture-only pending the owner-run holdout in GH#357. + + + ### VS Code and Zed editor setup `init --from` creates schema-driven editing support with every starter. For a project created by an diff --git a/docs/site/src/content/docs/tutorials/author-registry-project.mdx b/docs/site/src/content/docs/tutorials/author-registry-project.mdx index 28a889011..3f945017a 100644 --- a/docs/site/src/content/docs/tutorials/author-registry-project.mdx +++ b/docs/site/src/content/docs/tutorials/author-registry-project.mdx @@ -1,7 +1,7 @@ --- title: Author an HTTP Registry Stack project description: Copy the HTTP starter, run its offline fixtures, review the generated plan, and build unsigned Relay and Notary Config Bundle inputs. -status: draft +status: current owner: registry-docs source_repos: - registry-stack diff --git a/docs/site/src/data/generated/project-authoring-journeys.json b/docs/site/src/data/generated/project-authoring-journeys.json new file mode 100644 index 000000000..a9ec48403 --- /dev/null +++ b/docs/site/src/data/generated/project-authoring-journeys.json @@ -0,0 +1,341 @@ +[ + { + "id": "http", + "label": "Custom HTTP", + "summary": "One fixed bounded HTTP request with a closed response projection.", + "source": "crates/registryctl/assets/project-starters/bounded-http", + "classification": "maintained", + "topology": "combined", + "starter": "http", + "project_dir": "registry-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "person-record", + "fixture": "active-person", + "commands": [ + "registryctl init --from http --project-dir registry-project", + "registryctl authoring editor --project-dir registry-project", + "registryctl test --project-dir registry-project --integration person-record --fixture active-person --trace", + "registryctl test --project-dir registry-project --integration person-record --fixture active-person --watch", + "registryctl test --project-dir registry-project", + "registryctl check --project-dir registry-project --environment local --explain", + "registryctl build --project-dir registry-project --environment local" + ] + }, + { + "id": "custom-system", + "label": "Custom HTTP conformance workspace", + "summary": "Combined HTTP acquisition and registry-backed evidence coverage.", + "source": "crates/registryctl/tests/fixtures/project-authoring/custom-system", + "classification": "maintained", + "topology": "combined", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/custom-system", + "capabilities": [ + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "eligibility", + "fixture": "eligible-household", + "commands": [ + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --integration eligibility --fixture eligible-household --trace", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --integration eligibility --fixture eligible-household --watch", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system", + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --environment local" + ] + }, + { + "id": "dhis2-script", + "label": "DHIS2 script conformance workspace", + "summary": "Conformance coverage for the bounded Script adapter surface.", + "source": "crates/registryctl/tests/fixtures/project-authoring/dhis2-script", + "classification": "conformance-only", + "topology": "combined", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/dhis2-script", + "capabilities": [ + "test", + "check", + "build" + ], + "commands": [ + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/dhis2-script", + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/dhis2-script --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/dhis2-script --environment local" + ] + }, + { + "id": "dhis2-tracker", + "label": "DHIS2 Tracker", + "summary": "A product-neutral script adapter applied to a bounded DHIS2 Tracker read journey.", + "source": "crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker", + "classification": "maintained", + "topology": "combined", + "starter": "dhis2-tracker", + "project_dir": "dhis2-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "health-record", + "fixture": "complete-health-match", + "commands": [ + "registryctl init --from dhis2-tracker --project-dir dhis2-project", + "registryctl authoring editor --project-dir dhis2-project", + "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --trace", + "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --watch", + "registryctl test --project-dir dhis2-project", + "registryctl check --project-dir dhis2-project --environment local --explain", + "registryctl build --project-dir dhis2-project --environment local" + ] + }, + { + "id": "fhir-r4-coverage-active", + "label": "FHIR R4", + "summary": "A product-neutral script adapter with bounded FHIR R4 search-set parsing.", + "source": "crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active", + "classification": "maintained", + "topology": "combined", + "starter": "fhir-r4", + "project_dir": "fhir-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "coverage", + "fixture": "coverage-active", + "commands": [ + "registryctl init --from fhir-r4 --project-dir fhir-project", + "registryctl authoring editor --project-dir fhir-project", + "registryctl test --project-dir fhir-project --integration coverage --fixture coverage-active --trace", + "registryctl test --project-dir fhir-project --integration coverage --fixture coverage-active --watch", + "registryctl test --project-dir fhir-project", + "registryctl check --project-dir fhir-project --environment local --explain", + "registryctl build --project-dir fhir-project --environment local" + ] + }, + { + "id": "nia-attribute-release", + "label": "NIA attribute release", + "summary": "Solmara-focused conformance coverage for a bounded Relay attribute-release profile.", + "source": "crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release", + "classification": "conformance-only", + "focus": "solmara", + "topology": "relay-only", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release", + "capabilities": [ + "check", + "build" + ], + "commands": [ + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release --environment local" + ] + }, + { + "id": "opencrvs-dci", + "label": "OpenCRVS DCI", + "summary": "A product-neutral script adapter with the signed DCI search verification profile.", + "source": "crates/registryctl/tests/fixtures/project-authoring/opencrvs", + "classification": "maintained", + "topology": "combined", + "starter": "opencrvs-dci", + "project_dir": "opencrvs-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "birth-record", + "fixture": "birth-record-match", + "commands": [ + "registryctl init --from opencrvs-dci --project-dir opencrvs-project", + "registryctl authoring editor --project-dir opencrvs-project", + "registryctl test --project-dir opencrvs-project --integration birth-record --fixture birth-record-match --trace", + "registryctl test --project-dir opencrvs-project --integration birth-record --fixture birth-record-match --watch", + "registryctl test --project-dir opencrvs-project", + "registryctl check --project-dir opencrvs-project --environment local --explain", + "registryctl build --project-dir opencrvs-project --environment local" + ] + }, + { + "id": "opencrvs-country-variant", + "label": "OpenCRVS country variant", + "summary": "Country-owned DCI mapping coverage for match, no-match, and ambiguity.", + "source": "crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant", + "classification": "maintained", + "topology": "combined", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant", + "capabilities": [ + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "birth-record", + "fixture": "provincial-birth-match", + "commands": [ + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant --integration birth-record --fixture provincial-birth-match --trace", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant --integration birth-record --fixture provincial-birth-match --watch", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant", + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant --environment local" + ] + }, + { + "id": "openspp-exact", + "label": "OpenSPP exact lookup", + "summary": "Offline fixture evidence only, pending the owner-run holdout in GH#357.", + "source": "crates/registryctl/tests/fixtures/project-authoring/openspp-exact", + "classification": "maintained", + "topology": "combined", + "evidence": "offline-fixture-only-pending-357", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/openspp-exact", + "capabilities": [ + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "individual", + "fixture": "social-registry-match", + "commands": [ + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/openspp-exact --integration individual --fixture social-registry-match --trace", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/openspp-exact --integration individual --fixture social-registry-match --watch", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/openspp-exact", + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/openspp-exact --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/openspp-exact --environment local" + ] + }, + { + "id": "relay-only-materialization", + "label": "Relay-only materialization", + "summary": "Fixtureless Relay materialization configuration with no public records service.", + "source": "crates/registryctl/tests/fixtures/project-authoring/relay-only-materialization", + "classification": "maintained", + "topology": "relay-only", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/relay-only-materialization", + "capabilities": [ + "check", + "build" + ], + "commands": [ + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/relay-only-materialization --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/relay-only-materialization --environment local" + ] + }, + { + "id": "relay-only-records", + "label": "Relay-only records API", + "summary": "Fixtureless Relay records configuration with no Notary inputs.", + "source": "crates/registryctl/tests/fixtures/project-authoring/relay-only-records", + "classification": "maintained", + "topology": "relay-only", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/relay-only-records", + "capabilities": [ + "check", + "build" + ], + "commands": [ + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/relay-only-records --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/relay-only-records --environment local" + ] + }, + { + "id": "snapshot", + "label": "Exact snapshot", + "summary": "An exact lookup over one immutable local materialization.", + "source": "crates/registryctl/tests/fixtures/project-authoring/snapshot-exact", + "classification": "maintained", + "topology": "combined", + "starter": "snapshot", + "project_dir": "snapshot-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "person-snapshot", + "fixture": "snapshot-match", + "commands": [ + "registryctl init --from snapshot --project-dir snapshot-project", + "registryctl authoring editor --project-dir snapshot-project", + "registryctl test --project-dir snapshot-project --integration person-snapshot --fixture snapshot-match --trace", + "registryctl test --project-dir snapshot-project --integration person-snapshot --fixture snapshot-match --watch", + "registryctl test --project-dir snapshot-project", + "registryctl check --project-dir snapshot-project --environment local --explain", + "registryctl build --project-dir snapshot-project --environment local" + ] + }, + { + "id": "snapshot-with-records", + "label": "Snapshot with records API", + "summary": "Match and no-match evidence sharing one authorized Relay materialization with records.", + "source": "crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records", + "classification": "maintained", + "topology": "combined", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records", + "capabilities": [ + "trace", + "watch", + "test", + "check", + "build" + ], + "integration": "person-snapshot", + "fixture": "snapshot-match", + "commands": [ + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records --integration person-snapshot --fixture snapshot-match --trace", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records --integration person-snapshot --fixture snapshot-match --watch", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records", + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records --environment local" + ] + }, + { + "id": "notary-only-evaluation", + "label": "Notary-only evaluation", + "summary": "Local policy evaluation only, with no credential profile, issuance, or direct source capability.", + "source": "crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation", + "classification": "maintained", + "topology": "notary-only", + "project_dir": "crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation", + "capabilities": [ + "check", + "build" + ], + "commands": [ + "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation --environment local --explain", + "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/notary-only-evaluation --environment local" + ] + } +] diff --git a/docs/site/src/data/generated/project-starters.json b/docs/site/src/data/generated/project-starters.json index 323a5b3f7..3d778655d 100644 --- a/docs/site/src/data/generated/project-starters.json +++ b/docs/site/src/data/generated/project-starters.json @@ -1,26 +1,52 @@ [ { - "starter": "http", + "id": "http", "label": "Custom HTTP", "summary": "One fixed bounded HTTP request with a closed response projection.", "source": "crates/registryctl/assets/project-starters/bounded-http", + "classification": "maintained", + "topology": "combined", + "starter": "http", + "project_dir": "registry-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], "integration": "person-record", "fixture": "active-person", "commands": [ - "registryctl init --from http --project-dir http-project", - "registryctl authoring editor --project-dir http-project", - "registryctl test --project-dir http-project --integration person-record --fixture active-person --trace", - "registryctl test --project-dir http-project --integration person-record --fixture active-person --watch", - "registryctl test --project-dir http-project", - "registryctl check --project-dir http-project --environment local --explain", - "registryctl build --project-dir http-project --environment local" + "registryctl init --from http --project-dir registry-project", + "registryctl authoring editor --project-dir registry-project", + "registryctl test --project-dir registry-project --integration person-record --fixture active-person --trace", + "registryctl test --project-dir registry-project --integration person-record --fixture active-person --watch", + "registryctl test --project-dir registry-project", + "registryctl check --project-dir registry-project --environment local --explain", + "registryctl build --project-dir registry-project --environment local" ] }, { - "starter": "dhis2-tracker", + "id": "dhis2-tracker", "label": "DHIS2 Tracker", "summary": "A product-neutral script adapter applied to a bounded DHIS2 Tracker read journey.", "source": "crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker", + "classification": "maintained", + "topology": "combined", + "starter": "dhis2-tracker", + "project_dir": "dhis2-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], "integration": "health-record", "fixture": "complete-health-match", "commands": [ @@ -34,10 +60,23 @@ ] }, { - "starter": "opencrvs-dci", + "id": "opencrvs-dci", "label": "OpenCRVS DCI", "summary": "A product-neutral script adapter with the signed DCI search verification profile.", "source": "crates/registryctl/tests/fixtures/project-authoring/opencrvs", + "classification": "maintained", + "topology": "combined", + "starter": "opencrvs-dci", + "project_dir": "opencrvs-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], "integration": "birth-record", "fixture": "birth-record-match", "commands": [ @@ -51,10 +90,23 @@ ] }, { - "starter": "fhir-r4", + "id": "fhir-r4-coverage-active", "label": "FHIR R4", "summary": "A product-neutral script adapter with bounded FHIR R4 search-set parsing.", "source": "crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active", + "classification": "maintained", + "topology": "combined", + "starter": "fhir-r4", + "project_dir": "fhir-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], "integration": "coverage", "fixture": "coverage-active", "commands": [ @@ -68,10 +120,23 @@ ] }, { - "starter": "snapshot", + "id": "snapshot", "label": "Exact snapshot", "summary": "An exact lookup over one immutable local materialization.", "source": "crates/registryctl/tests/fixtures/project-authoring/snapshot-exact", + "classification": "maintained", + "topology": "combined", + "starter": "snapshot", + "project_dir": "snapshot-project", + "capabilities": [ + "init", + "editor", + "trace", + "watch", + "test", + "check", + "build" + ], "integration": "person-snapshot", "fixture": "snapshot-match", "commands": [