#![allow(
clippy::disallowed_types,
reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
exchanges) — not the application (#1694); the carriers here are cfg(test)-only, so \
#[expect] would be unfulfilled in the non-test build"
)]
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use crate::artifacts::ArtifactSet;
use crate::exec::headers::structural_token;
use crate::ids::{CapabilityName, CaseId, CorpusKey, SmOperationRef, ViewName};
use crate::literal::{Literal, ViolationRef};
use crate::load::LoadError;
use crate::model::assertion::{Assertion, EquivalentTarget, assertion_refs};
use crate::model::binding::{HeaderMatcher, OperationBinding, placeholder_names};
use crate::model::capability::Realization;
use crate::model::case::{
CaseCore, ExpectSpec, FlowStep, ImportRequirement, MatrixCell, Parameters,
PartyRelationshipRequirement,
};
use crate::model::value::TemplatedValue;
use crate::model::wire_surface::{ServedExtension, WireSurface};
use crate::refgrammar::{CaptureField, TimeExpr, ValueRef};
use crate::vocab::{CaseKind, CaseStatus, Disposition, FormatName, Iteration, OutcomeKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CheckId {
Load,
IdUniqueness,
KindShape,
ReferenceGrammar,
LiteralGrammar,
SmOperation,
SpecRef,
BindingCompleteness,
BindingFilename,
StepArguments,
MatcherPlaceholder,
VerifiedBy,
CorpusIntegrity,
AmbiguityLink,
OptionTag,
CapabilityTier,
VocabDrift,
JourneyEnvelope,
ClaimCompleteness,
GuardScope,
ServedExtensionDeclaration,
CapabilityDepth,
WorkloadCoverage,
RealizationScope,
SurfaceCoverage,
}
impl CheckId {
#[must_use]
pub fn token(self) -> &'static str {
match self {
Self::Load => "load",
Self::IdUniqueness => "id-uniqueness",
Self::KindShape => "kind-shape",
Self::ReferenceGrammar => "reference-grammar",
Self::LiteralGrammar => "literal-grammar",
Self::SmOperation => "sm-operation",
Self::SpecRef => "spec-ref",
Self::BindingCompleteness => "binding-completeness",
Self::BindingFilename => "binding-filename",
Self::StepArguments => "step-arguments",
Self::MatcherPlaceholder => "matcher-placeholder",
Self::VerifiedBy => "verified-by",
Self::CorpusIntegrity => "corpus-integrity",
Self::AmbiguityLink => "ambiguity-link",
Self::OptionTag => "option-tag",
Self::CapabilityTier => "capability-tier",
Self::VocabDrift => "vocab-drift",
Self::JourneyEnvelope => "journey-envelope",
Self::ClaimCompleteness => "claim-completeness",
Self::GuardScope => "guard-scope",
Self::ServedExtensionDeclaration => "served-extension-declaration",
Self::CapabilityDepth => "capability-depth",
Self::WorkloadCoverage => "workload-coverage",
Self::RealizationScope => "realization-scope",
Self::SurfaceCoverage => "surface-coverage",
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub check: CheckId,
pub artifact: String,
pub message: String,
}
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] {}: {}",
self.check.token(),
self.artifact,
self.message
)
}
}
#[derive(Debug)]
pub struct Context<'a> {
pub set: &'a ArtifactSet,
pub load_errors: &'a [LoadError],
pub spec_root: Option<&'a Path>,
}
#[must_use]
pub fn validate(ctx: &Context<'_>) -> Vec<Finding> {
let mut findings = Vec::new();
let spec = ctx.spec_root.map(SpecIndex::new);
for e in ctx.load_errors {
findings.push(Finding {
check: CheckId::Load,
artifact: e.path().display().to_string(),
message: e.detail(),
});
}
check_id_uniqueness(ctx.set, &mut findings);
for (_path, case) in &ctx.set.cases {
let who = case.id.to_string();
check_kind_shape(case, &who, &mut findings);
check_references(case, &who, ctx.set, &mut findings);
check_literals(case, &who, &mut findings);
check_capability_tier(case, &who, ctx.set, &mut findings);
check_links(case, &who, ctx.set, &mut findings);
if let Some(spec) = spec.as_ref() {
check_sm_operations(case, &who, spec, &mut findings);
check_spec_refs(case, &who, spec, &mut findings);
}
}
check_binding_completeness(ctx.set, &mut findings);
check_step_arguments(ctx.set, &mut findings);
check_matcher_placeholders(ctx.set, &mut findings);
for (path, binding) in &ctx.set.bindings {
let who = path.display().to_string();
if let Err(message) = binding.check_invariants() {
push(&mut findings, CheckId::KindShape, &who, message);
}
check_binding_filename(path, binding, &mut findings);
if let (Some(decl), Some((_, register))) = (&binding.unrealized, &ctx.set.register)
&& register.get(&decl.ambiguity).is_none()
{
push(
&mut findings,
CheckId::AmbiguityLink,
&who,
format!(
"unrealized declaration cites {} which is not in the register",
decl.ambiguity
),
);
}
if let Some(spec) = spec.as_ref() {
resolve_sm_operation(&binding.sm_operation, &who, spec, &mut findings);
check_binding_sources(binding, &who, spec, &mut findings);
}
}
check_corpus_integrity(ctx.set, &mut findings);
if let Some(spec) = spec.as_ref() {
check_corpus_spec_refs(ctx.set, spec, &mut findings);
check_register_sources(ctx.set, spec, &mut findings);
}
check_vocab_drift(ctx.set, &mut findings);
check_journey_envelope(ctx.set, &mut findings);
check_claim_completeness(ctx.set, &mut findings);
check_guard_scope(ctx.set, &mut findings);
check_served_extension_declarations(ctx.set, &mut findings);
check_capability_depth(ctx.set, &mut findings);
check_workload_coverage(ctx.set, &mut findings);
check_realization_scope(ctx.set, &mut findings);
check_surface_coverage(ctx.set, spec.as_ref(), &mut findings);
findings
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Resolution {
Gating,
ExcusedUnrealized,
OptionGated,
}
fn resolution(set: &ArtifactSet, case: &CaseCore) -> Resolution {
if crate::run::fully_unrealized(set, case).is_some() {
Resolution::ExcusedUnrealized
} else if case.option.is_some() {
Resolution::OptionGated
} else {
Resolution::Gating
}
}
fn suspended_report_only(set: &ArtifactSet, case: &CaseCore) -> bool {
let Some((_, register)) = &set.register else {
return false;
};
case.ambiguities.iter().any(|id| {
register
.get(id)
.is_some_and(|e| e.disposition == Disposition::ReportOnly)
})
}
#[must_use]
pub fn verdict_bearing<'a>(set: &'a ArtifactSet, cap: &CapabilityName) -> Vec<&'a CaseCore> {
set.cases
.iter()
.map(|(_, c)| c)
.filter(|c| {
c.status == CaseStatus::Active
&& c.capabilities.contains(cap)
&& !suspended_report_only(set, c)
})
.collect()
}
fn check_claim_completeness(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let Some((matrix_path, matrix)) = &set.matrix else {
return;
};
let matrix_who = matrix_path.display().to_string();
for (party_path, statement) in &set.parties {
let who = party_path.display().to_string();
for cap in &statement.claims.capabilities {
if matrix.get(cap).is_none() {
continue;
}
if verdict_bearing(set, cap).is_empty() {
push(
findings,
CheckId::ClaimCompleteness,
&who,
format!(
"claimed capability {cap} has zero verdict-bearing catalogue cases — \
declaring a capability is the obligation to run the CNF framework \
against it; author its battery or withdraw the claim"
),
);
}
}
}
for (name, entry) in matrix.entries() {
let cases = verdict_bearing(set, name);
let all_excused = !cases.is_empty()
&& cases
.iter()
.all(|c| resolution(set, c) != Resolution::Gating);
match (&entry.evidence_exception, all_excused) {
(None, true) => push(
findings,
CheckId::ClaimCompleteness,
&matrix_who,
format!(
"{name}: every one of its {} verdict-bearing case(s) resolves excused or \
deselected, so the capability can never carry executed evidence — name the \
adjudicating register entry in `evidence_exception`, realize the wire, or \
move the capability to the extension surface",
cases.len()
),
),
(Some(adjudication), true) => {
if set
.register
.as_ref()
.is_some_and(|(_, r)| r.get(&adjudication.register).is_none())
{
push(
findings,
CheckId::ClaimCompleteness,
&matrix_who,
format!(
"{name}: evidence_exception cites {} which is not in the register",
adjudication.register
),
);
}
}
(Some(adjudication), false) => push(
findings,
CheckId::ClaimCompleteness,
&matrix_who,
format!(
"{name}: evidence_exception ({}) is stale — the capability now has cases \
that can carry executed evidence ({} of {}); delete the exception",
adjudication.register,
cases
.iter()
.filter(|c| resolution(set, c) == Resolution::Gating)
.count(),
cases.len()
),
),
(None, false) => {}
}
}
}
fn check_guard_scope(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let Some((_, matrix)) = &set.matrix else {
return;
};
let scoping_phrases = [
"not-applicable",
"not applicable",
"applies only",
"only applies",
"apply only",
"unless the sut",
"when the sut declares no",
"when the sut does not",
];
let declaration_phrases = ["declares no", "does not declare", "declaring no"];
for (path, case) in &set.cases {
let who = path.display().to_string();
for guard in &case.guards {
let lowered = guard.to_lowercase();
if declaration_phrases.iter().any(|p| lowered.contains(p)) {
for name in crate::run::addressed_instances(case) {
if mentions_word(guard, name.as_str()) {
push(
findings,
CheckId::GuardScope,
&who,
format!(
"guard {guard:?} restates the undeclared-instance selection \
rule for `{name}`, which the runner implements globally from \
the flow's own `on:` addressing — drop the guard and keep any \
spec citation in spec_refs"
),
);
}
}
if case.requires.terminology.is_some() && lowered.contains("terminology") {
push(
findings,
CheckId::GuardScope,
&who,
format!(
"guard {guard:?} restates the terminology selection rule the \
runner implements from this case's own `requires.terminology` — \
drop the guard and keep any spec citation in spec_refs"
),
);
}
let requires_profile = case.requires.spec_profile.is_some()
|| case
.requires
.instances
.as_ref()
.is_some_and(|map| map.values().any(|r| r.spec_profile.is_some()));
if requires_profile && lowered.contains("spec_profile") {
push(
findings,
CheckId::GuardScope,
&who,
format!(
"guard {guard:?} restates the generation-set selection rule the \
runner implements from this case's own `requires.spec_profile` — \
drop the guard and keep any spec citation in spec_refs"
),
);
}
}
if !scoping_phrases.iter().any(|p| lowered.contains(p)) {
continue;
}
for (name, _) in matrix.entries() {
if !mentions_word(guard, &name.to_string()) {
continue;
}
let gated = case.capabilities.contains(name);
push(
findings,
CheckId::GuardScope,
&who,
if gated {
format!(
"guard {guard:?} restates the capability-scoping rule for {name}, \
which the runner implements globally from the case's own \
`capabilities:` list — drop the guard; a per-case restatement can \
drift from the implemented rule with nothing to catch it"
)
} else {
format!(
"guard {guard:?} states a selection rule scoped to {name}, but the \
case does not gate that capability — the runner selects on \
`capabilities:` alone, so this rule is stated and not implemented; \
declare the capability or drop the claim"
)
},
);
}
}
}
}
fn mentions_word(text: &str, word: &str) -> bool {
let boundary = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric() && c != '_');
text.match_indices(word).any(|(at, _)| {
boundary(text.get(..at).and_then(|s| s.chars().next_back()))
&& boundary(text.get(at + word.len()..).and_then(|s| s.chars().next()))
})
}
fn check_served_extension_declarations(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let known: BTreeSet<&str> = set
.wire_surface
.as_ref()
.map_or_else(BTreeSet::new, |(_, w)| {
w.served_extensions
.iter()
.map(|e| e.family.as_str())
.collect()
});
for (party_path, statement) in &set.parties {
let who = party_path.display().to_string();
let mut seen: BTreeSet<&str> = BTreeSet::new();
for family in &statement.served_extensions {
if !seen.insert(family.as_str()) {
push(
findings,
CheckId::ServedExtensionDeclaration,
&who,
format!("served_extensions declares {family:?} more than once"),
);
}
if !known.contains(family.as_str()) {
push(
findings,
CheckId::ServedExtensionDeclaration,
&who,
format!(
"served_extensions declares {family:?}, which is not a family of the \
served_extensions axis of vocab/wire_surface.yaml — a declared family \
must carry its routes and configuration gate there, or the statement \
publishes a name with nothing behind it"
),
);
}
}
}
}
fn check_capability_depth(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let Some((matrix_path, matrix)) = &set.matrix else {
return;
};
let who = matrix_path.display().to_string();
for (name, entry) in matrix.entries() {
let count = verdict_bearing(set, name).len();
if count < entry.min_cases {
push(
findings,
CheckId::CapabilityDepth,
&who,
format!(
"{name}: {count} verdict-bearing case(s) against a floor of {} — short by \
{}; coverage only ratchets up, so restore the battery (never lower the \
floor)",
entry.min_cases,
entry.min_cases.saturating_sub(count)
),
);
}
}
}
fn path_shape(path: &str) -> String {
path.split('/')
.map(|segment| {
if segment.starts_with('{') && segment.ends_with('}') {
"{}"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/")
}
fn drives_only_extension_bindings(set: &ArtifactSet, case: &CaseCore) -> bool {
let mut saw_binding = false;
for step in &case.flow {
let Some(binding) = select_binding_for_step(set, case, step) else {
continue;
};
saw_binding = true;
if !binding.is_extension() {
return false;
}
}
saw_binding
}
fn check_realization_scope(set: &ArtifactSet, findings: &mut Vec<Finding>) {
check_extension_bindings(set, findings);
check_realization_markers(set, findings);
}
fn check_extension_bindings(set: &ArtifactSet, findings: &mut Vec<Finding>) {
for (path, binding) in &set.bindings {
let Some(decl) = &binding.extension else {
continue;
};
let who = path.display().to_string();
if set
.register
.as_ref()
.is_some_and(|(_, r)| r.get(&decl.ambiguity).is_none())
{
push(
findings,
CheckId::RealizationScope,
&who,
format!(
"extension declaration cites {} which is not in the register",
decl.ambiguity
),
);
}
let Some((_, wire_surface)) = &set.wire_surface else {
continue;
};
let Some(family) = wire_surface
.served_extensions
.iter()
.find(|e| e.family == decl.family)
else {
push(
findings,
CheckId::RealizationScope,
&who,
format!(
"extension family {:?} is not declared in the served_extensions axis of \
vocab/wire_surface.yaml — an extension binding may only drive a route the \
SUT declares outwardly",
decl.family
),
);
continue;
};
let Some(request) = binding.request.as_ref() else {
continue; };
let wanted = path_shape(request.path.raw());
let declared = family.routes.iter().any(|route| {
ServedExtension::route_path(route)
.is_some_and(|declared| path_shape(declared).ends_with(wanted.as_str()))
});
if !declared {
push(
findings,
CheckId::RealizationScope,
&who,
format!(
"request path {} is not one of the routes the {:?} served_extensions family \
declares — declare the route or bind a declared one",
request.path.raw(),
decl.family
),
);
}
}
}
fn check_realization_markers(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let Some((matrix_path, matrix)) = &set.matrix else {
return;
};
let matrix_who = matrix_path.display().to_string();
for (name, entry) in matrix.entries() {
let cases = verdict_bearing(set, name);
if cases.is_empty() {
continue; }
let all_extension = cases
.iter()
.all(|case| drives_only_extension_bindings(set, case));
match (entry.realization, all_extension) {
(Realization::ReleasedWire, true) => push(
findings,
CheckId::RealizationScope,
&matrix_who,
format!(
"{name}: every one of its {} verdict-bearing case(s) drives EXTENSION \
routes only, so the row must carry `realization: extension` — a \
released-wire marker would claim openEHR wire conformance the release \
does not define",
cases.len()
),
),
(Realization::Extension, false) => push(
findings,
CheckId::RealizationScope,
&matrix_who,
format!(
"{name}: `realization: extension` is stale — at least one of its \
verdict-bearing cases drives RELEASED ITS-REST operations; delete the \
marker so the row claims the conformance it earns"
),
),
_ => {}
}
}
}
fn workload_exercised(set: &ArtifactSet) -> BTreeSet<&'static str> {
let mut exercised = BTreeSet::new();
let Some((_, catalogue)) = &set.journeys else {
return exercised;
};
for (_, case) in &set.performance {
for (name, _) in &case.workload.journeys {
let Some(journey) = catalogue.get(name) else {
continue; };
for stage in &journey.stages {
if let Ok(op) = crate::perf::PerfOp::parse(&stage.op) {
exercised.extend(op.capabilities().iter().copied());
}
}
}
}
exercised
}
fn check_workload_coverage(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let Some((matrix_path, matrix)) = &set.matrix else {
return;
};
if set.performance.is_empty() {
return; }
let matrix_who = matrix_path.display().to_string();
let exercised = workload_exercised(set);
for (party_path, statement) in &set.parties {
let who = party_path.display().to_string();
for cap in &statement.claims.capabilities {
let Some(entry) = matrix.get(cap) else {
continue;
};
if exercised.contains(cap.as_str()) || entry.workload_exclusion.is_some() {
continue;
}
push(
findings,
CheckId::WorkloadCoverage,
&who,
format!(
"claimed capability {cap} is neither exercised by the measured \
hospital-simulation workload nor carries a `workload_exclusion` — extend \
the journey catalogue or adjudicate the exclusion in the capability matrix"
),
);
}
}
for (name, entry) in matrix.entries() {
let Some(adjudication) = &entry.workload_exclusion else {
continue;
};
if set
.register
.as_ref()
.is_some_and(|(_, r)| r.get(&adjudication.register).is_none())
{
push(
findings,
CheckId::WorkloadCoverage,
&matrix_who,
format!(
"{name}: workload_exclusion cites {} which is not in the register",
adjudication.register
),
);
}
if exercised.contains(name.as_str()) {
push(
findings,
CheckId::WorkloadCoverage,
&matrix_who,
format!(
"{name}: workload_exclusion ({}) is stale — the hospital simulation now \
exercises the capability; delete the exclusion",
adjudication.register
),
);
}
}
}
fn check_journey_envelope(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let Some((path, catalogue)) = &set.journeys else {
if !set.performance.is_empty() {
push(
findings,
CheckId::JourneyEnvelope,
"vocab/journey_catalogue.yaml",
"performance cases exist but the journey catalogue is missing".to_owned(),
);
}
return;
};
let who = path.display().to_string();
if let Err(message) = catalogue.check_invariants() {
push(findings, CheckId::JourneyEnvelope, &who, message);
return;
}
if let Some((_, manifest)) = &set.corpus {
for (name, journey) in &catalogue.0 {
for stage in &journey.stages {
let Some(template) = &stage.template else {
continue;
};
for (key, role) in [
(template.clone(), "OPT"),
(format!("{template}.example"), "example payload"),
] {
match CorpusKey::parse(&key) {
Ok(parsed) if manifest.get(&parsed).is_some() => {}
_ => push(
findings,
CheckId::JourneyEnvelope,
&who,
format!(
"journey {name} template {template}: corpus manifest has no \
{role} entry {key}"
),
),
}
}
}
}
}
if let Some((_, manifest)) = &set.corpus {
let mut needed: Vec<crate::perf::AuxPayloadKind> = Vec::new();
for (_, journey) in &catalogue.0 {
for stage in &journey.stages {
if let Some(kind) = crate::perf::PerfOp::parse(&stage.op)
.ok()
.and_then(crate::perf::PerfOp::aux_payload)
&& !needed.contains(&kind)
{
needed.push(kind);
}
}
}
for kind in needed {
let keys: &[&str] = match kind {
crate::perf::AuxPayloadKind::Flat => &[
crate::perf_run::pack::FLAT_OPT_KEY,
crate::perf_run::pack::FLAT_BODY_KEY,
],
crate::perf::AuxPayloadKind::Person => &[
crate::perf_run::pack::PERSON_KEY,
crate::perf_run::pack::PERSON_AMENDED_KEY,
],
crate::perf::AuxPayloadKind::PartyRelationship => {
&[crate::perf_run::pack::PARTY_RELATIONSHIP_KEY]
}
crate::perf::AuxPayloadKind::Tdd => &[
crate::perf_run::pack::TDD_OPT_KEY,
crate::perf_run::pack::TDD_BODY_KEY,
],
};
for key in keys {
match CorpusKey::parse(key) {
Ok(parsed) if manifest.get(&parsed).is_some() => {}
_ => push(
findings,
CheckId::JourneyEnvelope,
&who,
format!(
"the catalogue names a stage whose payload is {kind:?}, but the \
corpus manifest has no entry {key}"
),
),
}
}
}
}
for (case_path, case) in &set.performance {
if let Err(message) = catalogue.expansion(&case.workload.journeys) {
push(
findings,
CheckId::JourneyEnvelope,
&case_path.display().to_string(),
message,
);
}
}
}
fn push(findings: &mut Vec<Finding>, check: CheckId, artifact: &str, message: String) {
findings.push(Finding {
check,
artifact: artifact.to_owned(),
message,
});
}
fn check_id_uniqueness(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (path, case) in &set.cases {
if !seen.insert(case.id.as_str()) {
push(
findings,
CheckId::IdUniqueness,
&path.display().to_string(),
format!("case id {} is declared more than once", case.id),
);
}
}
}
fn check_kind_shape(case: &CaseCore, who: &str, findings: &mut Vec<Finding>) {
check_kind_blocks(case, who, findings);
check_parameters_shape(case, who, findings);
check_assertion_shape(case, who, findings);
check_flow_shape(case, who, findings);
for guard in &case.guards {
if !guard.contains('—') && !guard.to_lowercase().contains("master") {
push(
findings,
CheckId::KindShape,
who,
format!("guard {guard:?} carries no spec citation"),
);
}
}
}
fn check_kind_blocks(case: &CaseCore, who: &str, findings: &mut Vec<Finding>) {
match case.kind {
CaseKind::Functional => {
if case.sm_operation.is_none() || case.flow.is_empty() {
push(
findings,
CheckId::KindShape,
who,
"functional case must carry sm_operation and a non-empty flow".to_owned(),
);
}
if case.decision_table.is_some() || case.constraint_context.is_some() {
push(
findings,
CheckId::KindShape,
who,
"functional case must not carry content blocks".to_owned(),
);
}
}
CaseKind::Content => {
if case.rm_class.is_none()
|| case.constraint_context.is_none()
|| case.decision_table.is_none()
{
push(
findings,
CheckId::KindShape,
who,
"content case must carry rm_class, constraint_context and decision_table"
.to_owned(),
);
}
}
}
}
fn check_parameters_shape(case: &CaseCore, who: &str, findings: &mut Vec<Finding>) {
if let Some(parameters) = &case.parameters {
match (¶meters.matrix, ¶meters.fixture_set) {
(Some(_), Some(_)) | (None, None) => push(
findings,
CheckId::KindShape,
who,
"parameters must carry exactly one of matrix | fixture_set".to_owned(),
),
_ => {}
}
if let Some(matrix) = ¶meters.matrix {
for (i, row) in matrix.rows.iter().enumerate() {
if row.len() != matrix.columns.len() {
push(
findings,
CheckId::KindShape,
who,
format!(
"matrix row {i} has {} cells for {} columns",
row.len(),
matrix.columns.len()
),
);
}
}
let expected_col = matrix.columns.iter().position(|c| c == "expected");
if let Some(col) = expected_col {
for (i, row) in matrix.rows.iter().enumerate() {
match row.get(col) {
Some(MatrixCell::Literal(serde_json::Value::String(s)))
if OutcomeKind::from_token(s).is_some() => {}
_ => push(
findings,
CheckId::KindShape,
who,
format!("matrix row {i}: `expected` cell must be an outcome kind"),
),
}
}
}
}
}
}
fn check_assertion_shape(case: &CaseCore, who: &str, findings: &mut Vec<Finding>) {
let aggregate_needs_single_pass = case
.postconditions
.iter()
.chain(case.flow.iter().flat_map(|s| s.assertions.iter()))
.any(Assertion::is_aggregate);
if aggregate_needs_single_pass {
let single_pass = matches!(
case.parameters,
Some(Parameters {
iteration: Iteration::SinglePass,
..
})
);
if !single_pass {
push(
findings,
CheckId::KindShape,
who,
"aggregate assertions require parameters.iteration: single_pass".to_owned(),
);
}
}
for assertion in case
.postconditions
.iter()
.chain(case.flow.iter().flat_map(|s| s.assertions.iter()))
{
if let Err(message) = assertion.check_invariants() {
push(findings, CheckId::KindShape, who, message);
}
if let Assertion::State { verified_by, .. } = assertion {
let verified = verified_by.is_some()
|| !case.verified_by.is_empty()
|| case.flow.len() > 1
|| case.flow.iter().any(|s| !s.assertions.is_empty());
if !verified {
push(
findings,
CheckId::KindShape,
who,
"state assertion needs verified_by or an in-case verification step".to_owned(),
);
}
}
}
}
fn check_flow_shape(case: &CaseCore, who: &str, findings: &mut Vec<Finding>) {
let mut last_step = 0_u32;
for step in &case.flow {
if step.step <= last_step {
push(
findings,
CheckId::KindShape,
who,
format!(
"flow step numbers must strictly increase (step {})",
step.step
),
);
}
last_step = step.step;
if matches!(step.expect, ExpectSpec::FixtureExpected)
&& case
.parameters
.as_ref()
.is_none_or(|p| p.fixture_set.is_none())
{
push(
findings,
CheckId::KindShape,
who,
"expect: ${fixture.expected} requires parameters.fixture_set".to_owned(),
);
}
if let ExpectSpec::Kind(kind) = step.expect {
for (name, source) in step.captures() {
if source.outcome != kind {
push(
findings,
CheckId::KindShape,
who,
format!(
"capture {name} reads outcome `{}` but the step expects `{}`",
source.outcome.token(),
kind.token()
),
);
}
}
}
}
}
fn matrix_columns(case: &CaseCore) -> Vec<&str> {
case.parameters
.as_ref()
.and_then(|p| p.matrix.as_ref())
.map(|m| m.columns.iter().map(String::as_str).collect())
.unwrap_or_default()
}
struct RefCtx<'a> {
columns: Vec<&'a str>,
has_fixtures: bool,
who: &'a str,
set: &'a ArtifactSet,
}
fn check_one_ref(
r: &ValueRef,
ctx: &RefCtx<'_>,
defined: &BTreeSet<String>,
findings: &mut Vec<Finding>,
) {
let who = ctx.who;
match r {
ValueRef::Row(column) => {
if !ctx.columns.contains(&column.as_str()) {
push(
findings,
CheckId::ReferenceGrammar,
who,
format!("${{row.{column}}} names no matrix column"),
);
}
}
ValueRef::Fixture(_) | ValueRef::FixtureDataSet => {
if !ctx.has_fixtures {
push(
findings,
CheckId::ReferenceGrammar,
who,
"${fixture.*} reference without parameters.fixture_set".to_owned(),
);
}
}
ValueRef::Capture { name, optional } => {
if *optional {
push(
findings,
CheckId::ReferenceGrammar,
who,
format!("${{{name}?}} optional form is binding-template-only"),
);
} else if !defined.contains(name.as_str()) {
push(
findings,
CheckId::ReferenceGrammar,
who,
format!("${{{name}}} references no earlier capture or requires handle"),
);
}
}
ValueRef::DataSet { key, view } => {
check_ds_ref(key, view.as_ref(), who, ctx.set, findings);
}
ValueRef::Recipe(name) => {
let declared = ctx.set.corpus.as_ref().is_some_and(|(_, corpus)| {
corpus.entries().iter().any(|(_, e)| {
e.recipes
.as_deref()
.unwrap_or_default()
.iter()
.any(|(n, _)| n == name)
|| e.generated_by.as_ref().is_some_and(|g| g.recipe == *name)
})
});
if !declared {
push(
findings,
CheckId::ReferenceGrammar,
who,
format!("${{recipe:{name}(row)}} is not declared in the corpus manifest"),
);
}
}
ValueRef::Ixit(_) => {}
ValueRef::Time(expr) => {
let (a, b) = match expr {
TimeExpr::Before(t) | TimeExpr::After(t) => (t, None),
TimeExpr::Between(t1, t2) => (t1, Some(t2)),
};
for t in std::iter::once(a).chain(b) {
if !defined.contains(t.as_str()) {
push(
findings,
CheckId::ReferenceGrammar,
who,
format!("${{time:…({t})}} references no earlier capture"),
);
}
}
}
}
}
fn check_references(case: &CaseCore, who: &str, set: &ArtifactSet, findings: &mut Vec<Finding>) {
let ctx = RefCtx {
columns: matrix_columns(case),
has_fixtures: case
.parameters
.as_ref()
.is_some_and(|p| p.fixture_set.is_some()),
who,
set,
};
let mut defined: BTreeSet<String> = case
.requires
.minted_handles()
.iter()
.map(ToString::to_string)
.collect();
for step in &case.flow {
for (_, value) in step.with_entries() {
for r in value.refs() {
check_one_ref(r, &ctx, &defined, findings);
}
}
for value in step.scope_templates() {
for r in value.refs() {
check_one_ref(r, &ctx, &defined, findings);
}
}
for (name, _source) in step.captures() {
defined.insert(name.to_string());
}
for assertion in &step.assertions {
for r in assertion_refs(assertion) {
check_one_ref(&r, &ctx, &defined, findings);
}
}
}
for assertion in &case.postconditions {
for r in assertion_refs(assertion) {
check_one_ref(&r, &ctx, &defined, findings);
}
if let Assertion::Equivalent {
to: EquivalentTarget::Committed,
..
} = assertion
{
if !case.flow.iter().any(|s| !s.with_entries().is_empty()) {
push(
findings,
CheckId::ReferenceGrammar,
who,
"equivalent to: committed, but no flow step commits a payload".to_owned(),
);
}
}
}
let relationship_keys: Vec<&CorpusKey> = match &case.requires.party_relationship {
Some(PartyRelationshipRequirement::Exists {
source,
target,
relationship,
}) => vec![source, target, relationship],
Some(PartyRelationshipRequirement::None) | None => Vec::new(),
};
let import_key: Option<&CorpusKey> = match &case.requires.import {
Some(ImportRequirement::Received { extract, .. }) => Some(extract),
Some(ImportRequirement::None) | None => None,
};
for key in case
.data_sets
.iter()
.chain(case.requires.templates.iter())
.chain(case.requires.commit.iter())
.chain(case.constraint_context.as_ref().map(|c| &c.template))
.chain(relationship_keys)
.chain(import_key)
{
check_ds_ref(key, None, who, set, findings);
}
}
fn check_ds_ref(
key: &CorpusKey,
view: Option<&ViewName>,
who: &str,
set: &ArtifactSet,
findings: &mut Vec<Finding>,
) {
let Some((_, corpus)) = &set.corpus else {
return;
};
match corpus.get(key) {
None => push(
findings,
CheckId::CorpusIntegrity,
who,
format!("corpus key {key} is not in the manifest"),
),
Some(entry) => {
if let Some(view) = view
&& entry.view(view).is_none()
{
push(
findings,
CheckId::CorpusIntegrity,
who,
format!("view {view} is not declared on corpus entry {key}"),
);
}
}
}
}
fn check_member_tokens(case: &CaseCore, who: &str, findings: &mut Vec<Finding>) {
struct ClosedKey {
key: &'static str,
accepts: fn(&str) -> bool,
expected: &'static str,
}
let closed = [
ClosedKey {
key: "change_type",
accepts: |t| crate::vocab::MemberChangeType::from_token(t).is_some(),
expected: "a member of the openEHR audit_change_type group \
(creation | amendment | modification | synthesis | deleted | \
attestation | restoration | format conversion | unknown)",
},
ClosedKey {
key: "_type",
accepts: |t| crate::vocab::MemberVersionType::from_token(t).is_some(),
expected: "a class of the RM VERSION family the commit wire is addressed with \
(UPDATE_VERSION | ORIGINAL_VERSION | IMPORTED_VERSION)",
},
ClosedKey {
key: "lifecycle_state",
accepts: |t| crate::vocab::VersionLifecycleState::from_token(t).is_some(),
expected: "a state of the openEHR version_lifecycle_state group \
(complete | incomplete | deleted | inactive | abandoned)",
},
];
for step in &case.flow {
for (name, value) in step.with_entries() {
if name != "versions" {
continue;
}
let TemplatedValue::Seq(members) = value else {
continue;
};
for (i, member) in members.iter().enumerate() {
let TemplatedValue::Map(entries) = member else {
continue;
};
for (key, cell) in entries {
let Some(closed_key) = closed.iter().find(|c| c.key == key) else {
continue;
};
let TemplatedValue::Text(template) = cell else {
continue;
};
let token = template.raw();
if !token.contains("${") && !(closed_key.accepts)(token) {
push(
findings,
CheckId::LiteralGrammar,
who,
format!(
"step {}: versions[{i}].{key}: {token:?} is not {}",
step.step, closed_key.expected
),
);
}
}
}
}
}
}
fn check_literals(case: &CaseCore, who: &str, findings: &mut Vec<Finding>) {
check_member_tokens(case, who, findings);
let Some(table) = &case.decision_table else {
return;
};
let violates_col = table.columns.iter().position(|c| c == "violates");
let expected_col = table.columns.iter().position(|c| c == "expected");
for (i, row) in table.rows.iter().enumerate() {
for (j, cell) in row.iter().enumerate() {
if Some(j) == violates_col {
match cell {
serde_json::Value::Array(items) => {
for item in items {
match item.as_str().map(ViolationRef::parse) {
Some(Ok(_)) => {}
Some(Err(e)) => push(
findings,
CheckId::LiteralGrammar,
who,
format!("row {i}: {e}"),
),
None => push(
findings,
CheckId::LiteralGrammar,
who,
format!("row {i}: violates entries must be strings"),
),
}
}
}
_ => push(
findings,
CheckId::LiteralGrammar,
who,
format!("row {i}: violates cell must be a list"),
),
}
} else if Some(j) == expected_col {
if !matches!(cell.as_str(), Some("accepted" | "rejected")) {
push(
findings,
CheckId::LiteralGrammar,
who,
format!("row {i}: expected cell must be accepted | rejected"),
);
}
} else if let Err(e) = Literal::from_cell(cell) {
push(
findings,
CheckId::LiteralGrammar,
who,
format!("row {i}: {e}"),
);
}
}
}
}
fn check_capability_tier(
case: &CaseCore,
who: &str,
set: &ArtifactSet,
findings: &mut Vec<Finding>,
) {
let Some((_, matrix)) = &set.matrix else {
return;
};
let mut tiers = BTreeSet::new();
for capability in case.capabilities.iter().chain(case.exercises.iter()) {
match matrix.get(capability) {
None => push(
findings,
CheckId::CapabilityTier,
who,
format!("capability {capability} is not in the capability matrix"),
),
Some(entry) => {
if case.capabilities.contains(capability) {
tiers.insert(entry.tier);
}
}
}
}
for tier in &case.profiles {
if !tiers.contains(tier) {
push(
findings,
CheckId::CapabilityTier,
who,
format!(
"profiles lists {tier:?} but no verdict-bearing capability carries that tier"
),
);
}
}
for tier in &tiers {
if !case.profiles.contains(tier) {
push(
findings,
CheckId::CapabilityTier,
who,
format!("capability tier {tier:?} is missing from profiles"),
);
}
}
}
fn check_links(case: &CaseCore, who: &str, set: &ArtifactSet, findings: &mut Vec<Finding>) {
if let Some((_, register)) = &set.register {
for id in &case.ambiguities {
if register.get(id).is_none() {
push(
findings,
CheckId::AmbiguityLink,
who,
format!("{id} is not in the ambiguity register"),
);
}
}
if let Some(option) = &case.option
&& !register.declares_option(option)
{
push(
findings,
CheckId::OptionTag,
who,
format!("option tag {option} is not declared by any option_select register entry"),
);
}
}
let ids: BTreeSet<&CaseId> = set.cases.iter().map(|(_, c)| &c.id).collect();
let assertion_targets = case
.postconditions
.iter()
.chain(case.flow.iter().flat_map(|s| s.assertions.iter()))
.filter_map(|a| match a {
Assertion::State {
verified_by: Some(target),
..
} => Some(target),
_ => None,
});
for target in case.verified_by.iter().chain(assertion_targets) {
if !ids.contains(target) {
push(
findings,
CheckId::VerifiedBy,
who,
format!("verified_by target {target} does not exist"),
);
}
}
}
type InterfaceOperations = Result<Rc<[String]>, String>;
type SectionKey = (PathBuf, u8);
#[derive(Debug)]
struct SpecIndex<'a> {
root: &'a Path,
texts: RefCell<BTreeMap<PathBuf, Result<Rc<str>, String>>>,
components: RefCell<BTreeMap<PathBuf, Rc<ComponentFiles>>>,
sections: RefCell<BTreeMap<SectionKey, Rc<BTreeSet<String>>>>,
attributes: RefCell<BTreeMap<PathBuf, Rc<BTreeMap<String, String>>>>,
interfaces: RefCell<BTreeMap<String, InterfaceOperations>>,
xml_schemas: Option<PathBuf>,
json_schemas: Option<PathBuf>,
rest_oas: Option<PathBuf>,
}
fn bundle_root(spec_root: &Path, relative: &str) -> Option<PathBuf> {
let vendored = spec_root.parent()?;
let bundle = vendored.join(relative);
bundle.is_dir().then_some(bundle)
}
#[derive(Debug, Default)]
struct ComponentFiles {
all: Vec<(String, PathBuf)>,
by_name: BTreeMap<String, Vec<PathBuf>>,
}
impl<'a> SpecIndex<'a> {
fn new(root: &'a Path) -> Self {
Self {
root,
texts: RefCell::new(BTreeMap::new()),
components: RefCell::new(BTreeMap::new()),
sections: RefCell::new(BTreeMap::new()),
attributes: RefCell::new(BTreeMap::new()),
interfaces: RefCell::new(BTreeMap::new()),
xml_schemas: bundle_root(root, "its-xml-schemas"),
json_schemas: bundle_root(root, "its-json-schemas"),
rest_oas: bundle_root(root, "rest-oas"),
}
}
fn component_roots(&self, component: &str, dir: &str) -> Vec<PathBuf> {
let mut roots = Vec::new();
let docs = self.root.join(dir);
if docs.is_dir() {
roots.push(docs);
}
let second = match component {
"ITS-XML" => &self.xml_schemas,
"ITS-JSON" => &self.json_schemas,
"ITS-REST" => &self.rest_oas,
_ => &None,
};
if let Some(bundle) = second {
roots.push(bundle.clone());
}
roots
}
fn root(&self) -> &Path {
self.root
}
fn read(&self, path: &Path) -> Result<Rc<str>, String> {
if let Some(hit) = self.texts.borrow().get(path) {
return hit.clone();
}
let result = std::fs::read_to_string(path)
.map(|text| Rc::from(text.as_str()))
.map_err(|error| error.to_string());
self.texts
.borrow_mut()
.insert(path.to_owned(), result.clone());
result
}
fn component_files(&self, dir: &Path) -> Rc<ComponentFiles> {
if let Some(hit) = self.components.borrow().get(dir) {
return Rc::clone(hit);
}
let mut listing = ComponentFiles::default();
let mut stack = vec![dir.to_owned()];
while let Some(current) = stack.pop() {
let Ok(entries) = std::fs::read_dir(¤t) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(relative) = path.strip_prefix(dir) else {
continue;
};
let lowered = relative
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
.to_lowercase();
if let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) {
listing
.by_name
.entry(name.to_lowercase())
.or_default()
.push(relative.to_owned());
}
listing.all.push((lowered, relative.to_owned()));
}
}
listing.all.sort();
let listing = Rc::new(listing);
self.components
.borrow_mut()
.insert(dir.to_owned(), Rc::clone(&listing));
listing
}
fn attributes(&self, dir: &Path) -> Rc<BTreeMap<String, String>> {
if let Some(hit) = self.attributes.borrow().get(dir) {
return Rc::clone(hit);
}
let mut attributes = BTreeMap::new();
for book in ["master.adoc", "manifest_vars.adoc"] {
let Ok(text) = self.read(&dir.join(book)) else {
continue;
};
for line in text.lines() {
let Some(rest) = line.strip_prefix(':') else {
continue;
};
let Some((name, value)) = rest.split_once(':') else {
continue;
};
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
{
attributes
.entry(name.to_owned())
.or_insert_with(|| value.trim().to_owned());
}
}
}
let attributes = Rc::new(attributes);
self.attributes
.borrow_mut()
.insert(dir.to_owned(), Rc::clone(&attributes));
attributes
}
fn document_sections(&self, component: &Path, relative: &Path) -> Rc<BTreeSet<String>> {
self.sections_at_depth(component, relative, INCLUDE_DEPTH)
}
fn sections_at_depth(
&self,
component: &Path,
relative: &Path,
depth: u8,
) -> Rc<BTreeSet<String>> {
let key = (relative.to_owned(), depth);
if let Some(hit) = self.sections.borrow().get(&key) {
return Rc::clone(hit);
}
self.sections
.borrow_mut()
.insert(key.clone(), Rc::new(BTreeSet::new()));
let path = component.join(relative);
let mut names = BTreeSet::new();
if let Ok(text) = self.read(&path) {
let yaml = matches!(
path.extension().and_then(std::ffi::OsStr::to_str),
Some("yaml" | "yml" | "json")
);
let xsd = matches!(
path.extension().and_then(std::ffi::OsStr::to_str),
Some("xsd")
);
if xsd {
names.extend(xsd_declared_names(&text));
} else if yaml {
names.extend(structured_keys(&text));
} else {
names.extend(asciidoc_section_names(&text));
if depth > 0 {
let directory = path.parent().unwrap_or(component).to_owned();
let attributes = self.attributes(&directory);
for target in include_targets(&text, &attributes) {
for included in self.included_files(component, &target) {
names.extend(
self.sections_at_depth(component, &included, depth - 1)
.iter()
.cloned(),
);
}
}
}
}
}
let names = Rc::new(names);
self.sections.borrow_mut().insert(key, Rc::clone(&names));
names
}
fn included_files(&self, component: &Path, target: &str) -> Vec<PathBuf> {
let name = target.rsplit('/').next().unwrap_or(target).to_lowercase();
let files = self.component_files(component);
match name.rsplit_once('}') {
None => files.by_name.get(&name).cloned().unwrap_or_default(),
Some((_, suffix)) if !suffix.is_empty() => files
.by_name
.iter()
.filter(|(candidate, _)| candidate.ends_with(suffix))
.flat_map(|(_, paths)| paths.iter().cloned())
.collect(),
Some(_) => Vec::new(),
}
}
fn interface_operations(&self, interface: &str) -> InterfaceOperations {
if let Some(hit) = self.interfaces.borrow().get(interface) {
return hit.clone();
}
let result = parse_sm_interface_operations(self, interface).map(Rc::from);
self.interfaces
.borrow_mut()
.insert(interface.to_owned(), result.clone());
result
}
}
const INCLUDE_DEPTH: u8 = 2;
fn include_targets(text: &str, attributes: &BTreeMap<String, String>) -> Vec<String> {
let mut out = Vec::new();
for line in text.lines() {
let Some(rest) = line.trim_start().strip_prefix("include::") else {
continue;
};
let Some((target, _)) = rest.split_once('[') else {
continue;
};
let mut target = target.to_owned();
for (name, value) in attributes {
if value.is_empty() {
continue;
}
target = target.replace(&format!("{{{name}}}"), value);
}
out.push(target);
}
out
}
fn asciidoc_section_names(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in text.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed
.strip_prefix('=')
.or_else(|| trimmed.strip_prefix('#'))
{
let heading = heading.trim_start_matches(['=', '#']).trim();
if !heading.is_empty() {
let name = normalize_section(heading);
for suffix in [" class", " interface", " enumeration"] {
if let Some(bare) = name.strip_suffix(suffix) {
out.insert(bare.to_owned());
}
}
out.insert(name);
}
}
if let Some(anchor) = trimmed.strip_prefix("[[").or_else(|| {
trimmed
.strip_prefix("[#")
.filter(|rest| rest.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_'))
}) {
let anchor = anchor
.split([',', ']'])
.next()
.unwrap_or_default()
.trim_start_matches('_');
if !anchor.is_empty() {
out.insert(normalize_section(anchor));
}
}
if let Some(attributes) = trimmed.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
for attribute in attributes.split(',') {
if let Some(id) = attribute.trim().strip_prefix("id=") {
let id = id.trim_matches(['"', '\'']);
if !id.is_empty() {
out.insert(normalize_section(id));
}
}
}
}
if let Some(label) = table_cell_label(trimmed) {
out.insert(label);
}
if let Some(title) = trimmed.strip_prefix('.')
&& title.starts_with(|c: char| c.is_ascii_alphabetic())
&& !title.contains('|')
{
out.insert(normalize_section(title));
}
if let Some(rest) = trimmed.strip_prefix("[comment]: # (title:")
&& let Some((title, _)) = rest.split_once(')')
{
out.insert(normalize_section(title));
}
}
out
}
fn table_cell_label(line: &str) -> Option<String> {
let (prefix, rest) = line.split_once("|*")?;
if !prefix
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '+' | '^' | '<' | '>' | 'h' | 'a' | 'm' | 's'))
{
return None;
}
let label = rest.split('*').next()?.trim();
if label.is_empty() || !label.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
return None;
}
Some(normalize_section(label))
}
fn xsd_declared_names(text: &str) -> BTreeSet<String> {
let mut names = BTreeSet::new();
for tail in text.split("name=").skip(1) {
let Some(rest) = tail.strip_prefix('"').or_else(|| tail.strip_prefix('\'')) else {
continue;
};
let quote = if tail.starts_with('"') { '"' } else { '\'' };
let Some(value) = rest.split(quote).next() else {
continue;
};
let local = value.rsplit(':').next().unwrap_or(value).trim();
if !local.is_empty() {
names.insert(normalize_section(local));
}
}
names
}
fn structured_keys(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in text.lines() {
let trimmed = line.trim_start().trim_start_matches("- ");
let key = trimmed.trim_start_matches(['\'', '"']);
let Some((key, _)) = key.split_once(':') else {
continue;
};
let key = key.trim_end_matches(['\'', '"']);
if !key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '$'))
{
out.insert(normalize_section(key));
}
}
out
}
fn sm_class_file(spec_root: &Path, interface: &str) -> PathBuf {
spec_root
.join("SM/docs/UML/classes")
.join(format!("{}.adoc", interface.to_lowercase()))
}
fn resolve_sm_operation(
op: &SmOperationRef,
who: &str,
spec: &SpecIndex<'_>,
findings: &mut Vec<Finding>,
) {
if non_sm_operation_source(op).is_some() {
return;
}
if op.interface().starts_with(PSEUDO_INTERFACE_PREFIX) {
push(
findings,
CheckId::SmOperation,
who,
format!(
"{op} uses the reserved {PSEUDO_INTERFACE_PREFIX}* pseudo-interface but is not \
pinned in the NON_SM_REST_OPERATIONS table (src/validate.rs) — \
a non-SM anchor exists only for a RELEASED ITS-REST operation the SM defines no \
interface for, and that table is the only place one is declared"
),
);
return;
}
let file = sm_class_file(spec.root(), op.interface());
match spec.read(&file) {
Err(_) => push(
findings,
CheckId::SmOperation,
who,
format!(
"interface {} has no vendored SM class export ({})",
op.interface(),
file.display()
),
),
Ok(text) => {
if !text.contains(&format!("|*{}*", op.operation())) {
push(
findings,
CheckId::SmOperation,
who,
format!(
"operation {op} is not defined by the vendored SM interface ({})",
file.display()
),
);
}
}
}
}
fn check_sm_operations(
case: &CaseCore,
who: &str,
spec: &SpecIndex<'_>,
findings: &mut Vec<Finding>,
) {
let Some(anchor) = &case.sm_operation else {
return;
};
resolve_sm_operation(anchor, who, spec, findings);
for step in &case.flow {
let op = if step.call.contains('.') {
match SmOperationRef::parse(&step.call) {
Ok(op) => op,
Err(e) => {
push(findings, CheckId::SmOperation, who, e.to_string());
continue;
}
}
} else {
anchor.sibling(&step.call)
};
resolve_sm_operation(&op, who, spec, findings);
}
}
fn component_dir(token: &str) -> Option<&'static str> {
Some(match token {
"SM" => "SM",
"CNF" => "CNF",
"RM" => "RM",
"BASE" => "BASE",
"AM" => "AM",
"QUERY" => "QUERY",
"TERM" => "TERM",
"LANG" => "LANG",
"ITS-REST" => "ITS-REST",
"ITS-JSON" => "ITS-JSON",
"ITS-XML" => "ITS-XML",
_ => return None,
})
}
const CITATION_SOURCE_MARKERS: [&str; 1] = ["OAS"];
#[derive(Debug)]
pub struct CitationClause<'a> {
pub component: &'a str,
pub tokens: Vec<&'a str>,
pub sections: Vec<&'a str>,
}
fn path_like(token: &str) -> bool {
!token.is_empty()
&& token.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '.' | '_' | '/' | '-' | '{' | '}' | ',' | '*' | '+')
})
}
#[must_use]
pub fn citation_clauses(citation: &str) -> Vec<CitationClause<'_>> {
let mut clauses = Vec::new();
for semi in citation.split(';') {
for fragment in semi.split(" + ") {
let fragment = fragment.trim();
let mut words = fragment.split_whitespace();
let Some(component) = words.next() else {
continue;
};
if component_dir(component).is_none() {
continue;
}
let mut tokens = Vec::new();
for word in words {
if word.contains('§') || !path_like(word) {
break;
}
let word = word.trim_end_matches([',', '.']);
if word.is_empty() || CITATION_SOURCE_MARKERS.contains(&word) {
continue;
}
tokens.push(word);
}
let sections = fragment
.split('§')
.skip(1)
.map(|rest| {
let rest = rest.trim_start();
match rest.strip_prefix('"') {
Some(quoted) => quoted.split('"').next().unwrap_or(quoted),
None => rest,
}
})
.filter(|s| !s.trim().is_empty())
.collect();
clauses.push(CitationClause {
component,
tokens,
sections,
});
}
}
if clauses.is_empty() {
let component = citation.split_whitespace().next().unwrap_or("");
clauses.push(CitationClause {
component,
tokens: Vec::new(),
sections: Vec::new(),
});
}
clauses
}
pub const MAX_CITATION_VARIANTS: usize = 32;
fn expand_one_token(token: &str) -> Vec<String> {
let mut budget = MAX_CITATION_VARIANTS;
expand_one_token_within(token, &mut budget).unwrap_or_else(|| vec![token.to_owned()])
}
fn expand_one_token_within(token: &str, budget: &mut usize) -> Option<Vec<String>> {
*budget = budget.checked_sub(1)?;
let Some(open) = token.find('{') else {
return Some(vec![token.to_owned()]);
};
let Some(close) = token.get(open..).and_then(|rest| rest.find('}')) else {
return Some(vec![token.to_owned()]);
};
let (Some(head), Some(body), Some(tail)) = (
token.get(..open),
token.get(open + 1..open + close),
token.get(open + close + 1..),
) else {
return Some(vec![token.to_owned()]);
};
let mut expanded = Vec::new();
for alternative in body.split(',') {
expanded.extend(expand_one_token_within(
&format!("{head}{alternative}{tail}"),
budget,
)?);
}
Some(expanded)
}
#[must_use]
pub fn expand_braces(tokens: &[&str]) -> Vec<Vec<String>> {
let mut variants: Vec<Vec<String>> = vec![Vec::new()];
for token in tokens {
let expansions = expand_one_token(token);
let mut next = Vec::with_capacity(variants.len().saturating_mul(expansions.len()));
for variant in &variants {
for expansion in &expansions {
let mut grown = variant.clone();
grown.push(expansion.clone());
next.push(grown);
}
}
if next.len() > MAX_CITATION_VARIANTS {
return vec![tokens.iter().map(|t| (*t).to_owned()).collect()];
}
variants = next;
}
variants
}
fn resolve_documents(spec: &SpecIndex<'_>, dir: &Path, tokens: &[&str]) -> Vec<PathBuf> {
let Some(last) = tokens.last() else {
return Vec::new();
};
let lowered: Vec<String> = tokens.iter().map(|t| t.to_lowercase()).collect();
let last = last.rsplit('/').next().unwrap_or(last).to_lowercase();
let files = spec.component_files(dir);
let mut out = Vec::new();
for (lowered_path, path) in &files.all {
if !lowered
.iter()
.all(|token| lowered_path.contains(token.as_str()))
{
continue;
}
let mut segments = lowered_path.split('/');
let name = segments.next_back().unwrap_or_default();
let names_file =
name == last || name.starts_with(&last) || name.ends_with(&format!(".{last}"));
if names_file || segments.any(|segment| segment == last) {
out.push(path.clone());
}
}
out
}
fn normalize_section(text: &str) -> String {
let mut out = String::new();
let mut pending_space = false;
for ch in text.chars() {
match ch {
'"' | '\'' | '‘' | '’' | '“' | '”' => {}
'`' | '*' | '_' | '\u{a0}' => pending_space = !out.is_empty(),
c if c.is_whitespace() => pending_space = !out.is_empty(),
c => {
if pending_space {
out.push(' ');
pending_space = false;
}
out.extend(c.to_lowercase());
}
}
}
out
}
#[must_use]
pub fn section_candidates(section: &str) -> BTreeSet<String> {
let full = normalize_section(section);
let mut out = BTreeSet::new();
let mut add = |text: &str| {
let text = text
.trim()
.trim_end_matches([',', ';', '.', '/', '-', ':', ')'])
.trim();
if !text.is_empty() {
out.insert(text.to_owned());
}
};
for cut in [" (", " — ", " -- ", " - ", ", ", ": ", "; ", " §"] {
if let Some((head, _)) = full.split_once(cut) {
add(head);
}
}
if let Some((head, _)) = full.split_once('.') {
add(head);
}
add(&full);
out
}
fn section_resolves(candidates: &BTreeSet<String>, names: &BTreeSet<String>) -> bool {
candidates.iter().any(|candidate| {
names.iter().any(|name| {
name == candidate
|| (name.len() >= 4 && candidate.contains(name.as_str()))
|| (candidate.len() >= 3 && name.contains(candidate.as_str()))
})
})
}
fn check_citations(
citations: &[&str],
who: &str,
spec: &SpecIndex<'_>,
findings: &mut Vec<Finding>,
) {
for citation in citations {
if citation.trim().is_empty() {
push(findings, CheckId::SpecRef, who, "empty spec_ref".to_owned());
continue;
}
for clause in citation_clauses(citation) {
let Some(dir) = component_dir(clause.component) else {
push(
findings,
CheckId::SpecRef,
who,
format!("{citation:?}: unknown component {:?}", clause.component),
);
continue;
};
let roots = spec.component_roots(clause.component, dir);
let Some(first) = roots.first() else {
push(
findings,
CheckId::SpecRef,
who,
format!(
"{citation:?}: vendored component dir {} missing",
spec.root().join(dir).display()
),
);
continue;
};
if clause.tokens.is_empty() {
let _ = first; continue;
}
let mut documents: Vec<(&PathBuf, PathBuf)> = Vec::new();
let mut unmatched: Option<String> = None;
for variant in expand_braces(&clause.tokens) {
let tokens: Vec<&str> = variant.iter().map(String::as_str).collect();
let mut hits: Vec<(&PathBuf, PathBuf)> = Vec::new();
for root in &roots {
hits.extend(
resolve_documents(spec, root, &tokens)
.into_iter()
.map(|document| (root, document)),
);
}
if hits.is_empty() {
unmatched = Some(variant.join(" "));
break;
}
documents.extend(hits);
}
if let Some(missing) = unmatched {
push(
findings,
CheckId::SpecRef,
who,
format!("{citation:?}: no vendored document under {dir} matches {missing:?}"),
);
continue;
}
if clause.sections.is_empty() {
continue;
}
let mut names = BTreeSet::new();
for (root, document) in &documents {
names.extend(spec.document_sections(root, document).iter().cloned());
}
for section in &clause.sections {
if !section_resolves(§ion_candidates(section), &names) {
push(
findings,
CheckId::SpecRef,
who,
format!(
"{citation:?}: the {} vendored document(s) matching {:?} carry no \
section matching §{}",
documents.len(),
clause.tokens.join(" "),
section.trim()
),
);
}
}
}
}
}
fn check_spec_refs(case: &CaseCore, who: &str, spec: &SpecIndex<'_>, findings: &mut Vec<Finding>) {
let mut citations: Vec<&str> = case.spec_refs.iter().map(String::as_str).collect();
if let Some(fixtures) = case
.parameters
.as_ref()
.and_then(|p| p.fixture_set.as_ref())
{
citations.extend(fixtures.iter().filter_map(|f| f.spec_ref.as_deref()));
}
check_citations(&citations, who, spec, findings);
}
const SPEC_SILENCE_FLAG_PREFIX: &str = "no openehr spec governs";
const REGISTER_CLAUSE_PREFIX: &str = "register amb-";
fn check_binding_sources(
binding: &OperationBinding,
who: &str,
spec: &SpecIndex<'_>,
findings: &mut Vec<Finding>,
) {
let sources = [
binding
.unrealized
.as_ref()
.map(|d| ("unrealized", &d.source)),
binding.extension.as_ref().map(|d| ("extension", &d.source)),
];
for (field, source) in sources.into_iter().flatten() {
let source = source.trim();
if source.is_empty() {
push(
findings,
CheckId::SpecRef,
who,
format!("{field}.source is empty"),
);
continue;
}
for fragment in source.split(';').flat_map(|semi| semi.split(" + ")) {
let fragment = fragment.trim();
if fragment.is_empty() {
continue;
}
let opens_component = fragment
.split_whitespace()
.next()
.is_some_and(|word| component_dir(word).is_some());
let normalized = normalize_section(fragment);
if opens_component
|| normalized.starts_with(SPEC_SILENCE_FLAG_PREFIX)
|| normalized.starts_with(REGISTER_CLAUSE_PREFIX)
{
continue;
}
push(
findings,
CheckId::SpecRef,
who,
format!(
"{field}.source clause {fragment:?} opens with neither a spec component nor \
the spec-silence flag nor the register entry — the citation gate would drop \
it unread; separate the derivation's citations with `;` or ` + `"
),
);
}
check_citations(
&[source],
&format!("{who} [{field}.source]"),
spec,
findings,
);
}
}
fn check_register_sources(set: &ArtifactSet, spec: &SpecIndex<'_>, findings: &mut Vec<Finding>) {
let Some((path, register)) = &set.register else {
return;
};
let who = path.display().to_string();
for (id, entry) in register.entries() {
check_citations(
&[entry.source.as_str()],
&format!("{who} [{id}] source"),
spec,
findings,
);
}
}
fn check_corpus_spec_refs(set: &ArtifactSet, spec: &SpecIndex<'_>, findings: &mut Vec<Finding>) {
let Some((path, manifest)) = &set.corpus else {
return;
};
let who = path.display().to_string();
for (key, entry) in manifest.entries() {
let Some(citation) = entry.validity.spec_ref.as_deref() else {
continue;
};
check_citations(&[citation], &format!("{who} [{key}]"), spec, findings);
}
}
const BODY_ROLE_ALIASES: [&str; 2] = ["composition", "opt"];
const CONTRIBUTION_KEYS: [&str; 2] = ["versions", "audit"];
fn template_capture_names(template: &crate::refgrammar::Template, into: &mut BTreeSet<String>) {
for reference in template.refs() {
if let ValueRef::Capture { name, .. } = reference {
into.insert(name.to_string());
}
}
}
fn value_capture_names(value: &TemplatedValue, into: &mut BTreeSet<String>) {
match value {
TemplatedValue::Text(template) => template_capture_names(template, into),
TemplatedValue::Seq(items) => {
for item in items {
value_capture_names(item, into);
}
}
TemplatedValue::Map(entries) => {
for (_, item) in entries {
value_capture_names(item, into);
}
}
TemplatedValue::Null | TemplatedValue::Bool(_) | TemplatedValue::Number(_) => {}
}
}
fn value_data_set_refs(value: &TemplatedValue, into: &mut BTreeSet<String>) {
match value {
TemplatedValue::Text(template) => {
for reference in template.refs() {
if let ValueRef::DataSet { key, .. } = reference {
into.insert(key.to_string());
}
}
}
TemplatedValue::Seq(items) => {
for item in items {
value_data_set_refs(item, into);
}
}
TemplatedValue::Map(entries) => {
for (_, item) in entries {
value_data_set_refs(item, into);
}
}
TemplatedValue::Null | TemplatedValue::Bool(_) | TemplatedValue::Number(_) => {}
}
}
fn could_be_payload(value: &TemplatedValue, text_role: bool) -> bool {
match value {
TemplatedValue::Seq(_) | TemplatedValue::Map(_) => true,
TemplatedValue::Text(template) => {
text_role
|| template.refs().any(|r| {
matches!(
r,
ValueRef::DataSet { .. }
| ValueRef::FixtureDataSet
| ValueRef::Recipe(_)
| ValueRef::Capture { .. }
| ValueRef::Row(_)
| ValueRef::Fixture(_)
)
})
}
TemplatedValue::Null | TemplatedValue::Bool(_) | TemplatedValue::Number(_) => false,
}
}
fn consumed_with_keys(
set: &ArtifactSet,
binding: &OperationBinding,
step: &FlowStep,
) -> BTreeSet<String> {
let mut consumed = BTreeSet::new();
for (_, sibling) in &set.bindings {
if sibling.sm_operation == binding.sm_operation
&& let Some(param) = sibling
.variant
.as_deref()
.and_then(|v| v.strip_prefix("with_"))
{
consumed.insert(param.to_owned());
}
}
let Some(request) = &binding.request else {
return consumed;
};
for param in request.path.params() {
consumed.insert(param.to_string());
}
for (name, value) in request.query.iter().flatten() {
consumed.insert(name.clone());
for template in value.templates() {
template_capture_names(template, &mut consumed);
}
}
for (_, template) in request.headers.iter().flatten() {
template_capture_names(template, &mut consumed);
}
let requires_template_id = binding.format_headers.iter().flatten().any(|(_, map)| {
map.0
.iter()
.any(|(_, req)| matches!(req, crate::model::binding::FormatHeaderReq::Required))
});
if requires_template_id {
for (key, value) in step.with_entries() {
let mut refs = BTreeSet::new();
value_data_set_refs(value, &mut refs);
if !refs.is_empty() {
consumed.insert(key.clone());
}
}
}
match &request.body {
None => {}
Some(crate::model::binding::RequestBody::Named { name, .. }) => {
let authored = |key: &str| step.with_entries().iter().any(|(k, _)| k == key);
if name == "contribution" && authored("versions") {
consumed.extend(CONTRIBUTION_KEYS.iter().map(|k| (*k).to_owned()));
}
else if let Some(hit) = std::iter::once(name.as_str())
.chain(BODY_ROLE_ALIASES)
.find(|key| authored(key))
{
consumed.insert(hit.to_owned());
}
else {
let text_role = name.ends_with("_text");
for (key, value) in step.with_entries() {
if could_be_payload(value, text_role) {
consumed.insert(key.clone());
}
}
}
}
Some(crate::model::binding::RequestBody::Structured(template)) => {
value_capture_names(template, &mut consumed);
}
Some(crate::model::binding::RequestBody::Patched { from_capture, set }) => {
consumed.insert(from_capture.to_string());
for (_, value) in set {
if let Ok(value) = TemplatedValue::from_value(value) {
value_capture_names(&value, &mut consumed);
}
}
}
}
consumed
}
fn driven_case(case: &CaseCore) -> std::borrow::Cow<'_, CaseCore> {
if matches!(case.kind, CaseKind::Content) {
std::borrow::Cow::Owned(crate::run::synthesize_content_case(case))
} else {
std::borrow::Cow::Borrowed(case)
}
}
fn step_binding<'a>(
set: &'a ArtifactSet,
anchor: &SmOperationRef,
step: &FlowStep,
) -> Option<&'a OperationBinding> {
let op = if step.call.contains('.') {
SmOperationRef::parse(&step.call).ok()?
} else {
anchor.sibling(&step.call)
};
let mut bindings: Vec<&OperationBinding> = set
.bindings
.iter()
.map(|(_, b)| b)
.filter(|b| b.sm_operation == op)
.collect();
if let Some(v) = &step.variant
&& bindings
.iter()
.any(|b| b.variant.as_deref() == Some(v.as_str()))
{
bindings.retain(|b| b.variant.as_deref() == Some(v.as_str()));
} else if bindings.iter().any(|b| b.variant.is_none()) {
bindings.retain(|b| b.variant.is_none());
}
bindings.first().copied()
}
fn check_matcher_placeholders(set: &ArtifactSet, findings: &mut Vec<Finding>) {
for (_, authored) in &set.cases {
let driven = driven_case(authored);
let case = driven.as_ref();
let Some(anchor) = &case.sm_operation else {
continue;
};
let who = case.id.to_string();
let mut captured: BTreeSet<String> = case
.requires
.minted_handles()
.iter()
.map(ToString::to_string)
.collect();
for step in &case.flow {
if let Some(binding) = step_binding(set, anchor, step)
&& !binding.is_unrealized()
{
let mut in_scope = captured.clone();
in_scope.extend(step.with_entries().iter().map(|(key, _)| key.clone()));
let kinds: BTreeSet<OutcomeKind> =
step_observable_kinds(case, step).into_iter().collect();
for kind in kinds {
let Some(expectation) = binding.outcome(kind) else {
continue; };
for (header, declared) in expectation.headers.iter().flatten() {
let HeaderMatcher::Pattern(pattern) = &declared.matcher else {
continue;
};
for name in placeholder_names(pattern) {
if structural_token(name).is_some() || in_scope.contains(name) {
continue;
}
push(
findings,
CheckId::MatcherPlaceholder,
&who,
format!(
"step {}: the {} `{}` outcome matches header {header} with \
<{name}>, which is neither a structural token nor in the \
template scope at that step (requires-minted handles + \
earlier captures + this step's `with:` arguments) — the \
matcher refuses instead of judging, so the row reddens on \
the runner's own resolution failure",
step.step,
binding.sm_operation,
kind.token(),
),
);
}
}
}
}
for (name, _source) in step.captures() {
captured.insert(name.to_string());
}
}
}
}
fn check_step_arguments(set: &ArtifactSet, findings: &mut Vec<Finding>) {
for (_, authored) in &set.cases {
let driven = driven_case(authored);
let case = driven.as_ref();
let Some(anchor) = &case.sm_operation else {
continue;
};
let who = case.id.to_string();
for step in &case.flow {
if step.with_entries().is_empty() {
continue;
}
let Some(binding) = step_binding(set, anchor, step) else {
continue; };
if binding.is_unrealized() {
continue;
}
let consumed = consumed_with_keys(set, binding, step);
for (key, _) in step.with_entries() {
if consumed.contains(key) {
continue;
}
push(
findings,
CheckId::StepArguments,
&who,
format!(
"step {}: `with.{key}` is authored but {} reads it on no request path \
(not a path param, query parameter, header/body template reference, \
payload role or variant selector) — the SUT is driven as if the key \
were absent, so anything the case asserts about it passes vacuously",
step.step, binding.sm_operation
),
);
}
}
}
}
fn step_observable_kinds(case: &CaseCore, step: &FlowStep) -> Vec<OutcomeKind> {
let mut kinds: Vec<OutcomeKind> = Vec::new();
match step.expect {
ExpectSpec::Kind(kind) => kinds.push(kind),
ExpectSpec::FixtureExpected => {
if let Some(fixtures) = case
.parameters
.as_ref()
.and_then(|p| p.fixture_set.as_ref())
{
kinds.extend(fixtures.iter().map(|f| f.expected));
}
}
}
if let Some(matrix) = case.parameters.as_ref().and_then(|p| p.matrix.as_ref())
&& let Some(col) = matrix.columns.iter().position(|c| c == "expected")
{
kinds.extend(matrix.rows.iter().filter_map(|row| match row.get(col) {
Some(MatrixCell::Literal(serde_json::Value::String(s))) => OutcomeKind::from_token(s),
_ => None,
}));
}
kinds
}
fn check_binding_completeness(set: &ArtifactSet, findings: &mut Vec<Finding>) {
for (_, case) in &set.cases {
let Some(anchor) = &case.sm_operation else {
continue;
};
let who = case.id.to_string();
for step in &case.flow {
let op = if step.call.contains('.') {
match SmOperationRef::parse(&step.call) {
Ok(op) => op,
Err(_) => continue, }
} else {
anchor.sibling(&step.call)
};
let mut bindings: Vec<_> = set
.bindings
.iter()
.filter(|(_, b)| b.sm_operation == op)
.collect();
if bindings.is_empty() {
push(
findings,
CheckId::BindingCompleteness,
&who,
format!("no binding declares operation {op}"),
);
continue;
}
if let Some(v) = &step.variant
&& bindings
.iter()
.any(|(_, b)| b.variant.as_deref() == Some(v.as_str()))
{
bindings.retain(|(_, b)| b.variant.as_deref() == Some(v.as_str()));
} else {
let has_variantless = bindings.iter().any(|(_, b)| b.variant.is_none());
if has_variantless {
bindings.retain(|(_, b)| b.variant.is_none());
}
}
if bindings.iter().all(|(_, b)| b.is_unrealized()) {
continue;
}
let kinds = step_observable_kinds(case, step);
let universal: Vec<&str> = set
.selectors
.as_ref()
.and_then(|(_, s)| s.universal_outcomes.as_deref())
.unwrap_or_default()
.iter()
.map(|(k, _)| k.as_str())
.collect();
for (path, binding) in bindings {
for kind in &kinds {
if universal.contains(&kind.token()) {
continue;
}
if binding.outcome(*kind).is_none() {
push(
findings,
CheckId::BindingCompleteness,
&who,
format!(
"outcome kind `{}` on {op} is not mapped by {}",
kind.token(),
path.display()
),
);
}
}
for (name, source) in step.captures() {
if let CaptureField::Field { name: field, .. } = &source.field
&& !binding.maps_capture(field)
{
push(
findings,
CheckId::BindingCompleteness,
&who,
format!(
"capture {name} needs wire source `{field}` on {op}, not mapped by {}",
path.display()
),
);
}
}
}
}
}
}
fn expected_binding_stem(binding: &OperationBinding) -> String {
match &binding.variant {
Some(variant) => format!("{}-{variant}", binding.sm_operation),
None => binding.sm_operation.to_string(),
}
}
fn check_binding_filename(path: &Path, binding: &OperationBinding, findings: &mut Vec<Finding>) {
let who = path.display().to_string();
let expected = expected_binding_stem(binding);
match path.file_stem().and_then(std::ffi::OsStr::to_str) {
None => push(
findings,
CheckId::BindingFilename,
&who,
format!("binding file has no readable stem; expected {expected}.yaml"),
),
Some(stem) if stem != expected => push(
findings,
CheckId::BindingFilename,
&who,
format!(
"file stem {stem:?} disagrees with the declared identity \
(sm_operation {}{}) — rename the file to {expected}.yaml",
binding.sm_operation,
binding
.variant
.as_deref()
.map_or(String::new(), |v| format!(", variant {v:?}")),
),
),
Some(_) => {}
}
}
#[cfg(test)]
mod binding_filename_tests {
use super::*;
fn binding(variant: Option<&str>) -> OperationBinding {
let mut doc = serde_json::json!({
"sm_operation": "I_EHR_SERVICE.create_ehr",
"its": "its-rest",
"request": { "method": "POST", "path": "/ehr" },
"outcomes": { "created": { "status": 201 } }
});
if let (Some(variant), Some(map)) = (variant, doc.as_object_mut()) {
map.insert("variant".to_owned(), serde_json::json!(variant));
}
serde_json::from_value(doc).unwrap()
}
fn findings_for(file: &str, variant: Option<&str>) -> Vec<Finding> {
let mut findings = Vec::new();
check_binding_filename(
&PathBuf::from("bindings/its-rest").join(file),
&binding(variant),
&mut findings,
);
findings
}
#[test]
fn variant_less_binding_is_named_after_its_operation() {
assert!(findings_for("I_EHR_SERVICE.create_ehr.yaml", None).is_empty());
let findings = findings_for("create_ehr.yaml", None);
assert_eq!(findings.len(), 1);
let finding = findings.first().unwrap();
assert_eq!(finding.check, CheckId::BindingFilename);
assert!(
finding
.message
.contains("rename the file to I_EHR_SERVICE.create_ehr.yaml"),
"{}",
finding.message
);
}
#[test]
fn variant_binding_carries_its_variant_in_the_stem() {
assert!(
findings_for(
"I_EHR_SERVICE.create_ehr-with_ehr_id.yaml",
Some("with_ehr_id")
)
.is_empty()
);
let findings = findings_for("I_EHR_SERVICE.create_ehr-with_id.yaml", Some("with_ehr_id"));
assert_eq!(findings.len(), 1);
let finding = findings.first().unwrap();
assert!(finding.message.contains("with_id"), "{}", finding.message);
assert!(
finding
.message
.contains("rename the file to I_EHR_SERVICE.create_ehr-with_ehr_id.yaml"),
"{}",
finding.message
);
assert_eq!(
findings_for("I_EHR_SERVICE.create_ehr.yaml", Some("with_ehr_id")).len(),
1
);
}
#[test]
fn expected_stem_is_the_declared_identity() {
assert_eq!(
expected_binding_stem(&binding(None)),
"I_EHR_SERVICE.create_ehr"
);
assert_eq!(
expected_binding_stem(&binding(Some("wrong_media_type"))),
"I_EHR_SERVICE.create_ehr-wrong_media_type"
);
}
}
#[cfg(test)]
mod matcher_placeholder_tests {
use super::*;
fn world(placeholder: &str, capture_on_step_one: bool) -> ArtifactSet {
let binding: OperationBinding = serde_json::from_value(serde_json::json!({
"sm_operation": "I_EHR_COMPOSITION.create_composition",
"its": "its-rest",
"request": { "method": "POST", "path": "/ehr/{ehr_id}/composition", "body": "composition" },
"outcomes": {
"created": {
"status": 201,
"headers": { "ETag": format!("pattern:W/\"<{placeholder}>::<system_id>::1\"") }
}
},
"captures": { "versioned_object_uid": { "from": "header ETag", "strip": "weak-quotes" } }
}))
.unwrap();
let mut step = serde_json::json!({
"step": 1,
"call": "create_composition",
"with": { "ehr_id": "${ehr_id}", "composition": "${ds:cnf.x}" },
"expect": "created"
});
if capture_on_step_one && let Some(map) = step.as_object_mut() {
map.insert(
"capture".to_owned(),
serde_json::json!({ placeholder: format!("created.{placeholder}") }),
);
}
let case: CaseCore = serde_json::from_value(serde_json::json!({
"id": "X-matcher", "kind": "functional", "component": "EHR_COMPOSITION",
"sm_operation": "I_EHR_COMPOSITION.create_composition",
"test_purpose": "t", "description": "d", "spec_refs": [],
"requires": { "ehr": { "commits": "none" } },
"flow": [step]
}))
.unwrap();
let mut set = ArtifactSet::default();
set.bindings.push((PathBuf::from("b.yaml"), binding));
set.cases.push((PathBuf::from("c.yaml"), case));
set
}
fn findings_for(placeholder: &str, capture_on_step_one: bool) -> Vec<Finding> {
let mut findings = Vec::new();
check_matcher_placeholders(&world(placeholder, capture_on_step_one), &mut findings);
findings
}
#[test]
fn an_unresolvable_placeholder_is_a_finding() {
let findings = findings_for("no_such_name", false);
assert_eq!(findings.len(), 1, "{findings:?}");
let finding = findings.first().unwrap();
assert_eq!(finding.check, CheckId::MatcherPlaceholder);
assert!(
finding.message.contains("<no_such_name>"),
"{}",
finding.message
);
}
#[test]
fn a_same_step_capture_does_not_put_a_name_in_scope() {
assert_eq!(findings_for("no_such_name", true).len(), 1);
}
#[test]
fn structural_tokens_need_no_capture() {
assert!(findings_for("versioned_object_uid", false).is_empty());
assert!(findings_for("template_hrid", false).is_empty());
}
#[test]
fn minted_handles_and_step_arguments_are_in_scope() {
assert!(findings_for("ehr_id", false).is_empty());
assert!(findings_for("composition", false).is_empty());
}
fn content_world(placeholder: &str, content_path: &str) -> ArtifactSet {
let binding: OperationBinding = serde_json::from_value(serde_json::json!({
"sm_operation": "I_EHR_COMPOSITION.create_composition",
"its": "its-rest",
"request": { "method": "POST", "path": content_path, "body": "composition" },
"outcomes": {
"created": {
"status": 201,
"headers": { "ETag": format!("pattern:W/\"<{placeholder}>::<system_id>::1\"") }
},
"validation_failed": { "status": 422 }
}
}))
.unwrap();
let case: CaseCore = serde_json::from_value(serde_json::json!({
"id": "CONT-X-validate_open", "kind": "content", "component": "CONTENT",
"rm_class": "DV_COUNT",
"test_purpose": "t", "description": "d", "spec_refs": [],
"constraint_context": {
"template": "cnf.content.minimal",
"path": "/content[at0001]",
"constraint_columns": []
},
"decision_table": {
"columns": ["magnitude", "expected", "violates"],
"rows": [[1, "accepted", []]]
}
}))
.unwrap();
let mut set = ArtifactSet::default();
set.bindings.push((PathBuf::from("b.yaml"), binding));
set.cases.push((PathBuf::from("c.yaml"), case));
set
}
#[test]
fn a_content_cases_synthesized_flow_is_judged_by_both_step_gates() {
let clean = content_world("versioned_object_uid", "/ehr/{ehr_id}/composition");
let mut findings = Vec::new();
check_matcher_placeholders(&clean, &mut findings);
check_step_arguments(&clean, &mut findings);
assert!(findings.is_empty(), "{findings:?}");
let mut findings = Vec::new();
check_matcher_placeholders(
&content_world("no_such_name", "/ehr/{ehr_id}/composition"),
&mut findings,
);
assert_eq!(findings.len(), 1, "{findings:?}");
let finding = findings.first().unwrap();
assert_eq!(finding.check, CheckId::MatcherPlaceholder);
assert!(
finding.message.contains("<no_such_name>"),
"{}",
finding.message
);
let mut findings = Vec::new();
check_step_arguments(
&content_world("versioned_object_uid", "/composition"),
&mut findings,
);
assert_eq!(findings.len(), 1, "{findings:?}");
let finding = findings.first().unwrap();
assert_eq!(finding.check, CheckId::StepArguments);
assert!(
finding.message.contains("`with.ehr_id`"),
"{}",
finding.message
);
}
}
fn check_corpus_integrity(set: &ArtifactSet, findings: &mut Vec<Finding>) {
let Some((path, corpus)) = &set.corpus else {
return;
};
let who = path.display().to_string();
for (key, entry) in corpus.entries() {
if let Err(message) = entry.check_invariants() {
push(
findings,
CheckId::CorpusIntegrity,
&who,
format!("{key}: {message}"),
);
}
if let (Some(source), Some(dir)) = (&entry.source, &set.corpus_dir) {
let file = dir.join(source);
if !file.is_file() {
push(
findings,
CheckId::CorpusIntegrity,
&who,
format!("{key}: source {source} does not exist"),
);
}
}
for (view, _) in entry.views.iter().flatten() {
if !crate::exec::resolve::Resolver::REGISTERED_VIEWS.contains(&view.as_str()) {
push(
findings,
CheckId::CorpusIntegrity,
&who,
format!("{key}: view {view} has no registered evaluator (exec::resolve)"),
);
}
}
}
}
fn check_vocab_drift(set: &ArtifactSet, findings: &mut Vec<Finding>) {
if let Some((path, outcomes)) = &set.outcomes
&& let Err(drift) = outcomes.check_against_enum()
{
for message in drift {
push(
findings,
CheckId::VocabDrift,
&path.display().to_string(),
message,
);
}
}
if let Some((path, selectors)) = &set.selectors
&& let Err(drift) = selectors.check_against_enum()
{
for message in drift {
push(
findings,
CheckId::VocabDrift,
&path.display().to_string(),
message,
);
}
}
if let Some((path, matrix)) = &set.matrix {
let who = path.display().to_string();
for message in matrix
.check_tier_scoping()
.err()
.into_iter()
.chain(matrix.check_realization_scoping().err())
.flatten()
{
push(findings, CheckId::VocabDrift, &who, message);
}
}
if let Some((path, register)) = &set.register {
for (id, entry) in register.entries() {
if let Err(message) = entry.check_invariants() {
push(
findings,
CheckId::VocabDrift,
&path.display().to_string(),
format!("{id}: {message}"),
);
}
}
}
}
const PLATFORM_INTERFACES: &[&str] = &[
"I_EHR_SERVICE",
"I_EHR_STATUS",
"I_EHR_COMPOSITION",
"I_EHR_DIRECTORY",
"I_EHR_CONTRIBUTION",
"I_DEFINITION_ADL14",
"I_DEFINITION_ADL2",
"I_DEFINITION_QUERY",
"I_QUERY_SERVICE",
"I_DEMOGRAPHIC_SERVICE",
"I_PARTY",
"I_PARTY_RELATIONSHIP",
"I_VALIDITY_CHECKER",
"I_ADMIN_SERVICE",
"I_ADMIN_ARCHIVE",
"I_ADMIN_DUMP_LOAD",
"I_EHR_INDEX",
"I_TERMINOLOGY_SERVICE",
"I_MESSAGE_SERVICE",
"I_EHR_EXTRACT_SERVICE",
"I_TDD_SERVICE",
"I_SUBJECT_PROXY_SERVICE",
"I_DATA_BINDING",
"I_SYSTEM_LOG",
];
const PSEUDO_INTERFACE_PREFIX: &str = "I_ITS_REST_";
const NON_SM_REST_OPERATIONS: &[(&str, &str)] = &[
(
"I_ITS_REST_SYSTEM.options",
"ITS-REST docs/system/Description.md (STABLE System API) + overview \
Requests_and_responses.md §HTTP Methods (OPTIONS)",
),
(
"I_ITS_REST_SMART.discovery",
"ITS-REST docs/smart_app_launch/master04-service_discovery.adoc §Service Discovery — \
\"The configuration endpoint should be always available relative to the _Platform_ base \
URL\", served as `application/json`, its `services` map carrying at minimum \
`org.openehr.rest` with an absolute `baseUrl` (§Services). The SM models no Platform \
interface (the Platform is \"a software ecosystem comprising at minimum an Authorization \
Server, an openEHR Clinical Data Repository (CDR), and a FHIR Server\" — \
master02-overview.adoc §Glossary — not an SM service), and SMART is the one API area of \
the release with no OpenAPI group, so the operation is enumerable only from this table",
),
(
"I_ITS_REST_ITEM_TAGS.ehr_tags_get",
"ITS-REST specifications/operations/ehr_tags_get.yaml — GET \
/ehr/{ehr_id}/tags, the EHR-scoped list of \"the ITEM_TAG resources \
associated with any target VERSION or VERSIONED_OBJECT within the EHR \
identified by ehr_id\"",
),
(
"I_ITS_REST_ITEM_TAGS.demographic_tags_get",
"ITS-REST specifications/operations/demographic_tags_get.yaml — GET \
/demographic/tags, the space-wide list of \"the ITEM_TAG resources \
associated with any target VERSION or VERSIONED_PARTY within the \
Demographic space\" (its unbounded scope is adjudicated in AMB-138)",
),
(
"I_ITS_REST_ITEM_TAGS.composition_tags_get",
"ITS-REST specifications/operations/composition_tags_get.yaml — GET \
/ehr/{ehr_id}/composition/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.composition_tags_update",
"ITS-REST specifications/operations/composition_tags_update.yaml — PUT \
/ehr/{ehr_id}/composition/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.composition_tags_delete",
"ITS-REST specifications/operations/composition_tags_delete.yaml — \
DELETE /ehr/{ehr_id}/composition/{uid_based_id}/tags/{key}",
),
(
"I_ITS_REST_ITEM_TAGS.ehr_status_tags_get",
"ITS-REST specifications/operations/ehr_status_tags_get.yaml — GET \
/ehr/{ehr_id}/ehr_status/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.ehr_status_tags_update",
"ITS-REST specifications/operations/ehr_status_tags_update.yaml — PUT \
/ehr/{ehr_id}/ehr_status/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.ehr_status_tags_delete",
"ITS-REST specifications/operations/ehr_status_tags_delete.yaml — \
DELETE /ehr/{ehr_id}/ehr_status/{uid_based_id}/tags/{key}",
),
(
"I_ITS_REST_ITEM_TAGS.person_tags_get",
"ITS-REST specifications/operations/person_tags_get.yaml — GET \
/demographic/person/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.person_tags_update",
"ITS-REST specifications/operations/person_tags_update.yaml — PUT \
/demographic/person/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.person_tags_delete",
"ITS-REST specifications/operations/person_tags_delete.yaml — DELETE \
/demographic/person/{uid_based_id}/tags/{key}",
),
(
"I_ITS_REST_ITEM_TAGS.agent_tags_get",
"ITS-REST specifications/operations/agent_tags_get.yaml — GET \
/demographic/agent/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.agent_tags_update",
"ITS-REST specifications/operations/agent_tags_update.yaml — PUT \
/demographic/agent/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.agent_tags_delete",
"ITS-REST specifications/operations/agent_tags_delete.yaml — DELETE \
/demographic/agent/{uid_based_id}/tags/{key}",
),
(
"I_ITS_REST_ITEM_TAGS.group_tags_get",
"ITS-REST specifications/operations/group_tags_get.yaml — GET \
/demographic/group/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.group_tags_update",
"ITS-REST specifications/operations/group_tags_update.yaml — PUT \
/demographic/group/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.group_tags_delete",
"ITS-REST specifications/operations/group_tags_delete.yaml — DELETE \
/demographic/group/{uid_based_id}/tags/{key}",
),
(
"I_ITS_REST_ITEM_TAGS.organisation_tags_get",
"ITS-REST specifications/operations/organisation_tags_get.yaml — GET \
/demographic/organisation/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.organisation_tags_update",
"ITS-REST specifications/operations/organisation_tags_update.yaml — PUT \
/demographic/organisation/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.organisation_tags_delete",
"ITS-REST specifications/operations/organisation_tags_delete.yaml — \
DELETE /demographic/organisation/{uid_based_id}/tags/{key}",
),
(
"I_ITS_REST_ITEM_TAGS.role_tags_get",
"ITS-REST specifications/operations/role_tags_get.yaml — GET \
/demographic/role/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.role_tags_update",
"ITS-REST specifications/operations/role_tags_update.yaml — PUT \
/demographic/role/{uid_based_id}/tags",
),
(
"I_ITS_REST_ITEM_TAGS.role_tags_delete",
"ITS-REST specifications/operations/role_tags_delete.yaml — DELETE \
/demographic/role/{uid_based_id}/tags/{key}",
),
(
"I_ITS_REST_REVISION_HISTORY.versioned_composition_revision_history",
"ITS-REST specifications/operations/versioned_composition_revision_history.yaml \
— GET /ehr/{ehr_id}/versioned_composition/{versioned_object_uid}/revision_history, \
\"Retrieves revision history of the VERSIONED_COMPOSITION identified by \
`versioned_object_uid` and associated with the EHR identified by `ehr_id`\"",
),
(
"I_ITS_REST_REVISION_HISTORY.versioned_ehr_status_revision_history",
"ITS-REST specifications/operations/versioned_ehr_status_revision_history.yaml \
— GET /ehr/{ehr_id}/versioned_ehr_status/revision_history, \"Retrieves \
revision history of the VERSIONED_EHR_STATUS associated with the EHR \
identified by `ehr_id`\"",
),
(
"I_ITS_REST_REVISION_HISTORY.versioned_party_revision_history",
"ITS-REST specifications/operations/versioned_party_revision_history.yaml — \
GET /demographic/versioned_party/{versioned_object_uid}/revision_history, \
\"Retrieves revision history of the VERSIONED_PARTY identified by \
`versioned_object_uid`\"",
),
(
"I_ITS_REST_VERSIONED_PARTY.versioned_party_get",
"ITS-REST specifications/operations/versioned_party_get.yaml — GET \
/demographic/versioned_party/{versioned_object_uid}, \"Retrieves a \
VERSIONED_PARTY identified by `versioned_object_uid`\" (register AMB-136: \
I_PARTY declares no container read)",
),
];
fn non_sm_operation_source(op: &SmOperationRef) -> Option<&'static str> {
let reference = op.to_string();
NON_SM_REST_OPERATIONS
.iter()
.find(|(name, _)| *name == reference)
.map(|(_, source)| *source)
}
fn parse_sm_interface_operations(
spec: &SpecIndex<'_>,
interface: &str,
) -> Result<Vec<String>, String> {
let file = sm_class_file(spec.root(), interface);
let text = spec.read(&file).map_err(|error| {
format!(
"interface {interface} has no vendored SM class export ({}): {error}",
file.display()
)
})?;
let mut ops: Vec<String> = Vec::new();
for line in text.lines() {
let Some(rest) = line.strip_prefix("|*") else {
continue;
};
let Some((name, tail)) = rest.split_once("* ") else {
continue;
};
if !tail.starts_with('(') || name.starts_with("i_") {
continue;
}
let lower_snake = name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if lower_snake && !ops.iter().any(|o| o == name) {
ops.push(name.to_owned());
}
}
Ok(ops)
}
fn check_surface_coverage(
set: &ArtifactSet,
spec: Option<&SpecIndex<'_>>,
findings: &mut Vec<Finding>,
) {
let empty = WireSurface::default();
let wire_surface = set.wire_surface.as_ref().map_or(&empty, |(_, w)| w);
if let Some(spec) = spec {
check_surface_sm_operations(set, spec, wire_surface, findings);
check_axis3_section_derivation(wire_surface, spec, AXIS3_SECTION_EXCLUSIONS, findings);
}
check_binding_branch_coverage(set, wire_surface, findings);
check_wire_surface_elements(set, wire_surface, findings);
check_served_extensions(set, wire_surface, findings);
}
fn check_served_extensions(
set: &ArtifactSet,
wire_surface: &WireSurface,
findings: &mut Vec<Finding>,
) {
let released: Vec<&str> = set
.bindings
.iter()
.filter(|(_, b)| !b.is_unrealized() && !b.is_extension())
.filter_map(|(_, b)| b.request.as_ref())
.map(|r| r.path.raw())
.collect();
let released_roots: BTreeSet<&str> = released
.iter()
.filter_map(|p| p.trim_start_matches('/').split('/').next())
.collect();
for extension in &wire_surface.served_extensions {
for route in &extension.routes {
let Some(path) = ServedExtension::route_path(route) else {
continue; };
for binding_path in &released {
let claims = path == *binding_path
|| path.strip_suffix(*binding_path).is_some_and(|mount| {
!mount.is_empty()
&& !mount.ends_with('/')
&& mount
.rsplit('/')
.next()
.is_some_and(|last| !released_roots.contains(last))
});
if claims {
push(
findings,
CheckId::SurfaceCoverage,
&extension.family,
format!(
"served_extensions route {route:?} claims the released ITS-REST path \
{binding_path} — an extension family may not declare an operation \
the release defines"
),
);
}
}
}
}
}
fn check_surface_sm_operations(
set: &ArtifactSet,
spec: &SpecIndex<'_>,
wire_surface: &WireSurface,
findings: &mut Vec<Finding>,
) {
for interface in PLATFORM_INTERFACES {
let ops = match spec.interface_operations(interface) {
Ok(ops) => ops,
Err(message) => {
push(findings, CheckId::SurfaceCoverage, interface, message);
continue;
}
};
for name in ops.iter() {
let Ok(op) = SmOperationRef::parse(&format!("{interface}.{name}")) else {
continue;
};
let bound = set.bindings.iter().any(|(_, b)| b.sm_operation == op);
if bound || wire_surface.sm_exception(&op).is_some() {
continue;
}
push(
findings,
CheckId::SurfaceCoverage,
&op.to_string(),
"SM operation has no its-rest binding and no wire_surface.yaml sm_operations \
exception — add a binding (realized or unrealized) or a cited \
off_wire/variant_of/coverage_gap entry"
.to_owned(),
);
}
}
for (name, source) in NON_SM_REST_OPERATIONS {
let Ok(op) = SmOperationRef::parse(name) else {
push(
findings,
CheckId::SurfaceCoverage,
name,
"NON_SM_REST_OPERATIONS entry is not a parsable operation reference".to_owned(),
);
continue;
};
let bound = set.bindings.iter().any(|(_, b)| b.sm_operation == op);
if bound || wire_surface.sm_exception(&op).is_some() {
continue;
}
push(
findings,
CheckId::SurfaceCoverage,
&op.to_string(),
format!(
"non-SM ITS-REST operation ({source}) has no its-rest binding and no \
wire_surface.yaml sm_operations exception — add a binding (realized or \
unrealized) or a cited off_wire/variant_of/coverage_gap entry"
),
);
}
for ex in &wire_surface.sm_operations {
if set
.bindings
.iter()
.any(|(_, b)| b.sm_operation == ex.operation)
{
push(
findings,
CheckId::SurfaceCoverage,
&ex.operation.to_string(),
"wire_surface.yaml sm_operations exception is redundant — the operation now has \
an its-rest binding; remove the exception"
.to_owned(),
);
}
}
}
type BranchKey = (SmOperationRef, Option<String>);
fn select_binding_for_step<'a>(
set: &'a ArtifactSet,
case: &CaseCore,
step: &FlowStep,
) -> Option<&'a OperationBinding> {
let op = if step.call.contains('.') {
SmOperationRef::parse(&step.call).ok()?
} else {
case.sm_operation.as_ref()?.sibling(&step.call)
};
if let Some(v) = &step.variant
&& let Some((_, b)) = set
.bindings
.iter()
.find(|(_, b)| b.sm_operation == op && b.variant.as_deref() == Some(v.as_str()))
{
return Some(b);
}
set.bindings
.iter()
.find(|(_, b)| b.sm_operation == op && b.variant.is_none())
.map(|(_, b)| b)
}
fn step_format(case: &CaseCore, step: &FlowStep) -> FormatName {
step.format
.or_else(|| case.formats.first().copied())
.unwrap_or(FormatName::CanonicalJson)
}
fn exercised_branches(
set: &ArtifactSet,
) -> BTreeMap<BranchKey, (BTreeSet<OutcomeKind>, BTreeSet<FormatName>)> {
let mut map: BTreeMap<BranchKey, (BTreeSet<OutcomeKind>, BTreeSet<FormatName>)> =
BTreeMap::new();
for (_, case) in &set.cases {
for step in &case.flow {
let Some(binding) = select_binding_for_step(set, case, step) else {
continue;
};
if binding.is_unrealized() {
continue;
}
let key = (binding.sm_operation.clone(), binding.variant.clone());
let entry = map.entry(key).or_default();
for kind in step_observable_kinds(case, step) {
entry.0.insert(kind);
}
entry.1.insert(step_format(case, step));
}
}
map
}
fn binding_label(binding: &OperationBinding) -> String {
match &binding.variant {
Some(v) => format!("{}#{v}", binding.sm_operation),
None => binding.sm_operation.to_string(),
}
}
fn format_token(format: FormatName) -> &'static str {
match format {
FormatName::CanonicalJson => "canonical-json",
FormatName::CanonicalXml => "canonical-xml",
FormatName::WtFlat => "wt-flat",
FormatName::WtStructured => "wt-structured",
FormatName::Wt => "wt",
}
}
fn universal_outcome_tokens(set: &ArtifactSet) -> Vec<&str> {
set.selectors
.as_ref()
.and_then(|(_, s)| s.universal_outcomes.as_deref())
.unwrap_or_default()
.iter()
.map(|(k, _)| k.as_str())
.collect()
}
fn check_binding_branch_coverage(
set: &ArtifactSet,
wire_surface: &WireSurface,
findings: &mut Vec<Finding>,
) {
let exercised = exercised_branches(set);
let universal = universal_outcome_tokens(set);
for (_, binding) in &set.bindings {
if binding.is_unrealized() {
continue;
}
let variant = binding.variant.as_deref();
let key = (binding.sm_operation.clone(), binding.variant.clone());
let done = exercised.get(&key);
let outcomes_done = done.map(|d| &d.0);
let formats_done = done.map(|d| &d.1);
let who = binding_label(binding);
for (okey, _) in binding.outcomes.as_deref().unwrap_or_default() {
let kind = okey.0;
if universal.contains(&kind.token())
|| outcomes_done.is_some_and(|s| s.contains(&kind))
|| wire_surface
.outcome_exception(&binding.sm_operation, variant, kind)
.is_some()
{
continue;
}
push(
findings,
CheckId::SurfaceCoverage,
&who,
format!(
"outcome `{}` is declared by the binding but no case exercises it and no \
wire_surface.yaml branch exception covers it",
kind.token()
),
);
}
for format in &binding.formats {
if formats_done.is_some_and(|s| s.contains(format))
|| wire_surface
.format_exception(&binding.sm_operation, variant, *format)
.is_some()
{
continue;
}
push(
findings,
CheckId::SurfaceCoverage,
&who,
format!(
"format `{}` is declared by the binding but no case exercises it and no \
wire_surface.yaml branch exception covers it",
format_token(*format)
),
);
}
}
}
fn check_wire_surface_elements(
set: &ArtifactSet,
wire_surface: &WireSurface,
findings: &mut Vec<Finding>,
) {
if let Err(messages) = wire_surface.check_invariants() {
for message in messages {
push(
findings,
CheckId::SurfaceCoverage,
"vocab/wire_surface.yaml",
message,
);
}
}
let case_ids: BTreeSet<&str> = set.cases.iter().map(|(_, c)| c.id.as_str()).collect();
for element in &wire_surface.elements {
for cid in &element.covered_by {
if !case_ids.contains(cid.as_str()) {
push(
findings,
CheckId::SurfaceCoverage,
&element.id,
format!("covered_by case {cid} does not exist"),
);
}
}
if let Some(ex) = &element.exception
&& let Some(reg) = &ex.register
&& set
.register
.as_ref()
.is_none_or(|(_, r)| r.get(reg).is_none())
{
push(
findings,
CheckId::SurfaceCoverage,
&element.id,
format!("exception cites {reg} which is not in the ambiguity register"),
);
}
}
for branch in &wire_surface.branches {
let matches = set.bindings.iter().any(|(_, b)| {
!b.is_unrealized()
&& b.sm_operation == branch.binding
&& (branch.variant.is_none() || b.variant.as_deref() == branch.variant.as_deref())
});
if !matches {
push(
findings,
CheckId::SurfaceCoverage,
&branch.binding.to_string(),
"wire_surface.yaml branch exception matches no realized binding (stale)".to_owned(),
);
}
}
}
const AXIS3_OVERVIEW_DOCS: &[&str] = &[
"ITS-REST/specifications/docs/overview/Requests_and_responses.md",
"ITS-REST/specifications/docs/overview/Resources.md",
];
const AXIS3_SECTION_EXCLUSIONS: &[(&str, &str)] = &[];
fn normalize_heading(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn markdown_section_headings(text: &str) -> Vec<String> {
let mut headings: Vec<String> = Vec::new();
let mut in_fence = false;
for line in text.lines() {
let trimmed = line.trim_end();
if trimmed.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let Some(rest) = trimmed
.strip_prefix("# ")
.or_else(|| trimmed.strip_prefix("## "))
else {
continue;
};
let heading = rest.trim().to_owned();
if heading.is_empty() || headings.iter().any(|h| h == &heading) {
continue;
}
headings.push(heading);
}
headings
}
fn wire_surface_source_texts(wire_surface: &WireSurface) -> Vec<String> {
let mut sources: Vec<String> = Vec::new();
for element in &wire_surface.elements {
sources.push(normalize_heading(&element.source));
}
for branch in &wire_surface.branches {
sources.push(normalize_heading(&branch.source));
}
sources
}
struct DocDerivation {
doc: &'static str,
unreadable: bool,
covered: Vec<String>,
excluded: Vec<String>,
uncovered: Vec<String>,
}
fn axis3_derivation(
wire_surface: &WireSurface,
spec: &SpecIndex<'_>,
exclusions: &[(&str, &str)],
) -> Vec<DocDerivation> {
let sources = wire_surface_source_texts(wire_surface);
let mut out = Vec::new();
for doc in AXIS3_OVERVIEW_DOCS.iter().copied() {
let Ok(text) = spec.read(&spec.root().join(doc)) else {
out.push(DocDerivation {
doc,
unreadable: true,
covered: Vec::new(),
excluded: Vec::new(),
uncovered: Vec::new(),
});
continue;
};
let mut derivation = DocDerivation {
doc,
unreadable: false,
covered: Vec::new(),
excluded: Vec::new(),
uncovered: Vec::new(),
};
for heading in markdown_section_headings(&text) {
let needle = normalize_heading(&heading);
if sources.iter().any(|source| source.contains(&needle)) {
derivation.covered.push(heading);
} else if exclusions
.iter()
.any(|(excluded, _)| normalize_heading(excluded) == needle)
{
derivation.excluded.push(heading);
} else {
derivation.uncovered.push(heading);
}
}
out.push(derivation);
}
out
}
fn check_axis3_section_derivation(
wire_surface: &WireSurface,
spec: &SpecIndex<'_>,
exclusions: &[(&str, &str)],
findings: &mut Vec<Finding>,
) {
let derivations = axis3_derivation(wire_surface, spec, exclusions);
let mut every_heading: BTreeSet<String> = BTreeSet::new();
let mut covered_headings: BTreeSet<String> = BTreeSet::new();
for derivation in &derivations {
if derivation.unreadable {
push(
findings,
CheckId::SurfaceCoverage,
derivation.doc,
"released overview chapter is not readable under the vendored spec root — the \
Axis-3 section derivation cannot run"
.to_owned(),
);
continue;
}
for heading in derivation
.covered
.iter()
.chain(&derivation.excluded)
.chain(&derivation.uncovered)
{
every_heading.insert(normalize_heading(heading));
}
for heading in &derivation.covered {
covered_headings.insert(normalize_heading(heading));
}
for heading in &derivation.uncovered {
push(
findings,
CheckId::SurfaceCoverage,
derivation.doc,
format!(
"§{heading} is a section of a RELEASED overview chapter that no \
wire_surface.yaml elements/branches source names — add a cross-cutting \
element for the behaviour (covered_by a case, or a cited exception), or pin \
the heading in AXIS3_SECTION_EXCLUSIONS \
(src/validate.rs) with the citation saying why it defines \
no distinct testable wire behaviour"
),
);
}
}
if derivations.iter().any(|d| d.unreadable) {
return; }
for (heading, _) in exclusions {
let needle = normalize_heading(heading);
if !every_heading.contains(&needle) {
push(
findings,
CheckId::SurfaceCoverage,
"AXIS3_SECTION_EXCLUSIONS",
format!("exclusion {heading:?} names no section of the released overview chapters"),
);
} else if covered_headings.contains(&needle) {
push(
findings,
CheckId::SurfaceCoverage,
"AXIS3_SECTION_EXCLUSIONS",
format!(
"exclusion {heading:?} is stale — an authored wire_surface.yaml source now \
names that section; remove the exclusion"
),
);
}
}
}
#[must_use]
#[expect(
clippy::too_many_lines,
reason = "one deterministic report-rendering seam"
)]
pub fn render_coverage_report(set: &ArtifactSet, spec_root: Option<&Path>) -> String {
use std::fmt::Write;
let empty = WireSurface::default();
let wire_surface = set.wire_surface.as_ref().map_or(&empty, |(_, w)| w);
let spec = spec_root.map(SpecIndex::new);
let mut out = String::new();
out.push_str(
"# CNF wire-surface coverage report\n\n\
Generated by `veredictum validate --specs …` (the `surface-coverage` gate, issue #271). \
Deterministic — regenerated in place, never hand-edited. The wire surface is enumerated \
from the RELEASED spec components (the SM platform interfaces + the ITS-REST docs text), \
never the vendored OAS. Every un-exercised behaviour is either a covering case or a \
cited `vocab/wire_surface.yaml` exception; silence is not coverage.\n\n",
);
if let Some(spec) = spec.as_ref() {
out.push_str("## Axis 1 — SM-operation coverage (per platform interface)\n\n");
out.push_str("| Interface | Operations | Realized | Unrealized | Off-wire / exception |\n");
out.push_str("|---|--:|--:|--:|--:|\n");
for interface in PLATFORM_INTERFACES {
let Ok(ops) = spec.interface_operations(interface) else {
let _ = writeln!(out, "| {interface} | (no vendored SM class export) | | | |");
continue;
};
let (mut realized, mut unrealized, mut excepted) = (0_usize, 0_usize, 0_usize);
for name in ops.iter() {
let Ok(op) = SmOperationRef::parse(&format!("{interface}.{name}")) else {
continue;
};
let binding = set.bindings.iter().find(|(_, b)| b.sm_operation == op);
match binding {
Some((_, b)) if b.is_unrealized() => unrealized += 1,
Some(_) => realized += 1,
None if wire_surface.sm_exception(&op).is_some() => excepted += 1,
None => {}
}
}
let _ = writeln!(
out,
"| {interface} | {} | {realized} | {unrealized} | {excepted} |",
ops.len()
);
}
let mut pseudo: BTreeMap<String, Vec<SmOperationRef>> = BTreeMap::new();
for (name, _) in NON_SM_REST_OPERATIONS {
let Ok(op) = SmOperationRef::parse(name) else {
continue;
};
pseudo
.entry(op.interface().to_owned())
.or_default()
.push(op);
}
for (interface, ops) in pseudo {
let (mut realized, mut unrealized, mut excepted) = (0_usize, 0_usize, 0_usize);
for op in &ops {
match set.bindings.iter().find(|(_, b)| b.sm_operation == *op) {
Some((_, b)) if b.is_unrealized() => unrealized += 1,
Some(_) => realized += 1,
None if wire_surface.sm_exception(op).is_some() => excepted += 1,
None => {}
}
}
let _ = writeln!(
out,
"| {interface} (docs-text pinned, non-SM) | {} | {realized} | {unrealized} | \
{excepted} |",
ops.len()
);
}
out.push('\n');
}
out.push_str("## Axis 2 — per-binding outcome/format coverage\n\n");
out.push_str("| Binding | Outcomes covered | Formats covered |\n");
out.push_str("|---|---|---|\n");
let exercised = exercised_branches(set);
let universal = universal_outcome_tokens(set);
let mut realized: Vec<&OperationBinding> = set
.bindings
.iter()
.map(|(_, b)| b)
.filter(|b| !b.is_unrealized())
.collect();
realized.sort_by_key(|b| binding_label(b));
for binding in realized {
let variant = binding.variant.as_deref();
let key = (binding.sm_operation.clone(), binding.variant.clone());
let done = exercised.get(&key);
let (mut ocov, mut oexc, mut ogap) = (0_usize, 0_usize, 0_usize);
for (okey, _) in binding.outcomes.as_deref().unwrap_or_default() {
let kind = okey.0;
if universal.contains(&kind.token()) {
continue;
}
if done.is_some_and(|d| d.0.contains(&kind)) {
ocov += 1;
} else if wire_surface
.outcome_exception(&binding.sm_operation, variant, kind)
.is_some()
{
oexc += 1;
} else {
ogap += 1;
}
}
let (mut fcov, mut fexc, mut fgap) = (0_usize, 0_usize, 0_usize);
for format in &binding.formats {
if done.is_some_and(|d| d.1.contains(format)) {
fcov += 1;
} else if wire_surface
.format_exception(&binding.sm_operation, variant, *format)
.is_some()
{
fexc += 1;
} else {
fgap += 1;
}
}
let _ = writeln!(
out,
"| `{}` | {ocov} exercised / {oexc} excepted / {ogap} gap | {fcov} exercised / {fexc} excepted / {fgap} gap |",
binding_label(binding)
);
}
out.push('\n');
out.push_str("## Axis 3 — cross-cutting wire-surface behaviours\n\n");
out.push_str("| Element | Coverage |\n|---|---|\n");
for element in &wire_surface.elements {
let coverage = if let Some(ex) = &element.exception {
match &ex.register {
Some(reg) => format!("exception: {} ({reg})", ex.reason.token()),
None => format!("exception: {}", ex.reason.token()),
}
} else {
format!("{} case(s)", element.covered_by.len())
};
let _ = writeln!(out, "| `{}` | {coverage} |", element.id);
}
out.push('\n');
if let Some(spec) = spec.as_ref() {
out.push_str("### Axis 3 derivation — RELEASED overview sections\n\n");
out.push_str(
"The element list above is AUTHORED; this table is DERIVED — every `#`/`##` section \
of the two released overview chapters must be named by an authored \
`elements`/`branches` source or pinned in `AXIS3_SECTION_EXCLUSIONS`.\n\n",
);
out.push_str(
"| Chapter | Sections | Named by a source | Excluded (pinned) | Uncovered |\n",
);
out.push_str("|---|--:|--:|--:|--:|\n");
for derivation in axis3_derivation(wire_surface, spec, AXIS3_SECTION_EXCLUSIONS) {
if derivation.unreadable {
let _ = writeln!(out, "| `{}` | (not readable) | | | |", derivation.doc);
continue;
}
let sections =
derivation.covered.len() + derivation.excluded.len() + derivation.uncovered.len();
let _ = writeln!(
out,
"| `{}` | {sections} | {} | {} | {} |",
derivation.doc,
derivation.covered.len(),
derivation.excluded.len(),
derivation.uncovered.len()
);
}
out.push('\n');
}
out
}
#[cfg(test)]
mod surface_tests {
use super::*;
#[test]
fn sm_operation_source_is_the_sm_tree_never_the_oas() {
let file = sm_class_file(Path::new("/root"), "I_EHR_SERVICE");
let text = file.to_string_lossy();
assert!(
text.ends_with("SM/docs/UML/classes/i_ehr_service.adoc"),
"{text}"
);
assert!(!text.contains("oas") && !text.contains("rest-oas") && !text.contains("openapi"));
assert!(PLATFORM_INTERFACES.iter().all(|i| i.starts_with("I_")));
}
#[test]
fn sm_interface_operations_parses_service_ops_only() {
let adoc = "\
|===\n\
h|*Interface*\n\
2+^h|*I_FIXTURE*\n\
h|*Functions*\n\
^h|*Signature*\n\
h|*1..1*\n\
|*create_thing* ( +\n\
x: STRING +\n\
): THING\n\
h|*1..1*\n\
|*get_thing* ( +\n\
): THING\n\
h|*1..1*\n\
|*i_ehr* ( +\n\
): I_EHR\n\
|===\n";
let dir = assert_fs::TempDir::new().unwrap();
let classes = dir.path().join("SM/docs/UML/classes");
std::fs::create_dir_all(&classes).unwrap();
std::fs::write(classes.join("i_fixture.adoc"), adoc).unwrap();
let spec = SpecIndex::new(dir.path());
let ops = spec.interface_operations("I_FIXTURE").unwrap();
assert_eq!(
*ops,
vec!["create_thing".to_owned(), "get_thing".to_owned()]
);
assert!(spec.interface_operations("I_ABSENT").is_err());
}
fn spec_tree_fixture() -> assert_fs::TempDir {
let dir = assert_fs::TempDir::new().unwrap();
let chapter = dir.path().join("RM/docs/ehr_extract");
let classes = dir.path().join("RM/docs/UML/classes");
std::fs::create_dir_all(&chapter).unwrap();
std::fs::create_dir_all(&classes).unwrap();
std::fs::write(
chapter.join("master.adoc"),
":pkg: org.openehr.rm.ehr_extract.\n",
)
.unwrap();
std::fs::write(
chapter.join("master04-common_package.adoc"),
"= Common Package\n\n== Version Specification\n\n\
include::{uml_export_dir}/classes/{pkg}extract_manifest.adoc[]\n",
)
.unwrap();
std::fs::write(
classes.join("org.openehr.rm.ehr_extract.extract_manifest.adoc"),
"=== EXTRACT_MANIFEST Class\n\n|===\nh|*Attributes*\nh|*1..1*\n\
|*entities*: `List<EXTRACT_ENTITY_MANIFEST>`\n|===\n",
)
.unwrap();
dir
}
fn citation_findings(citation: &str, spec: &SpecIndex<'_>) -> Vec<Finding> {
let mut findings = Vec::new();
check_citations(&[citation], "T-1", spec, &mut findings);
findings
}
#[test]
fn register_source_prose_passes_and_citations_resolve() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
assert!(
citation_findings(
"silence CONFIRMED first-hand (grep of the chapter: zero hits); \
RM ehr_extract master04-common_package §Version Specification; \
the stalled guide is never authority",
&spec
)
.is_empty()
);
}
#[test]
fn register_source_without_any_citation_clause_is_accused() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
let out = citation_findings("ALL SOURCES READ FIRST-HAND, nothing cited", &spec);
assert_eq!(out.len(), 1, "{out:?}");
assert!(out[0].message.contains("unknown component"), "{out:?}");
}
#[test]
fn register_source_phantom_document_and_section_fail() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
let phantom_doc = citation_findings("RM ehr_extract master99-nonexistent §Anything", &spec);
assert_eq!(phantom_doc.len(), 1, "{phantom_doc:?}");
let phantom_section = citation_findings(
"RM ehr_extract master04-common_package §No Such Heading Anywhere",
&spec,
);
assert_eq!(phantom_section.len(), 1, "{phantom_section:?}");
}
#[test]
fn brace_shorthand_expands_and_a_half_phantom_variant_fails() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
assert!(
citation_findings(
"RM ehr_extract UML/classes/org.openehr.rm.ehr_extract.{extract_manifest}.adoc \
§Attributes",
&spec
)
.is_empty()
);
let out = citation_findings(
"RM ehr_extract \
UML/classes/org.openehr.rm.ehr_extract.{extract_manifest,nonexistent}.adoc",
&spec,
);
assert_eq!(out.len(), 1, "{out:?}");
assert!(out[0].message.contains("nonexistent"), "{out:?}");
}
fn workspace_fixture() -> assert_fs::TempDir {
let dir = assert_fs::TempDir::new().unwrap();
let docs = dir
.path()
.join("specs/openehr/ITS-XML/components/RM/Release-1.0.2");
let schemas = dir
.path()
.join("specs/its-xml-schemas/components/RM/Release-1.0.2/documents");
std::fs::create_dir_all(&docs).unwrap();
std::fs::create_dir_all(&schemas).unwrap();
std::fs::write(docs.join("README.adoc"), "= XML Schemas\n\n== Releases\n").unwrap();
std::fs::write(
schemas.join("Composition.xsd"),
"<xs:schema targetNamespace=\"http://schemas.openehr.org/v1\">\n\
<xs:element name=\"composition\" type=\"COMPOSITION\"/>\n\
<xs:complexType name=\"COMPOSITION\"/>\n</xs:schema>\n",
)
.unwrap();
dir
}
#[test]
fn its_xml_citations_resolve_against_the_vendored_schema_bundle() {
let dir = workspace_fixture();
let root = dir.path().join("specs/openehr");
let spec = SpecIndex::new(&root);
let roots = spec.component_roots("ITS-XML", "ITS-XML");
assert_eq!(roots.len(), 2, "{roots:?}");
assert_eq!(spec.component_roots("RM", "RM").len(), 0, "no RM docs dir");
assert!(
citation_findings(
"ITS-XML components/RM/Release-1.0.2/README.adoc §Releases",
&spec
)
.is_empty()
);
assert!(
citation_findings(
"ITS-XML components/RM/Release-1.0.2/documents/Composition.xsd §composition",
&spec
)
.is_empty()
);
assert!(
citation_findings(
"ITS-XML components/RM/Release-1.0.2/documents/Composition.xsd §COMPOSITION",
&spec
)
.is_empty()
);
let out = citation_findings(
"ITS-XML components/RM/Release-1.0.2/documents/Composition.xsd §invented_element",
&spec,
);
assert_eq!(out.len(), 1, "{out:?}");
let out = citation_findings(
"ITS-JSON components/RM/Release-1.0.2/documents/Composition.xsd",
&spec,
);
assert_eq!(out.len(), 1, "{out:?}");
}
#[test]
fn xsd_declared_names_reads_every_name_attribute() {
let names = xsd_declared_names(
"<xs:element name=\"composition\" type=\"openehr:COMPOSITION\"/>\
<xs:complexType name='LOCATABLE'><xs:attribute name=\"archetype_node_id\"/>\
</xs:complexType>",
);
assert!(names.contains("composition"), "{names:?}");
assert!(names.contains("locatable"), "{names:?}");
assert!(names.contains("archetype node id"), "{names:?}");
assert_eq!(names.len(), 3, "{names:?}");
}
#[test]
fn spec_ref_gate_resolves_documents_and_sections() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
assert!(
citation_findings(
"RM ehr_extract master04-common_package §Version Specification",
&spec
)
.is_empty()
);
assert!(
citation_findings(
"RM ehr_extract master04-common_package §EXTRACT_MANIFEST",
&spec
)
.is_empty()
);
assert!(
citation_findings(
"RM ehr_extract UML/classes/org.openehr.rm.ehr_extract.extract_manifest.adoc \
§Attributes (entities 1..1)",
&spec
)
.is_empty()
);
assert!(citation_findings("RM", &spec).is_empty());
}
#[test]
fn spec_ref_gate_refuses_a_phantom_document() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
let findings = citation_findings(
"RM ehr_extract master99-invented_package §Version Specification",
&spec,
);
assert_eq!(findings.len(), 1, "{findings:?}");
let message = &findings.first().unwrap().message;
assert!(message.contains("no vendored document"), "{message}");
let findings = citation_findings("BASE ehr_extract master04-common_package", &spec);
assert_eq!(findings.len(), 1, "{findings:?}");
let findings = citation_findings("NONESUCH master04 §Whatever", &spec);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings
.first()
.unwrap()
.message
.contains("unknown component"),
"{findings:?}"
);
}
#[test]
fn spec_ref_gate_refuses_a_phantom_section() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
let findings = citation_findings(
"RM ehr_extract master04-common_package §Invented Section",
&spec,
);
assert_eq!(findings.len(), 1, "{findings:?}");
let message = &findings.first().unwrap().message;
assert!(message.contains("carry no section matching"), "{message}");
let findings = citation_findings(
"RM ehr_extract master04-common_package §Version Specification; \
RM ehr_extract master04-common_package §Invented Section",
&spec,
);
assert_eq!(findings.len(), 1, "{findings:?}");
}
#[test]
fn binding_source_gate_reads_every_clause_of_the_derivation() {
let dir = spec_tree_fixture();
let spec = SpecIndex::new(dir.path());
let binding = |source: &str| -> OperationBinding {
serde_json::from_value(serde_json::json!({
"sm_operation": "I_EHR_SERVICE.create_ehr",
"its": "its-rest",
"unrealized": {
"reason": "r",
"source": source,
"ambiguity": "AMB-1"
}
}))
.expect("fixture binding parses")
};
let findings = |source: &str| -> Vec<Finding> {
let mut out = Vec::new();
check_binding_sources(&binding(source), "b.yaml", &spec, &mut out);
out
};
assert!(
findings(
"RM ehr_extract master04-common_package §Version Specification; \
RM ehr_extract master04-common_package §EXTRACT_MANIFEST"
)
.is_empty()
);
assert!(
findings(
"RM ehr_extract master04-common_package; \
no openEHR spec governs this — our own design/extension"
)
.is_empty()
);
let out = findings(
"RM ehr_extract master04-common_package; \
UML/classes/org.openehr.rm.ehr_extract.extract_manifest.adoc (a gloss)",
);
assert_eq!(out.len(), 1, "{out:?}");
assert!(
out.first().expect("one finding").message.contains(
"opens with neither a spec component nor the spec-silence flag nor the register"
),
"{out:?}"
);
let out = findings(
"RM ehr_extract master04-common_package vs RM ehr_extract master99-invented_package",
);
assert_eq!(out.len(), 1, "{out:?}");
let out = findings(
"RM ehr_extract master04-common_package; RM ehr_extract master99-invented_package",
);
assert_eq!(out.len(), 1, "{out:?}");
assert!(
out.first()
.expect("one finding")
.message
.contains("no vendored document"),
"{out:?}"
);
}
fn step_argument_findings(
binding: serde_json::Value,
with: &serde_json::Value,
) -> Vec<Finding> {
let binding: OperationBinding =
serde_json::from_value(binding).expect("fixture binding parses");
let case: CaseCore = serde_json::from_value(serde_json::json!({
"id": "T-ARG", "kind": "functional", "component": "EHR",
"sm_operation": "I_EHR_CONTRIBUTION.commit_contribution",
"test_purpose": "t", "description": "d", "spec_refs": [],
"capabilities": [],
"flow": [ {
"step": 1, "call": "commit_contribution",
"with": with, "expect": "created"
} ]
}))
.expect("fixture case parses");
let mut set = ArtifactSet::default();
set.bindings.push((PathBuf::from("b.yaml"), binding));
set.cases.push((PathBuf::from("c.yaml"), case));
let mut out = Vec::new();
check_step_arguments(&set, &mut out);
out
}
fn contribution_binding() -> serde_json::Value {
serde_json::json!({
"sm_operation": "I_EHR_CONTRIBUTION.commit_contribution",
"its": "its-rest",
"request": {
"method": "POST",
"path": "/ehr/{ehr_id}/contribution",
"query": { "dry_run": "${dry_run?}" },
"body": "contribution"
},
"outcomes": { "created": { "status": 201 } }
})
}
#[test]
fn step_arguments_gate_credits_every_read_key() {
let out = step_argument_findings(
contribution_binding(),
&serde_json::json!({
"ehr_id": "${ehr_id}",
"dry_run": "true",
"versions": [{ "data": "${ds:x}", "change_type": "creation" }],
"audit": { "committer": { "name": "Dr Example" } }
}),
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn step_arguments_gate_accuses_an_unread_payload_key() {
let out = step_argument_findings(
serde_json::json!({
"sm_operation": "I_EHR_CONTRIBUTION.commit_contribution",
"its": "its-rest",
"request": {
"method": "POST",
"path": "/ehr/{ehr_id}/composition",
"body": "composition"
},
"outcomes": { "created": { "status": 201 } }
}),
&serde_json::json!({
"ehr_id": "${ehr_id}",
"composition": "${ds:x}",
"audit": { "time_committed": "1990-01-01T00:00:00Z" }
}),
);
assert_eq!(out.len(), 1, "{out:?}");
let finding = out.first().expect("one finding");
assert_eq!(finding.check, CheckId::StepArguments);
assert!(finding.message.contains("with.audit"), "{finding:?}");
assert!(finding.message.contains("vacuously"), "{finding:?}");
}
#[test]
fn step_arguments_gate_accuses_a_key_no_form_declares() {
let out = step_argument_findings(
contribution_binding(),
&serde_json::json!({ "ehr_id": "${ehr_id}", "decoration": "x" }),
);
assert_eq!(out.len(), 1, "{out:?}");
assert!(
out.first()
.expect("one finding")
.message
.contains("with.decoration"),
"{out:?}"
);
}
#[test]
fn step_arguments_gate_credits_a_text_role_payload() {
let out = step_argument_findings(
serde_json::json!({
"sm_operation": "I_EHR_CONTRIBUTION.commit_contribution",
"its": "its-rest",
"request": {
"method": "PUT",
"path": "/definition/query/{qualified_query_name}",
"body": "aql_text"
},
"outcomes": { "created": { "status": 200 } }
}),
&serde_json::json!({
"qualified_query_name": "org.openehr.cnf::q",
"query": "SELECT c FROM EHR e CONTAINS COMPOSITION c"
}),
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn step_arguments_gate_credits_structured_body_members() {
let out = step_argument_findings(
serde_json::json!({
"sm_operation": "I_EHR_CONTRIBUTION.commit_contribution",
"its": "its-rest",
"request": {
"method": "POST",
"path": "/query/aql",
"body": { "q": "${q}", "offset": "${offset?}" }
},
"outcomes": { "created": { "status": 200 } }
}),
&serde_json::json!({ "q": "SELECT c FROM EHR e", "offset": "0" }),
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn step_arguments_gate_credits_the_auto_variant_selector() {
let variantless: OperationBinding = serde_json::from_value(serde_json::json!({
"sm_operation": "I_EHR_SERVICE.create_ehr",
"its": "its-rest",
"request": { "method": "POST", "path": "/ehr" },
"outcomes": { "created": { "status": 201 } }
}))
.expect("fixture binding parses");
let with_ehr_id: OperationBinding = serde_json::from_value(serde_json::json!({
"sm_operation": "I_EHR_SERVICE.create_ehr",
"its": "its-rest",
"variant": "with_ehr_id",
"request": { "method": "PUT", "path": "/ehr/{ehr_id}" },
"outcomes": { "created": { "status": 201 } }
}))
.expect("fixture binding parses");
let case: CaseCore = serde_json::from_value(serde_json::json!({
"id": "T-VAR", "kind": "functional", "component": "EHR",
"sm_operation": "I_EHR_SERVICE.create_ehr",
"test_purpose": "t", "description": "d", "spec_refs": [],
"capabilities": [],
"flow": [ {
"step": 1, "call": "create_ehr",
"with": { "ehr_id": "11111111-1111-4111-8111-111111111111" },
"expect": "created"
} ]
}))
.expect("fixture case parses");
let mut set = ArtifactSet::default();
set.bindings.push((PathBuf::from("a.yaml"), variantless));
set.bindings.push((PathBuf::from("b.yaml"), with_ehr_id));
set.cases.push((PathBuf::from("c.yaml"), case));
let mut findings = Vec::new();
check_step_arguments(&set, &mut findings);
assert!(findings.is_empty(), "{findings:?}");
}
fn build_set(with_exception: bool) -> ArtifactSet {
let binding: OperationBinding = serde_json::from_value(serde_json::json!({
"sm_operation": "I_EHR_SERVICE.create_ehr",
"its": "its-rest",
"request": { "method": "POST", "path": "/ehr" },
"formats": ["canonical-json"],
"outcomes": { "created": { "status": 201 }, "already_exists": { "status": 409 } }
}))
.unwrap();
let case: CaseCore = serde_json::from_value(serde_json::json!({
"id": "T-1", "kind": "functional", "component": "EHR",
"sm_operation": "I_EHR_SERVICE.create_ehr",
"test_purpose": "t", "description": "d", "spec_refs": ["SM i_ehr_service.adoc"],
"capabilities": [],
"flow": [ { "step": 1, "call": "create_ehr", "expect": "created" } ]
}))
.unwrap();
let mut set = ArtifactSet::default();
set.bindings.push((PathBuf::from("b.yaml"), binding));
set.cases.push((PathBuf::from("c.yaml"), case));
if with_exception {
let wire: WireSurface = serde_json::from_value(serde_json::json!({
"branches": [ {
"binding": "I_EHR_SERVICE.create_ehr", "outcome": "already_exists",
"reason": "coverage_gap",
"source": "ITS-REST Requests_and_responses.md §HTTP status codes"
} ]
}))
.unwrap();
set.wire_surface = Some((PathBuf::from("wire_surface.yaml"), wire));
}
set
}
#[test]
fn axis2_flags_unexercised_outcome_then_exception_suppresses_it() {
let set = build_set(false);
let empty = WireSurface::default();
let mut findings = Vec::new();
check_binding_branch_coverage(&set, &empty, &mut findings);
assert!(
findings.iter().any(
|f| f.check == CheckId::SurfaceCoverage && f.message.contains("already_exists")
),
"expected an already_exists gap, got: {findings:?}"
);
let set = build_set(true);
let wire = set.wire_surface.as_ref().map(|(_, w)| w).unwrap();
let mut findings = Vec::new();
check_binding_branch_coverage(&set, wire, &mut findings);
assert!(
findings.is_empty(),
"the branch exception should suppress the gap, got: {findings:?}"
);
}
#[test]
fn axis4_declares_without_gating() {
let set = build_set(true);
let wire = set.wire_surface.as_ref().map(|(_, w)| w).unwrap();
let mut baseline = Vec::new();
check_binding_branch_coverage(&set, wire, &mut baseline);
check_wire_surface_elements(&set, wire, &mut baseline);
assert!(baseline.is_empty(), "{baseline:?}");
let declared: WireSurface = serde_json::from_value(serde_json::json!({
"branches": [ {
"binding": "I_EHR_SERVICE.create_ehr", "outcome": "already_exists",
"reason": "coverage_gap",
"source": "ITS-REST Requests_and_responses.md §HTTP status codes"
} ],
"served_extensions": [ {
"family": "management",
"routes": ["GET /management/info"],
"config_gate": "management.enabled",
"spec_silence": "no released clause governs the URI space beyond the resource set",
"never_gates": true
} ]
}))
.unwrap();
let mut with_axis = Vec::new();
check_binding_branch_coverage(&set, &declared, &mut with_axis);
check_wire_surface_elements(&set, &declared, &mut with_axis);
check_served_extensions(&set, &declared, &mut with_axis);
assert!(
with_axis.is_empty(),
"the outward axis must never add an obligation, got: {with_axis:?}"
);
let claiming: WireSurface = serde_json::from_value(serde_json::json!({
"served_extensions": [ {
"family": "impostor",
"routes": ["POST /ferroehr/rest/openehr/v1/ehr"],
"config_gate": "always on",
"spec_silence": "s",
"never_gates": true
} ]
}))
.unwrap();
let mut findings = Vec::new();
check_served_extensions(&set, &claiming, &mut findings);
assert!(
findings
.iter()
.any(|f| f.message.contains("claims the released")),
"expected a released-path claim finding, got: {findings:?}"
);
}
#[test]
fn axis3_element_covered_by_must_resolve() {
let set = build_set(false);
let wire: WireSurface = serde_json::from_value(serde_json::json!({
"elements": [ {
"id": "x", "description": "d",
"source": "ITS-REST Requests_and_responses.md §Location",
"covered_by": ["NO-SUCH-CASE"]
} ]
}))
.unwrap();
let mut findings = Vec::new();
check_wire_surface_elements(&set, &wire, &mut findings);
assert!(
findings
.iter()
.any(|f| f.check == CheckId::SurfaceCoverage && f.message.contains("NO-SUCH-CASE")),
"expected an unresolved covered_by finding, got: {findings:?}"
);
}
#[test]
fn pinned_pseudo_operation_resolves_and_an_unpinned_one_is_a_finding() {
let empty_root = assert_fs::TempDir::new().unwrap();
let spec = SpecIndex::new(empty_root.path());
let (pinned_name, _) = *NON_SM_REST_OPERATIONS.first().unwrap();
let pinned = SmOperationRef::parse(pinned_name).unwrap();
let mut findings = Vec::new();
resolve_sm_operation(&pinned, "b.yaml", &spec, &mut findings);
assert!(findings.is_empty(), "{findings:?}");
let invented = SmOperationRef::parse("I_ITS_REST_SYSTEM.invented").unwrap();
let mut findings = Vec::new();
resolve_sm_operation(&invented, "b.yaml", &spec, &mut findings);
assert!(
findings
.iter()
.any(|f| f.check == CheckId::SmOperation
&& f.message.contains("NON_SM_REST_OPERATIONS")),
"expected an unpinned-pseudo-interface finding, got: {findings:?}"
);
let sm = SmOperationRef::parse("I_EHR_SERVICE.create_ehr").unwrap();
let mut findings = Vec::new();
resolve_sm_operation(&sm, "b.yaml", &spec, &mut findings);
assert!(
findings
.iter()
.any(|f| f.message.contains("no vendored SM class export")),
"{findings:?}"
);
}
#[test]
fn axis1_requires_a_binding_for_a_pinned_non_sm_operation() {
let set = build_set(false);
let empty_root = assert_fs::TempDir::new().unwrap();
let empty = WireSurface::default();
let (pinned, pinned_source) = *NON_SM_REST_OPERATIONS.first().unwrap();
let mut findings = Vec::new();
check_surface_sm_operations(
&set,
&SpecIndex::new(empty_root.path()),
&empty,
&mut findings,
);
assert!(
findings
.iter()
.any(|f| f.artifact == pinned && f.message.contains("non-SM ITS-REST operation")),
"expected an unbound non-SM operation finding, got: {findings:?}"
);
let excepted: WireSurface = serde_json::from_value(serde_json::json!({
"sm_operations": [ {
"operation": pinned,
"reason": "coverage_gap",
"source": pinned_source
} ]
}))
.unwrap();
let mut findings = Vec::new();
check_surface_sm_operations(
&set,
&SpecIndex::new(empty_root.path()),
&excepted,
&mut findings,
);
assert!(
!findings.iter().any(|f| f.artifact == pinned),
"the exception should suppress the finding, got: {findings:?}"
);
}
#[test]
fn pinned_non_sm_table_is_wellformed_across_several_pseudo_interfaces() {
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut interfaces: BTreeSet<String> = BTreeSet::new();
for (name, source) in NON_SM_REST_OPERATIONS {
let op = SmOperationRef::parse(name)
.unwrap_or_else(|e| panic!("pinned row {name} does not parse: {e}"));
assert!(
op.interface().starts_with(PSEUDO_INTERFACE_PREFIX),
"pinned row {name} must use the reserved pseudo-interface prefix"
);
assert!(!source.trim().is_empty(), "pinned row {name} has no source");
assert!(seen.insert(*name), "pinned row {name} is listed twice");
interfaces.insert(op.interface().to_owned());
}
assert!(
interfaces.len() > 1,
"the table must stay interface-general, got {interfaces:?}"
);
assert!(interfaces.contains("I_ITS_REST_ITEM_TAGS"));
let empty_root = assert_fs::TempDir::new().unwrap();
let spec = SpecIndex::new(empty_root.path());
let invented = SmOperationRef::parse("I_ITS_REST_ITEM_TAGS.folder_tags_get").unwrap();
let mut findings = Vec::new();
resolve_sm_operation(&invented, "b.yaml", &spec, &mut findings);
assert!(
findings
.iter()
.any(|f| f.check == CheckId::SmOperation
&& f.message.contains("NON_SM_REST_OPERATIONS")),
"expected an unpinned-pseudo-interface finding, got: {findings:?}"
);
}
#[test]
fn axis1_report_groups_rows_per_pseudo_interface() {
let mut set = build_set(false);
let tag_binding: OperationBinding = serde_json::from_value(serde_json::json!({
"sm_operation": "I_ITS_REST_ITEM_TAGS.composition_tags_get",
"its": "its-rest",
"request": { "method": "GET", "path": "/ehr/{ehr_id}/composition/{uid_based_id}/tags" },
"formats": ["canonical-json"],
"outcomes": { "ok": { "status": 200 } }
}))
.unwrap();
set.bindings.push((PathBuf::from("tags.yaml"), tag_binding));
let empty_root = assert_fs::TempDir::new().unwrap();
let report = render_coverage_report(&set, Some(empty_root.path()));
let tag_ops = NON_SM_REST_OPERATIONS
.iter()
.filter(|(name, _)| name.starts_with("I_ITS_REST_ITEM_TAGS."))
.count();
assert!(
report.contains("| I_ITS_REST_SYSTEM (docs-text pinned, non-SM) | 1 | 0 | 0 | 0 |"),
"{report}"
);
assert!(
report.contains(&format!(
"| I_ITS_REST_ITEM_TAGS (docs-text pinned, non-SM) | {tag_ops} | 1 | 0 | 0 |"
)),
"{report}"
);
}
#[test]
fn markdown_headings_take_h1_h2_only_and_skip_fences() {
let doc = "\
[comment]: # (title: Fixture)\n\
\n\
# HTTP Methods\n\
some prose\n\
## Prefer only identifier \n\
```http\n\
# not a heading\n\
```\n\
### too deep\n\
#nospace\n\
# HTTP Methods\n";
let headings = markdown_section_headings(doc);
assert_eq!(
headings,
vec![
"HTTP Methods".to_owned(),
"Prefer only identifier".to_owned()
]
);
}
#[test]
fn axis3_derivation_matches_sources_and_honours_exclusions() {
let dir = assert_fs::TempDir::new().unwrap();
for doc in AXIS3_OVERVIEW_DOCS {
let path = dir.path().join(doc);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "# Covered Section\n\n## Silent Section\n").unwrap();
}
let wire: WireSurface = serde_json::from_value(serde_json::json!({
"elements": [ {
"id": "x", "description": "d",
"source": "ITS-REST Requests_and_responses.md §covered\n section (the rule)",
"exception": { "reason": "coverage_gap" }
} ]
}))
.unwrap();
let derivations = axis3_derivation(&wire, &SpecIndex::new(dir.path()), &[]);
for derivation in &derivations {
assert!(!derivation.unreadable);
assert_eq!(derivation.covered, vec!["Covered Section".to_owned()]);
assert_eq!(derivation.uncovered, vec!["Silent Section".to_owned()]);
}
let excluded = axis3_derivation(
&wire,
&SpecIndex::new(dir.path()),
&[("silent section", "why")],
);
for derivation in &excluded {
assert_eq!(derivation.excluded, vec!["Silent Section".to_owned()]);
assert!(derivation.uncovered.is_empty());
}
}
#[test]
fn axis3_derivation_findings_and_exclusion_ratchet() {
let dir = assert_fs::TempDir::new().unwrap();
for doc in AXIS3_OVERVIEW_DOCS {
let path = dir.path().join(doc);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "# Covered Section\n\n## Silent Section\n").unwrap();
}
let wire: WireSurface = serde_json::from_value(serde_json::json!({
"elements": [ {
"id": "x", "description": "d",
"source": "ITS-REST Requests_and_responses.md §Covered Section",
"exception": { "reason": "coverage_gap" }
} ]
}))
.unwrap();
let mut findings = Vec::new();
check_axis3_section_derivation(&wire, &SpecIndex::new(dir.path()), &[], &mut findings);
assert!(
findings
.iter()
.any(|f| f.check == CheckId::SurfaceCoverage
&& f.message.contains("§Silent Section")),
"expected an uncovered-section finding, got: {findings:?}"
);
let mut findings = Vec::new();
check_axis3_section_derivation(
&wire,
&SpecIndex::new(dir.path()),
&[
("Covered Section", "already named by a source"),
("No Such Section", "names nothing"),
("Silent Section", "no distinct testable wire behaviour"),
],
&mut findings,
);
assert!(
findings.iter().any(|f| f.message.contains("is stale")),
"expected a stale-exclusion finding, got: {findings:?}"
);
assert!(
findings
.iter()
.any(|f| f.message.contains("names no section")),
"expected an unknown-exclusion finding, got: {findings:?}"
);
assert!(
!findings.iter().any(|f| f.message.contains("§Silent")),
"the honest exclusion must suppress its section finding, got: {findings:?}"
);
}
#[test]
fn brace_expansion_is_bounded_per_token() {
let authored = expand_braces(&["operations/directory_{update,delete}.yaml"]);
assert_eq!(authored.len(), 2, "{authored:?}");
let bomb = "{a,b}".repeat(22);
let expanded = expand_braces(&[bomb.as_str()]);
assert_eq!(
expanded,
vec![vec![bomb.clone()]],
"a token past the ceiling comes back unexpanded"
);
assert!(expanded.len() <= MAX_CITATION_VARIANTS);
}
#[test]
fn axis3_derivation_reports_an_unreadable_chapter() {
let dir = assert_fs::TempDir::new().unwrap();
let wire = WireSurface::default();
let mut findings = Vec::new();
check_axis3_section_derivation(&wire, &SpecIndex::new(dir.path()), &[], &mut findings);
assert_eq!(findings.len(), AXIS3_OVERVIEW_DOCS.len(), "{findings:?}");
assert!(
findings.iter().all(|f| f.message.contains("not readable")),
"{findings:?}"
);
}
}