Skip to content

Commit cc2a638

Browse files
authored
Merge pull request #642 from pulseengine/feat/req-237-aspice-chain-hint
feat(validate): lifecycle gap names the ASPICE verification chain (REQ-237, #350)
2 parents 49b44e5 + 2ca2f3a commit cc2a638

3 files changed

Lines changed: 112 additions & 6 deletions

File tree

artifacts/requirements.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7557,7 +7557,7 @@ artifacts:
75577557
- id: REQ-237
75587558
type: requirement
75597559
title: direct test->sw-req link (skip full ASPICE design chain)
7560-
status: proposed
7560+
status: verified
75617561
description: "Allow a test to link directly to a sw-req for verified status without the full ASPICE design chain; guide the completeness error toward it. #350. v0.23."
75627562
provenance:
75637563
created-by: ai-assisted

rivet-cli/src/main.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5276,6 +5276,59 @@ fn cmd_validate_new_since(cli: &Cli, since_ref: &str, fail_on: &str) -> Result<b
52765276
Ok(!hit)
52775277
}
52785278

5279+
/// Render a list of artifact-type names as `` `a` ``, `` `a` or `b` ``,
5280+
/// `` `a`, `b` or `c` `` for a human-facing hint.
5281+
fn fmt_type_list(types: &[&str]) -> String {
5282+
let quoted: Vec<String> = types.iter().map(|t| format!("`{t}`")).collect();
5283+
match quoted.split_last() {
5284+
None => "(nothing)".to_string(),
5285+
Some((last, [])) => last.clone(),
5286+
Some((last, rest)) => format!("{} or {last}", rest.join(", ")),
5287+
}
5288+
}
5289+
5290+
/// #350 (REQ-237): explain the ASPICE chain for a lifecycle completeness gap.
5291+
///
5292+
/// Given the schema, the type of the artifact with the gap, and a missing
5293+
/// downstream type, return a hint when that missing type cannot link DIRECTLY
5294+
/// to the source type — naming what it *does* attach to, so the required
5295+
/// intermediate artifact is obvious. e.g. for a `sw-req` missing a
5296+
/// `unit-verification`: "a `unit-verification` `verifies` `sw-detail-design`,
5297+
/// not `sw-req` directly — add an intermediate `sw-detail-design` …". Returns
5298+
/// `None` when the missing type CAN link directly (the gap is just "add this
5299+
/// backlink", already clear) or the schema has no link info for it.
5300+
fn aspice_chain_hint(
5301+
schema: &rivet_core::schema::Schema,
5302+
source_type: &str,
5303+
missing_type: &str,
5304+
) -> Option<String> {
5305+
let td = schema.artifact_type(missing_type)?;
5306+
let mut targets: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
5307+
let mut via: Option<String> = None;
5308+
for lf in &td.link_fields {
5309+
if lf.target_types.is_empty() {
5310+
continue;
5311+
}
5312+
if via.is_none() {
5313+
via = Some(lf.link_type.clone());
5314+
}
5315+
for t in &lf.target_types {
5316+
targets.insert(t.clone());
5317+
}
5318+
}
5319+
// No link info, or it can attach directly to the source → nothing to
5320+
// explain (the "missing" line already tells the whole story).
5321+
if targets.is_empty() || targets.contains(source_type) {
5322+
return None;
5323+
}
5324+
let via = via.unwrap_or_else(|| "its link".to_string());
5325+
let intermediates: Vec<&str> = targets.iter().map(String::as_str).collect();
5326+
let tl = fmt_type_list(&intermediates);
5327+
Some(format!(
5328+
"a `{missing_type}` `{via}` {tl}, not `{source_type}` directly — add an intermediate {tl} that traces to this artifact and is `{via}`-linked by the `{missing_type}`"
5329+
))
5330+
}
5331+
52795332
/// Validate a full project (with rivet.yaml).
52805333
#[allow(clippy::too_many_arguments)]
52815334
fn cmd_validate(
@@ -6075,12 +6128,21 @@ fn cmd_validate(
60756128
gap.artifact_status.as_deref().unwrap_or("none"),
60766129
gap.missing.join(", "),
60776130
);
6131+
// #350 (REQ-237): a missing type is often not directly linkable
6132+
// to this artifact — e.g. a `unit-verification` verifies a
6133+
// `sw-detail-design`, not a `sw-req` — so authoring a direct
6134+
// link is rejected and the bare "missing" list points at the
6135+
// wrong fix. Name the chain: for each missing type whose own
6136+
// links can't target this artifact's type, say what it DOES
6137+
// attach to, so the intermediate artifact is obvious.
6138+
for missing in &gap.missing {
6139+
if let Some(hint) = aspice_chain_hint(&schema, &gap.artifact_type, missing) {
6140+
eprintln!(" → {hint}");
6141+
}
6142+
}
60786143
}
6079-
// The bare "missing: <types>" list says what, not how — which link
6080-
// type connects them, and that some listed types may only attach
6081-
// further down the chain (issue #350). Point at the per-artifact
6082-
// explainer, which names the exact incoming link + allowed source
6083-
// types (and any alternates) for each gap.
6144+
// The bare "missing: <types>" list says what, not how. Point at the
6145+
// per-artifact explainer for the exact link types and alternates.
60846146
if let Some(first) = lifecycle_gaps.first() {
60856147
println!(
60866148
" → run `rivet validate --explain {}` to see which link type and source types satisfy a gap",

rivet-cli/tests/cli_commands.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,50 @@ fn validate_surfaces_parse_error_on_malformed_artifact_file() {
679679
);
680680
}
681681

682+
/// #350 (REQ-237): the lifecycle completeness gap for an implemented sw-req
683+
/// must NAME the ASPICE chain — a `unit-verification` verifies a
684+
/// `sw-detail-design`, not the `sw-req` directly, so authoring a direct link is
685+
/// rejected and the bare "missing" list points at the wrong fix. The hint tells
686+
/// the author to add the intermediate.
687+
///
688+
/// rivet: verifies REQ-237
689+
#[test]
690+
fn lifecycle_gap_names_the_aspice_verification_chain() {
691+
let tmp = tempfile::tempdir().expect("temp dir");
692+
let dir = tmp.path();
693+
let dirs = dir.to_str().unwrap();
694+
std::fs::create_dir_all(dir.join("artifacts")).unwrap();
695+
std::fs::write(
696+
dir.join("rivet.yaml"),
697+
"project:\n name: p\n schemas: [common, aspice]\n\
698+
sources:\n - path: artifacts\n format: generic-yaml\n",
699+
)
700+
.unwrap();
701+
// An implemented sw-req with an upstream link (so the gap lists specific
702+
// missing verification types rather than "no downstream artifacts").
703+
std::fs::write(
704+
dir.join("artifacts/a.yaml"),
705+
"artifacts:\n \
706+
- id: SYS-001\n type: system-req\n title: sys\n status: approved\n \
707+
- id: SL-TR-003\n type: sw-req\n title: sw\n status: implemented\n \
708+
links:\n - type: derives-from\n target: SYS-001\n",
709+
)
710+
.unwrap();
711+
712+
let out = Command::new(rivet_bin())
713+
.args(["--project", dirs, "validate"])
714+
.output()
715+
.expect("validate");
716+
// The gap hints are emitted on stderr alongside the gap list.
717+
let err = String::from_utf8_lossy(&out.stderr);
718+
assert!(
719+
err.contains("not `sw-req` directly")
720+
&& err.contains("sw-detail-design")
721+
&& err.contains("unit-verification"),
722+
"the lifecycle gap must name the ASPICE chain for the sw-req; stderr:\n{err}"
723+
);
724+
}
725+
682726
/// #620 (REQ-241): `rivet validate` (default salsa path) and
683727
/// `rivet validate --direct` (library path) must produce IDENTICAL results
684728
/// on the same project. A user reported them disagreeing — one flagging

0 commit comments

Comments
 (0)