@@ -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
0 commit comments