diff --git a/crates/intent/Cargo.lock b/crates/intent/Cargo.lock index e5be080..d5c9222 100644 --- a/crates/intent/Cargo.lock +++ b/crates/intent/Cargo.lock @@ -906,6 +906,7 @@ dependencies = [ "clap", "jsonschema", "parking_lot", + "regex", "serde", "serde_json", "tempfile", diff --git a/crates/intent/Cargo.toml b/crates/intent/Cargo.toml index b0a4aa1..f9a2a73 100644 --- a/crates/intent/Cargo.toml +++ b/crates/intent/Cargo.toml @@ -16,6 +16,7 @@ path = "src/lib.rs" clap = { version = "4.5", features = ["derive", "env", "string"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +regex = "1" ai-gateway = { git = "https://github.com/overengineeringstudio/effect-utils", rev = "61cdf616db95c56fd016b1b78d6dc3010fd83f3c" } jsonschema = { version = "=0.58.6", default-features = false } tokio = { version = "1", features = ["rt", "time"] } diff --git a/crates/intent/README.md b/crates/intent/README.md index cf290a5..4669f7f 100644 --- a/crates/intent/README.md +++ b/crates/intent/README.md @@ -37,6 +37,13 @@ label; local links expose their normalized repository-relative path, while external links have no target location. Citation records expose the normalized ID or ordinal and the scope used for resolution. +Heading anchors lowercase Unicode text, retain alphabetic characters, decimal +and letter numbers, combining marks and connector punctuation, and replace each +ASCII space with a hyphen. Other numbers and non-alphabetic symbols are removed. +Existing hyphens are neither collapsed nor trimmed. Graph extraction and link +checking share this reduction. +Repeated headings receive collision-safe `-1`, `-2`, … suffixes within a file. + ## `check` exiting 0 does not mean it read anything `intent check` on an empty directory, or on a path holding no Intent artifacts at all, diff --git a/crates/intent/src/graph_references.rs b/crates/intent/src/graph_references.rs index fff955c..e769bcc 100644 --- a/crates/intent/src/graph_references.rs +++ b/crates/intent/src/graph_references.rs @@ -663,19 +663,25 @@ fn anchors_in(document: &Document) -> BTreeMap { anchors } -fn github_anchor(heading: &str) -> String { - let mut output = String::new(); - let mut last_dash = false; +pub(crate) fn github_anchor(heading: &str) -> String { + static UNICODE_ANCHOR_CHARACTER: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + regex::Regex::new(r"\A[\p{Alphabetic}\p{Nd}\p{Nl}\p{M}\p{Pc}]\z") + .expect("valid GitHub anchor character pattern") + }); + let mut output = String::with_capacity(heading.len()); + let mut encoded = [0; 4]; for ch in heading.chars().flat_map(char::to_lowercase) { - if ch.is_ascii_alphanumeric() { - output.push(ch); - last_dash = false; - } else if (ch.is_whitespace() || ch == '-') && !last_dash && !output.is_empty() { + if ch == ' ' { output.push('-'); - last_dash = true; + } else if ch.is_ascii_alphanumeric() + || matches!(ch, '-' | '_') + || (!ch.is_ascii() && UNICODE_ANCHOR_CHARACTER.is_match(ch.encode_utf8(&mut encoded))) + { + output.push(ch); } } - output.trim_matches('-').to_string() + output } fn req_traces_in(document: &Document) -> Vec { @@ -1273,3 +1279,62 @@ fn relative_display(root: &Path, path: &Path) -> String { fn first_line_end(content: &str) -> usize { content.find('\n').unwrap_or(content.len()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_heading_anchors_preserve_spacing_and_unicode() { + for (heading, expected) in [ + ("Phase C — molty retirement", "phase-c--molty-retirement"), + ("Phase C – molty retirement", "phase-c--molty-retirement"), + (" - ", "---"), + ("! leading and trailing ? ", "-leading-and-trailing--"), + ("a.b:c/d(e)&f", "abcdef"), + ("`code`", "code"), + ("hello 😀 world", "hello--world"), + ("Übersicht", "übersicht"), + ("e\u{301} Ⅳ ٢", "e\u{301}-ⅳ-٢"), + ("under_score ‿ connector", "under_score-‿-connector"), + ("*emphasis*", "emphasis"), + ("a\tb", "ab"), + ("--already--", "--already--"), + ("x²", "x"), + ("x½", "x"), + ("x①", "x"), + ("ⓐ", "ⓐ"), + // Representative category fixtures from github-slugger@3461c435, + // test/fixtures.json: Other_Number and Other_Symbol. + ("a² ¹ ½ ৴ ৶ ৹b", "a-----b"), + ("a¦ ୰ Ⓔ Ⓣ ⓘ ⓨ 🄺 🅙 🅨 🅻b", "a--ⓔ-ⓣ-ⓘ-ⓨ-🄺-🅙-🅨-🅻b"), + ("heading with a - dash", "heading-with-a---dash"), + ("Привет non-latin 你好", "привет-non-latin-你好"), + ] { + assert_eq!(github_anchor(heading), expected, "{heading:?}"); + } + } + + #[test] + fn repeated_heading_anchors_keep_collision_suffixes() { + let content = "# Phase C — molty retirement\n# Phase C — molty retirement\n\ + # Phase C — molty retirement-1\n# Phase C — molty retirement\n"; + let document = Document { + path: PathBuf::from("test.md"), + repo_path: "test.md".to_string(), + content: content.to_string(), + line_starts: std::iter::once(0) + .chain(content.match_indices('\n').map(|(index, _)| index + 1)) + .collect(), + }; + let anchors = anchors_in(&document); + for anchor in [ + "phase-c--molty-retirement", + "phase-c--molty-retirement-1", + "phase-c--molty-retirement-1-1", + "phase-c--molty-retirement-2", + ] { + assert!(anchors.contains_key(anchor), "{anchor}"); + } + } +} diff --git a/crates/intent/src/lib.rs b/crates/intent/src/lib.rs index ba6dc85..d2ade28 100644 --- a/crates/intent/src/lib.rs +++ b/crates/intent/src/lib.rs @@ -2074,7 +2074,7 @@ fn anchors_for(content: &str) -> HashSet { if heading.is_empty() { continue; } - let mut anchor = github_anchor(heading); + let mut anchor = graph_references::github_anchor(heading); let base = anchor.clone(); let mut index = 1; while seen.contains(&anchor) { @@ -2087,21 +2087,6 @@ fn anchors_for(content: &str) -> HashSet { anchors } -fn github_anchor(heading: &str) -> String { - let mut out = String::new(); - let mut last_dash = false; - for ch in heading.chars().flat_map(char::to_lowercase) { - if ch.is_ascii_alphanumeric() { - out.push(ch); - last_dash = false; - } else if (ch.is_whitespace() || ch == '-') && !last_dash && !out.is_empty() { - out.push('-'); - last_dash = true; - } - } - out.trim_matches('-').to_string() -} - #[derive(Debug)] struct Section { heading: String, diff --git a/crates/intent/tests/intent_check.rs b/crates/intent/tests/intent_check.rs index 49705ae..406c0a2 100644 --- a/crates/intent/tests/intent_check.rs +++ b/crates/intent/tests/intent_check.rs @@ -552,6 +552,26 @@ fn valid_minimal_intent_tree_passes_json_check() { assert_eq!(report["diagnostics"].as_array().unwrap().len(), 0); } +#[test] +fn check_resolves_em_dash_heading_anchors_locally_and_across_files() { + let h = Harness::new(); + fs::write( + h.repo.join("context/intent/spec.md"), + "# Phase C — molty retirement\n\nSee [local](#phase-c--molty-retirement).\n", + ) + .expect("heading with local link"); + fs::write( + h.repo.join("context/intent/requirements.md"), + "# Requirements\n\nSee [phase](./spec.md#phase-c--molty-retirement).\n", + ) + .expect("cross-file heading link"); + + let output = h.check(&["--json", "--profile", "strict"]); + let report = stdout_json(&output); + assert!(output.status.success(), "{report}"); + assert_eq!(report["diagnostics"].as_array().unwrap().len(), 0); +} + #[test] fn missing_local_markdown_links_are_transitional_locally_and_blocking_in_strict_profile() { let h = Harness::new();