Skip to content

Commit 96dcb9a

Browse files
authored
Merge pull request #640 from pulseengine/fix/577-validate-commit-name-parity
fix(validate): sync artifact-id shape with the commit-trailer parser (REQ-239, #577)
2 parents 8204255 + e7d348a commit 96dcb9a

3 files changed

Lines changed: 122 additions & 10 deletions

File tree

artifacts/requirements.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7579,7 +7579,7 @@ artifacts:
75797579
- id: REQ-239
75807580
type: requirement
75817581
title: validate <-> commit-name parsing parity
7582-
status: proposed
7582+
status: verified
75837583
description: "Sync the validate naming checks with commit-name parsing so naming issues are caught early and consistently. #577. v0.23."
75847584
provenance:
75857585
created-by: ai-assisted

rivet-core/src/commits.rs

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -322,23 +322,38 @@ pub fn extract_artifact_refs(value: &str) -> (Vec<String>, Vec<String>) {
322322
/// but is not itself a valid artifact ID. This deliberately excludes
323323
/// ordinary hyphenated prose (no digit) and bare numbers (no hyphen),
324324
/// so `rivet commits` flags genuine typos without choking on free text.
325-
fn looks_like_artifact_id_attempt(token: &str) -> bool {
325+
///
326+
/// `pub` because `rivet validate` reuses it (#577): an artifact whose id
327+
/// looks like a botched numbered id (e.g. a dotted suffix `H-3.2`) validates
328+
/// fine but can't be referenced in a commit trailer — validate warns early so
329+
/// the mismatch isn't discovered only at commit time.
330+
pub fn looks_like_artifact_id_attempt(token: &str) -> bool {
326331
!is_artifact_id(token) && token.contains('-') && token.chars().any(|c| c.is_ascii_digit())
327332
}
328333

329-
/// Check whether a string looks like an artifact ID.
334+
/// Check whether a string has the shape rivet recognises as an artifact ID
335+
/// in a **commit trailer** (`Implements: <ID>`). This is the single source of
336+
/// truth for that shape — `rivet validate` calls it too, so a project can't
337+
/// have IDs that validate but silently fail to trace through commits (#577).
330338
///
331-
/// Matches simple IDs like `REQ-001` and compound-prefix IDs like
332-
/// `UCA-C-10`. The last hyphen-separated segment must be all digits;
333-
/// all preceding segments must be non-empty uppercase ASCII.
334-
fn is_artifact_id(s: &str) -> bool {
339+
/// Matches simple IDs like `REQ-001` and compound-prefix IDs like `UCA-C-10`.
340+
/// The last hyphen-separated segment must be all digits; every preceding
341+
/// segment must be non-empty, contain at least one uppercase ASCII letter, and
342+
/// consist only of uppercase ASCII letters or digits — so a digit-bearing
343+
/// prefix like `MAD1-101` is accepted (#577), while `123-4` (no letter),
344+
/// `mad1-1` (lowercase), and `H-3.2` (dotted suffix) are not.
345+
pub fn is_artifact_id(s: &str) -> bool {
335346
if let Some(pos) = s.rfind('-') {
336347
let prefix = &s[..pos];
337348
let suffix = &s[pos + 1..];
338349
!prefix.is_empty()
339-
&& prefix
340-
.split('-')
341-
.all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_uppercase()))
350+
&& prefix.split('-').all(|seg| {
351+
!seg.is_empty()
352+
&& seg
353+
.chars()
354+
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
355+
&& seg.chars().any(|c| c.is_ascii_uppercase())
356+
})
342357
&& !suffix.is_empty()
343358
&& suffix.chars().all(|c| c.is_ascii_digit())
344359
} else {
@@ -1271,6 +1286,30 @@ mod tests {
12711286
assert!(!is_artifact_id("-1"));
12721287
}
12731288

1289+
// #577 (REQ-239): a digit-bearing prefix segment (e.g. `MAD1`) is a valid
1290+
// commit-trailer ref — the parser used to require letter-only prefixes,
1291+
// forcing a rename loop. Segments must still contain at least one letter
1292+
// and the suffix must be all digits.
1293+
// rivet: verifies REQ-239
1294+
#[test]
1295+
fn artifact_id_accepts_digit_bearing_prefix() {
1296+
assert!(is_artifact_id("MAD1-101"), "digit in prefix segment is ok");
1297+
assert!(is_artifact_id("A1-B2-3"), "digits across compound segments");
1298+
assert!(is_artifact_id("REQ-001"), "plain case still works");
1299+
// Still rejected: no letter, lowercase, dotted suffix, non-digit suffix.
1300+
assert!(!is_artifact_id("123-4"), "prefix segment needs a letter");
1301+
assert!(!is_artifact_id("mad1-1"), "lowercase prefix rejected");
1302+
assert!(!is_artifact_id("H-3.2"), "dotted suffix is not all-digits");
1303+
assert!(!is_artifact_id("REQ-00A"), "non-digit suffix rejected");
1304+
// …and the parity heuristic flags the botched-but-digit-bearing ones.
1305+
assert!(looks_like_artifact_id_attempt("H-3.2"));
1306+
assert!(!looks_like_artifact_id_attempt("MAD1-101"));
1307+
assert!(
1308+
!looks_like_artifact_id_attempt("ARCH-CORE-COMMITS"),
1309+
"a descriptive (no-digit) id is not a botched numbered id"
1310+
);
1311+
}
1312+
12741313
// -- integration: extract_artifact_ids with ranges --
12751314

12761315
// rivet: verifies REQ-017

rivet-core/src/validate.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,32 @@ pub fn validate_structural_with_externals_and_variant(
636636
if is_external_artifact(artifact) {
637637
continue;
638638
}
639+
// #577 (REQ-239): an id that looks like a botched numbered id — a
640+
// hyphen and a digit but not a parseable artifact id, e.g. a dotted
641+
// suffix `H-3.2` — validates fine but cannot be referenced in a commit
642+
// trailer (`Implements: <id>`). Warn early so the mismatch isn't
643+
// discovered only when `rivet commit` rejects the trailer. Shares the
644+
// exact shape rule with the commit parser (`crate::commits`) so the
645+
// two never diverge (the relaxation there now accepts digit-bearing
646+
// prefixes like `MAD1-101`, so only genuinely un-referenceable shapes
647+
// remain flagged here). Externally-prefixed ids are already skipped.
648+
if crate::commits::looks_like_artifact_id_attempt(&artifact.id) {
649+
diagnostics.push(Diagnostic {
650+
source_file: None,
651+
line: None,
652+
column: None,
653+
severity: Severity::Warning,
654+
artifact_id: Some(artifact.id.clone()),
655+
rule: "commit-ref-shape".to_string(),
656+
message: format!(
657+
"id '{}' can't be used as a commit-trailer reference \
658+
(trailers need an uppercase-alphanumeric prefix and an \
659+
all-digit suffix, e.g. REQ-001); rename it to trace this \
660+
artifact through commits",
661+
artifact.id
662+
),
663+
});
664+
}
639665
let type_def = match lookup_type(artifact, schema, externals) {
640666
TypeLookup::Found(td) => td,
641667
TypeLookup::Unknown => {
@@ -2719,6 +2745,53 @@ then:
27192745
);
27202746
}
27212747

2748+
/// #577 (REQ-239): validate warns when an artifact id can't be used as a
2749+
/// commit-trailer reference — a dotted suffix (`H-3.2`) is flagged, while a
2750+
/// now-accepted digit-bearing prefix (`MAD1-101`) is not. Keeps validate
2751+
/// and the commit parser in sync so naming issues surface at validate time.
2752+
///
2753+
/// rivet: verifies REQ-239
2754+
#[test]
2755+
fn validate_warns_on_non_commit_referenceable_id() {
2756+
let mut file = minimal_schema("test");
2757+
file.artifact_types = vec![ArtifactTypeDef {
2758+
name: "requirement".to_string(),
2759+
description: "REQ".to_string(),
2760+
fields: vec![],
2761+
link_fields: vec![],
2762+
aspice_process: None,
2763+
common_mistakes: vec![],
2764+
example: None,
2765+
yaml_section: None,
2766+
yaml_sections: vec![],
2767+
yaml_section_suffix: None,
2768+
shorthand_links: std::collections::BTreeMap::new(),
2769+
}];
2770+
file.traceability_rules = vec![];
2771+
let schema = Schema::merge(&[file]);
2772+
2773+
let mut store = Store::new();
2774+
store
2775+
.insert(minimal_artifact("H-3.2", "requirement"))
2776+
.unwrap();
2777+
store
2778+
.insert(minimal_artifact("MAD1-101", "requirement"))
2779+
.unwrap();
2780+
2781+
let graph = LinkGraph::build(&store, &schema);
2782+
let diags = validate(&store, &schema, &graph);
2783+
let flagged: Vec<&str> = diags
2784+
.iter()
2785+
.filter(|d| d.rule == "commit-ref-shape")
2786+
.filter_map(|d| d.artifact_id.as_deref())
2787+
.collect();
2788+
assert_eq!(
2789+
flagged,
2790+
vec!["H-3.2"],
2791+
"only the dotted-suffix id is un-referenceable; MAD1-101 is now valid"
2792+
);
2793+
}
2794+
27222795
/// Issue #349: `required-backlink` written as the INVERSE link-type
27232796
/// name (e.g. `supported-by`, the convention used by
27242797
/// `schemas/safety-case.yaml`) was never matched against the stored

0 commit comments

Comments
 (0)