diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e0292c..eb4ecef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v7.0.1 - uses: dtolnay/rust-toolchain@v1 with: - toolchain: v1 + toolchain: stable components: rustfmt, clippy - uses: actions/cache@v6.1.0 with: diff --git a/services/seamstressd/.formatter.exs b/services/seamstressd/.formatter.exs new file mode 100644 index 0000000..f0a8a2c --- /dev/null +++ b/services/seamstressd/.formatter.exs @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/services/seamstressd/lib/seamstressd/runner.ex b/services/seamstressd/lib/seamstressd/runner.ex index 3ccdc42..d0101bd 100644 --- a/services/seamstressd/lib/seamstressd/runner.ex +++ b/services/seamstressd/lib/seamstressd/runner.ex @@ -6,10 +6,21 @@ defmodule Seamstressd.Runner do """ def validate!(root) do + repo_root = Path.expand("../../../..", __DIR__) cmd = "cargo" - args = ["run", "--quiet", "--manifest-path", "tools/seamctl/Cargo.toml", "--", "validate", "--root", root] - {out, code} = System.cmd(cmd, args, stderr_to_stdout: true) + args = [ + "run", + "--quiet", + "--manifest-path", + "tools/seamctl/Cargo.toml", + "--", + "validate", + "--root", + root + ] + + {out, code} = System.cmd(cmd, args, cd: repo_root, stderr_to_stdout: true) IO.write(out) diff --git a/services/seamstressd/mix.exs b/services/seamstressd/mix.exs index a3e42c8..6564f6e 100644 --- a/services/seamstressd/mix.exs +++ b/services/seamstressd/mix.exs @@ -27,7 +27,7 @@ defmodule Seamstressd.MixProject do defp aliases do [ - "seam.validate": ["run -e 'Seamstressd.Runner.validate!(".")'"] + "seam.validate": ["run -e 'Seamstressd.Runner.validate!(\".\")'"] ] end end diff --git a/services/seamstressd/test/e2e_test.exs b/services/seamstressd/test/e2e_test.exs index bd8a830..afc015e 100644 --- a/services/seamstressd/test/e2e_test.exs +++ b/services/seamstressd/test/e2e_test.exs @@ -16,7 +16,12 @@ defmodule Seamstressd.E2ETest do # Create a minimal temporary workspace that mimics a real seamstress repo. # Returns {:ok, root_path} where root_path contains seams/records/. defp tmp_workspace(label) do - root = Path.join(System.tmp_dir!(), "seamstress_e2e_#{label}_#{System.unique_integer([:positive])}") + root = + Path.join( + System.tmp_dir!(), + "seamstress_e2e_#{label}_#{System.unique_integer([:positive])}" + ) + seams_dir = Path.join([root, "seams", "records"]) schema_dir = Path.join([root, "seams", "schema"]) File.mkdir_p!(seams_dir) @@ -26,12 +31,8 @@ defmodule Seamstressd.E2ETest do # Write a placeholder schema so seamctl has something to compile against. defp write_schema(root) do - schema = %{ - "$schema" => "http://json-schema.org/draft-07/schema#", - "type" => "object" - } path = Path.join([root, "seams", "schema", "seam-record.schema.json"]) - File.write!(path, Jason.encode!(schema)) + File.write!(path, ~s({"$schema":"http://json-schema.org/draft-07/schema#","type":"object"})) path end diff --git a/services/seamstressd/test/property_test.exs b/services/seamstressd/test/property_test.exs index 6e348eb..f7c33da 100644 --- a/services/seamstressd/test/property_test.exs +++ b/services/seamstressd/test/property_test.exs @@ -29,7 +29,12 @@ defmodule Seamstressd.PropertyTest do # Generate a stream of empty temporary directories (exist, but no records). defp empty_temp_dirs(count) do for i <- 1..count do - path = Path.join(System.tmp_dir!(), "seamstress_prop_empty_#{i}_#{System.unique_integer([:positive])}") + path = + Path.join( + System.tmp_dir!(), + "seamstress_prop_empty_#{i}_#{System.unique_integer([:positive])}" + ) + File.mkdir_p!(path) path end @@ -38,7 +43,12 @@ defmodule Seamstressd.PropertyTest do # Generate paths with seams/records/ present but no .seam.json files. defp dirs_with_records_dir_but_no_files(count) do for i <- 1..count do - root = Path.join(System.tmp_dir!(), "seamstress_prop_nofiles_#{i}_#{System.unique_integer([:positive])}") + root = + Path.join( + System.tmp_dir!(), + "seamstress_prop_nofiles_#{i}_#{System.unique_integer([:positive])}" + ) + records = Path.join([root, "seams", "records"]) File.mkdir_p!(records) # Add a decoy file that is NOT a .seam.json @@ -144,6 +154,7 @@ defmodule Seamstressd.PropertyTest do end unique_classes = Enum.uniq(classes) + assert length(unique_classes) == 1, "Expected a single error class across runs, got: #{inspect(unique_classes)}" end diff --git a/services/seamstressd/test/runner_test.exs b/services/seamstressd/test/runner_test.exs index 0e67bd3..5227c14 100644 --- a/services/seamstressd/test/runner_test.exs +++ b/services/seamstressd/test/runner_test.exs @@ -10,87 +10,16 @@ defmodule Seamstressd.RunnerTest do # Helpers # --------------------------------------------------------------------------- - # Build a temporary directory tree containing the minimal file structure - # that seamctl expects (seams/schema/ and seams/records/). Returns the - # root path as a string so tests can call Runner.validate!/1 against it. + # Build a temporary records tree accepted by the repository schema. + # Return its root path so tests can call Runner.validate!/1 against it. defp make_valid_seam_tree(root) do - schema_dir = Path.join([root, "seams", "schema"]) records_dir = Path.join([root, "seams", "records"]) - File.mkdir_p!(schema_dir) File.mkdir_p!(records_dir) - # Minimal JSON Schema accepted by jsonschema Draft-7 - schema = %{ - "$schema" => "http://json-schema.org/draft-07/schema#", - "type" => "object", - "properties" => %{ - "id" => %{"type" => "string"}, - "title" => %{"type" => "string"}, - "status" => %{"type" => "string"}, - "owners" => %{"type" => "array"}, - "side_a" => %{"type" => "string"}, - "side_b" => %{"type" => "string"}, - "boundary_type" => %{"type" => "string"}, - "data_flows" => %{"type" => "array"}, - "contract_artifacts" => %{"type" => "array"}, - "compat_policy" => %{"type" => "object"}, - "semantic_invariants" => %{"type" => "array"}, - "security_invariants" => %{"type" => "array"}, - "privacy_invariants" => %{"type" => "array"}, - "test_vectors" => %{"type" => "array"}, - "checks" => %{"type" => "object"}, - "slo" => %{"type" => "object"}, - "failure_behavior" => %{"type" => "object"}, - "observability" => %{"type" => "object"}, - "change_process" => %{"type" => "object"}, - "rollout_backout" => %{"type" => "object"} - }, - "required" => ["id", "title", "status", "owners", "side_a", "side_b", - "boundary_type", "data_flows", "contract_artifacts", - "compat_policy", "semantic_invariants", "security_invariants", - "privacy_invariants", "test_vectors", "checks", "slo", - "failure_behavior", "observability", "change_process", - "rollout_backout"] - } - - schema_path = Path.join(schema_dir, "seam-record.schema.json") - File.write!(schema_path, Jason.encode!(schema)) - - # Minimal valid seam record - record = %{ - "id" => "seam-001", - "title" => "Test Seam", - "status" => "active", - "owners" => [%{"name" => "Test Owner", "contact" => "test@example.com", "role" => "owner"}], - "side_a" => "service-a", - "side_b" => "service-b", - "boundary_type" => "api", - "data_flows" => [%{"name" => "request", "direction" => "a->b", "description" => "HTTP request"}], - "contract_artifacts" => [], - "compat_policy" => %{"strategy" => "semver", "rules" => ["no breaking changes"], "deprecation_window_days" => 30}, - "semantic_invariants" => ["idempotent"], - "security_invariants" => ["authenticated"], - "privacy_invariants" => ["no PII in logs"], - "test_vectors" => [], - "checks" => %{ - "conformance" => %{"status" => "pass", "notes" => ""}, - "no_hidden_channels" => %{"status" => "pass", "notes" => ""}, - "evolution" => %{"status" => "pass", "notes" => ""} - }, - "slo" => %{"latency_ms_p95" => 100, "error_rate_max" => 0.01}, - "failure_behavior" => %{"timeouts" => "5s", "retries" => "3", "backpressure" => "drop", "idempotency" => "yes"}, - "observability" => %{ - "metrics" => ["requests_total"], - "logs" => %{"required_fields" => ["trace_id"]}, - "tracing" => %{"propagation" => ["W3C"]} - }, - "change_process" => %{"required_reviewers" => ["owner"], "gates" => ["ci-pass"]}, - "rollout_backout" => %{"rollout_steps" => ["deploy"], "backout_steps" => ["rollback"]} - } - - record_path = Path.join(records_dir, "seam-001.seam.json") - File.write!(record_path, Jason.encode!(record)) - + # Use the example accepted by seamctl's repository schema, including + # its self-referencing contract artifact. + example = Path.expand("../../../seams/records/_example.seam.json", __DIR__) + File.cp!(example, Path.join(records_dir, "_example.seam.json")) root end @@ -109,7 +38,9 @@ defmodule Seamstressd.RunnerTest do # but the important thing is the function accepts a string argument. # We capture the raise rather than letting it crash the test. assert_raise RuntimeError, fn -> - Seamstressd.Runner.validate!("/tmp/seamstress_nonexistent_#{System.unique_integer([:positive])}") + Seamstressd.Runner.validate!( + "/tmp/seamstress_nonexistent_#{System.unique_integer([:positive])}" + ) end end @@ -169,7 +100,9 @@ defmodule Seamstressd.RunnerTest do end test "validate!/1 rejects path with only non-seam files" do - root = Path.join(System.tmp_dir!(), "seamstress_norecords_#{System.unique_integer([:positive])}") + root = + Path.join(System.tmp_dir!(), "seamstress_norecords_#{System.unique_integer([:positive])}") + File.mkdir_p!(Path.join([root, "seams", "records"])) File.write!(Path.join([root, "seams", "records", "README.txt"]), "not a seam file") diff --git a/tools/seamctl/src/graph.rs b/tools/seamctl/src/graph.rs index 7e3639b..7121796 100644 --- a/tools/seamctl/src/graph.rs +++ b/tools/seamctl/src/graph.rs @@ -21,7 +21,8 @@ fn discover(root: &Utf8PathBuf) -> Vec { continue; } let s = p.to_string_lossy(); - let is_candidate_dir = s.contains("/seams/records/") || (s.contains("/targets/") && s.contains("/seams/records/")); + let is_candidate_dir = s.contains("/seams/records/") + || (s.contains("/targets/") && s.contains("/seams/records/")); if !is_candidate_dir { continue; } @@ -50,18 +51,25 @@ pub fn run(root: Option<&str>, format: &str, out: Option<&str>) -> Result<()> { let raw = std::fs::read_to_string(&f).with_context(|| format!("read {f}"))?; let rec: SeamRecord = serde_json::from_str(&raw).with_context(|| format!("decode {f}"))?; - let a_idx = *node_map.entry(rec.side_a.clone()).or_insert_with(|| g.add_node(rec.side_a.clone())); - let b_idx = *node_map.entry(rec.side_b.clone()).or_insert_with(|| g.add_node(rec.side_b.clone())); + let a_idx = *node_map + .entry(rec.side_a.clone()) + .or_insert_with(|| g.add_node(rec.side_a.clone())); + let b_idx = *node_map + .entry(rec.side_b.clone()) + .or_insert_with(|| g.add_node(rec.side_b.clone())); g.add_edge(a_idx, b_idx, rec.id.clone()); seams.push(rec.id); } let components: Vec<&String> = g.node_weights().collect(); - let edges: Vec = g.edge_indices().map(|ei| { - let (a, b) = g.edge_endpoints(ei).unwrap(); - let id = &g[ei]; - json!({"from": g[a], "to": g[b], "seam": id}) - }).collect(); + let edges: Vec = g + .edge_indices() + .map(|ei| { + let (a, b) = g.edge_endpoints(ei).unwrap(); + let id = &g[ei]; + json!({"from": g[a], "to": g[b], "seam": id}) + }) + .collect(); let payload = json!({ "components": components, @@ -76,7 +84,10 @@ pub fn run(root: Option<&str>, format: &str, out: Option<&str>) -> Result<()> { for ei in g.edge_indices() { let (a, b) = g.edge_endpoints(ei).unwrap(); let id = &g[ei]; - s.push_str(&format!(" \"{}\" -> \"{}\" [label=\"{}\"];\n", g[a], g[b], id)); + s.push_str(&format!( + " \"{}\" -> \"{}\" [label=\"{}\"];\n", + g[a], g[b], id + )); } s.push_str("}\n"); s diff --git a/tools/seamctl/src/main.rs b/tools/seamctl/src/main.rs index bfcce37..780424b 100644 --- a/tools/seamctl/src/main.rs +++ b/tools/seamctl/src/main.rs @@ -1,13 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -mod cli; -mod graph; -mod model; -mod report; -mod validate; - use anyhow::Result; use clap::Parser; +use seamctl::cli; fn main() { if let Err(e) = run() { diff --git a/tools/seamctl/src/report.rs b/tools/seamctl/src/report.rs index e4cd2b4..834c8dc 100644 --- a/tools/seamctl/src/report.rs +++ b/tools/seamctl/src/report.rs @@ -22,7 +22,8 @@ fn discover(root: &Utf8PathBuf) -> Vec { continue; } let s = p.to_string_lossy(); - let is_candidate_dir = s.contains("/seams/records/") || (s.contains("/targets/") && s.contains("/seams/records/")); + let is_candidate_dir = s.contains("/seams/records/") + || (s.contains("/targets/") && s.contains("/seams/records/")); if !is_candidate_dir { continue; } @@ -44,7 +45,9 @@ fn risk_flags(rec: &SeamRecord) -> Vec { if rec.checks.no_hidden_channels.status.to_lowercase() != "done" { flags.push("no_hidden_channels_not_done".into()); } - if rec.failure_behavior.backpressure.trim().is_empty() || rec.failure_behavior.backpressure == "TODO" { + if rec.failure_behavior.backpressure.trim().is_empty() + || rec.failure_behavior.backpressure == "TODO" + { flags.push("backpressure_unspecified".into()); } if rec.observability.metrics.is_empty() { diff --git a/tools/seamctl/src/validate.rs b/tools/seamctl/src/validate.rs index 1c59965..40b31a6 100644 --- a/tools/seamctl/src/validate.rs +++ b/tools/seamctl/src/validate.rs @@ -25,7 +25,8 @@ fn discover_seam_files(root: &Utf8PathBuf) -> Vec { } let s = p.to_string_lossy(); // Search common roots; keep simple and robust in v0. - let is_candidate_dir = s.contains("/seams/records/") || s.contains("/targets/") && s.contains("/seams/records/"); + let is_candidate_dir = s.contains("/seams/records/") + || s.contains("/targets/") && s.contains("/seams/records/"); if !is_candidate_dir { continue; } @@ -53,7 +54,11 @@ fn compile_schema(schema_path: &Utf8PathBuf) -> Result { .map_err(|e| anyhow!("schema compile failed: {e}")) } -fn policy_checks(record: &SeamRecord, record_path: &Utf8PathBuf, root: &Utf8PathBuf) -> Vec { +fn policy_checks( + record: &SeamRecord, + record_path: &Utf8PathBuf, + root: &Utf8PathBuf, +) -> Vec { let mut errs = vec![]; if record.owners.is_empty() { errs.push("owners must be non-empty".into()); @@ -67,7 +72,10 @@ fn policy_checks(record: &SeamRecord, record_path: &Utf8PathBuf, root: &Utf8Path // Ensure checklist statuses exist and are non-empty for (name, st) in [ ("checks.conformance", &record.checks.conformance.status), - ("checks.no_hidden_channels", &record.checks.no_hidden_channels.status), + ( + "checks.no_hidden_channels", + &record.checks.no_hidden_channels.status, + ), ("checks.evolution", &record.checks.evolution.status), ] { if st.trim().is_empty() { @@ -79,13 +87,19 @@ fn policy_checks(record: &SeamRecord, record_path: &Utf8PathBuf, root: &Utf8Path for a in &record.contract_artifacts { let p = root.join(&a.path); if !p.exists() { - errs.push(format!("missing contract_artifact path: {} (from {})", a.path, record_path)); + errs.push(format!( + "missing contract_artifact path: {} (from {})", + a.path, record_path + )); } } for tv in &record.test_vectors { let p = root.join(tv); if !p.exists() { - errs.push(format!("missing test_vectors path: {} (from {})", tv, record_path)); + errs.push(format!( + "missing test_vectors path: {} (from {})", + tv, record_path + )); } } @@ -94,13 +108,17 @@ fn policy_checks(record: &SeamRecord, record_path: &Utf8PathBuf, root: &Utf8Path pub fn run(root: Option<&str>, schema: Option<&str>) -> Result<()> { let root = root.map(Utf8PathBuf::from).unwrap_or_else(default_root); - let schema_path = schema.map(Utf8PathBuf::from).unwrap_or_else(default_schema_path); + let schema_path = schema + .map(Utf8PathBuf::from) + .unwrap_or_else(default_schema_path); let compiled = compile_schema(&schema_path)?; let seam_files = discover_seam_files(&root); if seam_files.is_empty() { - return Err(anyhow!("no *.seam.json files found under seams/records or targets/**/seams/records")); + return Err(anyhow!( + "no *.seam.json files found under seams/records or targets/**/seams/records" + )); } let mut schema_errors = vec![]; @@ -115,7 +133,8 @@ pub fn run(root: Option<&str>, schema: Option<&str>) -> Result<()> { continue; } // Deserialize to run policy checks - let rec: SeamRecord = serde_json::from_value(v).with_context(|| format!("decode seam record {f}"))?; + let rec: SeamRecord = + serde_json::from_value(v).with_context(|| format!("decode seam record {f}"))?; for pe in policy_checks(&rec, &f, &root) { policy_errors.push(format!("{f}: {pe}")); } diff --git a/tools/seamctl/tests/seamctl_test.rs b/tools/seamctl/tests/seamctl_test.rs index 551aed7..f559f8e 100644 --- a/tools/seamctl/tests/seamctl_test.rs +++ b/tools/seamctl/tests/seamctl_test.rs @@ -24,10 +24,13 @@ use std::path::{Path, PathBuf}; /// Returns a fresh temporary directory unique to this test invocation. fn tmp_dir(label: &str) -> PathBuf { let base = std::env::temp_dir(); - let name = format!("seamctl_test_{label}_{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos()); + let name = format!( + "seamctl_test_{label}_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); let path = base.join(name); fs::create_dir_all(&path).expect("failed to create temp dir"); path @@ -89,7 +92,7 @@ fn write_valid_record(dir: &Path, id: &str) { } /// Write the minimal permissive JSON Schema that seamctl compiles. -fn write_permissive_schema(dir: &Path) { +fn write_permissive_schema(dir: &Path) -> PathBuf { let schema_dir = dir.join("seams").join("schema"); fs::create_dir_all(&schema_dir).unwrap(); @@ -99,7 +102,8 @@ fn write_permissive_schema(dir: &Path) { }); let path = schema_dir.join("seam-record.schema.json"); - fs::write(path, serde_json::to_string(&schema).unwrap()).unwrap(); + fs::write(&path, serde_json::to_string(&schema).unwrap()).unwrap(); + path } // --------------------------------------------------------------------------- @@ -123,8 +127,8 @@ mod validate_tests { #[test] fn schema_only_no_records_returns_error() { let root = tmp_dir("validate_schema_only"); - write_permissive_schema(&root); - let result = validate::run(Some(root.to_str().unwrap()), None); + let schema = write_permissive_schema(&root); + let result = validate::run(Some(root.to_str().unwrap()), Some(schema.to_str().unwrap())); assert!(result.is_err(), "expected Err when no seam records exist"); } @@ -132,10 +136,10 @@ mod validate_tests { #[test] fn valid_record_passes_validation() { let root = tmp_dir("validate_valid"); - write_permissive_schema(&root); + let schema = write_permissive_schema(&root); write_valid_record(&root, "seam-valid-001"); - let result = validate::run(Some(root.to_str().unwrap()), None); + let result = validate::run(Some(root.to_str().unwrap()), Some(schema.to_str().unwrap())); assert!(result.is_ok(), "expected Ok for valid record: {result:?}"); } @@ -150,26 +154,29 @@ mod validate_tests { #[test] fn multiple_valid_records_pass() { let root = tmp_dir("validate_multi"); - write_permissive_schema(&root); + let schema = write_permissive_schema(&root); write_valid_record(&root, "seam-multi-001"); write_valid_record(&root, "seam-multi-002"); write_valid_record(&root, "seam-multi-003"); - let result = validate::run(Some(root.to_str().unwrap()), None); - assert!(result.is_ok(), "expected Ok for multiple valid records: {result:?}"); + let result = validate::run(Some(root.to_str().unwrap()), Some(schema.to_str().unwrap())); + assert!( + result.is_ok(), + "expected Ok for multiple valid records: {result:?}" + ); } /// A record with invalid JSON should cause a parse error. #[test] fn malformed_json_returns_error() { let root = tmp_dir("validate_malformed"); - write_permissive_schema(&root); + let schema = write_permissive_schema(&root); let records_dir = root.join("seams").join("records"); fs::create_dir_all(&records_dir).unwrap(); fs::write(records_dir.join("bad.seam.json"), b"{ not valid json }").unwrap(); - let result = validate::run(Some(root.to_str().unwrap()), None); + let result = validate::run(Some(root.to_str().unwrap()), Some(schema.to_str().unwrap())); assert!(result.is_err(), "expected Err for malformed JSON"); } } @@ -210,7 +217,10 @@ mod graph_tests { let content = fs::read_to_string(&out_path).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert!(parsed.get("edges").is_some(), "JSON output must have 'edges' key"); + assert!( + parsed.get("edges").is_some(), + "JSON output must have 'edges' key" + ); } /// Unsupported format must return an error. @@ -255,8 +265,14 @@ mod report_tests { assert!(out_path.exists(), "report file must be created"); let content = fs::read_to_string(&out_path).unwrap(); - assert!(content.contains("= Seam Report"), "report must start with AsciiDoc title"); - assert!(content.contains("seam-report-001"), "report must include record ID"); + assert!( + content.contains("= Seam Report"), + "report must start with AsciiDoc title" + ); + assert!( + content.contains("seam-report-001"), + "report must include record ID" + ); } } @@ -266,7 +282,7 @@ mod report_tests { #[cfg(test)] mod model_tests { - use seamctl::model::{SeamRecord, Owner}; + use seamctl::model::{Owner, SeamRecord}; /// A fully specified record JSON must deserialise without error. #[test] @@ -315,7 +331,10 @@ mod model_tests { }); let record: Result = serde_json::from_value(json); - assert!(record.is_ok(), "expected successful deserialisation: {record:?}"); + assert!( + record.is_ok(), + "expected successful deserialisation: {record:?}" + ); let r = record.unwrap(); assert_eq!(r.id, "seam-model-001");