diff --git a/Cargo.lock b/Cargo.lock index 224464d..b9d6265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -719,6 +725,15 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -946,6 +961,15 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1087,7 +1111,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "bitflags", + "bitflags 2.11.0", "debugid", "rustc-hash 2.1.1", "serde", @@ -1750,6 +1774,32 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lsp-server" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6ada348dbc2703cbe7637b2dda05cff84d3da2819c24abcb305dd613e0ba2e" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "mach2" version = "0.4.3" @@ -1894,7 +1944,7 @@ version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2099,7 +2149,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", @@ -2116,7 +2166,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "bitflags", + "bitflags 2.11.0", "memchr", "pulldown-cmark-escape", "unicase", @@ -2291,7 +2341,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2412,6 +2462,8 @@ dependencies = [ "env_logger", "etch", "log", + "lsp-server", + "lsp-types", "petgraph 0.7.1", "rivet-core", "serde", @@ -2490,7 +2542,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2503,7 +2555,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2665,7 +2717,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2746,6 +2798,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -3009,7 +3072,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3030,7 +3093,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cap-fs-ext", "cap-std", "fd-lock", @@ -3262,7 +3325,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -3604,7 +3667,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -3617,7 +3680,7 @@ version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ - "bitflags", + "bitflags 2.11.0", "indexmap", "semver", ] @@ -3641,7 +3704,7 @@ checksum = "39bef52be4fb4c5b47d36f847172e896bc94b35c9c6a6f07117686bd16ed89a7" dependencies = [ "addr2line", "async-trait", - "bitflags", + "bitflags 2.11.0", "bumpalo", "cc", "cfg-if", @@ -3880,7 +3943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "102d0d70dbfede00e4cc9c24e86df6d32c03bf6f5ad06b5d6c76b0a4a5004c4a" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "heck", "indexmap", "wit-parser", @@ -3893,7 +3956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea938f6f4f11e5ffe6d8b6f34c9a994821db9511c3e9c98e535896f27d06bb92" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.11.0", "bytes", "cap-fs-ext", "cap-net-ext", @@ -3976,7 +4039,7 @@ version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dca2bf96d20f0c70e6741cc6c8c1a9ee4c3c0310c7ad1971242628c083cc9a5" dependencies = [ - "bitflags", + "bitflags 2.11.0", "thiserror 2.0.18", "tracing", "wasmtime", @@ -4233,7 +4296,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "windows-sys 0.59.0", ] @@ -4318,7 +4381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/artifacts/decisions.yaml b/artifacts/decisions.yaml index f1647a1..849a949 100644 --- a/artifacts/decisions.yaml +++ b/artifacts/decisions.yaml @@ -12,6 +12,7 @@ artifacts: - type: satisfies target: REQ-006 fields: + baseline: v0.1.0 rationale: > OSLC is an OASIS standard that Polarion, DOORS, and codebeamer already support natively. One adapter handles all tools. Per-tool @@ -41,6 +42,7 @@ artifacts: - type: satisfies target: REQ-004 fields: + baseline: v0.1.0 rationale: > petgraph is a mature, well-tested Rust graph library. It provides efficient algorithms for cycle detection, topological sort, and @@ -66,6 +68,7 @@ artifacts: - type: satisfies target: REQ-003 fields: + baseline: v0.1.0 source-ref: rivet-core/src/schema.rs:1 rationale: > Merging allows a base common schema to be extended by domain-specific @@ -86,6 +89,7 @@ artifacts: - type: satisfies target: REQ-008 fields: + baseline: v0.1.0 rationale: > WIT provides a language-agnostic interface definition. Adapters can be written in any language that compiles to WASM components, not @@ -106,6 +110,7 @@ artifacts: - type: satisfies target: REQ-007 fields: + baseline: v0.1.0 rationale: > axum is already a workspace dependency. HTMX keeps the frontend simple (server-rendered HTML fragments). No JS build toolchain @@ -126,6 +131,7 @@ artifacts: - type: satisfies target: REQ-001 fields: + baseline: v0.1.0 rationale: > Flat structures are easier to navigate and understand. Modern Rust projects favor fewer nesting levels with clear module @@ -148,6 +154,7 @@ artifacts: - type: satisfies target: REQ-009 fields: + baseline: v0.1.0 rationale: > GitHub releases are immutable, versioned, and fetchable via API. Tying test evidence to releases creates append-only per-version @@ -171,6 +178,7 @@ artifacts: - type: satisfies target: REQ-012 fields: + baseline: v0.1.0 rationale: > Edition 2024 brings the latest language features. The CI pipeline mirrors ASPICE SWE.4-6 verification levels: unit tests (proptest, @@ -193,6 +201,7 @@ artifacts: - type: satisfies target: REQ-013 fields: + baseline: v0.1.0 rationale: > Criterion provides statistical analysis, regression detection, and HTML reports. Benchmarks at multiple scales catch O(n^2) @@ -217,6 +226,7 @@ artifacts: - type: satisfies target: REQ-016 fields: + baseline: v0.1.0 rationale: > ASPICE 4.0 broadened "testing" to "verification measures" — the schema should reflect this. Cybersecurity as a separate schema @@ -241,6 +251,7 @@ artifacts: - type: satisfies target: REQ-017 fields: + baseline: v0.2.0-dev rationale: > Git trailers are a well-supported standard, parseable via git log --format='%(trailers)', git interpret-trailers, and @@ -265,6 +276,7 @@ artifacts: - type: satisfies target: REQ-017 fields: + baseline: v0.2.0-dev rationale: > Git is the single source of truth for commit data. Materializing commits to YAML creates a redundant data store that drifts from @@ -291,6 +303,7 @@ artifacts: - type: satisfies target: REQ-019 fields: + baseline: v0.2.0-dev rationale: > Type-based exemption handles the 80% case (dependency bumps, formatting, CI tweaks) with zero friction. The explicit skip diff --git a/artifacts/features.yaml b/artifacts/features.yaml index f258a70..f1e31ce 100644 --- a/artifacts/features.yaml +++ b/artifacts/features.yaml @@ -16,6 +16,7 @@ artifacts: target: DD-031 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-002 type: feature @@ -32,6 +33,7 @@ artifacts: target: DD-032 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-003 type: feature @@ -46,6 +48,7 @@ artifacts: target: REQ-010 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-004 type: feature @@ -62,6 +65,7 @@ artifacts: target: DD-002 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-005 type: feature @@ -77,6 +81,7 @@ artifacts: target: REQ-004 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-006 type: feature @@ -91,6 +96,7 @@ artifacts: target: REQ-004 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-007 type: feature @@ -105,6 +111,7 @@ artifacts: target: REQ-007 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-008 type: feature @@ -121,6 +128,7 @@ artifacts: target: REQ-002 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-009 type: feature @@ -137,6 +145,7 @@ artifacts: target: DD-005 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-010 type: feature @@ -152,6 +161,7 @@ artifacts: target: DD-034 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-011 type: feature @@ -168,6 +178,7 @@ artifacts: target: DD-001 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-012 type: feature @@ -184,6 +195,7 @@ artifacts: target: DD-004 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-013 type: feature @@ -198,6 +210,7 @@ artifacts: target: REQ-014 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-014 type: feature @@ -215,6 +228,7 @@ artifacts: target: REQ-009 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-015 type: feature @@ -232,6 +246,7 @@ artifacts: target: DD-009 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-016 type: feature @@ -253,6 +268,7 @@ artifacts: target: DD-010 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-017 type: feature @@ -272,6 +288,7 @@ artifacts: target: DD-010 fields: phase: phase-1 + baseline: v0.1.0 - id: FEAT-018 type: feature @@ -291,6 +308,7 @@ artifacts: target: DD-033 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-019 type: feature @@ -307,6 +325,7 @@ artifacts: target: REQ-001 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-020 type: feature @@ -324,6 +343,7 @@ artifacts: target: REQ-007 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-021 type: feature @@ -340,6 +360,7 @@ artifacts: target: REQ-010 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-022 type: feature @@ -356,6 +377,7 @@ artifacts: target: REQ-007 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-023 type: feature @@ -373,6 +395,7 @@ artifacts: target: REQ-010 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-024 type: feature @@ -389,6 +412,7 @@ artifacts: target: REQ-007 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-025 type: feature @@ -405,6 +429,7 @@ artifacts: target: REQ-007 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-026 type: feature @@ -422,6 +447,7 @@ artifacts: target: REQ-010 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-027 type: feature @@ -437,6 +463,7 @@ artifacts: target: REQ-007 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-028 type: feature @@ -453,6 +480,7 @@ artifacts: target: REQ-007 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-029 type: feature @@ -472,6 +500,7 @@ artifacts: target: REQ-018 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-030 type: feature @@ -491,6 +520,7 @@ artifacts: target: REQ-019 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-031 type: feature @@ -507,6 +537,7 @@ artifacts: target: REQ-017 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-032 type: feature @@ -526,6 +557,7 @@ artifacts: target: DD-012 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-033 type: feature @@ -636,6 +668,7 @@ artifacts: target: DD-018 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-041 type: feature @@ -655,6 +688,7 @@ artifacts: target: DD-019 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-042 type: feature @@ -674,6 +708,7 @@ artifacts: target: DD-020 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-043 type: feature @@ -694,6 +729,7 @@ artifacts: target: DD-021 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-044 type: feature @@ -714,6 +750,7 @@ artifacts: target: DD-022 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-045 type: feature @@ -733,6 +770,7 @@ artifacts: target: REQ-007 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-057 type: feature @@ -751,6 +789,7 @@ artifacts: target: REQ-007 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-052 type: feature @@ -771,6 +810,7 @@ artifacts: target: DD-028 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-053 type: feature @@ -790,6 +830,7 @@ artifacts: target: DD-028 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-054 type: feature @@ -811,6 +852,7 @@ artifacts: target: DD-028 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-055 type: feature @@ -834,6 +876,7 @@ artifacts: target: DD-028 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-056 type: feature @@ -853,6 +896,7 @@ artifacts: target: REQ-007 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-046 type: feature @@ -875,6 +919,7 @@ artifacts: target: DD-023 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-047 type: feature @@ -897,6 +942,7 @@ artifacts: target: DD-024 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-048 type: feature @@ -920,6 +966,7 @@ artifacts: target: DD-024 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-049 type: feature @@ -939,6 +986,7 @@ artifacts: target: DD-025 fields: phase: phase-3 + baseline: v0.2.0-dev - id: FEAT-050 type: feature @@ -1075,6 +1123,7 @@ artifacts: target: REQ-011 fields: phase: phase-2 + baseline: v0.1.0 - id: FEAT-065 type: feature @@ -1092,3 +1141,4 @@ artifacts: target: REQ-012 fields: phase: phase-2 + baseline: v0.1.0 diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 0a47ac7..e9f80f3 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -10,6 +10,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.1.0 - id: REQ-002 type: requirement @@ -23,6 +24,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.1.0 - id: REQ-003 type: requirement @@ -36,6 +38,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.1.0 - id: REQ-004 type: requirement @@ -49,6 +52,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.1.0 links: - type: satisfies @@ -69,6 +73,7 @@ artifacts: fields: priority: should category: interface + baseline: v0.1.0 links: - type: satisfies @@ -87,6 +92,7 @@ artifacts: fields: priority: should category: interface + baseline: v0.1.0 links: - type: satisfies @@ -105,6 +111,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.1.0 links: - type: satisfies target: SC-18 @@ -120,6 +127,7 @@ artifacts: fields: priority: should category: functional + baseline: v0.1.0 links: - type: satisfies target: SC-16 @@ -136,6 +144,7 @@ artifacts: fields: priority: should category: functional + baseline: v0.1.0 links: - type: satisfies @@ -152,6 +161,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.1.0 links: - type: satisfies @@ -166,6 +176,7 @@ artifacts: fields: priority: must category: constraint + baseline: v0.1.0 - id: REQ-012 type: requirement @@ -179,6 +190,7 @@ artifacts: fields: priority: must category: non-functional + baseline: v0.1.0 - id: REQ-013 type: requirement @@ -192,6 +204,7 @@ artifacts: fields: priority: must category: non-functional + baseline: v0.1.0 - id: REQ-014 type: requirement @@ -206,6 +219,7 @@ artifacts: fields: priority: must category: non-functional + baseline: v0.1.0 - id: REQ-015 type: requirement @@ -223,6 +237,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.1.0 links: - type: satisfies @@ -240,6 +255,7 @@ artifacts: fields: priority: should category: functional + baseline: v0.1.0 links: - type: satisfies @@ -258,6 +274,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.2.0-dev links: - type: satisfies @@ -277,6 +294,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.2.0-dev links: - type: satisfies @@ -294,6 +312,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.2.0-dev links: - type: satisfies @@ -309,6 +328,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.2.0-dev links: - type: satisfies @@ -326,6 +346,7 @@ artifacts: fields: priority: should category: functional + baseline: v0.2.0-dev links: - type: satisfies @@ -341,6 +362,7 @@ artifacts: fields: priority: should category: functional + baseline: v0.2.0-dev - id: REQ-023 type: requirement @@ -362,6 +384,7 @@ artifacts: priority: should category: functional upstream-ref: "eclipse-score/docs-as-code#180" + baseline: v0.2.0-dev - id: REQ-024 type: requirement @@ -377,6 +400,7 @@ artifacts: priority: should category: functional upstream-ref: "eclipse-score/docs-as-code#314, eclipse-score/process_description#535" + baseline: v0.2.0-dev links: - type: satisfies @@ -395,6 +419,7 @@ artifacts: priority: should category: interface upstream-ref: "eclipse-score/score#1695" + baseline: v0.2.0-dev links: - type: satisfies @@ -414,6 +439,7 @@ artifacts: priority: should category: functional upstream-ref: "eclipse-score/score#2521, eclipse-score/score#2619" + baseline: v0.2.0-dev links: - type: satisfies @@ -434,6 +460,7 @@ artifacts: priority: should category: functional upstream-ref: "eclipse-score/reference_integration (known_good.json)" + baseline: v0.2.0-dev links: - type: satisfies @@ -455,6 +482,7 @@ artifacts: fields: priority: must category: non-functional + baseline: v0.2.0-dev - id: REQ-029 type: requirement @@ -475,6 +503,7 @@ artifacts: fields: priority: must category: non-functional + baseline: v0.2.0-dev - id: REQ-031 type: requirement @@ -499,6 +528,7 @@ artifacts: fields: priority: must category: functional + baseline: v0.2.0-dev - id: REQ-030 type: requirement @@ -519,6 +549,7 @@ artifacts: fields: priority: should category: non-functional + baseline: v0.2.0-dev - id: REQ-032 type: requirement @@ -530,6 +561,7 @@ artifacts: fields: category: functional priority: should + baseline: v0.2.0-dev - id: REQ-033 type: requirement @@ -541,6 +573,7 @@ artifacts: fields: category: functional priority: should + baseline: v0.2.0-dev - id: REQ-034 type: requirement @@ -552,6 +585,7 @@ artifacts: fields: category: functional priority: must + baseline: v0.2.0-dev links: - type: satisfies target: SC-2 @@ -566,6 +600,7 @@ artifacts: fields: category: functional priority: must + baseline: v0.2.0-dev - id: REQ-036 type: requirement @@ -577,3 +612,4 @@ artifacts: fields: category: functional priority: should + baseline: v0.2.0-dev diff --git a/docs/plans/2026-03-21-baseline-scoped-validation-design.md b/docs/plans/2026-03-21-baseline-scoped-validation-design.md new file mode 100644 index 0000000..c609259 --- /dev/null +++ b/docs/plans/2026-03-21-baseline-scoped-validation-design.md @@ -0,0 +1,542 @@ +# Baseline-Scoped Validation Design + +**Date:** 2026-03-21 +**Status:** Draft +**Artifacts:** [[REQ-021]], [[REQ-024]], [[FEAT-036]], [[FEAT-041]], [[DD-016]], [[DD-019]] + +## Problem + +Rivet currently validates all artifacts as a single flat set. When a project +evolves across releases (v0.1.0, v0.2.0, ...), there is no way to say: + +> "All artifacts committed to baseline v0.1.0 are fully linked and validated. +> Artifacts planned for v0.2.0 may still have gaps -- that is expected." + +Today, `rivet validate` reports warnings for every artifact that lacks +downstream traceability, including artifacts deliberately scoped for a +future release. This makes validation noisy and masks real problems in the +current release scope. + +The existing `phase` field on features (values: phase-1, phase-2, phase-3, +future) is an informal workaround. It lives only on `feature` artifacts, is +not schema-enforced across types, and has no integration with the validation +engine, coverage computation, or export pipeline. + +The existing `rivet baseline verify` command checks for git tag presence +across repos (DD-016) but does not scope validation to the artifacts in that +baseline. + +## Prior Art + +### sphinx-needs + +sphinx-needs stores versioned snapshots of all needs in `needs.json`, keyed +by the documentation version from `conf.py`. Each build appends a new +version entry. Comparison happens post-hoc by diffing two version snapshots. +There is no per-artifact baseline field or selective validation -- all needs +in a build are validated equally. + +Limitations: no concept of "this need ships in v2.0 so skip it for v1.0 +validation." Impact analysis (issue #685) is aspirational, not implemented. + +### Eclipse SCORE + +SCORE's process description (SUP.8, SUP.10) requires configuration baselines +and change request traceability. Their toolchain uses sphinx-needs with a +`known_good.json` manifest for cross-repo pinning. Baselining is at the +repository level (git tags), not at the artifact level. They face the same +gap: no way to scope validation to a release subset. + +### Industry Standard (ASPICE SUP.8 / ISO 26262 Part 8) + +A **configuration baseline** is a formally approved set of configuration items +at a point in time. Baselines are snapshots, not filters -- all items in the +baseline must be consistent and complete. A new baseline is created for each +release milestone. Items not yet in a baseline are "work in progress" and +are excluded from baseline validation. + +## Design + +### Core Concept: Baselines as Named Artifact Sets + +A **baseline** is a named set of artifact IDs representing a release scope. +Each artifact declares which baseline(s) it belongs to via a `baseline` field. +Validation, coverage, and export can then be scoped to a specific baseline. + +Key properties: +- An artifact can belong to multiple baselines (e.g., REQ-001 ships in + both v0.1.0 and v0.2.0 because v0.2.0 is a superset). +- Baselines are **cumulative by default**: v0.2.0 includes everything in + v0.1.0 plus new artifacts. This matches how software releases work. +- The baseline name is a free-form string, but convention is semver + (v0.1.0, v0.2.0) or milestone names (mvp, ga). +- Baselines are distinct from git tags but can be cross-referenced: + `rivet baseline verify v0.1.0` checks that the git tag `baseline/v0.1.0` + exists and that all artifacts in the v0.1.0 baseline are present at that + commit. + +### Baseline Configuration in rivet.yaml + +```yaml +project: + name: rivet + version: "0.1.0" + schemas: + - common + - dev + - aadl + - stpa + +baselines: + v0.1.0: + description: "Phase 1+2: core validation, schemas, dashboard, CLI" + # Cumulative: false means only explicitly listed artifacts. + # Cumulative: true (default) means "all artifacts from prior baselines + # plus anything tagged with this baseline." + cumulative: true + # Optional: link to git tag for cross-referencing + git-tag: baseline/v0.1.0 + + v0.2.0: + description: "Phase 3: commit traceability, cross-repo, mutation" + cumulative: true + git-tag: baseline/v0.2.0 +``` + +**Why baselines are in rivet.yaml, not a separate file:** +Baseline definitions are project-level metadata, like schemas and sources. +They evolve with the project and should be version-controlled alongside +the artifact sources. A separate `baselines.yaml` file adds indirection +without benefit. + +### Artifact Field: `baseline` + +Every artifact type gains an optional `baseline` field. This is a common +base field (like `status` and `tags`), not a per-type schema field. + +```yaml +artifacts: + - id: REQ-001 + type: requirement + title: Text-file-first artifact management + status: approved + baseline: v0.1.0 # Ships in v0.1.0 + # ... + + - id: REQ-020 + type: requirement + title: Cross-repository artifact linking + status: approved + baseline: v0.2.0 # Ships in v0.2.0 + # ... +``` + +**Rules:** +- `baseline` is a string (not a list). An artifact belongs to the named + baseline and all subsequent cumulative baselines. +- If `baseline` is omitted, the artifact is "unscoped" -- included in + full validation but excluded from baseline-scoped validation. +- The `baseline` field is optional and defaults to `null`. + +**Why a single string, not a list:** +In a cumulative model, an artifact only needs to declare the *first* +baseline it ships in. If v0.2.0 is cumulative on v0.1.0, then an +artifact with `baseline: v0.1.0` is automatically in v0.2.0. This is +simpler to maintain and matches how release scoping works in practice. +You add an artifact to the current milestone; you do not re-tag it for +every future milestone. + +### Baseline Ordering + +Baselines have an implicit ordering derived from their declaration order +in `rivet.yaml`. The first declared baseline is the earliest; subsequent +baselines are later. This ordering is used for cumulative inclusion: +`v0.2.0` includes all artifacts from `v0.1.0` because v0.1.0 is declared +before v0.2.0. + +```yaml +baselines: + v0.1.0: { ... } # order 0 + v0.2.0: { ... } # order 1 -- includes v0.1.0 artifacts + v1.0.0: { ... } # order 2 -- includes v0.1.0 + v0.2.0 artifacts +``` + +**Alternative considered:** Explicit `includes: [v0.1.0]` chains. Rejected +because it is redundant when baselines are almost always linear progressions. +If non-linear baselines are needed later (e.g., a hotfix branch that +cherry-picks from v0.2.0 but not all of it), an explicit `includes` field +can be added as an opt-in override. + +### Artifact Resolution for a Baseline + +Given baseline `B` at position `P` in the ordered baseline list: + +1. Collect all artifacts where `baseline` is set to `B` or to any baseline + at position `< P` (i.e., earlier baselines). +2. If `B.cumulative` is false, collect only artifacts where `baseline == B`. +3. Artifacts with no `baseline` field are **excluded** from scoped + validation. + +``` +resolve_baseline("v0.2.0", cumulative=true): + return artifacts where baseline in {"v0.1.0", "v0.2.0"} + +resolve_baseline("v0.1.0", cumulative=true): + return artifacts where baseline == "v0.1.0" +``` + +### Validation Scoping + +#### `rivet validate --baseline v0.1.0` + +When `--baseline` is specified: + +1. **Resolve the artifact set** for the named baseline (see above). +2. **Build a scoped Store** containing only those artifacts. +3. **Build the link graph** from the scoped store. Links to artifacts + outside the baseline are treated as **external references** (not + broken links). This is critical: a v0.1.0 artifact may link to a + v0.2.0 artifact via `satisfies`, and that link is valid but the + target is outside the current baseline scope. +4. **Run structural validation** (phases 1-7) against the scoped store. +5. **Run traceability rule checks** only for artifacts in the scoped set. + A requirement in v0.1.0 must have its `satisfies` backlinks from + features **also in v0.1.0** (or earlier baselines). +6. **Run conditional rules** against the scoped set. +7. **Report coverage** as percentage of in-baseline artifacts that satisfy + each traceability rule. + +**Cross-baseline link semantics:** +- A link from an in-baseline artifact to an out-of-baseline artifact is + **valid but not counted for traceability coverage**. It exists in the + full graph but does not satisfy the "must have downstream in this + baseline" requirement. +- A link from an out-of-baseline artifact to an in-baseline artifact is + ignored (the source is not being validated). +- Broken links (target does not exist at all) are still errors regardless + of baseline scope. + +#### `rivet validate` (no baseline flag) + +Unchanged behavior: validates all artifacts, all rules, full store. The +`baseline` field is simply ignored as a regular custom field. + +#### Implementation Approach + +The validation engine does not need to change internally. The scoping +happens at the **store construction** level: + +```rust +/// Build a store containing only artifacts in the given baseline. +pub fn scoped_store(full_store: &Store, baseline: &str, config: &BaselineConfig) -> Store { + let included_baselines = config.resolve_cumulative(baseline); + let mut scoped = Store::new(); + for artifact in full_store.iter() { + if let Some(bl) = artifact.baseline() { + if included_baselines.contains(bl) { + let _ = scoped.insert(artifact.clone()); + } + } + } + scoped +} +``` + +The link graph, validator, coverage engine, and lifecycle checker all +operate on a `Store` -- they do not need to know about baselines. The +only change is building the store with the right subset of artifacts. + +**Cross-baseline link handling** requires a refinement: when building +the scoped `LinkGraph`, links to out-of-scope artifacts should not be +recorded as broken links. This requires a small change to `LinkGraph::build`: + +```rust +/// Build a link graph, treating missing targets that exist in +/// `full_store` as external (not broken). +pub fn build_scoped(scoped: &Store, schema: &Schema, full: &Store) -> LinkGraph { + // ... same as build(), but when a link target is not in `scoped` + // but IS in `full`, record it as an external reference rather + // than a broken link. +} +``` + +### Coverage Scoping + +`rivet coverage --baseline v0.1.0` computes coverage only over artifacts +in the named baseline. The denominator for each traceability rule is the +count of source-type artifacts *in the baseline*, not the full store. + +This lets a project track per-release coverage: +- v0.1.0: 95% coverage (a few draft features not yet linked) +- v0.2.0: 80% coverage (new artifacts being developed) +- Full: 85% coverage (weighted average) + +### Lifecycle Completeness Scoping + +`check_lifecycle_completeness` currently checks all artifacts with +"traced" statuses (approved, implemented, done, accepted, verified). +With baseline scoping: + +- Only artifacts in the baseline are checked. +- A requirement in v0.1.0 needs downstream features **also in v0.1.0**. +- A feature planned for v0.2.0 that satisfies a v0.1.0 requirement does + NOT count as coverage for v0.1.0 baseline validation. + +This is the key insight: **traceability completeness is evaluated within +the baseline boundary**, not across the full artifact set. + +### Export Scoping + +`rivet export --html --baseline v0.1.0` generates a compliance report +containing only artifacts in the named baseline. This produces a +self-contained release evidence package: + +- Index page shows "Baseline: v0.1.0" in the header +- Requirements page lists only v0.1.0 requirements +- Coverage matrix shows only v0.1.0 traceability +- Validation results reflect only v0.1.0 rules +- Links to out-of-baseline artifacts are rendered as + "external" (greyed out, with a note like "ships in v0.2.0") + +The existing `config.js` version switcher (DD-038) naturally complements +baseline-scoped export: each version in the switcher corresponds to a +baseline export. + +### Impact Analysis Integration + +`rivet impact --baseline v0.1.0` restricts impact analysis to artifacts +in the baseline. When computing transitive impact, the graph walk stops +at the baseline boundary -- changes to v0.2.0 artifacts do not propagate +into v0.1.0 impact results. + +### Dashboard Integration + +The dashboard gains a baseline selector (dropdown in the header bar, +similar to the existing search). When a baseline is selected: + +- Artifact list shows only in-baseline artifacts +- Graph view shows only in-baseline nodes (with out-of-baseline + neighbors shown as faded ghost nodes) +- Coverage section shows baseline-scoped coverage +- Validation section shows baseline-scoped results +- Matrix view shows baseline-scoped traceability + +The URL reflects the selection: `/artifacts?baseline=v0.1.0` + +### CLI Interface Changes + +New flags on existing commands: + +``` +rivet validate --baseline v0.1.0 +rivet coverage --baseline v0.1.0 +rivet list --baseline v0.1.0 +rivet stats --baseline v0.1.0 +rivet matrix --baseline v0.1.0 +rivet export --html --baseline v0.1.0 +rivet impact --baseline v0.1.0 +rivet graph --baseline v0.1.0 +``` + +New subcommands on `rivet baseline`: + +``` +rivet baseline verify v0.1.0 # existing: check git tags across repos +rivet baseline list # existing: list git tags +rivet baseline show v0.1.0 # NEW: list artifact IDs in a baseline +rivet baseline status # NEW: summary table of all baselines + # with artifact counts and coverage % +``` + +`rivet baseline status` output: + +``` +Baselines: + v0.1.0 247 artifacts 94.3% coverage 0 errors 2 warnings + v0.2.0 327 artifacts 81.7% coverage 3 errors 8 warnings + (full) 352 artifacts 79.2% coverage 5 errors 12 warnings +``` + +### Migration from `phase` to `baseline` + +Current state: feature artifacts have `fields.phase: phase-1|phase-2|phase-3|future`. +Other artifact types (requirements, decisions) do not have a phase field. + +Migration plan: + +1. **Add `baseline` field to the Artifact model** as an optional base field + (same level as `status`, `tags`). No schema change needed for this -- + it is a structural field. + +2. **Add baselines block to rivet.yaml**: + ```yaml + baselines: + v0.1.0: + description: "Phase 1+2" + v0.2.0: + description: "Phase 3" + ``` + +3. **Derive baseline from phase for features** using a one-time migration + script (or manual update): + - `phase: phase-1` or `phase: phase-2` -> `baseline: v0.1.0` + - `phase: phase-3` -> `baseline: v0.2.0` + - `phase: future` -> no baseline (unscoped) + +4. **Assign baselines to non-feature artifacts**: + - Requirements REQ-001 through REQ-019: `baseline: v0.1.0` + - Requirements REQ-020 through REQ-036: `baseline: v0.2.0` + - Design decisions DD-001 through DD-010: `baseline: v0.1.0` + - Design decisions DD-011 onward: `baseline: v0.2.0` + - STPA artifacts: `baseline: v0.1.0` (all shipped in phase 1) + - Architecture artifacts: `baseline: v0.2.0` + +5. **Keep the `phase` field** on features for backward compatibility, + but deprecate it. The `phase` field becomes informational only; + `baseline` is the authoritative scoping field. + +6. **Update the dev schema** to add `baseline` to the allowed-values + list or remove the phase field entirely in a future release. + +### Relationship to Existing Baseline Features + +| Feature | Current | With this design | +|---------|---------|-----------------| +| `rivet baseline verify` | Checks git tag presence across repos | Also validates artifact completeness within the baseline | +| `rivet baseline list` | Lists git tags | Also shows artifact counts per baseline | +| `rivet impact --since` | Compares current store to a git ref | Can additionally scope to a baseline | +| `rivet export --html` | Exports all artifacts | Can scope to a baseline for release evidence | +| `phase` field | Informal, features only | Superseded by `baseline` on all artifact types | + +### Data Model Changes + +```rust +// model.rs -- add baseline field to Artifact +pub struct Artifact { + pub id: ArtifactId, + pub artifact_type: String, + pub title: String, + pub description: Option, + pub status: Option, + pub tags: Vec, + pub links: Vec, + pub fields: BTreeMap, + pub source_file: Option, + pub baseline: Option, // NEW +} + +// model.rs -- add baseline config to ProjectConfig +pub struct ProjectConfig { + pub project: ProjectMetadata, + pub sources: Vec, + pub docs: Vec, + pub results: Option, + pub commits: Option, + pub externals: Option>, + pub baselines: Option>, // NEW + // IndexMap preserves insertion order for baseline ordering +} + +pub struct BaselineDef { + pub description: Option, + pub cumulative: bool, // default: true + pub git_tag: Option, +} +``` + +### Store Changes + +```rust +// store.rs -- add baseline-scoped store builder +impl Store { + /// Create a new store containing only artifacts in the given baseline. + pub fn scoped(&self, baseline: &str, config: &BaselineConfig) -> Store { + // ... + } + + /// Get the set of artifact IDs in a baseline. + pub fn ids_in_baseline(&self, baseline: &str, config: &BaselineConfig) -> HashSet { + // ... + } +} +``` + +### LinkGraph Changes + +```rust +// links.rs -- add scoped build +impl LinkGraph { + /// Build a link graph for a scoped store, treating links to + /// out-of-scope (but existing in full store) targets as external. + pub fn build_scoped(scoped: &Store, schema: &Schema, full: &Store) -> LinkGraph { + // ... + } +} +``` + +### Query Changes + +```rust +// query.rs -- add baseline filter +pub struct Query { + pub artifact_type: Option, + pub status: Option, + pub tag: Option, + pub has_link_type: Option, + pub missing_link_type: Option, + pub baseline: Option, // NEW +} +``` + +## Implementation Plan + +### Phase 1: Minimal Viable Baseline (3-4 hours) + +1. Add `baseline: Option` to `Artifact` struct in model.rs +2. Parse `baseline` field in generic-yaml and stpa-yaml adapters +3. Add `baselines` config block parsing to `ProjectConfig` +4. Implement `Store::scoped()` and `LinkGraph::build_scoped()` +5. Add `--baseline` flag to `rivet validate` +6. Add `--baseline` flag to `rivet coverage` +7. Migrate rivet's own artifacts: add baseline field to all artifact files + +### Phase 2: Full Integration (2-3 hours) + +8. Add `--baseline` flag to `rivet list`, `stats`, `matrix`, `export` +9. Add `rivet baseline show` and `rivet baseline status` subcommands +10. Add `baseline` filter to `Query` struct +11. Dashboard baseline selector dropdown + +### Phase 3: Polish (1-2 hours) + +12. `rivet baseline verify` enhanced: check artifact completeness +13. Impact analysis baseline scoping +14. Documentation and help text updates +15. Deprecation warning for `phase` field + +## Open Questions + +1. **Should `baseline` be a base field or a custom field?** This design + proposes a base field (like status, tags) because it has semantic + meaning to the validation engine. A custom field in `fields` would + require schema changes per domain and could not be enforced uniformly. + **Decision: base field.** + +2. **What about artifacts shared across non-cumulative baselines?** For + example, a hotfix release that includes REQ-001 from v0.1.0 and + REQ-025 from v0.2.0 but not all of v0.2.0. The current design handles + this via `cumulative: false` baselines that only include explicitly + tagged artifacts. The artifact would need `baseline: hotfix-1` set + manually. This is expected to be rare. **Decision: defer until needed.** + +3. **Should baseline validation be strict or advisory?** This design makes + `--baseline` a filter that scopes validation. All errors within the + baseline are real errors. Missing links to out-of-baseline artifacts + are not errors. **Decision: strict within scope, lenient at boundary.** + +4. **How does this interact with OSLC configuration management?** OSLC + Config Management (OSLC-CM) defines global configurations and baselines + as sets of versioned resources. The `baseline` field maps to the OSLC + concept of a configuration context. Future OSLC sync could map rivet + baselines to OSLC global configurations. **Decision: design is + compatible; OSLC mapping is future work.** diff --git a/docs/plans/2026-03-21-lsp-salsa-architecture-design.md b/docs/plans/2026-03-21-lsp-salsa-architecture-design.md new file mode 100644 index 0000000..60b6aec --- /dev/null +++ b/docs/plans/2026-03-21-lsp-salsa-architecture-design.md @@ -0,0 +1,959 @@ +# LSP + Salsa Incremental Architecture Design + +**Date:** 2026-03-21 +**Builds on:** [rowan + salsa completion plan](2026-03-16-rowan-salsa-completion.md) +**Related artifacts:** REQ-029, REQ-036, DD-032, FEAT-056, FEAT-057 + +--- + +## 1. Current State Summary + +### What exists today in rivet-core/src/db.rs + +The salsa 0.26 database is already functional: + +- **Inputs:** `SourceFile` (path + content), `SchemaInput` (name + content), with `SourceFileSet` / `SchemaInputSet` containers. +- **Tracked functions:** `parse_artifacts(file) -> Vec`, `validate_all(sources, schemas) -> Vec`, `evaluate_conditional_rules(sources, schemas) -> Vec`. +- **Non-tracked helpers:** `build_pipeline`, `build_store`, `build_schema` -- called from tracked functions but not individually cached because `Store` and `LinkGraph` lack `PartialEq`. +- **Concrete database:** `RivetDatabase` with `load_schemas()`, `load_sources()`, `update_source()`, `store()`, `schema()`, `diagnostics()`. +- **16 tests** covering incremental recomputation, conditional rules, determinism, and boundary cases. +- **CLI integration:** `rivet validate --incremental` and `--verify-incremental` flags (opt-in). + +### What exists in spar (reference architecture) + +Spar's LSP at `/Volumes/Home/git/pulseengine/spar/crates/spar-cli/src/lsp.rs` uses: + +- **`lsp-server` 0.7 + `lsp-types` 0.97** (synchronous, single-threaded event loop on stdin/stdout) +- **`ServerState`** struct holding: `documents` (open file contents), `item_trees` (parsed HIR per file), `global_scope` (workspace-wide name resolution), `open_files` list +- **Full text sync** (`TextDocumentSyncKind::FULL`) -- on each change, full document replaces previous +- **Features:** diagnostics, hover, document symbols, go-to-definition, completions, code actions, formatting, rename, inlay hints, workspace symbols +- **No salsa in the LSP path** -- spar-base-db has a salsa database but the LSP does its own HashMap-based caching with `ItemTree` per file and a `GlobalScope` rebuilt on each change +- **File watchers** registered for `**/*.aadl` to pick up disk changes outside the editor + +### Key differences: Rivet vs Spar for LSP + +| Aspect | Spar | Rivet | +|--------|------|-------| +| File format | AADL (custom grammar) | YAML (well-known format) | +| Parser | Hand-written rowan CST | serde_yaml (no CST today) | +| Token-level precision | Yes (rowan TextSize) | No (line-level at best) | +| Workspace scope | All `.aadl` files | Files listed in `rivet.yaml` sources | +| Cross-file references | Classifier references | Artifact ID links | +| Incremental DB | salsa (not used in LSP) | salsa (should be used in LSP) | +| Runtime | Sync (lsp-server) | Async available (tokio already in tree) | + +--- + +## 2. Architecture Decision: lsp-server vs tower-lsp + +### Recommendation: `lsp-server` (same as spar) + +**Against the earlier DD-032 recommendation of tower-lsp.** Rationale: + +1. **Consistency with spar.** Both PulseEngine projects use the same LSP library. Shared patterns, shared bugs, shared knowledge. + +2. **tower-lsp is unmaintained.** The crate has had minimal activity. The `lsp-server` crate is maintained as part of rust-analyzer's ecosystem (the most battle-tested Rust LSP). + +3. **Simplicity.** `lsp-server` is a thin transport layer (~300 lines of glue). The complexity lives in our domain logic, not the protocol layer. We do not need tower middleware or async request handling -- artifact YAML validation is CPU-bound and fast enough to run synchronously. + +4. **Tokio is not needed for LSP.** The LSP server runs on stdin/stdout. The existing tokio dependency is for `rivet serve` (axum). The LSP process is separate -- it does not share a runtime with the dashboard. + +5. **Known pattern.** Spar's LSP is ~1200 lines and covers 11 features. We can port the structural pattern directly. + +**If tower-lsp becomes actively maintained or we need async request handling (e.g., for OSLC remote validation), we can migrate. The domain logic is transport-agnostic.** + +### Alternative considered: async-lsp + +The `async-lsp` crate is a newer alternative that uses tower's Service trait without tower-lsp's specific abstractions. Worth watching but too young for production use. + +--- + +## 3. Crate Structure Decision + +### Recommendation: Option B -- `rivet lsp` subcommand in rivet-cli + +**Not a separate `rivet-lsp` crate.** Rationale: + +1. **Single binary.** Users install `rivet` and get everything: validate, serve, lsp. No separate binary to manage. + +2. **Spar's pattern.** Spar puts its LSP in `spar-cli/src/lsp.rs` as a subcommand. It works well. + +3. **Shared state types.** The LSP needs `RivetDatabase`, `Store`, `Schema`, `Diagnostic` -- all from rivet-core. A separate crate would just re-export these. + +4. **Build time.** `lsp-server` and `lsp-types` are small crates. Adding them to rivet-cli does not meaningfully increase build time. + +5. **Feature gate if needed.** We can put the LSP behind `rivet-cli/features = ["lsp"]` and gate the `lsp-server`/`lsp-types` dependencies. But the default should include it. + +### File layout + +``` +rivet-cli/ + src/ + main.rs -- adds `Lsp` variant to Command enum + lsp/ + mod.rs -- run_lsp_server(), main_loop(), ServerState + diagnostics.rs -- rivet Diagnostic -> LSP Diagnostic conversion + hover.rs -- hover handlers + completion.rs -- completion handlers + definition.rs -- go-to-definition + symbols.rs -- document symbols, workspace symbols + actions.rs -- code actions + util.rs -- offset/position conversion, URI helpers +``` + +Splitting into submodules (unlike spar's single file) because Rivet's LSP domain is more complex: YAML position tracking, schema-aware completions, multi-source-format awareness. Spar's 1200-line single file works for AADL but would be unwieldy for Rivet's richer feature set. + +--- + +## 4. Salsa Database Design for LSP + +### 4.1 Why use salsa in the LSP (unlike spar) + +Spar's LSP does not use its salsa database. It re-parses on every change and manually caches `ItemTree` per file. This works because AADL parsing is fast and the scope rebuild is cheap. + +Rivet should use salsa because: + +1. **Validation is expensive.** Building the full link graph + running 8-phase validation across 300+ artifacts on every keystroke is wasteful. Salsa caches intermediate results. + +2. **The database already exists.** `RivetDatabase` with all tracked functions is ready. The LSP just needs to feed it file changes and read results. + +3. **Cross-file dependencies are complex.** Changing a link target in file A affects validation results for file B (broken links, traceability coverage). Salsa tracks these dependencies automatically. + +4. **Shared with other consumers.** The same database design serves `rivet validate --watch`, `rivet serve` (dashboard), and `rivet lsp`. One investment, three payoffs. + +### 4.2 Salsa Inputs + +The existing inputs are sufficient. No new salsa input types are needed for the LSP: + +```rust +// Already in db.rs: +#[salsa::input] +pub struct SourceFile { + pub path: String, + pub content: String, +} + +#[salsa::input] +pub struct SchemaInput { + pub name: String, + pub content: String, +} + +#[salsa::input] +pub struct SourceFileSet { pub files: Vec } + +#[salsa::input] +pub struct SchemaInputSet { pub schemas: Vec } +``` + +The LSP `ServerState` will hold: +- A `RivetDatabase` instance +- The `SourceFileSet` and `SchemaInputSet` handles +- A mapping from file URI to `SourceFile` salsa key (for targeted updates) + +### 4.3 Tracked Functions: Existing + New + +**Existing (no changes needed):** + +```rust +#[salsa::tracked] +fn parse_artifacts(db: &dyn salsa::Database, source: SourceFile) -> Vec; + +#[salsa::tracked] +fn validate_all(db, source_set, schema_set) -> Vec; + +#[salsa::tracked] +fn evaluate_conditional_rules(db, source_set, schema_set) -> Vec; +``` + +**New tracked functions for LSP features:** + +```rust +/// All artifact IDs in the workspace, sorted. Used for completions. +/// Cached -- only recomputed when any source file changes. +#[salsa::tracked] +fn all_artifact_ids(db: &dyn salsa::Database, source_set: SourceFileSet) -> Vec; + +/// All artifact type names from the merged schema. Used for type completions. +#[salsa::tracked] +fn all_type_names(db: &dyn salsa::Database, schema_set: SchemaInputSet) -> Vec; + +/// All link type names from the merged schema. Used for link type completions. +#[salsa::tracked] +fn all_link_types(db: &dyn salsa::Database, schema_set: SchemaInputSet) -> Vec; + +/// Artifact index: map from artifact ID -> (source file path, line number). +/// Used for go-to-definition. Cached per source file. +#[salsa::tracked] +fn artifact_locations(db: &dyn salsa::Database, source: SourceFile) + -> Vec<(String, usize)>; // (artifact_id, line_number) + +/// Diagnostics for a single file (filtered from all_diagnostics). +/// This is NOT a salsa tracked function -- it is a simple filter on the +/// validate_all result, done in the LSP layer. Salsa caches validate_all; +/// the per-file filter is cheap. +``` + +### 4.4 How Incremental Recomputation Works for YAML + +**Scenario:** User edits `artifacts/requirements.yaml` in VS Code. + +1. LSP receives `textDocument/didChange` with full document text. +2. `ServerState` calls `db.update_source(source_set, "artifacts/requirements.yaml", new_content)`. +3. Salsa marks the `SourceFile` input as changed. +4. Next query (e.g., `db.diagnostics(source_set, schema_set)`) triggers: + - `parse_artifacts` re-runs for the changed file only. Other files' parse results are cached. + - `build_store` re-assembles the `Store` (cheap: iterate cached parse results). + - `build_schema` is **not** re-run (schema files did not change). + - `LinkGraph::build` re-runs (depends on the store). + - `validate_structural` re-runs (depends on store + schema + graph). + - `evaluate_conditional_rules` re-runs (depends on store + schema). +5. LSP publishes diagnostics to the client. + +**What is NOT recomputed:** +- Parsing of unchanged source files +- Schema merging (schemas did not change) +- If the edit does not change the parse result (e.g., whitespace-only), salsa detects the `Vec` is identical and short-circuits all downstream queries + +**Future optimization (Phase D1 from the completion plan):** +Per-artifact salsa tracking would allow changing one artifact in a multi-artifact file to skip re-validation of the others. This requires `#[salsa::tracked] struct TrackedArtifact` with artifact ID as identity key. Not needed for initial LSP -- file-level granularity is fast enough for 300 artifacts. + +### 4.5 Connecting to the Existing Validation Pipeline + +The LSP does **not** replace the validation pipeline. It wraps it: + +``` +rivet validate (CLI) + └── validate::validate() ← direct call, no salsa + └── validate::validate() via db.diagnostics() ← --incremental flag + +rivet lsp + └── db.diagnostics() ← always uses salsa + +rivet serve + └── db.diagnostics() ← future: shared salsa DB (Phase A7) +``` + +The `validate::validate()` and `validate::validate_structural()` functions remain unchanged. The salsa tracked functions call them. The LSP reads results from salsa. This layered architecture means: + +- **No breaking changes** to the batch pipeline +- **SC-11 parity** can be verified: `--verify-incremental` confirms salsa produces identical results +- **One validation implementation** serves all consumers + +--- + +## 5. LSP Server Design + +### 5.1 ServerState + +```rust +struct ServerState { + /// The salsa incremental database. + db: RivetDatabase, + + /// Handle to all source file inputs. + source_set: SourceFileSet, + + /// Handle to all schema inputs. + schema_set: SchemaInputSet, + + /// Map from file URI string -> SourceFile salsa key. + /// Enables O(1) lookup for targeted updates on didChange. + uri_to_source: HashMap, + + /// Project configuration from rivet.yaml. + config: ProjectConfig, + + /// Workspace root directory. + workspace_root: PathBuf, + + /// URIs of files currently open in the editor. + open_files: Vec, + + /// Cached line indices for open files (for offset <-> position conversion). + /// Rebuilt when file content changes. + line_indices: HashMap, +} +``` + +### 5.2 Initialization Sequence + +``` +1. Client sends initialize request +2. Server extracts workspace root from InitializeParams +3. Server looks for rivet.yaml in workspace root +4. Server loads ProjectConfig from rivet.yaml +5. Server loads all schema files listed in config.project.schemas + -> Creates SchemaInput for each, builds SchemaInputSet +6. Server scans all source directories listed in config.sources + -> Creates SourceFile for each YAML file found, builds SourceFileSet +7. Server stores the SourceFileSet, SchemaInputSet, and uri_to_source map +8. Server runs initial validation: db.diagnostics(source_set, schema_set) +9. Server responds with capabilities +10. Server registers file watchers for *.yaml in workspace +``` + +### 5.3 Capabilities + +```rust +fn server_capabilities() -> ServerCapabilities { + ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL + )), + hover_provider: Some(HoverProviderCapability::Simple(true)), + definition_provider: Some(OneOf::Left(true)), + document_symbol_provider: Some(OneOf::Left(true)), + completion_provider: Some(CompletionOptions { + trigger_characters: Some(vec![ + ":".to_string(), // after "target:" or "type:" + " ".to_string(), // after "- id: " + ]), + resolve_provider: Some(false), + ..Default::default() + }), + workspace_symbol_provider: Some(OneOf::Left(true)), + code_action_provider: Some(CodeActionProviderCapability::Simple(true)), + ..Default::default() + } +} +``` + +### 5.4 Feature Implementations (Priority Order) + +#### Feature 1: Diagnostics + +**Trigger:** `didOpen`, `didChange`, `didSave`, `didChangeWatchedFiles` + +**Implementation:** + +```rust +fn publish_diagnostics(state: &ServerState, connection: &Connection, uri: &Uri) { + // Get all diagnostics from the salsa database. + let all_diags = state.db.diagnostics(state.source_set, state.schema_set); + + // Filter to diagnostics relevant to this file. + let file_path = uri_to_path(uri); + let file_diags: Vec<_> = all_diags.iter() + .filter(|d| diagnostic_belongs_to_file(d, &file_path, &state.db, state.source_set)) + .collect(); + + // Convert rivet::Diagnostic -> lsp_types::Diagnostic + let lsp_diags = file_diags.iter().map(|d| { + let severity = match d.severity { + Severity::Error => DiagnosticSeverity::ERROR, + Severity::Warning => DiagnosticSeverity::WARNING, + Severity::Info => DiagnosticSeverity::INFORMATION, + }; + + // Position: currently line-level only (no rowan CST yet). + // Use artifact_locations() to find the line of the artifact. + let range = find_diagnostic_range(d, state, uri); + + lsp_types::Diagnostic { + range, + severity: Some(severity), + source: Some("rivet".to_string()), + code: Some(NumberOrString::String(d.rule.clone())), + message: d.message.clone(), + ..Default::default() + } + }).collect(); + + // Publish. + let params = PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: lsp_diags, + version: None, + }; + send_notification::(connection, params); +} +``` + +**Diagnostic range resolution** (before rowan CST): +- If the diagnostic has an `artifact_id`, scan the file content for `- id: {artifact_id}` to find the line. +- Map the rule to a specific field when possible (e.g., `required-field` -> find the artifact block, report at the `- id:` line). +- Fallback: line 0, column 0. + +**Diagnostic range resolution** (after rowan CST -- Phase B): +- Diagnostics carry `TextRange` from the CST. +- Convert `TextRange` to `Position` using a `LineIndex`. +- Precise to the character. + +**Cross-file diagnostic publishing:** +When file A changes and creates a broken link to an artifact in file B, we need to publish updated diagnostics for file B too. Strategy: on any change, re-publish diagnostics for all open files (same as spar). This is cheap because `db.diagnostics()` is cached and only the filter + conversion runs per file. + +#### Feature 2: Go-to-Definition + +**Trigger:** `textDocument/definition` request + +**Implementation:** + +```rust +fn handle_goto_definition(state: &ServerState, params: GotoDefinitionParams) + -> Option +{ + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + let source = state.file_content(uri)?; + + // Find the word at the cursor position. + let word = word_at_position(&source, pos)?; + + // Check if this word is an artifact ID. + let store = state.db.store(state.source_set); + let artifact = store.get(&word)?; + + // Find the artifact's source file and line. + let source_path = artifact.source_file.as_ref()?; + let target_uri = path_to_uri(source_path); + + // Find the "- id: {word}" line in the target file. + let target_content = state.file_content_by_path(source_path)?; + let line = find_artifact_line(&target_content, &word)?; + + Some(GotoDefinitionResponse::Scalar(Location { + uri: target_uri, + range: Range::new( + Position::new(line as u32, 0), + Position::new(line as u32, 0), + ), + })) +} +``` + +**Where go-to-definition activates:** +- In `links:` blocks: `target: REQ-001` -- click REQ-001 to jump to its definition +- In `[[REQ-001]]` references in markdown documents +- In any YAML value that matches a known artifact ID + +**Word extraction strategy** (before rowan CST): +Use a simple regex or character scan at the cursor position. Artifact IDs match the pattern `[A-Z]+-[A-Z]*-?\d+` (e.g., REQ-001, DD-023, SUCA-CLI-1, H-1.2). Scan left and right from cursor to find word boundaries. + +#### Feature 3: Hover + +**Trigger:** `textDocument/hover` request + +**Implementation:** + +When hovering over an artifact ID, show: + +```markdown +**REQ-001** (requirement) + +**System shall be safe** + +Status: approved +Links: 3 outgoing, 5 incoming +Source: artifacts/requirements.yaml:12 + +--- +*satisfies:* DD-001, DD-003 +*verified-by:* TEST-001, TEST-002, TEST-003 +``` + +When hovering over a `type:` value, show the artifact type definition from the schema: + +```markdown +**requirement** (aspice schema) + +A system-level requirement + +Required fields: priority, req-type +Link fields: derives-from (-> sys-req), verified-by (<- test-case) +``` + +When hovering over a link type, show the link type definition: + +```markdown +**satisfies** + +Design satisfies a requirement +Inverse: satisfied-by +Source types: design-decision +Target types: requirement +``` + +#### Feature 4: Completions + +**Trigger:** Typing after specific YAML keys + +**Context detection strategy:** + +The LSP must determine what kind of completion to offer based on the cursor's YAML context. Without a rowan CST, we use a line-scanning approach: + +```rust +enum CompletionContext { + /// Cursor is after "target: " inside a links block + LinkTarget { current_link_type: Option }, + /// Cursor is after "type: " on an artifact + ArtifactType, + /// Cursor is after "- type: " inside a links block + LinkType, + /// Cursor is after "status: " + Status, + /// Cursor is after a field name with allowed-values in the schema + FieldValue { field_name: String, artifact_type: String }, + /// No completion context detected + None, +} + +fn detect_completion_context(content: &str, position: Position) -> CompletionContext { + let line = content.lines().nth(position.line as usize)?; + let prefix = &line[..position.character as usize]; + + // Check what key we're completing a value for + if prefix.trim_start().starts_with("target:") { + // Look upward for the enclosing "- type:" to know the link type + CompletionContext::LinkTarget { current_link_type: find_enclosing_link_type(content, position) } + } else if prefix.trim_start().starts_with("type:") && !is_inside_links_block(content, position) { + CompletionContext::ArtifactType + } else if prefix.trim_start().starts_with("type:") && is_inside_links_block(content, position) { + CompletionContext::LinkType + } else if prefix.trim_start().starts_with("status:") { + CompletionContext::Status + } else { + CompletionContext::None + } +} +``` + +**Completion items by context:** + +| Context | Source | Items | +|---------|--------|-------| +| `LinkTarget` | `all_artifact_ids(db, source_set)` | All artifact IDs, filtered by valid target types if link type is known | +| `ArtifactType` | `all_type_names(db, schema_set)` | All type names from all loaded schemas | +| `LinkType` | `all_link_types(db, schema_set)` | All link type names from all loaded schemas | +| `Status` | Schema `allowed-values` for status | lifecycle statuses (draft, approved, etc.) | +| `FieldValue` | Schema `allowed-values` for the field | Enum values from the schema field definition | + +**Schema-aware filtering for link targets:** + +When the user types `target: ` inside a links block with `type: satisfies`, the completion should only offer artifact IDs whose type is in the `target-types` list for the `satisfies` link type definition. This requires: + +1. Detect the current link type from the enclosing `- type: satisfies` line +2. Look up the link type definition in the schema: `target-types: [requirement]` +3. Filter the artifact ID list to only those with `artifact_type == "requirement"` + +#### Feature 5: Code Actions + +**5a. Create Missing Artifact** + +When a broken-link diagnostic exists (target ID does not exist), offer a code action: + +``` +Quick Fix: Create artifact 'REQ-042' +``` + +This creates a new artifact stub in the appropriate file (determined by naming convention or the file that contains the most artifacts of the expected type). + +**5b. Add Required Link** + +When a traceability-rule diagnostic exists (e.g., "DD-001 missing satisfies link to requirement"), offer: + +``` +Quick Fix: Add 'satisfies' link to DD-001 +``` + +This inserts a links block (or appends to an existing one) using `yaml_edit::YamlEditor` for correct indentation. + +**5c. Fix Allowed Values** + +When an allowed-values diagnostic exists, offer: + +``` +Quick Fix: Change 'status' to 'approved' (currently 'aproved') +``` + +#### Feature 6: Document Symbols + +**What to show in the outline view:** + +``` +artifacts/requirements.yaml + REQ-001 (requirement) - "System shall be safe" + REQ-002 (requirement) - "System shall be reliable" + REQ-003 (requirement) - "System shall be traceable" +``` + +Each artifact becomes a `DocumentSymbol` with: +- `name`: artifact ID +- `detail`: artifact type + title +- `kind`: `SymbolKind::STRUCT` (closest semantic match) +- `range`: line range of the artifact block (computed by scanning for `- id:` boundaries) +- `children`: none (artifacts are flat) + +#### Feature 7: Workspace Symbols + +**`workspace/symbol` query:** + +Search across all artifacts in the workspace by ID or title. User types "REQ" and sees all requirements. User types "safe" and sees all artifacts with "safe" in the title. + +```rust +fn handle_workspace_symbol(state: &ServerState, params: WorkspaceSymbolParams) + -> Option +{ + let query = params.query.to_lowercase(); + let store = state.db.store(state.source_set); + + let symbols: Vec = store.iter() + .filter(|a| { + a.id.to_lowercase().contains(&query) + || a.title.to_lowercase().contains(&query) + }) + .map(|a| { + SymbolInformation { + name: a.id.clone(), + kind: SymbolKind::STRUCT, + location: artifact_location(a, state), + container_name: Some(a.artifact_type.clone()), + ..Default::default() + } + }) + .collect(); + + Some(WorkspaceSymbolResponse::Flat(symbols)) +} +``` + +--- + +## 6. Crate Dependency Graph + +``` + ┌──────────────────────────┐ + │ VS Code Extension │ + │ (rivet-vscode, TypeScript)│ + └────────────┬─────────────┘ + │ spawns process, stdio + ▼ + ┌──────────────────────────┐ + │ rivet-cli │ + │ │ + │ main.rs (Cli, Command) │ + │ serve/ (axum + HTMX) │ + │ lsp/ (lsp-server) │ ← NEW + │ │ + │ deps: │ + │ rivet-core │ + │ lsp-server 0.7 │ ← NEW + │ lsp-types 0.97 │ ← NEW + │ clap, axum, tokio... │ + └────────────┬─────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ rivet-core │ + │ │ + │ db.rs (salsa DB) │ + │ validate.rs │ + │ store.rs, links.rs │ + │ schema.rs, model.rs │ + │ yaml_edit.rs │ + │ formats/ (adapters) │ + │ │ + │ deps: │ + │ salsa 0.26 │ + │ serde_yaml, petgraph │ + │ rowan (future CST) │ + └──────────────────────────┘ +``` + +No new crates. Two new dependencies added to `rivet-cli/Cargo.toml`: +- `lsp-server = "0.7"` +- `lsp-types = "0.97"` + +--- + +## 7. Key Types and Traits + +### 7.1 In rivet-core (no changes to existing types) + +```rust +// Already exists: +pub struct RivetDatabase { storage: salsa::Storage } +pub struct SourceFile { path: String, content: String } // salsa::input +pub struct SchemaInput { name: String, content: String } // salsa::input +pub struct Diagnostic { severity, artifact_id, rule, message } +pub struct Store { artifacts: HashMap } +pub struct Schema { artifact_types, link_types, traceability_rules, conditional_rules } +pub struct Artifact { id, artifact_type, title, description, status, tags, links, fields, source_file } +pub struct LinkGraph { forward, backward, broken, graph, node_map } +``` + +### 7.2 In rivet-core (new additions for LSP support) + +```rust +/// Source location for a diagnostic. Added to Diagnostic struct. +/// Phase A4 from the completion plan. +pub struct SourceLocation { + pub file: PathBuf, + pub line: u32, // 0-based + pub column: u32, // 0-based + pub end_line: u32, + pub end_column: u32, +} + +// Extended Diagnostic (backward-compatible addition): +pub struct Diagnostic { + pub severity: Severity, + pub artifact_id: Option, + pub rule: String, + pub message: String, + pub location: Option, // NEW +} +``` + +### 7.3 In rivet-cli/src/lsp/ (new) + +```rust +/// Line index for fast offset <-> line:column conversion. +pub struct LineIndex { + /// Byte offset of the start of each line. + line_starts: Vec, +} + +impl LineIndex { + pub fn new(text: &str) -> Self; + pub fn line_col(&self, offset: u32) -> (u32, u32); + pub fn offset(&self, line: u32, col: u32) -> u32; +} + +/// LSP server state. Wraps the salsa database and editor state. +pub struct ServerState { + db: RivetDatabase, + source_set: SourceFileSet, + schema_set: SchemaInputSet, + uri_to_source: HashMap, + config: ProjectConfig, + workspace_root: PathBuf, + open_files: Vec, + line_indices: HashMap, +} +``` + +--- + +## 8. VS Code Extension Design + +### 8.1 Extension Identity + +```json +{ + "name": "rivet-vscode", + "displayName": "Rivet — SDLC Traceability", + "publisher": "pulseengine", + "description": "Language support for Rivet artifact YAML files", + "categories": ["Programming Languages", "Linters"], + "activationEvents": [ + "workspaceContains:rivet.yaml" + ] +} +``` + +### 8.2 Language ID Strategy + +**Do NOT register a custom language.** Artifact files are plain YAML. Registering a custom language ID would break existing YAML tooling (syntax highlighting, bracket matching, YAML schema validation). + +Instead: +- Activate when `rivet.yaml` exists in the workspace root +- Apply to all `.yaml` files within source directories listed in `rivet.yaml` +- Co-exist with the built-in YAML extension and redhat.vscode-yaml + +**File association via `documentSelector` in LSP client configuration:** + +```typescript +const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: 'file', language: 'yaml', pattern: '**/artifacts/**/*.yaml' }, + { scheme: 'file', language: 'yaml', pattern: '**/safety/**/*.yaml' }, + { scheme: 'file', language: 'yaml', pattern: '**/schemas/**/*.yaml' }, + { scheme: 'file', language: 'yaml', pattern: '**/rivet.yaml' }, + ], +}; +``` + +Better: read `rivet.yaml` at activation time and construct the glob patterns dynamically from `config.sources[*].path`. + +### 8.3 Server Lifecycle + +```typescript +import { LanguageClient, ServerOptions } from 'vscode-languageclient/node'; + +let client: LanguageClient; + +export function activate(context: vscode.ExtensionContext) { + // Find the rivet binary. + const rivetPath = vscode.workspace.getConfiguration('rivet').get('path') + || 'rivet'; + + const serverOptions: ServerOptions = { + command: rivetPath, + args: ['lsp'], + }; + + client = new LanguageClient( + 'rivet', + 'Rivet Language Server', + serverOptions, + clientOptions, + ); + + client.start(); +} + +export function deactivate(): Thenable | undefined { + return client?.stop(); +} +``` + +### 8.4 Extension Settings + +```json +{ + "rivet.path": { + "type": "string", + "default": "rivet", + "description": "Path to the rivet binary" + }, + "rivet.validation.onSave": { + "type": "boolean", + "default": true, + "description": "Run validation on file save" + }, + "rivet.validation.onType": { + "type": "boolean", + "default": true, + "description": "Run validation as you type" + }, + "rivet.completion.artifactIds": { + "type": "boolean", + "default": true, + "description": "Suggest artifact IDs in link targets" + } +} +``` + +### 8.5 Extension Features Beyond LSP + +Some features can be contributed by the extension directly, without LSP: + +- **Tree view:** Show all artifacts organized by type in the explorer sidebar (via a TreeDataProvider that calls `rivet list --json`). +- **Status bar:** Show artifact count and validation status. +- **Commands:** `rivet.validate` (run full validation), `rivet.openDashboard` (run `rivet serve` and open browser). +- **CodeLens:** Show backlink counts above each `- id:` line (e.g., "3 references"). + +These are additive and can come after the initial LSP integration. + +--- + +## 9. Migration Path: Batch -> Incremental -> LSP + +### Phase 1: Current State (done) + +- Batch validation: `rivet validate` runs sequential pipeline +- Incremental validation: `rivet validate --incremental` uses salsa (opt-in) +- Dashboard: `rivet serve` reloads everything per request + +### Phase 2: Foundation (Phase A from completion plan) + +- A1: Add `PartialEq` to `Store`, `LinkGraph` +- A2: Lift `build_store`, `build_schema` to tracked functions +- A4: Add `SourceLocation` to `Diagnostic` +- A5: Make incremental the default + +### Phase 3: LSP Skeleton + +- Add `lsp-server` + `lsp-types` to rivet-cli +- Add `Lsp` variant to CLI `Command` enum +- Implement `ServerState` with salsa `RivetDatabase` +- Implement initialization: load `rivet.yaml`, schemas, sources +- Implement `didOpen` / `didChange` / `didSave` -> `db.update_source()` +- Publish diagnostics (line-level accuracy from artifact ID scanning) + +### Phase 4: Core LSP Features + +- Go-to-definition (artifact ID -> source file:line) +- Hover (artifact summary, type info, link type info) +- Completions (artifact IDs, types, link types, allowed values) +- Document symbols (artifact outline) +- Workspace symbols (search by ID or title) + +### Phase 5: VS Code Extension + +- Minimal extension: activate on rivet.yaml, spawn `rivet lsp` +- Document selector from rivet.yaml source paths +- Settings for binary path +- Publish to VS Code marketplace + +### Phase 6: Advanced Features (after rowan CST, Phase B) + +- Character-level diagnostic precision via rowan TextRange +- Code actions (create artifact, add link, fix values) +- Rename artifact ID (workspace-wide refactor) +- Document formatting +- Inlay hints (show link target titles inline) + +--- + +## 10. Risks and Open Questions + +### Risk: YAML Position Tracking Without rowan + +Before Phase B (rowan CST for YAML), we cannot point diagnostics at the exact character. Mitigation: scan for `- id: {ID}` patterns to get line-level accuracy. This is good enough for initial release -- most YAML LSP servers work at line granularity. + +### Risk: Large Workspaces + +A workspace with 1000+ artifact files could make the initial load slow. Mitigation: load files lazily (parse on first access) and use background threads for initial workspace scan. The salsa database handles incremental updates efficiently after initial load. + +### Risk: Multiple YAML Schemas in One Workspace + +A workspace might have `rivet.yaml` artifact files alongside other YAML files (CI configs, Kubernetes manifests). The LSP should not validate non-rivet YAML files. Mitigation: the `documentSelector` patterns are derived from `rivet.yaml` source paths. Only files within those paths receive diagnostics. + +### Risk: Schema Hot-Reload + +Schema files rarely change, but when they do (e.g., adding a new artifact type), the LSP must reload. Currently `SchemaInputSet` is created at startup. For schema changes, the LSP must either restart or implement schema file watching + `SchemaInput` content updates. + +### Open Question: Debouncing + +When the user types rapidly, each keystroke triggers `didChange`. Should we debounce validation? Options: +- **No debounce** (spar's approach): validation is fast enough with salsa caching. Try this first. +- **100ms debounce**: wait 100ms after last keystroke before publishing diagnostics. Use if validation is slow for large projects. + +### Open Question: Multi-root Workspaces + +VS Code supports multi-root workspaces. Each root might have its own `rivet.yaml`. Should we spawn one LSP process per root, or handle multiple roots in one process? Recommendation: one process per root (simpler, matches rust-analyzer's model). + +--- + +## 11. Artifacts to Create + +| ID | Type | Title | Links | +|----|------|-------|-------| +| DD-040 | design-decision | Use lsp-server (not tower-lsp) for Rivet LSP | satisfies REQ-036 | +| DD-041 | design-decision | LSP as rivet-cli subcommand, not separate crate | satisfies REQ-036 | +| DD-042 | design-decision | Salsa database shared between LSP and validation pipeline | satisfies REQ-029, REQ-036 | +| FEAT-060 | feature | `rivet lsp` subcommand starts LSP server on stdio | satisfies REQ-036, implements DD-041 | +| FEAT-061 | feature | LSP diagnostics with line-level accuracy | satisfies REQ-036, implements DD-042 | +| FEAT-062 | feature | LSP go-to-definition for artifact ID references | satisfies REQ-036 | +| FEAT-063 | feature | LSP completions for artifact IDs, types, and link types | satisfies REQ-036 | + +--- + +## 12. Implementation Estimate + +| Task | Effort | Dependencies | +|------|--------|-------------| +| LSP skeleton + didOpen/didChange/diagnostics | 2 days | Phase A4 (source locations) | +| Go-to-definition | 1 day | LSP skeleton | +| Hover | 1 day | LSP skeleton | +| Completions (artifact IDs, types, link types) | 2 days | LSP skeleton | +| Document symbols | 0.5 days | LSP skeleton | +| Workspace symbols | 0.5 days | LSP skeleton | +| Code actions (basic) | 1 day | yaml_edit.rs integration | +| VS Code extension (minimal) | 1 day | LSP working | +| VS Code extension (tree view, status bar) | 2 days | Extension working | +| **Total** | **~11 days** | | + +This excludes Phase A and Phase B work (salsa foundation and rowan CST) which are covered in the completion plan. diff --git a/rivet-cli/Cargo.toml b/rivet-cli/Cargo.toml index a272dea..0cc2540 100644 --- a/rivet-cli/Cargo.toml +++ b/rivet-cli/Cargo.toml @@ -30,6 +30,8 @@ tower-http = { workspace = true } etch = { path = "../etch" } petgraph = { workspace = true } urlencoding = { workspace = true } +lsp-server = "0.7" +lsp-types = "0.97" [dev-dependencies] serde_json = { workspace = true } diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index c90cd70..31274f3 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -181,6 +181,10 @@ enum Command { /// Skip cross-repo validation (broken external refs, backlinks, circular deps, version conflicts) #[arg(long)] skip_external_validation: bool, + + /// Scope validation to a named baseline (cumulative) + #[arg(long)] + baseline: Option, }, /// List artifacts, optionally filtered by type @@ -196,6 +200,10 @@ enum Command { /// Output format: "text" (default) or "json" #[arg(short, long, default_value = "text")] format: String, + + /// Scope listing to a named baseline (cumulative) + #[arg(long)] + baseline: Option, }, /// Show artifact summary statistics @@ -203,6 +211,10 @@ enum Command { /// Output format: "text" (default) or "json" #[arg(short, long, default_value = "text")] format: String, + + /// Scope statistics to a named baseline (cumulative) + #[arg(long)] + baseline: Option, }, /// Show traceability coverage report @@ -222,6 +234,10 @@ enum Command { /// Directories to scan for test markers (default: src/ tests/) #[arg(long = "scan-paths", value_delimiter = ',')] scan_paths: Vec, + + /// Scope coverage to a named baseline (cumulative) + #[arg(long)] + baseline: Option, }, /// Generate a traceability matrix @@ -305,6 +321,10 @@ enum Command { /// JSON array of version entries for config.js switcher: [{"label":"v0.1.0","path":"../v0.1.0/"}] #[arg(long)] versions: Option, + + /// Scope export to a named baseline (cumulative) + #[arg(long)] + baseline: Option, }, /// Introspect loaded schemas (types, links, rules) @@ -540,6 +560,9 @@ enum Command { /// Path to the batch YAML file file: PathBuf, }, + + /// Start the language server (LSP over stdio) + Lsp, } #[derive(Subcommand)] @@ -639,41 +662,55 @@ fn run(cli: Cli) -> Result { if let Command::CommitMsgCheck { file } = &cli.command { return cmd_commit_msg_check(&cli, file); } + if let Command::Lsp = &cli.command { + return cmd_lsp(&cli); + } match &cli.command { Command::Init { .. } | Command::Docs { .. } | Command::Context - | Command::CommitMsgCheck { .. } => unreachable!(), + | Command::CommitMsgCheck { .. } + | Command::Lsp => unreachable!(), Command::Stpa { path, schema } => cmd_stpa(path, schema.as_deref(), &cli), Command::Validate { format, incremental, verify_incremental, skip_external_validation, + baseline, } => cmd_validate( &cli, format, *incremental, *verify_incremental, *skip_external_validation, + baseline.as_deref(), ), Command::List { r#type, status, format, - } => cmd_list(&cli, r#type.as_deref(), status.as_deref(), format), - Command::Stats { format } => cmd_stats(&cli, format), + baseline, + } => cmd_list( + &cli, + r#type.as_deref(), + status.as_deref(), + format, + baseline.as_deref(), + ), + Command::Stats { format, baseline } => cmd_stats(&cli, format, baseline.as_deref()), Command::Coverage { format, fail_under, tests, scan_paths, + baseline, } => { if *tests { cmd_coverage_tests(&cli, format, scan_paths) } else { - cmd_coverage(&cli, format, fail_under.as_ref()) + cmd_coverage(&cli, format, fail_under.as_ref(), baseline.as_deref()) } } Command::Matrix { @@ -695,6 +732,7 @@ fn run(cli: Cli) -> Result { homepage, version_label, versions, + baseline, } => cmd_export( &cli, format, @@ -705,6 +743,7 @@ fn run(cli: Cli) -> Result { homepage.as_deref(), version_label.as_deref(), versions.as_deref(), + baseline.as_deref(), ), Command::Impact { since, @@ -1247,6 +1286,7 @@ fn cmd_validate( incremental: bool, verify_incremental: bool, skip_external_validation: bool, + baseline_name: Option<&str>, ) -> Result { check_for_updates(); @@ -1264,6 +1304,22 @@ fn cmd_validate( doc_store, .. } = ctx; + + // Apply baseline scoping if requested + let (store, graph) = if let Some(bl) = baseline_name { + if let Some(ref baselines) = config.baselines { + let scoped = store.scoped(bl, baselines); + let scoped_graph = LinkGraph::build(&scoped, &schema); + println!("Baseline: {bl} ({} artifacts in scope)\n", scoped.len()); + (scoped, scoped_graph) + } else { + eprintln!("warning: --baseline specified but no baselines defined in rivet.yaml"); + (store, graph) + } + } else { + (store, graph) + }; + let doc_store = doc_store.unwrap_or_default(); let mut diagnostics = validate::validate(&store, &schema, &graph); diagnostics.extend(validate::validate_documents(&doc_store, &store)); @@ -1714,9 +1770,10 @@ fn cmd_list( type_filter: Option<&str>, status_filter: Option<&str>, format: &str, + baseline_name: Option<&str>, ) -> Result { let ctx = ProjectContext::load(cli)?; - let store = ctx.store; + let store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); let query = rivet_core::query::Query { artifact_type: type_filter.map(|s| s.to_string()), @@ -1761,9 +1818,14 @@ fn cmd_list( } /// Print summary statistics. -fn cmd_stats(cli: &Cli, format: &str) -> Result { +fn cmd_stats(cli: &Cli, format: &str, baseline_name: Option<&str>) -> Result { let ctx = ProjectContext::load(cli)?; - let (store, graph) = (ctx.store, ctx.graph); + let store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); + let graph = if baseline_name.is_some() { + LinkGraph::build(&store, &ctx.schema) + } else { + ctx.graph + }; let orphans = graph.orphans(&store); @@ -1801,9 +1863,20 @@ fn cmd_stats(cli: &Cli, format: &str) -> Result { } /// Show traceability coverage report. -fn cmd_coverage(cli: &Cli, format: &str, fail_under: Option<&f64>) -> Result { +fn cmd_coverage( + cli: &Cli, + format: &str, + fail_under: Option<&f64>, + baseline_name: Option<&str>, +) -> Result { let ctx = ProjectContext::load(cli)?; - let (store, schema, graph) = (ctx.store, ctx.schema, ctx.graph); + let store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); + let schema = ctx.schema; + let graph = if baseline_name.is_some() { + LinkGraph::build(&store, &schema) + } else { + ctx.graph + }; let report = coverage::compute_coverage(&store, &schema, &graph); if format == "json" { @@ -2085,6 +2158,7 @@ fn cmd_export( homepage: Option<&str>, version_label: Option<&str>, versions_json: Option<&str>, + baseline_name: Option<&str>, ) -> Result { if format == "html" { return cmd_export_html( @@ -2102,7 +2176,8 @@ fn cmd_export( use rivet_core::adapter::{Adapter, AdapterConfig}; let ctx = ProjectContext::load(cli)?; - let artifacts: Vec<_> = ctx.store.iter().cloned().collect(); + let store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); + let artifacts: Vec<_> = store.iter().cloned().collect(); let config = AdapterConfig::default(); let bytes = match format { @@ -2322,6 +2397,7 @@ fn cmd_diff( incremental: false, verify_incremental: false, skip_external_validation: false, + baseline: None, }, }; let head_cli = Cli { @@ -2333,6 +2409,7 @@ fn cmd_diff( incremental: false, verify_incremental: false, skip_external_validation: false, + baseline: None, }, }; let bc = ProjectContext::load(&base_cli)?; @@ -3676,6 +3753,28 @@ fn cmd_baseline_list(cli: &Cli) -> Result { Ok(true) } +/// Apply baseline scoping to a store if a baseline name is provided. +/// +/// Returns the original store unmodified when no baseline is requested +/// or when no baselines are configured in the project. +fn apply_baseline_scope( + store: Store, + baseline_name: Option<&str>, + config: &ProjectConfig, +) -> Store { + let Some(bl) = baseline_name else { + return store; + }; + if let Some(ref baselines) = config.baselines { + let scoped = store.scoped(bl, baselines); + eprintln!("Baseline: {bl} ({} artifacts in scope)", scoped.len()); + scoped + } else { + eprintln!("warning: --baseline specified but no baselines defined in rivet.yaml"); + store + } +} + struct ProjectContext { config: ProjectConfig, store: Store, @@ -4378,6 +4477,57 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { Ok(true) } +fn cmd_lsp(_cli: &Cli) -> Result { + use lsp_server::{Connection, Message}; + use lsp_types::*; + + eprintln!("rivet lsp: starting language server..."); + + let (connection, io_threads) = Connection::stdio(); + + let server_capabilities = ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)), + diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions { + identifier: Some("rivet".to_string()), + ..Default::default() + })), + hover_provider: Some(HoverProviderCapability::Simple(true)), + definition_provider: Some(OneOf::Left(true)), + completion_provider: Some(CompletionOptions { + trigger_characters: Some(vec!["[".to_string(), ":".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + let init_params = connection.initialize(serde_json::to_value(server_capabilities).unwrap())?; + let _params: InitializeParams = serde_json::from_value(init_params)?; + + eprintln!("rivet lsp: initialized"); + + // Main message loop + for msg in &connection.receiver { + match msg { + Message::Request(req) => { + if connection.handle_shutdown(&req)? { + break; + } + // TODO: handle requests (hover, goto-definition, completion) + eprintln!("rivet lsp: unhandled request: {}", req.method); + } + Message::Notification(notif) => { + eprintln!("rivet lsp: notification: {}", notif.method); + // TODO: handle didOpen, didChange, didSave + } + Message::Response(_) => {} + } + } + + io_threads.join()?; + eprintln!("rivet lsp: shut down"); + Ok(true) +} + /// Substitute `$prev` in a string with the most recently generated ID. fn substitute_prev(s: &str, prev: &Option) -> String { if s == "$prev" { diff --git a/rivet-cli/src/serve/mod.rs b/rivet-cli/src/serve/mod.rs index 795fa34..d359f24 100644 --- a/rivet-cli/src/serve/mod.rs +++ b/rivet-cli/src/serve/mod.rs @@ -187,6 +187,9 @@ pub(crate) struct AppState { pub(crate) doc_dirs: Vec, /// External projects loaded at startup (empty if none configured). pub(crate) externals: Vec, + /// Cached validation diagnostics — computed once at load/reload time + /// instead of on every page request. + pub(crate) cached_diagnostics: Vec, } /// Convenience alias so handler signatures stay compact. @@ -291,6 +294,8 @@ fn reload_state( port, }; + let cached_diagnostics = rivet_core::validate::validate(&store, &schema, &graph); + Ok(AppState { store, schema, @@ -302,6 +307,7 @@ fn reload_state( schemas_dir: schemas_dir.to_path_buf(), doc_dirs, externals, + cached_diagnostics, }) } @@ -338,6 +344,8 @@ pub async fn run( port, }; + let cached_diagnostics = rivet_core::validate::validate(&store, &schema, &graph); + let state: SharedState = Arc::new(RwLock::new(AppState { store, schema, @@ -349,6 +357,7 @@ pub async fn run( schemas_dir, doc_dirs, externals: Vec::new(), + cached_diagnostics, })); let app = Router::new() diff --git a/rivet-cli/src/serve/views.rs b/rivet-cli/src/serve/views.rs index e0804b9..bc47da4 100644 --- a/rivet-cli/src/serve/views.rs +++ b/rivet-cli/src/serve/views.rs @@ -23,7 +23,6 @@ use rivet_core::matrix::{self, Direction}; use rivet_core::model::ProjectConfig; use rivet_core::schema::Severity; use rivet_core::store::Store; -use rivet_core::validate; use super::components::{self, ViewParams}; use super::layout; @@ -52,7 +51,7 @@ fn stats_partial(state: &AppState) -> String { types.sort(); let orphans = graph.orphans(store); - let diagnostics = validate::validate(store, &state.schema, graph); + let diagnostics = &state.cached_diagnostics; let errors = diagnostics .iter() .filter(|d| d.severity == Severity::Error) @@ -1486,7 +1485,7 @@ pub(crate) async fn validate_view( Query(params): Query, ) -> Html { let state = state.read().await; - let diagnostics = validate::validate(&state.store, &state.schema, &state.graph); + let diagnostics = &state.cached_diagnostics; let errors = diagnostics .iter() @@ -1532,15 +1531,9 @@ pub(crate) async fn validate_view( current_query, )); - // Show errors first, then warnings, then info - let mut sorted = diagnostics; - sorted.sort_by_key(|d| match d.severity { - Severity::Error => 0, - Severity::Warning => 1, - Severity::Info => 2, - }); - - // Apply severity filter + // Apply severity filter and sort: errors first, then warnings, then info. + // We work from the cached (immutable) diagnostics, so sorting is done via + // the filtered+collected vec below rather than mutating in place. let severity_filter = match current_status { "error" => Some(Severity::Error), "warning" => Some(Severity::Warning), @@ -1553,7 +1546,7 @@ pub(crate) async fn validate_view( Some(current_query.to_lowercase()) }; - let filtered: Vec<_> = sorted + let mut filtered: Vec<_> = diagnostics .iter() .filter(|d| { if let Some(ref sev) = severity_filter { @@ -1577,6 +1570,13 @@ pub(crate) async fn validate_view( }) .collect(); + // Sort: errors first, then warnings, then info + filtered.sort_by_key(|d| match d.severity { + Severity::Error => 0, + Severity::Warning => 1, + Severity::Info => 2, + }); + let filtered_count = filtered.len(); if filtered_count == 0 { html.push_str("

No matching issues found.

"); diff --git a/rivet-core/src/db.rs b/rivet-core/src/db.rs index b1812a2..f4a539c 100644 --- a/rivet-core/src/db.rs +++ b/rivet-core/src/db.rs @@ -140,10 +140,14 @@ pub fn evaluate_conditional_rules( &schema.conditional_rules, )); - // Evaluate each conditional rule against each artifact + // Evaluate each conditional rule against each artifact (pre-compile regexes) for rule in &schema.conditional_rules { + let compiled_re = rule.when.compile_regex(); for artifact in store.iter() { - if rule.when.matches_artifact(artifact) { + if rule + .when + .matches_artifact_with(artifact, compiled_re.as_ref()) + { diagnostics.extend(rule.then.check(artifact, &rule.name, rule.severity)); } } diff --git a/rivet-core/src/links.rs b/rivet-core/src/links.rs index 0c31c5b..fd3c0d1 100644 --- a/rivet-core/src/links.rs +++ b/rivet-core/src/links.rs @@ -59,40 +59,44 @@ impl LinkGraph { node_map.insert(artifact.id.clone(), idx); } - // Create edges for all links + // Create edges for all links. + // Hoist the forward-map entry outside the inner link loop so we only + // hash the source key once per artifact (not once per link). for artifact in store.iter() { + let src_id = &artifact.id; + if artifact.links.is_empty() { + continue; + } + + // Pre-fetch forward vec for this artifact (one hash lookup, not N) + let fwd_vec = forward.entry(src_id.clone()).or_default(); + for link in &artifact.links { - if store.contains(&link.target) { + let lt = &link.link_type; + let tgt = &link.target; + if store.contains(tgt) { // Valid link — add forward, backward, and graph edge - forward - .entry(artifact.id.clone()) - .or_default() - .push(ResolvedLink { - source: artifact.id.clone(), - target: link.target.clone(), - link_type: link.link_type.clone(), - }); - - let inverse_type = schema.inverse_of(&link.link_type).map(|s| s.to_string()); - backward - .entry(link.target.clone()) - .or_default() - .push(Backlink { - source: artifact.id.clone(), - link_type: link.link_type.clone(), - inverse_type, - }); - - if let (Some(&src), Some(&dst)) = - (node_map.get(&artifact.id), node_map.get(&link.target)) - { - graph.add_edge(src, dst, link.link_type.clone()); + fwd_vec.push(ResolvedLink { + source: src_id.clone(), + target: tgt.clone(), + link_type: lt.clone(), + }); + + let inverse_type = schema.inverse_of(lt).map(|s| s.to_string()); + backward.entry(tgt.clone()).or_default().push(Backlink { + source: src_id.clone(), + link_type: lt.clone(), + inverse_type, + }); + + if let (Some(&src), Some(&dst)) = (node_map.get(src_id), node_map.get(tgt)) { + graph.add_edge(src, dst, lt.clone()); } } else { broken.push(ResolvedLink { - source: artifact.id.clone(), - target: link.target.clone(), - link_type: link.link_type.clone(), + source: src_id.clone(), + target: tgt.clone(), + link_type: lt.clone(), }); } } diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index 1f62e32..f77aaf9 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -79,6 +79,27 @@ impl Artifact { pub fn has_link_type(&self, link_type: &str) -> bool { self.links.iter().any(|l| l.link_type == link_type) } + + /// Return the baseline this artifact belongs to, if any. + /// + /// The baseline is read from the `baseline` key in the `fields` map + /// rather than being a first-class struct field, keeping the data + /// model backward-compatible. + pub fn baseline(&self) -> Option<&str> { + self.fields.get("baseline").and_then(|v| v.as_str()) + } +} + +/// Configuration for a named baseline (release scope). +/// +/// Baselines are declared in order in `rivet.yaml`. Validation and +/// coverage can be scoped to a baseline, which cumulatively includes +/// all artifacts from earlier baselines. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BaselineConfig { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Configuration for commit-to-artifact traceability. @@ -140,6 +161,10 @@ pub struct ProjectConfig { /// External project dependencies for cross-repo linking. #[serde(default)] pub externals: Option>, + /// Named baselines for scoped validation and coverage. + /// Order matters: earlier baselines are cumulatively included in later ones. + #[serde(default)] + pub baselines: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rivet-core/src/schema.rs b/rivet-core/src/schema.rs index 63aa275..49384c2 100644 --- a/rivet-core/src/schema.rs +++ b/rivet-core/src/schema.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::path::Path; @@ -220,6 +221,10 @@ impl TryFrom for Condition { // Manual Serialize implementation for Condition → flat YAML output impl Condition { /// Check whether an artifact satisfies this condition. + /// + /// **Note:** For `Matches` conditions this compiles the regex on every call. + /// In hot loops, prefer [`matches_artifact_with`] and pre-compile via + /// [`compile_regex`]. #[inline] pub fn matches_artifact(&self, artifact: &Artifact) -> bool { match self { @@ -235,31 +240,62 @@ impl Condition { Condition::Exists { field } => get_field_value(artifact, field).is_some(), } } + + /// Like [`matches_artifact`] but accepts a pre-compiled regex for `Matches` + /// conditions, avoiding repeated `Regex::new` calls in tight loops. + #[inline] + pub fn matches_artifact_with(&self, artifact: &Artifact, compiled: Option<&Regex>) -> bool { + match self { + Condition::Equals { field, value } => { + get_field_value(artifact, field).is_some_and(|v| v == *value) + } + Condition::Matches { field, .. } => { + if let Some(re) = compiled { + get_field_value(artifact, field).is_some_and(|v| re.is_match(&v)) + } else { + // Fallback: compile inline (shouldn't normally happen) + self.matches_artifact(artifact) + } + } + Condition::Exists { field } => get_field_value(artifact, field).is_some(), + } + } + + /// Pre-compile the regex for a `Matches` condition. + /// Returns `None` for `Equals` / `Exists` conditions or invalid patterns. + pub fn compile_regex(&self) -> Option { + match self { + Condition::Matches { pattern, .. } => Regex::new(pattern).ok(), + _ => None, + } + } } /// Get a string value for a field from an artifact, checking base fields first. +/// +/// Returns a `Cow` to avoid cloning when the value is already a `&str`. #[inline] -fn get_field_value(artifact: &Artifact, field: &str) -> Option { +fn get_field_value<'a>(artifact: &'a Artifact, field: &str) -> Option> { match field { - "status" => artifact.status.clone(), - "description" => artifact.description.clone(), - "title" => Some(artifact.title.clone()), - "id" => Some(artifact.id.clone()), + "status" => artifact.status.as_deref().map(Cow::Borrowed), + "description" => artifact.description.as_deref().map(Cow::Borrowed), + "title" => Some(Cow::Borrowed(&artifact.title)), + "id" => Some(Cow::Borrowed(&artifact.id)), _ => { // Check tags: if field == "tags", join them if field == "tags" { if artifact.tags.is_empty() { None } else { - Some(artifact.tags.join(",")) + Some(Cow::Owned(artifact.tags.join(","))) } } else { // Check fields map artifact.fields.get(field).map(|v| match v { - serde_yaml::Value::String(s) => s.clone(), - serde_yaml::Value::Bool(b) => b.to_string(), - serde_yaml::Value::Number(n) => n.to_string(), - _ => format!("{v:?}"), + serde_yaml::Value::String(s) => Cow::Borrowed(s.as_str()), + serde_yaml::Value::Bool(b) => Cow::Owned(b.to_string()), + serde_yaml::Value::Number(n) => Cow::Owned(n.to_string()), + _ => Cow::Owned(format!("{v:?}")), }) } } diff --git a/rivet-core/src/store.rs b/rivet-core/src/store.rs index 2a04092..aab0e0a 100644 --- a/rivet-core/src/store.rs +++ b/rivet-core/src/store.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use crate::error::Error; -use crate::model::{Artifact, ArtifactId}; +use crate::model::{Artifact, ArtifactId, BaselineConfig}; /// In-memory artifact store. /// @@ -40,18 +40,24 @@ impl Store { let artifact_type = artifact.artifact_type.clone(); // Remove from old type index if updating - if let Some(old) = self.artifacts.get(&id) { + let is_update = if let Some(old) = self.artifacts.get(&id) { if old.artifact_type != artifact_type { if let Some(ids) = self.by_type.get_mut(&old.artifact_type) { ids.retain(|i| i != &id); } + // Type changed: not yet in the new type's list + false + } else { + // Same type: already in the type index, skip re-adding + true } - } + } else { + false + }; self.artifacts.insert(id.clone(), artifact); - let ids = self.by_type.entry(artifact_type).or_default(); - if !ids.contains(&id) { - ids.push(id); + if !is_update { + self.by_type.entry(artifact_type).or_default().push(id); } } @@ -105,4 +111,36 @@ impl Store { pub fn contains(&self, id: &str) -> bool { self.artifacts.contains_key(id) } + + /// Create a scoped store containing only artifacts in the given baseline + /// and all prior baselines (cumulative). + /// + /// Artifacts whose `baseline` field matches the target or any earlier + /// baseline (by declaration order) are included. Artifacts with no + /// baseline field are excluded from scoped stores. + pub fn scoped(&self, baseline: &str, baselines: &[BaselineConfig]) -> Store { + // Find the index of the target baseline in the ordered list + let target_idx = baselines.iter().position(|b| b.name == baseline); + let target_idx = match target_idx { + Some(idx) => idx, + None => return self.clone(), // Unknown baseline, return full store + }; + + // Collect baseline names up to and including target + let included: Vec<&str> = baselines[..=target_idx] + .iter() + .map(|b| b.name.as_str()) + .collect(); + + // Filter artifacts: include only those whose baseline is in the included set + let mut scoped = Store::new(); + for artifact in self.artifacts.values() { + if let Some(art_baseline) = artifact.baseline() { + if included.contains(&art_baseline) { + scoped.upsert(artifact.clone()); + } + } + } + scoped + } } diff --git a/rivet-core/src/validate.rs b/rivet-core/src/validate.rs index 77056ee..1f967ff 100644 --- a/rivet-core/src/validate.rs +++ b/rivet-core/src/validate.rs @@ -43,10 +43,14 @@ pub fn validate(store: &Store, schema: &Schema, graph: &LinkGraph) -> Vec if let Some(allowed) = &field.allowed_values { if let Some(value) = artifact.fields.get(&field.name) { if let Some(s) = value.as_str() { - if !allowed.contains(&s.to_string()) { + if !allowed.iter().any(|a| a == s) { diagnostics.push(Diagnostic { severity: Severity::Warning, artifact_id: Some(artifact.id.clone()), diff --git a/rivet.yaml b/rivet.yaml index 01b24bd..d64332c 100644 --- a/rivet.yaml +++ b/rivet.yaml @@ -22,6 +22,12 @@ docs: results: results +baselines: + - name: v0.1.0 + description: Initial release — core validation, STPA, ASPICE schemas, CLI, dashboard + - name: v0.2.0-dev + description: Current development — cross-repo, mutations, STPA-Sec, export, AADL + commits: format: trailers trailers: