diff --git a/Cargo.lock b/Cargo.lock index 17ed407..dcff37c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2379,8 +2379,10 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "serial_test", "spar-analysis", "spar-hir", + "tempfile", "thiserror 2.0.18", "tokio", "urlencoding", @@ -2564,6 +2566,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -2579,6 +2590,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "security-framework" version = "3.7.0" @@ -2700,6 +2717,32 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/artifacts/decisions.yaml b/artifacts/decisions.yaml index 693a7eb..bb68cb4 100644 --- a/artifacts/decisions.yaml +++ b/artifacts/decisions.yaml @@ -301,3 +301,68 @@ artifacts: No exemption mechanism (all commits must reference artifacts). Rejected because it creates excessive friction for routine maintenance commits that have no traceability value. + + - id: DD-014 + type: design-decision + title: Prefixed IDs over URI-style references + status: accepted + description: > + Cross-repo links use prefix:ID syntax (e.g., rivet:REQ-001) rather than + full URIs. Simpler to type, more readable in YAML. + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo] + fields: + decision: Use prefix:ID syntax with prefix declared in rivet.yaml + rationale: > + Simpler and more readable than URIs. Prefix is a local alias + configured per project, matching sphinx-needs id_prefix pattern. + + - id: DD-015 + type: design-decision + title: Mesh topology over hub-and-spoke + status: accepted + description: > + Any repo can link to any other repo directly. No central authority required. + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo] + fields: + decision: Any repo can link to any other repo directly + rationale: > + Avoids central authority requirement. Matches distributed team + workflows. Transitive resolution handles indirect dependencies. + + - id: DD-016 + type: design-decision + title: Distributed baselining over centralized manifest + status: accepted + description: > + Repos tag themselves with baseline/* tags; consistency verified not enforced. + links: + - type: satisfies + target: REQ-021 + tags: [cross-repo, baseline] + fields: + decision: Repos tag themselves with baseline/* tags; consistency verified not enforced + rationale: > + No platform repo required. Each repo joins baselines independently. + Matches OSLC global configuration model where contributions are optional. + + - id: DD-017 + type: design-decision + title: Transitive dependency resolution + status: accepted + description: > + Declare direct dependencies only; discover transitive deps automatically. + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo] + fields: + decision: Declare direct dependencies only; discover transitively + rationale: > + Scales naturally. Avoids redundant declarations. Similar to cargo/npm + dependency resolution. diff --git a/artifacts/features.yaml b/artifacts/features.yaml index f1fce12..0961621 100644 --- a/artifacts/features.yaml +++ b/artifacts/features.yaml @@ -514,3 +514,66 @@ artifacts: target: DD-012 fields: phase: phase-3 + + - id: FEAT-033 + type: feature + title: Externals config block and prefix resolution + status: draft + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo] + + - id: FEAT-034 + type: feature + title: rivet sync — fetch external repos + status: draft + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo, cli] + + - id: FEAT-035 + type: feature + title: rivet lock — pin externals to commits + status: draft + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo, cli] + + - id: FEAT-036 + type: feature + title: rivet baseline verify — cross-repo validation + status: draft + links: + - type: satisfies + target: REQ-021 + tags: [cross-repo, baseline, cli] + + - id: FEAT-037 + type: feature + title: Embedded WASM/JS assets for single binary + status: draft + links: + - type: satisfies + target: REQ-022 + tags: [packaging, wasm] + + - id: FEAT-038 + type: feature + title: Cross-repo link validation in rivet validate + status: draft + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo, validation] + + - id: FEAT-039 + type: feature + title: Dashboard external project browsing + status: draft + links: + - type: satisfies + target: REQ-020 + tags: [cross-repo, dashboard] diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index f9d45f5..af717f0 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -251,3 +251,39 @@ artifacts: fields: priority: must category: functional + + - id: REQ-020 + type: requirement + title: Cross-repository artifact linking via prefixed IDs + status: draft + description: > + Rivet projects must be able to declare external dependencies on other + rivet repositories and reference their artifacts using prefix:ID syntax. + tags: [cross-repo, traceability] + fields: + priority: must + category: functional + + - id: REQ-021 + type: requirement + title: Distributed baselining via convention tags + status: draft + description: > + Multiple rivet repositories must be able to form consistent baselines + using git tags without requiring a central platform repository. + tags: [cross-repo, baseline] + fields: + priority: should + category: functional + + - id: REQ-022 + type: requirement + title: Single-binary WASM asset embedding + status: draft + description: > + The rivet binary must optionally embed all WASM and JavaScript assets + so it can be distributed as a single self-contained executable. + tags: [packaging, wasm] + fields: + priority: should + category: functional diff --git a/rivet-cli/Cargo.toml b/rivet-cli/Cargo.toml index a272dea..d0ae5ba 100644 --- a/rivet-cli/Cargo.toml +++ b/rivet-cli/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [features] default = [] wasm = ["rivet-core/wasm"] +embed-wasm = [] [dependencies] rivet-core = { path = "../rivet-core" } diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index a23d3db..f74f3f0 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -67,19 +67,19 @@ const TOPICS: &[DocTopic] = &[ slug: "schema/stpa", title: "STPA safety analysis schema (10 types)", category: "Schemas", - content: embedded::SCHEMA_STPA, + content: STPA_DOC, }, DocTopic { slug: "schema/aspice", title: "Automotive SPICE schema (14 types, ASPICE 4.0)", category: "Schemas", - content: embedded::SCHEMA_ASPICE, + content: ASPICE_DOC, }, DocTopic { slug: "schema/cybersecurity", title: "Cybersecurity schema (SEC.1-4, 10 types)", category: "Schemas", - content: embedded::SCHEMA_CYBERSECURITY, + content: CYBERSECURITY_DOC, }, DocTopic { slug: "schema/aadl", @@ -87,6 +87,12 @@ const TOPICS: &[DocTopic] = &[ category: "Schemas", content: embedded::SCHEMA_AADL, }, + DocTopic { + slug: "cross-repo", + title: "Cross-Repository Linking", + category: "Reference", + content: CROSS_REPO_DOC, + }, ]; // ── Embedded documentation ────────────────────────────────────────────── @@ -592,6 +598,155 @@ the "unimplemented" report — useful when retrofitting traceability onto an existing project where historical commits lack trailers. "#; +const CROSS_REPO_DOC: &str = r#"# Cross-Repository Artifact Linking + +## Overview + +Rivet supports linking artifacts across multiple git repositories using +a mesh topology. Any rivet project can declare dependencies on other rivet +projects and reference their artifacts using prefixed IDs. + +## Configuration + +Declare external dependencies in `rivet.yaml`: + +```yaml +externals: + rivet: + git: https://github.com/pulseengine/rivet + ref: main + prefix: rivet + meld: + path: ../meld + prefix: meld +``` + +- `git` — clone URL for the external repo +- `path` — local filesystem path (alternative to `git`) +- `ref` — git ref to checkout (branch, tag, or commit SHA) +- `prefix` — short alias used in cross-links; must be unique + +## Cross-Link Syntax + +In artifact YAML, reference external artifacts with `prefix:ID`: + +```yaml +links: + - type: traces-to + target: rivet:REQ-001 + - type: mitigates + target: meld:H-1 +``` + +Resolution rules: +- Bare IDs (no colon) resolve locally as usual +- Prefixed IDs (`prefix:ID`) resolve against the named external +- Unknown prefixes are validation errors +- Missing IDs in the external are broken-reference errors + +## Commands + +### `rivet sync` + +Fetches external repos into `.rivet/repos/` cache: + +``` +rivet sync +``` + +For `git` externals: clones or fetches the repo +For `path` externals: creates a symlink + +### `rivet lock` + +Pins all externals to exact commit SHAs in `rivet.lock`: + +``` +rivet lock +rivet lock --update # refresh to latest refs +``` + +### `rivet validate` (with externals) + +Validates cross-repo links in addition to local validation: +- Loads external artifacts from `.rivet/repos/` cache +- Checks all prefixed references resolve correctly +- Detects circular dependencies between repos +- Reports version conflicts (same repo at different refs) +- Checks lifecycle completeness (V-model coverage) + +### `rivet baseline verify ` + +Verifies baseline consistency across repos: + +``` +rivet baseline verify v1.0 +rivet baseline verify v1.0 --strict +``` + +Checks each external for `baseline/` tag. +Without `--strict`: missing tags are warnings. +With `--strict`: missing tags are errors. + +## Distributed Baselining + +Repos participate in baselines by tagging: `git tag baseline/v1.0` + +- Tags follow the convention `baseline/` +- Each repo tags itself independently +- `rivet baseline verify` checks consistency across repos +- No central platform repository required + +## Design Decisions + +- **DD-014**: Prefixed IDs (`rivet:REQ-001`) over URI-style references +- **DD-015**: Mesh topology — any repo links to any other +- **DD-016**: Distributed baselining — repos tag themselves +- **DD-017**: Transitive dependency resolution — declare direct deps only +"#; + +const STPA_DOC: &str = concat!( + include_str!("../../schemas/stpa.yaml"), + r#" + +## References + +- Leveson, N.G. & Thomas, J.P. (2018). *STPA Handbook*. + MIT Partnership for Systems Approaches to Safety and Security (PSASS). + https://psas.scripts.mit.edu/home/get_file.php?name=STPA_handbook.pdf +- Leveson, N.G. (2011). *Engineering a Safer World*. + MIT Press. https://mitpress.mit.edu/9780262533690/ +"# +); + +const ASPICE_DOC: &str = concat!( + include_str!("../../schemas/aspice.yaml"), + r#" + +## References + +- Automotive SPICE Process Assessment / Reference Model v4.0. + VDA Quality Management Center. + https://www.automotivespice.com/ +- intacs — International Assessor Certification Scheme. + https://www.intacs.info/ +"# +); + +const CYBERSECURITY_DOC: &str = concat!( + include_str!("../../schemas/cybersecurity.yaml"), + r#" + +## References + +- ISO/SAE 21434:2021 — Road vehicles — Cybersecurity engineering. + https://www.iso.org/standard/70918.html +- UNECE WP.29 Regulation No. 155 — Cyber security and cyber security + management system. + https://unece.org/transport/documents/2021/03/standards/un-regulation-no-155 +"# +); + // ── Public API ────────────────────────────────────────────────────────── /// List all available documentation topics. diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index eaf16c0..a225f10 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -249,6 +249,22 @@ enum Command { port: u16, }, + /// Sync external project dependencies into .rivet/repos/ + Sync, + + /// Pin external dependencies to exact commits in rivet.lock + Lock { + /// Update all pins to latest refs + #[arg(long)] + update: bool, + }, + + /// Manage distributed baselines across repos + Baseline { + #[command(subcommand)] + action: BaselineAction, + }, + /// Import artifacts using a custom WASM adapter component #[cfg(feature = "wasm")] Import { @@ -296,6 +312,20 @@ enum SchemaAction { }, } +#[derive(Debug, Subcommand)] +enum BaselineAction { + /// Verify baseline consistency across all externals + Verify { + /// Baseline name (e.g., "v1.0") + name: String, + /// Fail on missing baseline tags (default: warn only) + #[arg(long)] + strict: bool, + }, + /// List baselines found across externals + List, +} + fn main() -> ExitCode { let cli = Cli::parse(); @@ -410,6 +440,12 @@ fn run(cli: Cli) -> Result { ))?; Ok(true) } + Command::Sync => cmd_sync(&cli), + Command::Lock { update } => cmd_lock(&cli, *update), + Command::Baseline { action } => match action { + BaselineAction::Verify { name, strict } => cmd_baseline_verify(&cli, name, *strict), + BaselineAction::List => cmd_baseline_list(&cli), + }, #[cfg(feature = "wasm")] Command::Import { adapter, @@ -840,6 +876,69 @@ fn cmd_validate(cli: &Cli, format: &str) -> Result { let mut diagnostics = validate::validate(&store, &schema, &graph); diagnostics.extend(validate::validate_documents(&doc_store, &store)); + // Cross-repo link validation + let config_path = cli.project.join("rivet.yaml"); + let config = rivet_core::load_project_config(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + + let mut cross_repo_broken: Vec = Vec::new(); + let mut backlinks: Vec = Vec::new(); + let mut circular_deps: Vec = Vec::new(); + let mut version_conflicts: Vec = Vec::new(); + if let Some(ref externals) = config.externals { + if !externals.is_empty() { + match rivet_core::externals::load_all_externals(externals, &cli.project) { + Ok(resolved) => { + // Build external ID sets + let mut external_ids: std::collections::BTreeMap< + String, + std::collections::HashSet, + > = std::collections::BTreeMap::new(); + for ext in &resolved { + let ids: std::collections::HashSet = + ext.artifacts.iter().map(|a| a.id.clone()).collect(); + external_ids.insert(ext.prefix.clone(), ids); + } + + // Collect local IDs and all link targets + let local_ids: std::collections::HashSet = + store.iter().map(|a| a.id.clone()).collect(); + let all_refs: Vec<&str> = store + .iter() + .flat_map(|a| a.links.iter().map(|l| l.target.as_str())) + .collect(); + + cross_repo_broken = + rivet_core::externals::validate_refs(&all_refs, &local_ids, &external_ids); + + // Compute backlinks from external artifacts pointing to local artifacts + backlinks = rivet_core::externals::compute_backlinks(&resolved, &local_ids); + } + Err(e) => { + eprintln!(" warning: could not load externals for cross-repo validation: {e}"); + } + } + + // Detect circular dependencies in the externals graph + circular_deps = rivet_core::externals::detect_circular_deps( + externals, + &config.project.name, + &cli.project, + ); + + // Detect version conflicts (same repo at different refs) + version_conflicts = rivet_core::externals::detect_version_conflicts( + externals, + &config.project.name, + &cli.project, + ); + } + } + + // Lifecycle completeness check + let all_artifacts: Vec<_> = store.iter().cloned().collect(); + let lifecycle_gaps = rivet_core::lifecycle::check_lifecycle_completeness(&all_artifacts); + let errors = diagnostics .iter() .filter(|d| d.severity == Severity::Error) @@ -852,6 +951,7 @@ fn cmd_validate(cli: &Cli, format: &str) -> Result { .iter() .filter(|d| d.severity == Severity::Info) .count(); + let cross_errors = cross_repo_broken.len(); if format == "json" { let diag_json: Vec = diagnostics @@ -864,12 +964,74 @@ fn cmd_validate(cli: &Cli, format: &str) -> Result { }) }) .collect(); + let cross_json: Vec = cross_repo_broken + .iter() + .map(|b| { + serde_json::json!({ + "reference": b.reference, + "reason": format!("{:?}", b.reason), + }) + }) + .collect(); + let backlinks_json: Vec = backlinks + .iter() + .map(|bl| { + serde_json::json!({ + "source_prefix": bl.source_prefix, + "source_id": bl.source_id, + "target": bl.target, + }) + }) + .collect(); + let cycles_json: Vec = circular_deps + .iter() + .map(|c| { + serde_json::json!({ + "chain": c.chain, + }) + }) + .collect(); + let conflicts_json: Vec = version_conflicts + .iter() + .map(|c| { + serde_json::json!({ + "repo_identifier": c.repo_identifier, + "versions": c.versions.iter().map(|v| { + serde_json::json!({ + "declared_by": v.declared_by, + "version": v.version, + }) + }).collect::>(), + }) + }) + .collect(); + let lifecycle_json: Vec = lifecycle_gaps + .iter() + .map(|g| { + serde_json::json!({ + "artifact_id": g.artifact_id, + "artifact_type": g.artifact_type, + "status": g.artifact_status, + "missing": g.missing, + }) + }) + .collect(); let output = serde_json::json!({ "command": "validate", "errors": errors, "warnings": warnings, "infos": infos, + "cross_repo_broken": cross_errors, + "backlinks": backlinks.len(), + "circular_deps": circular_deps.len(), + "version_conflicts": version_conflicts.len(), + "lifecycle_gaps": lifecycle_gaps.len(), "diagnostics": diag_json, + "broken_cross_refs": cross_json, + "cross_repo_backlinks": backlinks_json, + "circular_dependencies": cycles_json, + "version_conflict_details": conflicts_json, + "lifecycle_coverage": lifecycle_json, }); println!("{}", serde_json::to_string_pretty(&output).unwrap()); } else { @@ -883,15 +1045,77 @@ fn cmd_validate(cli: &Cli, format: &str) -> Result { print_diagnostics(&diagnostics); + if !cross_repo_broken.is_empty() { + println!(); + println!("Cross-repo link issues:"); + for b in &cross_repo_broken { + eprintln!(" broken cross-ref: {} — {:?}", b.reference, b.reason); + } + } + + if !backlinks.is_empty() { + println!(); + println!( + "Cross-repo backlinks: {} (external artifacts linking to local)", + backlinks.len() + ); + for bl in &backlinks { + println!(" {}:{} -> {}", bl.source_prefix, bl.source_id, bl.target); + } + } + + if !circular_deps.is_empty() { + println!(); + println!( + "warning: {} circular dependency chain(s) detected in externals graph:", + circular_deps.len() + ); + for cycle in &circular_deps { + println!(" {}", cycle.chain.join(" -> ")); + } + } + + if !version_conflicts.is_empty() { + println!(); + println!( + "warning: {} version conflict(s) detected in externals:", + version_conflicts.len() + ); + for c in &version_conflicts { + eprintln!(" {} referenced at different versions:", c.repo_identifier); + for entry in &c.versions { + eprintln!(" {} declares ref: {}", entry.declared_by, entry.version); + } + } + } + + if !lifecycle_gaps.is_empty() { + println!(); + println!("Lifecycle coverage gaps ({}):", lifecycle_gaps.len()); + for gap in &lifecycle_gaps { + eprintln!( + " {} ({}, status: {}) — missing: {}", + gap.artifact_id, + gap.artifact_type, + gap.artifact_status.as_deref().unwrap_or("none"), + gap.missing.join(", "), + ); + } + } + println!(); - if errors > 0 { - println!("Result: FAIL ({} errors, {} warnings)", errors, warnings); + let total_errors = errors + cross_errors; + if total_errors > 0 { + println!( + "Result: FAIL ({} errors, {} warnings, {} broken cross-refs)", + errors, warnings, cross_errors + ); } else { println!("Result: PASS ({} warnings)", warnings); } } - Ok(errors == 0) + Ok(errors == 0 && cross_errors == 0) } /// List artifacts. @@ -1983,6 +2207,173 @@ fn resolve_schemas_dir(cli: &Cli) -> PathBuf { } } +fn cmd_sync(cli: &Cli) -> Result { + let config = rivet_core::load_project_config(&cli.project.join("rivet.yaml")) + .with_context(|| format!("loading {}", cli.project.join("rivet.yaml").display()))?; + let externals = config.externals.as_ref(); + if externals.is_none() || externals.unwrap().is_empty() { + eprintln!("No externals declared in rivet.yaml"); + return Ok(true); + } + let externals = externals.unwrap(); + + // Ensure .rivet/ is gitignored + let added = rivet_core::externals::ensure_gitignore(&cli.project)?; + if added { + eprintln!("Added .rivet/ to .gitignore"); + } + + let results = rivet_core::externals::sync_all(externals, &cli.project)?; + for (name, path) in &results { + eprintln!(" Synced {} → {}", name, path.display()); + } + eprintln!("\n{} externals synced.", results.len()); + + // Check if a lockfile exists and warn about version drift + if let Some(lock) = rivet_core::externals::read_lockfile(&cli.project)? { + let cache_dir = cli.project.join(".rivet/repos"); + for (name, entry) in &lock.pins { + if let Some(ext) = externals.get(name) { + let ext_dir = + rivet_core::externals::resolve_external_dir(ext, &cache_dir, &cli.project); + if ext_dir.join(".git").exists() { + if let Ok(current) = rivet_core::externals::git_head_sha(&ext_dir) { + if current != entry.commit { + eprintln!( + " Warning: {} is at {} but lockfile pins {}", + name, + ¤t[..8.min(current.len())], + &entry.commit[..8.min(entry.commit.len())] + ); + } + } + } + } + } + } + + Ok(true) +} + +fn cmd_lock(cli: &Cli, update: bool) -> Result { + if update { + eprintln!("Note: --update refreshes all pins to latest refs"); + } + let config = rivet_core::load_project_config(&cli.project.join("rivet.yaml")) + .with_context(|| format!("loading {}", cli.project.join("rivet.yaml").display()))?; + let externals = config.externals.as_ref(); + if externals.is_none() || externals.unwrap().is_empty() { + eprintln!("No externals declared in rivet.yaml"); + return Ok(true); + } + let lock = rivet_core::externals::generate_lockfile(externals.unwrap(), &cli.project)?; + rivet_core::externals::write_lockfile(&lock, &cli.project)?; + eprintln!("Wrote rivet.lock with {} pins", lock.pins.len()); + Ok(true) +} + +fn cmd_baseline_verify(cli: &Cli, name: &str, strict: bool) -> Result { + let config = rivet_core::load_project_config(&cli.project.join("rivet.yaml")) + .with_context(|| "Failed to load rivet.yaml")?; + + let externals = match config.externals.as_ref() { + Some(e) if !e.is_empty() => e, + _ => { + eprintln!("No externals declared in rivet.yaml"); + return Ok(true); + } + }; + + let verification = rivet_core::externals::verify_baseline(name, externals, &cli.project)?; + + let mut all_present = true; + + // Report local status + match &verification.local_status { + rivet_core::externals::BaselineStatus::Present { commit } => { + eprintln!( + " local: baseline/{} @ {}", + name, + &commit[..8.min(commit.len())] + ); + } + rivet_core::externals::BaselineStatus::Missing => { + eprintln!(" local: baseline/{} MISSING", name); + all_present = false; + } + } + + // Report external statuses + for (prefix, status) in &verification.external_statuses { + match status { + rivet_core::externals::BaselineStatus::Present { commit } => { + eprintln!( + " {}: baseline/{} @ {}", + prefix, + name, + &commit[..8.min(commit.len())] + ); + } + rivet_core::externals::BaselineStatus::Missing => { + eprintln!(" {}: baseline/{} MISSING", prefix, name); + all_present = false; + } + } + } + + if all_present { + eprintln!("\nBaseline {} verified — all repos tagged.", name); + Ok(true) + } else if strict { + eprintln!("\nBaseline {} FAILED — missing tags (strict mode).", name); + Ok(false) + } else { + eprintln!( + "\nBaseline {} partial — some repos missing tags (warning).", + name + ); + Ok(true) // warnings don't fail + } +} + +fn cmd_baseline_list(cli: &Cli) -> Result { + let config = rivet_core::load_project_config(&cli.project.join("rivet.yaml")) + .with_context(|| "Failed to load rivet.yaml")?; + + // List local baselines + let local_tags = rivet_core::externals::list_baseline_tags(&cli.project)?; + eprintln!("Local baselines:"); + if local_tags.is_empty() { + eprintln!(" (none)"); + } else { + for tag in &local_tags { + eprintln!(" baseline/{}", tag); + } + } + + // List external baselines + if let Some(externals) = config.externals.as_ref() { + let cache_dir = cli.project.join(".rivet/repos"); + for ext in externals.values() { + let ext_dir = + rivet_core::externals::resolve_external_dir(ext, &cache_dir, &cli.project); + if ext_dir.exists() { + let tags = rivet_core::externals::list_baseline_tags(&ext_dir)?; + eprintln!("\n{} baselines:", ext.prefix); + if tags.is_empty() { + eprintln!(" (none)"); + } else { + for tag in &tags { + eprintln!(" baseline/{}", tag); + } + } + } + } + } + + Ok(true) +} + fn load_project(cli: &Cli) -> Result<(Store, rivet_core::schema::Schema, LinkGraph)> { let config_path = cli.project.join("rivet.yaml"); let config = rivet_core::load_project_config(&config_path) diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index 5ec89de..27a9ff7 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -11,6 +11,16 @@ use petgraph::graph::{Graph, NodeIndex}; use petgraph::visit::EdgeRef; use tokio::sync::RwLock; +/// Embedded WASM/JS assets for single-binary distribution. +/// Only available when built with `--features embed-wasm` and assets exist. +#[cfg(feature = "embed-wasm")] +mod embedded_wasm { + pub const SPAR_JS: &str = include_str!("../assets/wasm/js/spar_wasm.js"); + pub const CORE_WASM: &[u8] = include_bytes!("../assets/wasm/js/spar_wasm.core.wasm"); + pub const CORE2_WASM: &[u8] = include_bytes!("../assets/wasm/js/spar_wasm.core2.wasm"); + pub const CORE3_WASM: &[u8] = include_bytes!("../assets/wasm/js/spar_wasm.core3.wasm"); +} + use crate::{docs, schema_cmd}; use etch::filter::ego_subgraph; use etch::layout::{self as pgv_layout, EdgeInfo, LayoutOptions, NodeInfo}; @@ -484,8 +494,6 @@ async fn source_raw( /// GET /wasm/{*path} — serve jco-transpiled WASM assets for browser-side rendering. async fn wasm_asset(Path(path): Path) -> impl IntoResponse { - // Assets are in rivet-cli/assets/wasm/js/ relative to the binary, - // but we embed critical files at compile time for portability. let content_type = if path.ends_with(".js") { "application/javascript" } else if path.ends_with(".wasm") { @@ -496,8 +504,31 @@ async fn wasm_asset(Path(path): Path) -> impl IntoResponse { "application/octet-stream" }; - // Try to load from the assets directory next to the binary, or from - // the workspace assets dir during development. + // Try embedded assets first (when built with embed-wasm feature). + #[cfg(feature = "embed-wasm")] + { + let bytes: Option<&[u8]> = match path.as_str() { + "spar_wasm.js" => Some(embedded_wasm::SPAR_JS.as_bytes()), + "spar_wasm.core.wasm" => Some(embedded_wasm::CORE_WASM), + "spar_wasm.core2.wasm" => Some(embedded_wasm::CORE2_WASM), + "spar_wasm.core3.wasm" => Some(embedded_wasm::CORE3_WASM), + _ => None, + }; + if let Some(data) = bytes { + return ( + axum::http::StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type), + (axum::http::header::CACHE_CONTROL, "public, max-age=86400"), + ], + data.to_vec(), + ) + .into_response(); + } + } + + // Fallback to filesystem (development mode). + // Try the workspace assets dir first, then next to the binary. let candidates = [ std::env::current_dir() .unwrap_or_default() diff --git a/rivet-core/Cargo.toml b/rivet-core/Cargo.toml index eb08d8c..5784178 100644 --- a/rivet-core/Cargo.toml +++ b/rivet-core/Cargo.toml @@ -40,6 +40,8 @@ proptest = "1.5" criterion = { workspace = true } wiremock = "0.6" tokio = { workspace = true } +tempfile = "3" +serial_test = "3" [[bench]] name = "core_benchmarks" diff --git a/rivet-core/src/externals.rs b/rivet-core/src/externals.rs new file mode 100644 index 0000000..cc1a7a0 --- /dev/null +++ b/rivet-core/src/externals.rs @@ -0,0 +1,1371 @@ +// rivet-core/src/externals.rs + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::model::ExternalProject; + +/// A parsed artifact reference — either local or cross-repo. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtifactRef { + /// Local artifact ID (no prefix). + Local(String), + /// Cross-repo artifact: (prefix, id). + External { prefix: String, id: String }, +} + +/// Parse an artifact reference string. +/// +/// - `"REQ-001"` → `ArtifactRef::Local("REQ-001")` +/// - `"rivet:REQ-001"` → `ArtifactRef::External { prefix: "rivet", id: "REQ-001" }` +pub fn parse_artifact_ref(s: &str) -> ArtifactRef { + // Only split on first colon. The prefix must be purely alphabetic + // (no digits, hyphens, or dots) to avoid confusion with IDs like "H-1.2". + if let Some((prefix, id)) = s.split_once(':') { + if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_lowercase()) && !id.is_empty() { + return ArtifactRef::External { + prefix: prefix.to_string(), + id: id.to_string(), + }; + } + } + ArtifactRef::Local(s.to_string()) +} + +/// Sync a single external project into the cache directory. +/// +/// For `path` externals: creates a symlink from `.rivet/repos/` to the path. +/// For `git` externals: clones or fetches the repo, checks out the specified ref. +pub fn sync_external( + ext: &ExternalProject, + cache_dir: &Path, + project_dir: &Path, +) -> Result { + let dest = cache_dir.join(&ext.prefix); + std::fs::create_dir_all(cache_dir) + .map_err(|e| crate::error::Error::Io(format!("create cache dir: {e}")))?; + + if let Some(ref local_path) = ext.path { + // Resolve relative to project dir + let resolved = if Path::new(local_path).is_relative() { + project_dir.join(local_path) + } else { + PathBuf::from(local_path) + }; + let resolved = resolved + .canonicalize() + .map_err(|e| crate::error::Error::Io(format!("resolve path '{local_path}': {e}")))?; + + // Remove existing symlink/dir if present + if dest.exists() || dest.is_symlink() { + if dest.is_symlink() { + std::fs::remove_file(&dest).ok(); + } else { + std::fs::remove_dir_all(&dest).ok(); + } + } + + #[cfg(unix)] + std::os::unix::fs::symlink(&resolved, &dest) + .map_err(|e| crate::error::Error::Io(format!("symlink: {e}")))?; + + #[cfg(not(unix))] + { + copy_dir_recursive(&resolved, &dest)?; + } + + return Ok(dest); + } + + if let Some(ref git_url) = ext.git { + let git_ref = ext.git_ref.as_deref().unwrap_or("main"); + + if dest.join(".git").exists() { + // Fetch updates + let output = Command::new("git") + .args(["fetch", "origin"]) + .current_dir(&dest) + .output() + .map_err(|e| crate::error::Error::Io(format!("git fetch: {e}")))?; + if !output.status.success() { + return Err(crate::error::Error::Io(format!( + "git fetch failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + // Checkout ref + let output = Command::new("git") + .args(["checkout", git_ref]) + .current_dir(&dest) + .output() + .map_err(|e| crate::error::Error::Io(format!("git checkout: {e}")))?; + if !output.status.success() { + // Try as remote branch + Command::new("git") + .args(["checkout", &format!("origin/{git_ref}")]) + .current_dir(&dest) + .output() + .ok(); + } + } else { + // Clone fresh + let output = Command::new("git") + .args([ + "clone", + git_url, + dest.to_str() + .ok_or_else(|| crate::error::Error::Io("invalid cache path".into()))?, + ]) + .output() + .map_err(|e| crate::error::Error::Io(format!("git clone: {e}")))?; + if !output.status.success() { + return Err(crate::error::Error::Io(format!( + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + if git_ref != "main" && git_ref != "master" { + Command::new("git") + .args(["checkout", git_ref]) + .current_dir(&dest) + .output() + .ok(); + } + } + return Ok(dest); + } + + Err(crate::error::Error::Io( + "external must have either 'git' or 'path'".into(), + )) +} + +/// Resolve the directory for an external project. +/// +/// For `path` externals: resolves relative to project_dir, canonicalizes. +/// For `git` externals: returns `cache_dir/`. +pub fn resolve_external_dir( + ext: &ExternalProject, + cache_dir: &Path, + project_dir: &Path, +) -> PathBuf { + if let Some(ref local_path) = ext.path { + let p = if Path::new(local_path).is_relative() { + project_dir.join(local_path) + } else { + PathBuf::from(local_path) + }; + p.canonicalize().unwrap_or(p) + } else { + cache_dir.join(&ext.prefix) + } +} + +/// Sync all externals declared in the project config. +pub fn sync_all( + externals: &BTreeMap, + project_dir: &Path, +) -> Result, crate::error::Error> { + let cache_dir = project_dir.join(".rivet/repos"); + let mut results = Vec::new(); + for (name, ext) in externals { + let path = sync_external(ext, &cache_dir, project_dir)?; + results.push((name.clone(), path)); + } + Ok(results) +} + +/// Ensure `.rivet/` is in `.gitignore`. Returns true if added, false if already present. +pub fn ensure_gitignore(project_dir: &Path) -> Result { + let gitignore = project_dir.join(".gitignore"); + if gitignore.exists() { + let content = std::fs::read_to_string(&gitignore) + .map_err(|e| crate::error::Error::Io(format!("read .gitignore: {e}")))?; + if content + .lines() + .any(|l| l.trim() == ".rivet/" || l.trim() == ".rivet") + { + return Ok(false); // already present + } + } + // Append + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&gitignore) + .map_err(|e| crate::error::Error::Io(format!("open .gitignore: {e}")))?; + writeln!(f, "\n# Rivet external project cache\n.rivet/") + .map_err(|e| crate::error::Error::Io(format!("write .gitignore: {e}")))?; + Ok(true) // added +} + +/// Load artifacts from an external project directory. +/// +/// Reads the external project's `rivet.yaml`, discovers its sources, +/// and loads all artifacts. Does NOT validate against schema (the +/// external project validates itself). +pub fn load_external_project( + project_dir: &Path, +) -> Result, crate::error::Error> { + let config_path = project_dir.join("rivet.yaml"); + let config = crate::load_project_config(&config_path)?; + + let mut artifacts = Vec::new(); + for source in &config.sources { + let loaded = crate::load_artifacts(source, project_dir)?; + artifacts.extend(loaded); + } + Ok(artifacts) +} + +/// A resolved external with its loaded artifacts. +#[derive(Debug)] +pub struct ResolvedExternal { + pub prefix: String, + pub project_dir: PathBuf, + pub artifacts: Vec, +} + +/// Load all external projects from cache and return their artifacts. +pub fn load_all_externals( + externals: &BTreeMap, + project_dir: &Path, +) -> Result, crate::error::Error> { + let cache_dir = project_dir.join(".rivet/repos"); + let mut resolved = Vec::new(); + for ext in externals.values() { + let ext_dir = resolve_external_dir(ext, &cache_dir, project_dir); + let artifacts = load_external_project(&ext_dir)?; + resolved.push(ResolvedExternal { + prefix: ext.prefix.clone(), + project_dir: ext_dir, + artifacts, + }); + } + Ok(resolved) +} + +/// A broken cross-repo reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrokenRef { + pub reference: String, + pub reason: BrokenRefReason, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrokenRefReason { + UnknownPrefix(String), + NotFoundInExternal { prefix: String, id: String }, + NotFoundLocally(String), +} + +/// Validate a list of artifact reference strings against local and external ID sets. +/// +/// Returns a list of broken references. An empty return means all refs resolved. +pub fn validate_refs( + refs: &[&str], + local_ids: &std::collections::HashSet, + external_ids: &BTreeMap>, +) -> Vec { + let mut broken = Vec::new(); + for r in refs { + match parse_artifact_ref(r) { + ArtifactRef::Local(id) => { + if !local_ids.contains(&id) { + broken.push(BrokenRef { + reference: r.to_string(), + reason: BrokenRefReason::NotFoundLocally(id), + }); + } + } + ArtifactRef::External { prefix, id } => { + if let Some(ids) = external_ids.get(&prefix) { + if !ids.contains(&id) { + broken.push(BrokenRef { + reference: r.to_string(), + reason: BrokenRefReason::NotFoundInExternal { prefix, id }, + }); + } + } else { + broken.push(BrokenRef { + reference: r.to_string(), + reason: BrokenRefReason::UnknownPrefix(prefix), + }); + } + } + } + } + broken +} + +/// A lockfile pinning externals to exact commits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Lockfile { + pub pins: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + pub commit: String, + pub prefix: String, +} + +/// Read the current commit SHA of a git repository. +pub fn git_head_sha(repo_dir: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(repo_dir) + .output() + .map_err(|e| crate::error::Error::Io(format!("git rev-parse: {e}")))?; + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Generate a lockfile from current external state. +pub fn generate_lockfile( + externals: &BTreeMap, + project_dir: &Path, +) -> Result { + let cache_dir = project_dir.join(".rivet/repos"); + let mut pins = BTreeMap::new(); + for (name, ext) in externals { + let ext_dir = resolve_external_dir(ext, &cache_dir, project_dir); + let commit = git_head_sha(&ext_dir)?; + pins.insert( + name.clone(), + LockEntry { + git: ext.git.clone(), + commit, + prefix: ext.prefix.clone(), + }, + ); + } + Ok(Lockfile { pins }) +} + +/// Write lockfile to `rivet.lock`. +pub fn write_lockfile(lock: &Lockfile, project_dir: &Path) -> Result<(), crate::error::Error> { + let path = project_dir.join("rivet.lock"); + let yaml = serde_yaml::to_string(lock) + .map_err(|e| crate::error::Error::Schema(format!("serialize lockfile: {e}")))?; + std::fs::write(&path, yaml) + .map_err(|e| crate::error::Error::Io(format!("write rivet.lock: {e}")))?; + Ok(()) +} + +/// Read lockfile from `rivet.lock`. +pub fn read_lockfile(project_dir: &Path) -> Result, crate::error::Error> { + let path = project_dir.join("rivet.lock"); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| crate::error::Error::Io(format!("read rivet.lock: {e}")))?; + let lock: Lockfile = serde_yaml::from_str(&content) + .map_err(|e| crate::error::Error::Schema(format!("parse rivet.lock: {e}")))?; + Ok(Some(lock)) +} + +/// A detected version conflict: same repo referenced at different versions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionConflict { + /// The git URL or path that conflicts. + pub repo_identifier: String, + /// The different refs/versions found, with their source chain. + pub versions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictEntry { + /// The prefix that declares this dependency. + pub declared_by: String, + /// The git ref or "local path" for path externals. + pub version: String, +} + +/// Check for version conflicts across all externals (direct + transitive). +/// +/// Groups externals by their git URL. If the same URL appears with different +/// refs, reports a conflict. Direct dependencies take priority over transitive. +pub fn detect_version_conflicts( + externals: &BTreeMap, + project_name: &str, + project_dir: &Path, +) -> Vec { + // Build a map: git_url -> Vec<(declared_by, ref)> + let mut by_url: BTreeMap> = BTreeMap::new(); + + // Add direct dependencies + for (name, ext) in externals { + let repo_id = ext + .git + .clone() + .unwrap_or_else(|| ext.path.clone().unwrap_or_else(|| name.clone())); + let version = ext.git_ref.clone().unwrap_or_else(|| "HEAD".into()); + by_url.entry(repo_id).or_default().push(ConflictEntry { + declared_by: project_name.to_string(), + version, + }); + } + + // Add transitive dependencies (from each external's own rivet.yaml) + let cache_dir = project_dir.join(".rivet/repos"); + for ext in externals.values() { + let ext_dir = resolve_external_dir(ext, &cache_dir, project_dir); + let config_path = ext_dir.join("rivet.yaml"); + if let Ok(ext_config) = crate::load_project_config(&config_path) { + if let Some(ref ext_externals) = ext_config.externals { + for (ext_name, ext_ext) in ext_externals { + let repo_id = ext_ext.git.clone().unwrap_or_else(|| { + ext_ext.path.clone().unwrap_or_else(|| ext_name.clone()) + }); + let version = ext_ext.git_ref.clone().unwrap_or_else(|| "HEAD".into()); + by_url.entry(repo_id).or_default().push(ConflictEntry { + declared_by: ext.prefix.clone(), + version, + }); + } + } + } + } + + // Find conflicts: same repo with different versions + let mut conflicts = Vec::new(); + for (repo_id, entries) in &by_url { + if entries.len() < 2 { + continue; + } + // Check if versions actually differ + let first_version = &entries[0].version; + let has_conflict = entries.iter().any(|e| &e.version != first_version); + if has_conflict { + conflicts.push(VersionConflict { + repo_identifier: repo_id.clone(), + versions: entries.clone(), + }); + } + } + + conflicts +} + +/// Status of a baseline tag in a repository. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BaselineStatus { + Present { commit: String }, + Missing, +} + +impl BaselineStatus { + pub fn is_present(&self) -> bool { + matches!(self, BaselineStatus::Present { .. }) + } +} + +/// Check if a git repo has a specific baseline tag. +pub fn check_baseline_tag( + repo_dir: &Path, + baseline_name: &str, +) -> Result { + let tag = format!("baseline/{baseline_name}"); + let output = Command::new("git") + .args(["rev-parse", "--verify", &format!("refs/tags/{tag}")]) + .current_dir(repo_dir) + .output() + .map_err(|e| crate::error::Error::Io(format!("git rev-parse: {e}")))?; + + if output.status.success() { + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(BaselineStatus::Present { commit }) + } else { + Ok(BaselineStatus::Missing) + } +} + +/// List all baseline tags found in a repository. +pub fn list_baseline_tags(repo_dir: &Path) -> Result, crate::error::Error> { + let output = Command::new("git") + .args(["tag", "--list", "baseline/*"]) + .current_dir(repo_dir) + .output() + .map_err(|e| crate::error::Error::Io(format!("git tag list: {e}")))?; + + let tags = String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.trim_start_matches("baseline/").to_string()) + .collect(); + Ok(tags) +} + +/// Result of verifying a baseline across repos. +#[derive(Debug)] +pub struct BaselineVerification { + pub baseline_name: String, + pub local_status: BaselineStatus, + pub external_statuses: Vec<(String, BaselineStatus)>, +} + +/// Verify a baseline across the local repo and all externals. +/// +/// Checks each repo for the `baseline/` tag. +/// Optionally syncs externals at their baseline tag for cross-link validation. +pub fn verify_baseline( + baseline_name: &str, + externals: &BTreeMap, + project_dir: &Path, +) -> Result { + let cache_dir = project_dir.join(".rivet/repos"); + + // Check local repo + let local_status = check_baseline_tag(project_dir, baseline_name)?; + + // Check each external + let mut external_statuses = Vec::new(); + for ext in externals.values() { + let ext_dir = resolve_external_dir(ext, &cache_dir, project_dir); + + let status = if ext_dir.exists() { + check_baseline_tag(&ext_dir, baseline_name)? + } else { + BaselineStatus::Missing + }; + external_statuses.push((ext.prefix.clone(), status)); + } + + Ok(BaselineVerification { + baseline_name: baseline_name.to_string(), + local_status, + external_statuses, + }) +} + +/// Recursively copy a directory (used on non-unix platforms instead of symlinks). +#[cfg(not(unix))] +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), crate::error::Error> { + std::fs::create_dir_all(dst) + .map_err(|e| crate::error::Error::Io(format!("create dir: {e}")))?; + for entry in + std::fs::read_dir(src).map_err(|e| crate::error::Error::Io(format!("read dir: {e}")))? + { + let entry = entry.map_err(|e| crate::error::Error::Io(format!("read dir entry: {e}")))?; + let ty = entry + .file_type() + .map_err(|e| crate::error::Error::Io(format!("file type: {e}")))?; + let dest_path = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_recursive(&entry.path(), &dest_path)?; + } else { + std::fs::copy(entry.path(), &dest_path) + .map_err(|e| crate::error::Error::Io(format!("copy file: {e}")))?; + } + } + Ok(()) +} + +/// A backlink from an external artifact to a local (or other external) artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrossRepoBacklink { + /// The external project prefix where the link originates. + pub source_prefix: String, + /// The artifact ID in the external project that contains the link. + pub source_id: String, + /// The target reference (may be local ID or another prefix:ID). + pub target: String, +} + +/// Compute backlinks: scan external artifacts' links for references to local artifacts +/// or to other external projects. +/// +/// A "backlink" is a link stored in an external project's artifact that points back +/// to an artifact in the local project (or to another external). This enables +/// bidirectional awareness without requiring both sides to declare the link. +pub fn compute_backlinks( + resolved: &[ResolvedExternal], + local_ids: &std::collections::HashSet, +) -> Vec { + let mut backlinks = Vec::new(); + for ext in resolved { + for artifact in &ext.artifacts { + for link in &artifact.links { + let parsed = parse_artifact_ref(&link.target); + match parsed { + // External artifact links to a local ID in our project + ArtifactRef::Local(ref id) if local_ids.contains(id) => { + backlinks.push(CrossRepoBacklink { + source_prefix: ext.prefix.clone(), + source_id: artifact.id.clone(), + target: link.target.clone(), + }); + } + // External artifact links to another external (cross-external) + ArtifactRef::External { .. } => { + backlinks.push(CrossRepoBacklink { + source_prefix: ext.prefix.clone(), + source_id: artifact.id.clone(), + target: link.target.clone(), + }); + } + _ => {} + } + } + } + } + backlinks +} + +/// A detected circular dependency chain between repos. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CircularDependency { + /// The chain of prefixes forming the cycle (e.g., ["a", "b", "c", "a"]). + pub chain: Vec, +} + +/// Detect circular dependencies in the externals graph. +/// +/// Reads each external's own `rivet.yaml` to discover their declared externals, +/// then checks for cycles using DFS. Circular deps are warnings (valid in mesh +/// topology) but should be reported so users are aware. +pub fn detect_circular_deps( + externals: &BTreeMap, + project_name: &str, + project_dir: &Path, +) -> Vec { + let cache_dir = project_dir.join(".rivet/repos"); + let mut graph: BTreeMap> = BTreeMap::new(); + + // Add edges from current project + let deps: Vec = externals.keys().cloned().collect(); + graph.insert(project_name.to_string(), deps); + + // Add edges from each external's own externals + for ext in externals.values() { + let ext_dir = resolve_external_dir(ext, &cache_dir, project_dir); + let config_path = ext_dir.join("rivet.yaml"); + if let Ok(ext_config) = crate::load_project_config(&config_path) { + if let Some(ref ext_externals) = ext_config.externals { + let ext_deps: Vec = ext_externals.keys().cloned().collect(); + graph.insert(ext.prefix.clone(), ext_deps); + } + } + } + + // DFS cycle detection + let mut cycles = Vec::new(); + let mut visited = std::collections::HashSet::new(); + let mut path = Vec::new(); + + fn dfs( + node: &str, + graph: &BTreeMap>, + visited: &mut std::collections::HashSet, + path: &mut Vec, + cycles: &mut Vec, + ) { + if let Some(pos) = path.iter().position(|n| n == node) { + let mut chain: Vec = path[pos..].to_vec(); + chain.push(node.to_string()); + cycles.push(CircularDependency { chain }); + return; + } + if visited.contains(node) { + return; + } + path.push(node.to_string()); + if let Some(neighbors) = graph.get(node) { + for neighbor in neighbors { + dfs(neighbor, graph, visited, path, cycles); + } + } + path.pop(); + visited.insert(node.to_string()); + } + + for node in graph.keys() { + dfs(node, &graph, &mut visited, &mut path, &mut cycles); + } + + cycles +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn local_id_no_colon() { + assert_eq!( + parse_artifact_ref("REQ-001"), + ArtifactRef::Local("REQ-001".into()) + ); + } + + #[test] + fn external_id_with_prefix() { + assert_eq!( + parse_artifact_ref("rivet:REQ-001"), + ArtifactRef::External { + prefix: "rivet".into(), + id: "REQ-001".into(), + } + ); + } + + #[test] + fn local_id_with_hyphen_numbers() { + // IDs like "H-1.2" should not be confused with prefix:id + assert_eq!( + parse_artifact_ref("H-1.2"), + ArtifactRef::Local("H-1.2".into()) + ); + } + + #[test] + fn external_with_complex_id() { + assert_eq!( + parse_artifact_ref("meld:UCA-C-1"), + ArtifactRef::External { + prefix: "meld".into(), + id: "UCA-C-1".into(), + } + ); + } + + #[test] + #[serial] + fn sync_local_path_external() { + let dir = tempfile::tempdir().unwrap(); + // Create a fake external project with rivet.yaml and an artifact + let ext_dir = dir.path().join("ext-project"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write( + ext_dir.join("rivet.yaml"), + "project:\n name: ext\n version: '0.1.0'\n schemas: [common, dev]\nsources:\n - path: artifacts\n format: generic-yaml\n", + ) + .unwrap(); + let art_dir = ext_dir.join("artifacts"); + std::fs::create_dir_all(&art_dir).unwrap(); + std::fs::write( + art_dir.join("reqs.yaml"), + "artifacts:\n - id: EXT-001\n type: requirement\n title: External req\n", + ) + .unwrap(); + + let ext = crate::model::ExternalProject { + git: None, + path: Some(ext_dir.to_str().unwrap().into()), + git_ref: None, + prefix: "ext".into(), + }; + + let cache_dir = dir.path().join(".rivet/repos"); + let result = sync_external(&ext, &cache_dir, dir.path()); + assert!(result.is_ok()); + + // For path externals, the cache should contain a symlink or copy + let cached = cache_dir.join("ext"); + assert!(cached.exists()); + } + + #[test] + fn sync_external_requires_git_or_path() { + let dir = tempfile::tempdir().unwrap(); + let ext = crate::model::ExternalProject { + git: None, + path: None, + git_ref: None, + prefix: "bad".into(), + }; + let cache_dir = dir.path().join(".rivet/repos"); + let result = sync_external(&ext, &cache_dir, dir.path()); + assert!(result.is_err()); + } + + #[test] + fn ensure_gitignore_adds_entry() { + let dir = tempfile::tempdir().unwrap(); + // First call should add the entry + let added = ensure_gitignore(dir.path()).unwrap(); + assert!(added); + + // Second call should detect it already exists + let added_again = ensure_gitignore(dir.path()).unwrap(); + assert!(!added_again); + + // Verify contents + let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(content.contains(".rivet/")); + } + + #[test] + #[serial] + fn sync_all_multiple_externals() { + let dir = tempfile::tempdir().unwrap(); + + // Create two external projects + for name in &["alpha", "beta"] { + let ext_dir = dir.path().join(name); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write( + ext_dir.join("rivet.yaml"), + format!( + "project:\n name: {name}\n version: '0.1.0'\n schemas: [common]\nsources: []\n" + ), + ) + .unwrap(); + } + + let mut externals = std::collections::BTreeMap::new(); + externals.insert( + "alpha".into(), + crate::model::ExternalProject { + git: None, + path: Some(dir.path().join("alpha").to_str().unwrap().into()), + git_ref: None, + prefix: "alpha".into(), + }, + ); + externals.insert( + "beta".into(), + crate::model::ExternalProject { + git: None, + path: Some(dir.path().join("beta").to_str().unwrap().into()), + git_ref: None, + prefix: "beta".into(), + }, + ); + + let results = sync_all(&externals, dir.path()).unwrap(); + assert_eq!(results.len(), 2); + assert!(dir.path().join(".rivet/repos/alpha").exists()); + assert!(dir.path().join(".rivet/repos/beta").exists()); + } + + #[test] + fn validate_cross_repo_links() { + use std::collections::{BTreeMap, HashSet}; + + // Local artifacts + let local_ids: HashSet = ["REQ-001", "FEAT-001"] + .iter() + .map(|s| s.to_string()) + .collect(); + + // External artifacts keyed by prefix + let mut external_ids: BTreeMap> = BTreeMap::new(); + external_ids.insert( + "meld".into(), + ["UCA-C-1", "H-1"].iter().map(|s| s.to_string()).collect(), + ); + + // Valid references + let refs = vec!["REQ-001", "meld:UCA-C-1", "meld:H-1", "FEAT-001"]; + let broken = validate_refs(&refs, &local_ids, &external_ids); + assert!(broken.is_empty()); + + // Broken references + let refs2 = vec!["meld:NOPE-999", "unknown:REQ-001", "MISSING-001"]; + let broken2 = validate_refs(&refs2, &local_ids, &external_ids); + assert_eq!(broken2.len(), 3); + + // Verify specific reasons + assert_eq!( + broken2[0].reason, + BrokenRefReason::NotFoundInExternal { + prefix: "meld".into(), + id: "NOPE-999".into(), + } + ); + assert_eq!( + broken2[1].reason, + BrokenRefReason::UnknownPrefix("unknown".into()) + ); + assert_eq!( + broken2[2].reason, + BrokenRefReason::NotFoundLocally("MISSING-001".into()) + ); + } + + #[test] + fn lockfile_roundtrip() { + let mut pins = BTreeMap::new(); + pins.insert( + "rivet".into(), + LockEntry { + git: Some("https://github.com/pulseengine/rivet".into()), + commit: "abc123def456".into(), + prefix: "rivet".into(), + }, + ); + pins.insert( + "meld".into(), + LockEntry { + git: None, + commit: "789abc012def".into(), + prefix: "meld".into(), + }, + ); + + let lock = Lockfile { pins }; + let yaml = serde_yaml::to_string(&lock).unwrap(); + let parsed: Lockfile = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed.pins.len(), 2); + assert_eq!(parsed.pins["rivet"].commit, "abc123def456"); + assert!(parsed.pins["rivet"].git.is_some()); + assert!(parsed.pins["meld"].git.is_none()); + } + + #[test] + fn lockfile_write_and_read() { + let dir = tempfile::tempdir().unwrap(); + let mut pins = BTreeMap::new(); + pins.insert( + "ext".into(), + LockEntry { + git: Some("https://example.com/ext.git".into()), + commit: "deadbeef".into(), + prefix: "ext".into(), + }, + ); + let lock = Lockfile { pins }; + write_lockfile(&lock, dir.path()).unwrap(); + + let read_back = read_lockfile(dir.path()).unwrap(); + assert!(read_back.is_some()); + let read_back = read_back.unwrap(); + assert_eq!(read_back.pins.len(), 1); + assert_eq!(read_back.pins["ext"].commit, "deadbeef"); + } + + #[test] + fn read_lockfile_missing() { + let dir = tempfile::tempdir().unwrap(); + let result = read_lockfile(dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + #[serial] + fn load_external_artifacts() { + let dir = tempfile::tempdir().unwrap(); + let ext_dir = dir.path().join("ext"); + std::fs::create_dir_all(ext_dir.join("artifacts")).unwrap(); + std::fs::write( + ext_dir.join("rivet.yaml"), + "project:\n name: ext\n version: '0.1.0'\n schemas: [common, dev]\nsources:\n - path: artifacts\n format: generic-yaml\n", + ).unwrap(); + std::fs::write( + ext_dir.join("artifacts/reqs.yaml"), + "artifacts:\n - id: EXT-001\n type: requirement\n title: External req\n - id: EXT-002\n type: feature\n title: External feat\n", + ).unwrap(); + + let artifacts = load_external_project(&ext_dir).unwrap(); + assert_eq!(artifacts.len(), 2); + assert!(artifacts.iter().any(|a| a.id == "EXT-001")); + assert!(artifacts.iter().any(|a| a.id == "EXT-002")); + } + + #[test] + fn compute_backlinks_finds_reverse_refs() { + use crate::model::{Artifact, Link}; + + let mut local_ids = std::collections::HashSet::new(); + local_ids.insert("REQ-001".to_string()); + local_ids.insert("FEAT-001".to_string()); + + let ext_artifact = Artifact { + id: "EXT-UCA-1".to_string(), + artifact_type: "uca".to_string(), + title: "External UCA".to_string(), + description: None, + status: None, + tags: vec![], + links: vec![ + Link { + link_type: "traces-to".to_string(), + target: "REQ-001".to_string(), // links back to our local artifact + }, + Link { + link_type: "mitigates".to_string(), + target: "EXT-OTHER".to_string(), // links to something in their own project + }, + ], + fields: std::collections::BTreeMap::new(), + source_file: None, + }; + + let resolved = vec![ResolvedExternal { + prefix: "meld".to_string(), + project_dir: std::path::PathBuf::from("/tmp/meld"), + artifacts: vec![ext_artifact], + }]; + + let backlinks = compute_backlinks(&resolved, &local_ids); + assert_eq!(backlinks.len(), 1); + assert_eq!(backlinks[0].source_prefix, "meld"); + assert_eq!(backlinks[0].source_id, "EXT-UCA-1"); + assert_eq!(backlinks[0].target, "REQ-001"); + } + + #[test] + fn compute_backlinks_finds_cross_external_refs() { + use crate::model::{Artifact, Link}; + + let local_ids = std::collections::HashSet::new(); // empty — no local matches + + let ext_artifact = Artifact { + id: "EXT-UCA-1".to_string(), + artifact_type: "uca".to_string(), + title: "External UCA".to_string(), + description: None, + status: None, + tags: vec![], + links: vec![Link { + link_type: "traces-to".to_string(), + target: "other:REQ-001".to_string(), // cross-external ref + }], + fields: std::collections::BTreeMap::new(), + source_file: None, + }; + + let resolved = vec![ResolvedExternal { + prefix: "meld".to_string(), + project_dir: std::path::PathBuf::from("/tmp/meld"), + artifacts: vec![ext_artifact], + }]; + + let backlinks = compute_backlinks(&resolved, &local_ids); + assert_eq!(backlinks.len(), 1); + assert_eq!(backlinks[0].source_prefix, "meld"); + assert_eq!(backlinks[0].target, "other:REQ-001"); + } + + #[test] + fn compute_backlinks_empty_when_no_matches() { + use crate::model::Artifact; + + let mut local_ids = std::collections::HashSet::new(); + local_ids.insert("REQ-001".to_string()); + + let ext_artifact = Artifact { + id: "EXT-001".to_string(), + artifact_type: "requirement".to_string(), + title: "External req".to_string(), + description: None, + status: None, + tags: vec![], + links: vec![], // no links at all + fields: std::collections::BTreeMap::new(), + source_file: None, + }; + + let resolved = vec![ResolvedExternal { + prefix: "meld".to_string(), + project_dir: std::path::PathBuf::from("/tmp/meld"), + artifacts: vec![ext_artifact], + }]; + + let backlinks = compute_backlinks(&resolved, &local_ids); + assert!(backlinks.is_empty()); + } + + #[test] + fn detect_circular_deps_finds_cycle() { + // Test with actual temp dirs containing rivet.yaml files that reference each other + let dir = tempfile::tempdir().unwrap(); + + // Create project A (current project) that depends on B + // Create project B that depends on A (cycle: A -> B -> A) + let b_dir = dir.path().join("b"); + std::fs::create_dir_all(&b_dir).unwrap(); + std::fs::write( + b_dir.join("rivet.yaml"), + "project:\n name: b\n version: '0.1.0'\n schemas: [common]\nsources: []\nexternals:\n a:\n path: ../\n prefix: a\n", + ) + .unwrap(); + + let mut externals = BTreeMap::new(); + externals.insert( + "b".into(), + crate::model::ExternalProject { + git: None, + path: Some(b_dir.to_str().unwrap().into()), + git_ref: None, + prefix: "b".into(), + }, + ); + + let cycles = detect_circular_deps(&externals, "a", dir.path()); + assert!(!cycles.is_empty(), "should detect A->B->A cycle"); + // The cycle should contain both "a" and "b" + let chain = &cycles[0].chain; + assert!(chain.contains(&"a".to_string())); + assert!(chain.contains(&"b".to_string())); + assert_eq!( + chain.first(), + chain.last(), + "cycle must start and end with same node" + ); + } + + #[test] + fn detect_circular_deps_no_cycle() { + // Project A depends on B, B has no externals => no cycle + let dir = tempfile::tempdir().unwrap(); + + let b_dir = dir.path().join("b"); + std::fs::create_dir_all(&b_dir).unwrap(); + std::fs::write( + b_dir.join("rivet.yaml"), + "project:\n name: b\n version: '0.1.0'\n schemas: [common]\nsources: []\n", + ) + .unwrap(); + + let mut externals = BTreeMap::new(); + externals.insert( + "b".into(), + crate::model::ExternalProject { + git: None, + path: Some(b_dir.to_str().unwrap().into()), + git_ref: None, + prefix: "b".into(), + }, + ); + + let cycles = detect_circular_deps(&externals, "a", dir.path()); + assert!(cycles.is_empty(), "no cycle expected"); + } + + #[test] + fn circular_dependency_struct() { + let cycle = CircularDependency { + chain: vec!["a".into(), "b".into(), "c".into(), "a".into()], + }; + assert_eq!(cycle.chain.len(), 4); + assert_eq!(cycle.chain.first(), cycle.chain.last()); + } + + #[test] + fn detect_version_conflict_same_url_different_ref() { + let dir = tempfile::tempdir().unwrap(); + + // Create an external project that also depends on "shared" at a different ref + let ext_dir = dir.path().join("ext-a"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write( + ext_dir.join("rivet.yaml"), + r#"project: + name: ext-a + version: "0.1.0" + schemas: [common] +sources: [] +externals: + shared: + git: https://github.com/org/shared + ref: v2.0 + prefix: shared +"#, + ) + .unwrap(); + + // Direct externals: shared@v1.0 and ext-a (which depends on shared@v2.0) + let mut externals = BTreeMap::new(); + externals.insert( + "shared".into(), + crate::model::ExternalProject { + git: Some("https://github.com/org/shared".into()), + path: None, + git_ref: Some("v1.0".into()), + prefix: "shared".into(), + }, + ); + externals.insert( + "ext-a".into(), + crate::model::ExternalProject { + git: None, + path: Some(ext_dir.to_str().unwrap().into()), + git_ref: None, + prefix: "ext-a".into(), + }, + ); + + let conflicts = detect_version_conflicts(&externals, "myproject", dir.path()); + assert_eq!(conflicts.len(), 1); + assert_eq!( + conflicts[0].repo_identifier, + "https://github.com/org/shared" + ); + assert_eq!(conflicts[0].versions.len(), 2); + } + + #[test] + fn baseline_status_is_present() { + let present = BaselineStatus::Present { + commit: "abc123".into(), + }; + let missing = BaselineStatus::Missing; + assert!(present.is_present()); + assert!(!missing.is_present()); + } + + #[test] + #[serial] + fn check_baseline_tag_in_git_repo() { + let dir = tempfile::tempdir().unwrap(); + // Init a git repo with a baseline tag + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "tag.forceSignAnnotated", "false"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "tag.gpgSign", "false"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::fs::write(dir.path().join("file.txt"), "hello").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["tag", "baseline/v1.0"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let status = check_baseline_tag(dir.path(), "v1.0").unwrap(); + assert!(status.is_present()); + + let missing = check_baseline_tag(dir.path(), "v2.0").unwrap(); + assert!(!missing.is_present()); + } + + #[test] + #[serial] + fn list_baseline_tags_finds_tags() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "tag.forceSignAnnotated", "false"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "tag.gpgSign", "false"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::fs::write(dir.path().join("file.txt"), "hello").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["tag", "baseline/v1.0"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["tag", "baseline/v2.0"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let tags = list_baseline_tags(dir.path()).unwrap(); + assert!(tags.contains(&"v1.0".to_string())); + assert!(tags.contains(&"v2.0".to_string())); + } + + #[test] + fn no_conflict_when_same_version() { + let dir = tempfile::tempdir().unwrap(); + + let ext_dir = dir.path().join("ext-a"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write( + ext_dir.join("rivet.yaml"), + r#"project: + name: ext-a + version: "0.1.0" + schemas: [common] +sources: [] +externals: + shared: + git: https://github.com/org/shared + ref: v1.0 + prefix: shared +"#, + ) + .unwrap(); + + let mut externals = BTreeMap::new(); + externals.insert( + "shared".into(), + crate::model::ExternalProject { + git: Some("https://github.com/org/shared".into()), + path: None, + git_ref: Some("v1.0".into()), + prefix: "shared".into(), + }, + ); + externals.insert( + "ext-a".into(), + crate::model::ExternalProject { + git: None, + path: Some(ext_dir.to_str().unwrap().into()), + git_ref: None, + prefix: "ext-a".into(), + }, + ); + + let conflicts = detect_version_conflicts(&externals, "myproject", dir.path()); + assert!(conflicts.is_empty()); + } +} diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 31b7cec..168c442 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -5,7 +5,9 @@ pub mod diff; pub mod document; pub mod embedded; pub mod error; +pub mod externals; pub mod formats; +pub mod lifecycle; pub mod links; pub mod matrix; pub mod model; diff --git a/rivet-core/src/lifecycle.rs b/rivet-core/src/lifecycle.rs new file mode 100644 index 0000000..5a05e35 --- /dev/null +++ b/rivet-core/src/lifecycle.rs @@ -0,0 +1,218 @@ +use std::collections::{BTreeMap, HashSet}; + +use crate::model::Artifact; + +/// Expected downstream artifact types for lifecycle completeness. +/// Maps artifact_type -> list of expected downstream types that should trace to it. +fn expected_downstream() -> BTreeMap<&'static str, Vec<&'static str>> { + let mut m = BTreeMap::new(); + // Requirements should be traced by architecture, features, or design decisions + m.insert( + "requirement", + vec!["feature", "aadl-component", "design-decision"], + ); + // Features should be traced by design decisions or architecture + m.insert("feature", vec!["design-decision", "aadl-component"]); + // Design decisions should have implementing features or architecture + // (These are leaf nodes in many cases, so less strict) + m +} + +/// A gap in the lifecycle traceability chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LifecycleGap { + /// The artifact with incomplete coverage. + pub artifact_id: String, + pub artifact_type: String, + pub artifact_status: Option, + /// What's missing. + pub missing: Vec, +} + +/// Check lifecycle completeness for artifacts. +/// +/// For each requirement/feature that has a "done" or "implemented" status, +/// verify that downstream artifacts exist and link back to it. +/// Reports gaps where the traceability chain is incomplete. +pub fn check_lifecycle_completeness(artifacts: &[Artifact]) -> Vec { + let downstream_rules = expected_downstream(); + + // Build a reverse-link index: target_id -> set of (source_id, source_type) + let mut linked_by: BTreeMap> = BTreeMap::new(); + for a in artifacts { + for link in &a.links { + linked_by + .entry(link.target.clone()) + .or_default() + .push((&a.id, &a.artifact_type)); + } + } + + // Statuses that imply "this should be fully traced" + let traced_statuses: HashSet<&str> = + ["implemented", "done", "approved", "accepted", "verified"] + .iter() + .copied() + .collect(); + + let mut gaps = Vec::new(); + + for artifact in artifacts { + // Only check artifacts that have downstream expectations + let expected = match downstream_rules.get(artifact.artifact_type.as_str()) { + Some(e) => e, + None => continue, + }; + + // Only check artifacts with "done"-like status + let status = match &artifact.status { + Some(s) => s.as_str(), + None => continue, + }; + if !traced_statuses.contains(status) { + continue; + } + + // Check what actually links to this artifact + let linkers = linked_by.get(&artifact.id); + let linker_types: HashSet<&str> = linkers + .map(|v| v.iter().map(|(_, t)| *t).collect()) + .unwrap_or_default(); + + // Find missing downstream types + let missing: Vec = expected + .iter() + .filter(|&&t| !linker_types.contains(t)) + .map(|t| t.to_string()) + .collect(); + + // Report if any expected downstream types are missing + if !missing.is_empty() { + let has_any_downstream = !linker_types.is_empty(); + + gaps.push(LifecycleGap { + artifact_id: artifact.id.clone(), + artifact_type: artifact.artifact_type.clone(), + artifact_status: artifact.status.clone(), + missing: if has_any_downstream { + // Only report truly missing types + missing + } else { + // No downstream at all — report everything + vec!["no downstream artifacts found".into()] + }, + }); + } + } + + gaps +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Artifact, Link}; + + fn make_artifact( + id: &str, + atype: &str, + status: Option<&str>, + links: Vec<(&str, &str)>, + ) -> Artifact { + Artifact { + id: id.into(), + artifact_type: atype.into(), + title: format!("Test {id}"), + description: None, + status: status.map(|s| s.into()), + tags: vec![], + links: links + .into_iter() + .map(|(lt, t)| Link { + link_type: lt.into(), + target: t.into(), + }) + .collect(), + fields: BTreeMap::new(), + source_file: None, + } + } + + #[test] + fn implemented_req_without_downstream_reports_gap() { + let artifacts = vec![make_artifact( + "REQ-001", + "requirement", + Some("implemented"), + vec![], + )]; + let gaps = check_lifecycle_completeness(&artifacts); + assert_eq!(gaps.len(), 1); + assert_eq!(gaps[0].artifact_id, "REQ-001"); + } + + #[test] + fn implemented_req_with_feature_has_partial_coverage() { + let artifacts = vec![ + make_artifact("REQ-001", "requirement", Some("implemented"), vec![]), + make_artifact( + "FEAT-001", + "feature", + Some("done"), + vec![("satisfies", "REQ-001")], + ), + ]; + let gaps = check_lifecycle_completeness(&artifacts); + // REQ-001 has a feature but no architecture or design-decision → partial gap + // FEAT-001 has status "done" but no design-decision or aadl-component → gap too + assert_eq!(gaps.len(), 2); + let req_gap = gaps.iter().find(|g| g.artifact_id == "REQ-001").unwrap(); + assert!( + req_gap + .missing + .iter() + .any(|m| m.contains("aadl-component") || m.contains("design-decision")) + ); + let feat_gap = gaps.iter().find(|g| g.artifact_id == "FEAT-001").unwrap(); + assert!( + feat_gap + .missing + .iter() + .any(|m| m.contains("no downstream artifacts found")) + ); + } + + #[test] + fn draft_req_not_checked() { + let artifacts = vec![make_artifact( + "REQ-001", + "requirement", + Some("draft"), + vec![], + )]; + let gaps = check_lifecycle_completeness(&artifacts); + assert!(gaps.is_empty()); // draft status not checked + } + + #[test] + fn fully_covered_req_no_gap() { + let artifacts = vec![ + make_artifact("REQ-001", "requirement", Some("implemented"), vec![]), + make_artifact("FEAT-001", "feature", None, vec![("satisfies", "REQ-001")]), + make_artifact( + "DD-001", + "design-decision", + None, + vec![("satisfies", "REQ-001")], + ), + make_artifact( + "ARCH-001", + "aadl-component", + None, + vec![("allocated-from", "REQ-001")], + ), + ]; + let gaps = check_lifecycle_completeness(&artifacts); + assert!(gaps.is_empty()); // all expected downstream types present + } +} diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index 3841620..3411e87 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -101,6 +101,22 @@ fn default_skip_trailer() -> String { "Trace: skip".into() } +/// Configuration for a single external project dependency. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalProject { + /// Git clone URL (mutually exclusive with `path`). + #[serde(default)] + pub git: Option, + /// Local filesystem path (mutually exclusive with `git`). + #[serde(default)] + pub path: Option, + /// Git ref to checkout (branch, tag, or commit SHA). + #[serde(default, rename = "ref")] + pub git_ref: Option, + /// Short prefix used in cross-links (e.g., "rivet" for "rivet:REQ-001"). + pub prefix: String, +} + /// Project configuration loaded from `rivet.yaml`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectConfig { @@ -116,6 +132,9 @@ pub struct ProjectConfig { /// Commit traceability configuration. #[serde(default)] pub commits: Option, + /// External project dependencies for cross-repo linking. + #[serde(default)] + pub externals: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rivet-core/tests/externals_config.rs b/rivet-core/tests/externals_config.rs new file mode 100644 index 0000000..5b91338 --- /dev/null +++ b/rivet-core/tests/externals_config.rs @@ -0,0 +1,49 @@ +use rivet_core::model::ProjectConfig; + +#[test] +fn externals_parsed_from_yaml() { + let yaml = r#" +project: + name: test + version: "0.1.0" + schemas: [common, dev] +sources: [] +externals: + rivet: + git: https://github.com/pulseengine/rivet + ref: main + prefix: rivet + meld: + path: ../meld + prefix: meld +"#; + let config: ProjectConfig = serde_yaml::from_str(yaml).unwrap(); + let ext = config.externals.as_ref().unwrap(); + assert_eq!(ext.len(), 2); + + let rivet = &ext["rivet"]; + assert_eq!( + rivet.git.as_deref(), + Some("https://github.com/pulseengine/rivet") + ); + assert_eq!(rivet.git_ref.as_deref(), Some("main")); + assert_eq!(rivet.prefix, "rivet"); + + let meld = &ext["meld"]; + assert_eq!(meld.path.as_deref(), Some("../meld")); + assert!(meld.git.is_none()); + assert_eq!(meld.prefix, "meld"); +} + +#[test] +fn no_externals_is_none() { + let yaml = r#" +project: + name: test + version: "0.1.0" + schemas: [common] +sources: [] +"#; + let config: ProjectConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.externals.is_none()); +}