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
1 change: 1 addition & 0 deletions crates/intent/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/intent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
7 changes: 7 additions & 0 deletions crates/intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
83 changes: 74 additions & 9 deletions crates/intent/src/graph_references.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,19 +663,25 @@ fn anchors_in(document: &Document) -> BTreeMap<String, SourceLocation> {
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<regex::Regex> =
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<ReqTrace> {
Expand Down Expand Up @@ -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}");
}
}
}
17 changes: 1 addition & 16 deletions crates/intent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2074,7 +2074,7 @@ fn anchors_for(content: &str) -> HashSet<String> {
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) {
Expand All @@ -2087,21 +2087,6 @@ fn anchors_for(content: &str) -> HashSet<String> {
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,
Expand Down
20 changes: 20 additions & 0 deletions crates/intent/tests/intent_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading