diff --git a/crates/intent/src/graph_references.rs b/crates/intent/src/graph_references.rs index 7ad24fa..fff955c 100644 --- a/crates/intent/src/graph_references.rs +++ b/crates/intent/src/graph_references.rs @@ -701,11 +701,14 @@ fn req_traces_in(document: &Document) -> Vec { { cursor += 1; } - let contains_placeholder = bytes.get(cursor) == Some(&b'<'); + let followed_by_angle_bracket = bytes.get(cursor) == Some(&b'<'); while cursor > target_start && bytes[cursor - 1] == b'.' { cursor -= 1; } - if cursor > target_start && !contains_placeholder { + let target = &line[target_start..cursor]; + let incomplete_placeholder = + followed_by_angle_bracket && !is_complete_req_target(target); + if cursor > target_start && !incomplete_placeholder { traces.push(ReqTrace { written: line[target_start..cursor].to_string(), range: line_start + found..line_start + cursor, @@ -727,6 +730,23 @@ fn is_req_target_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/' | b'#') } +fn is_complete_req_target(target: &str) -> bool { + let id = target + .rsplit_once('#') + .map_or(target, |(_, fragment)| fragment); + let normalized; + let id = if id.bytes().any(|byte| byte.is_ascii_lowercase()) { + normalized = id.to_ascii_uppercase(); + normalized.as_str() + } else { + id + }; + let bytes = id.as_bytes(); + qualified_id_end(bytes, 0) + .or_else(|| local_id_end(bytes, 0)) + .is_some_and(|end| end == bytes.len()) +} + fn resolve_req_trace( corpus_root: &Path, repository_root: &Path, diff --git a/crates/intent/tests/intent_check.rs b/crates/intent/tests/intent_check.rs index 6c444e4..49705ae 100644 --- a/crates/intent/tests/intent_check.rs +++ b/crates/intent/tests/intent_check.rs @@ -468,7 +468,7 @@ fn graph_json_recognizes_all_supported_requirement_definition_forms() { .expect("requirements"); fs::write( h.repo.join("context/intent/spec.md"), - "# Spec\n\nThis builds on [requirements](./requirements.md).\n\nAPP-R02 and R03 constrain the graph.\nreq: APP-R02\nreq: \nreq: #\nreq: requirements.md#\nreq: APP-\n", + "# Spec\n\nThis builds on [requirements](./requirements.md).\n\nAPP-R02 and R03 constrain the graph.\nreq: APP-R02\nreq: APP-R02
\nreq: APP-R02\nreq: app-r02
\nreq: \nreq: #\nreq: requirements.md#\nreq: APP-\n", ) .expect("spec"); @@ -517,10 +517,23 @@ fn graph_json_recognizes_all_supported_requirement_definition_forms() { .collect::>(); assert_eq!( traces.len(), - 1, + 4, "template placeholders must not become requirement traces: {traces:#?}" ); - assert_eq!(traces[0]["written_target"], "APP-R02"); + assert_eq!( + traces + .iter() + .filter(|trace| trace["written_target"] == "APP-R02") + .count(), + 3, + "HTML immediately after a complete target must preserve the trace: {traces:#?}" + ); + assert!( + traces.iter().any(|trace| { + trace["written_target"] == "app-r02" && trace["normalized_target"] == "APP-R02" + }), + "completeness must use the same case normalization as resolution: {traces:#?}" + ); } #[test]