Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ members = [
"crates/spar-analysis",
"crates/spar-network",
"crates/spar-transform",
"crates/spar-variants",
"crates/spar-cli",
"crates/spar-codegen",
"crates/spar-render",
Expand Down Expand Up @@ -39,6 +40,7 @@ spar-render = { path = "crates/spar-render" }
spar-solver = { path = "crates/spar-solver" }
spar-sysml2 = { path = "crates/spar-sysml2" }
spar-transform = { path = "crates/spar-transform" }
spar-variants = { path = "crates/spar-variants" }
spar-codegen = { path = "crates/spar-codegen" }
rowan = "0.16"
salsa = "0.26"
Expand Down
18 changes: 18 additions & 0 deletions artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1186,4 +1186,22 @@ artifacts:
status: planned
tags: [network, wctt, v080]

# ── Track B: rivet ↔ spar variant binding (v0.7.x) ─────────────────

- id: REQ-VARIANT-001
type: requirement
title: Consume rivet's variant context blob (v1) and filter HIR
description: >
System shall consume rivet's variant context blob (v1 contract per
docs/contracts/rivet-spar-variant-v1.md) and filter HIR items by
intersection-semantics binding rules. The reader strictly accepts
rivet_spar_context_version "1" only — v2+ blobs are refused per
the contract's compatibility section. File-scoped (artifact) and
symbol-scoped bindings are honoured; an item is kept iff every
matching binding has its requires-set satisfied by the variant's
active features. Items with no matching binding are kept
unconditionally as variant-independent infrastructure.
status: planned
tags: [variants, track-b, v07x]

# Research findings tracked separately in research/findings.yaml
30 changes: 30 additions & 0 deletions artifacts/verification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1502,3 +1502,33 @@ artifacts:
links:
- type: satisfies
target: REQ-NETWORK-005

- id: TEST-VARIANTS-CONSUMER
type: feature
title: spar-variants consumer-side unit tests
description: >
Unit tests in crates/spar-variants/src/{context,binding,filter}.rs
that exercise the v1 contract reader end-to-end on the consumer
side. Covers: round-trip of a minimal v1 blob; strict rejection of
any rivet_spar_context_version other than "1"; deserialization of
both artifact- and symbol-scoped bindings (untagged enum); the
§"Example" blob from docs/contracts/rivet-spar-variant-v1.md
parses and populates every field; artifact-binding match on exact
and dot-slash-normalized paths plus non-match on a sibling path or
missing item path; symbol-binding match on self and on textually
nested FQNs (boundary-aware so DieselV2 is not matched by Diesel);
keep_in_variant intersection semantics (one unsatisfied requires
drops the item even if other bindings are satisfied);
keep_in_variant treats items with no matching binding as
variant-independent infrastructure (kept unconditionally);
keep_in_variant accepts an empty requires list as vacuously
satisfied.
fields:
method: automated-test
steps:
- run: cargo test -p spar-variants
status: passing
tags: [variants, track-b, v07x]
links:
- type: satisfies
target: REQ-VARIANT-001
15 changes: 15 additions & 0 deletions crates/spar-variants/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "spar-variants"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Consumer-side adapter for rivet's variant context blob (v1) — filters spar HIR by binding rules"

