From f608aa7c7446ca61dc334992667b92d06759a289 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:07:08 +0200 Subject: [PATCH 1/3] fix: preserve GitHub heading anchor hyphens and Unicode agent-identity: agent/dotfiles/st3-cleanup agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.9.1 agent-runtime: OMP 18.9.1 agent-model: anthropic/claude-opus-5-5 --- crates/intent/Cargo.lock | 1 + crates/intent/Cargo.toml | 1 + crates/intent/README.md | 5 ++ crates/intent/src/graph_references.rs | 72 ++++++++++++++++++++++++--- 4 files changed, 71 insertions(+), 8 deletions(-) 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..c0617f0 100644 --- a/crates/intent/README.md +++ b/crates/intent/README.md @@ -37,6 +37,11 @@ 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 letters, numbers, combining +marks and connector punctuation, remove other punctuation, and replace each +ASCII space with a hyphen. Existing hyphens are neither collapsed nor trimmed. +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..0c8299b 100644 --- a/crates/intent/src/graph_references.rs +++ b/crates/intent/src/graph_references.rs @@ -664,18 +664,25 @@ fn anchors_in(document: &Document) -> BTreeMap { } fn github_anchor(heading: &str) -> String { - let mut output = String::new(); - let mut last_dash = false; + static UNICODE_ANCHOR_CHARACTER: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + regex::Regex::new(r"\A[\p{L}\p{N}\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 +1280,52 @@ 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--"), + ] { + 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}"); + } + } +} From 161d9bc3192b53135381be7c6adb2adc30867c31 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:03:51 +0200 Subject: [PATCH 2/3] fix: share GitHub anchor reduction and match Unicode categories agent-identity: agent/dotfiles/st3-cleanup agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.10.2 agent-runtime: OMP 18.10.2 agent-model: anthropic/claude-opus-5-5 --- crates/intent/README.md | 8 +++++--- crates/intent/src/graph_references.rs | 14 ++++++++++++-- crates/intent/src/lib.rs | 17 +---------------- crates/intent/tests/intent_check.rs | 20 ++++++++++++++++++++ 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/crates/intent/README.md b/crates/intent/README.md index c0617f0..4669f7f 100644 --- a/crates/intent/README.md +++ b/crates/intent/README.md @@ -37,9 +37,11 @@ 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 letters, numbers, combining -marks and connector punctuation, remove other punctuation, and replace each -ASCII space with a hyphen. Existing hyphens are neither collapsed nor trimmed. +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 diff --git a/crates/intent/src/graph_references.rs b/crates/intent/src/graph_references.rs index 0c8299b..04d8248 100644 --- a/crates/intent/src/graph_references.rs +++ b/crates/intent/src/graph_references.rs @@ -663,10 +663,10 @@ fn anchors_in(document: &Document) -> BTreeMap { anchors } -fn github_anchor(heading: &str) -> String { +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{L}\p{N}\p{M}\p{Pc}]\z") + 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()); @@ -1301,6 +1301,16 @@ mod tests { ("*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:?}"); } 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(); From ac1a9f71a264c202a54f394cda2df0456ac2fbc1 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:40:25 +0200 Subject: [PATCH 3/3] style: rustfmt github_anchor agent-identity: agent/dotfiles/st3-cleanup agent-persona: generalist agent-supervisor: unavailable agent-tool: unknown agent-runtime: unknown agent-model: unknown --- crates/intent/src/graph_references.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/intent/src/graph_references.rs b/crates/intent/src/graph_references.rs index 04d8248..e769bcc 100644 --- a/crates/intent/src/graph_references.rs +++ b/crates/intent/src/graph_references.rs @@ -676,8 +676,7 @@ pub(crate) fn github_anchor(heading: &str) -> String { output.push('-'); } else if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') - || (!ch.is_ascii() - && UNICODE_ANCHOR_CHARACTER.is_match(ch.encode_utf8(&mut encoded))) + || (!ch.is_ascii() && UNICODE_ANCHOR_CHARACTER.is_match(ch.encode_utf8(&mut encoded))) { output.push(ch); }