diff --git a/crates/intent/src/graph_references.rs b/crates/intent/src/graph_references.rs index 7e5e23c..bd1b3b5 100644 --- a/crates/intent/src/graph_references.rs +++ b/crates/intent/src/graph_references.rs @@ -125,6 +125,16 @@ struct IdOccurrence { range: Range, } +#[derive(Clone, Debug)] +pub(super) struct StructuredId { + pub id: String, + pub title: String, + pub refs: Vec, + pub refines: Vec, + pub evidence: String, + range: Range, +} + #[derive(Clone, Debug)] struct ReqTrace { written: String, @@ -301,33 +311,81 @@ fn definition_occurrences(document: &Document) -> Vec { .and_then(|rest| rest.strip_prefix(". "))? }; let marker_offset = trimmed.len() - after_marker.len(); - let bold = after_marker.strip_prefix("**")?; - let close = bold.find("**")?; - let label = &bold[..close]; - let id = label - .trim_start() - .split(|ch: char| ch.is_whitespace() || ch == ':') - .next()?; - if !looks_like_definition_id(id) { - return None; - } - let id_in_label = label.find(id)?; - let start = line_start + indentation + marker_offset + 2 + id_in_label; - Some(IdOccurrence { - written: id.to_string(), - normalized: id.to_ascii_uppercase(), - range: start..start + id.len(), - }) + after_marker.strip_prefix("**")?; + structured_id_at(&document.content, line_start + indentation + marker_offset).map( + |definition| IdOccurrence { + written: definition.id.clone(), + normalized: definition.id.to_ascii_uppercase(), + range: definition.range, + }, + ) }) .collect() } -fn looks_like_definition_id(value: &str) -> bool { - value.len() >= 2 - && value.chars().any(|ch| ch.is_ascii_digit()) - && value - .chars() - .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '.' || ch == '-') +pub(super) fn structured_ids_outside_code(content: &str) -> Vec { + prose_lines(content) + .into_iter() + .filter_map(|(line_start, line)| { + line.find("**") + .and_then(|start| structured_id_at(content, line_start + start)) + }) + .collect() +} + +fn structured_id_at(content: &str, bold_start: usize) -> Option { + let label_start = bold_start.checked_add(2)?; + let label_tail = content.get(label_start..)?; + let label_end = label_start + label_tail.find("**")?; + let label_source = content.get(label_start..label_end)?; + if label_source.lines().skip(1).any(|line| { + let trimmed = line.trim_start(); + line.trim().is_empty() || trimmed.starts_with("```") || trimmed.starts_with("~~~") + }) { + return None; + } + + let label = label_source + .split_whitespace() + .collect::>() + .join(" "); + let label = label.trim().trim_end_matches(':').trim(); + let written_id = label + .split(|character: char| character.is_whitespace() || character == ':') + .next()?; + let id = written_id.trim_end_matches('.'); + if !super::looks_like_intent_id(id) { + return None; + } + let id_offset = label_source.find(written_id)?; + let range = label_start + id_offset..label_start + id_offset + id.len(); + let title = label + .get(written_id.len()..) + .unwrap_or_default() + .trim() + .to_string(); + let rest_start = label_end + 2; + let rest_end = content[rest_start..] + .find('\n') + .map_or(content.len(), |offset| rest_start + offset); + let rest = content.get(rest_start..rest_end)?.trim(); + let evidence_start = content[..bold_start] + .rfind('\n') + .map_or(0, |offset| offset + 1); + let evidence = content.get(evidence_start..rest_end)?.trim().to_string(); + + Some(StructuredId { + id: id.to_string(), + title: if title.is_empty() { + id.to_string() + } else { + title + }, + refs: super::refs_in_text(rest), + refines: super::refines_in_text(rest), + evidence, + range, + }) } fn group_definitions(definitions: &[Definition]) -> BTreeMap> { @@ -648,7 +706,7 @@ fn is_word_byte(byte: u8) -> bool { } fn is_req_target_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/' | b'#' | b'<' | b'>') + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/' | b'#') } fn resolve_req_trace( diff --git a/crates/intent/src/lib.rs b/crates/intent/src/lib.rs index 1b15416..19363b1 100644 --- a/crates/intent/src/lib.rs +++ b/crates/intent/src/lib.rs @@ -1180,7 +1180,7 @@ pub fn graph_root(root: &Path) -> Result let content = contents .get(path) .expect("graph reference builder read every Markdown file"); - for id in structured_ids_outside_code(content) { + for id in graph_references::structured_ids_outside_code(content) { nodes.insert(GraphNode { id: id.id.clone(), kind: graph_id_kind(&id.id).to_string(), @@ -1844,57 +1844,6 @@ fn wikilinks_in_line(line: &str) -> Vec { links } -#[derive(Debug)] -struct StructuredId { - id: String, - title: String, - refs: Vec, - refines: Vec, - evidence: String, -} - -fn structured_ids_outside_code(content: &str) -> Vec { - let mut ids = Vec::new(); - let mut in_fence = false; - for line in content.lines() { - if line.trim_start().starts_with("```") { - in_fence = !in_fence; - continue; - } - if in_fence { - continue; - } - if let Some(id) = structured_id_in_line(line) { - ids.push(id); - } - } - ids -} - -fn structured_id_in_line(line: &str) -> Option { - let start = line.find("**")? + 2; - let end = line[start..].find("**").map(|offset| start + offset)?; - let label = line[start..end].trim().trim_end_matches(':').trim(); - let mut parts = label.splitn(2, char::is_whitespace); - let id = parts.next()?.trim(); - if !looks_like_intent_id(id) { - return None; - } - let title = parts.next().unwrap_or("").trim().to_string(); - let rest = &line[end + 2..]; - Some(StructuredId { - id: id.to_string(), - title: if title.is_empty() { - id.to_string() - } else { - title - }, - refs: refs_in_text(rest), - refines: refines_in_text(rest), - evidence: line.trim().to_string(), - }) -} - fn looks_like_intent_id(value: &str) -> bool { value.len() >= 2 && value.chars().any(|ch| ch.is_ascii_digit()) @@ -1922,11 +1871,16 @@ fn refines_in_text(text: &str) -> Vec { } fn graph_id_kind(id: &str) -> &'static str { - if id.contains("-R") { + let local_kind = id + .as_bytes() + .first() + .copied() + .filter(|_| id.len() == 3 && id.as_bytes()[1..].iter().all(u8::is_ascii_digit)); + if local_kind == Some(b'R') || id.contains("-R") { "requirement" - } else if id.contains("-A") { + } else if local_kind == Some(b'A') || id.contains("-A") { "assumption" - } else if id.contains("-T") { + } else if local_kind == Some(b'T') || id.contains("-T") { "tradeoff" } else if id.starts_with("DQ") || id.contains("-DQ") { "design_question" diff --git a/crates/intent/tests/intent_check.rs b/crates/intent/tests/intent_check.rs index f6db0d0..e70a4fc 100644 --- a/crates/intent/tests/intent_check.rs +++ b/crates/intent/tests/intent_check.rs @@ -450,6 +450,70 @@ fn graph_json_resolves_traces_plain_ids_and_scoped_companion_ordinals() { } } +#[test] +fn graph_json_recognizes_all_supported_requirement_definition_forms() { + let h = Harness::new(); + fs::write( + h.repo.join("context/intent/requirements.md"), + "# Requirements\n\n- **A01 Local assumption:** fixture.\n- **R01 Local requirement:** fixture.\n- **T01 Local tradeoff:** fixture.\n- **APP-R02 A multiline\n requirement title:** fixture.\n- **R03.** Legacy local requirement.\n", + ) + .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: #\n", + ) + .expect("spec"); + + let output = h.graph(&["--json"]); + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let graph = stdout_json(&output); + let nodes = graph["nodes"].as_array().unwrap(); + for (id, kind, title) in [ + ("A01", "assumption", "Local assumption"), + ("R01", "requirement", "Local requirement"), + ("T01", "tradeoff", "Local tradeoff"), + ("APP-R02", "requirement", "A multiline requirement title"), + ("R03", "requirement", "R03"), + ] { + assert!( + nodes + .iter() + .any(|node| { node["id"] == id && node["kind"] == kind && node["title"] == title }), + "missing {kind} node {id}" + ); + } + assert!( + !nodes.iter().any(|node| node["id"] == "R03."), + "legacy punctuation is not part of the requirement ID" + ); + + let references = graph["references"].as_array().unwrap(); + for target in ["APP-R02", "R03"] { + assert!( + references.iter().any(|reference| { + reference["syntax"] == "id_citation" + && reference["written_target"] == target + && reference["resolution"] == "resolved" + }), + "missing resolved citation for {target}" + ); + } + assert!( + references.iter().all(|reference| { + reference["syntax"] != "req_trace" + || !reference["written_target"] + .as_str() + .is_some_and(|target| target.contains('<') || target.contains('>')) + }), + "template placeholders must not become requirement traces" + ); +} + #[test] fn valid_minimal_intent_tree_passes_json_check() { let h = Harness::new();