[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
spar-hir-def.workspace = true

[dev-dependencies]
pretty_assertions = "1"
212 changes: 212 additions & 0 deletions crates/spar-variants/src/binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
//! Binding match logic.
//!
//! Implements the "matches the item" half of the contract's
//! §"Binding resolution semantics" — given a HIR item described via
//! [`HasBindingIdentity`] and a [`Binding`], decide whether the binding
//! applies. The "kept iff requires ⊆ features for every match" half
//! lives in [`crate::filter`].

use crate::context::Binding;

/// Identity adapter that HIR items implement to participate in variant
/// filtering.
///
/// We keep this trait deliberately narrow — just enough to evaluate the
/// two binding shapes — so the bridge from spar-hir-def's
/// `ComponentInstance` (and friends) is a thin, mechanical mapping
/// rather than a deep dependency. The actual `impl HasBindingIdentity`
/// for spar-hir-def types lives outside this commit; tests use a local
/// stub.
pub trait HasBindingIdentity {
/// Project-relative source-file path the item was declared in, or
/// `None` if the item is synthetic (no source file). Returning
/// `None` makes [`Binding::Artifact`] always non-match.
fn artifact_path(&self) -> Option<&str>;

/// Fully-qualified AADL name, shape `Package::Type` or
/// `Package::Type.Implementation` (for top-level classifiers) or a
/// dotted extension thereof for nested items
/// (`Package::Type.Impl.subcomponent`). `None` for items with no
/// resolvable FQN, in which case [`Binding::Symbol`] is always
/// non-match.
fn fully_qualified_symbol(&self) -> Option<String>;
}

/// Normalize an artifact-binding path or an item path to a canonical
/// form for comparison: drop leading `./`, fold any backslash separators
/// to forward-slash. We do **not** resolve `..` or absolutize — the
/// contract specifies "relative to the project root" on both sides, so
/// a textual normalization is sufficient. Future work: case-folding on
/// case-insensitive filesystems if the need arises.
fn normalize_path(p: &str) -> String {
let trimmed = p.strip_prefix("./").unwrap_or(p);
trimmed.replace('\\', "/")
}

impl Binding {
/// True iff this binding's scope encompasses the given item, per
/// §"Binding resolution semantics" of the v1 contract.
///
/// For [`Binding::Symbol`], this commit implements prefix-matching:
/// the binding matches if the item's FQN equals the symbol or
/// starts with `<symbol>.` / `<symbol>::`. That covers the
/// "subcomponents, connections, properties, modes, flow specs
/// textually nested inside its body" clause of the contract.
///
/// TODO(track-b-commit-2+): the contract's §"Symbol granularity"
/// also notes inheritance is **orthogonal** to variant binding —
/// classifiers that `extends` a bound symbol must NOT inherit the
/// binding. Prefix-matching as implemented here cannot tell apart
/// "nested in body" from "named under" if a project ever uses
/// dot-separated FQNs that aren't true textual nesting (none today
/// in spar-hir-def, so this is safe for commit 1). The eventual
/// fix is to plumb a richer "contains" relation through the HIR
/// adapter rather than relying on string-shape.
pub fn matches<I: HasBindingIdentity + ?Sized>(&self, item: &I) -> bool {
match self {
Binding::Artifact { artifact, .. } => match item.artifact_path() {
Some(item_path) => normalize_path(item_path) == normalize_path(artifact),
None => false,
},
Binding::Symbol { symbol, .. } => match item.fully_qualified_symbol() {
Some(fqn) => symbol_matches(&fqn, symbol),
None => false,
},
}
}

/// The `requires` list, regardless of binding kind.
pub fn requires(&self) -> &[String] {
match self {
Binding::Artifact { requires, .. } | Binding::Symbol { requires, .. } => requires,
}
}
}

/// Prefix-match an item FQN against a binding's symbol per the
/// "self-or-nested-in-body" rule. Boundary character must be `.` or
/// `::` so that `Engines::Engine.Diesel` does NOT match
/// `Engines::Engine.DieselV2`.
fn symbol_matches(item_fqn: &str, binding_symbol: &str) -> bool {
if item_fqn == binding_symbol {
return true;
}
let Some(rest) = item_fqn.strip_prefix(binding_symbol) else {
return false;
};
rest.starts_with('.') || rest.starts_with("::")
}

#[cfg(test)]
mod tests {
use super::*;

/// Test stub — the spar-hir-def adapter ships with the spar-cli
/// commit (Track B commit 2), per design.
struct StubItem {
path: Option<String>,
fqn: Option<String>,
}

impl HasBindingIdentity for StubItem {
fn artifact_path(&self) -> Option<&str> {
self.path.as_deref()
}

fn fully_qualified_symbol(&self) -> Option<String> {
self.fqn.clone()
}
}

fn artifact_binding(path: &str) -> Binding {
Binding::Artifact {
artifact: path.to_string(),
requires: vec![],
}
}

fn symbol_binding(sym: &str) -> Binding {
Binding::Symbol {
symbol: sym.to_string(),
requires: vec![],
}
}

#[test]
fn artifact_binding_matches_exact_path() {
let b = artifact_binding("spec/engines/diesel.aadl");
let item = StubItem {
path: Some("spec/engines/diesel.aadl".to_string()),
fqn: None,
};
assert!(b.matches(&item));
}

#[test]
fn artifact_binding_matches_after_dot_slash_normalization() {
// Defensive: emitter or HIR could carry `./` prefix on either
// side. Normalization should make both forms equivalent.
let b = artifact_binding("./spec/engines/diesel.aadl");
let item = StubItem {
path: Some("spec/engines/diesel.aadl".to_string()),
fqn: None,
};
assert!(b.matches(&item));
}

#[test]
fn artifact_binding_doesnt_match_other_path() {
let b = artifact_binding("spec/engines/diesel.aadl");
let item = StubItem {
path: Some("spec/engines/electric.aadl".to_string()),
fqn: None,
};
assert!(!b.matches(&item));
}

#[test]
fn artifact_binding_doesnt_match_when_item_has_no_path() {
let b = artifact_binding("spec/engines/diesel.aadl");
let item = StubItem {
path: None,
fqn: Some("Engines::Engine.Diesel".to_string()),
};
assert!(!b.matches(&item));
}

#[test]
fn symbol_binding_matches_self() {
let b = symbol_binding("Engines::Engine.Diesel");
let item = StubItem {
path: None,
fqn: Some("Engines::Engine.Diesel".to_string()),
};
assert!(b.matches(&item));
}

#[test]
fn symbol_binding_matches_nested() {
// A subcomponent textually nested inside Engine.Diesel — e.g.
// `cylinder1: device …` declared in the implementation body.
// The HIR adapter is responsible for assembling this dotted
// FQN; the binding matcher only sees strings.
let b = symbol_binding("Engines::Engine.Diesel");
let item = StubItem {
path: None,
fqn: Some("Engines::Engine.Diesel.cylinder1".to_string()),
};
assert!(b.matches(&item));
}

#[test]
fn symbol_binding_doesnt_match_sibling_with_shared_prefix() {
// Boundary check: `Diesel` MUST NOT match `DieselV2` just
// because the latter starts with the former.
let b = symbol_binding("Engines::Engine.Diesel");
let item = StubItem {
path: None,
fqn: Some("Engines::Engine.DieselV2".to_string()),
};
assert!(!b.matches(&item));
}
}
Loading
Loading