Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 82 additions & 24 deletions crates/intent/src/graph_references.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ struct IdOccurrence {
range: Range<usize>,
}

#[derive(Clone, Debug)]
pub(super) struct StructuredId {
pub id: String,
pub title: String,
pub refs: Vec<String>,
pub refines: Vec<String>,
pub evidence: String,
range: Range<usize>,
}

#[derive(Clone, Debug)]
struct ReqTrace {
written: String,
Expand Down Expand Up @@ -301,33 +311,81 @@ fn definition_occurrences(document: &Document) -> Vec<IdOccurrence> {
.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<StructuredId> {
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<StructuredId> {
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::<Vec<_>>()
.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<String, Vec<Definition>> {
Expand Down Expand Up @@ -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'#')
Comment thread
schickling-assistant marked this conversation as resolved.
Resolved
}

fn resolve_req_trace(
Expand Down
64 changes: 9 additions & 55 deletions crates/intent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1180,7 +1180,7 @@ pub fn graph_root(root: &Path) -> Result<GraphReport, Box<dyn std::error::Error>
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(),
Expand Down Expand Up @@ -1844,57 +1844,6 @@ fn wikilinks_in_line(line: &str) -> Vec<String> {
links
}

#[derive(Debug)]
struct StructuredId {
id: String,
title: String,
refs: Vec<String>,
refines: Vec<String>,
evidence: String,
}

fn structured_ids_outside_code(content: &str) -> Vec<StructuredId> {
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<StructuredId> {
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())
Expand Down Expand Up @@ -1922,11 +1871,16 @@ fn refines_in_text(text: &str) -> Vec<String> {
}

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"
Expand Down
64 changes: 64 additions & 0 deletions crates/intent/tests/intent_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ID>\nreq: <repo-relative-requirements-path>#<ID>\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();
Expand Down
Loading