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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

- Improve Markdown as first-class agent context. Repeated automatic heading
slugs now follow deterministic source-order suffixes, every structural block
has a section-qualified identity, and project resolution connects exact,
extensionless, repository-root, directory-index, and unique-wikilink targets
to document headings or source-file inventory. Missing fragments and
ambiguous targets remain unresolved, while exact document relationships now
survive the default low inference level. Advance extraction semantics to v12
so affected cached facts rebuild.

- Improve natural code discovery on independently reviewed Python and
TypeScript libraries. Ranking now recognizes common operation vocabulary,
compound protocol owners, container roles, and decoder-chain intent without
Expand Down
8 changes: 8 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,14 @@ changing node identity; consumers must preserve unknown attributes, edge
direction, multiplicity, and source ranges. Markdown frontmatter is part of
the file hash, so metadata-only edits invalidate compatible extraction/cache
entries and are rebuilt under the current extraction semantics version.
Structural blocks now carry section-qualified names and automatic duplicate
heading slugs use deterministic source-order suffixes. Project resolution may
connect local document links to a unique heading, document root, directory
index, or source-file inventory node. Extension inference and wikilink stem
matching are bounded closed rules; ambiguity or a missing fragment never picks
one candidate or falls back to the document root. These additive attributes and
relationships rebuild under extraction semantics v12; graph schema v1 and
existing relationship direction remain unchanged.

HTML (`.html`/`.htm`) now has the same source-driven structural contract. HTML
nodes and link evidence preserve exact source ranges and deterministic order;
Expand Down
18 changes: 16 additions & 2 deletions crates/compass-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8231,14 +8231,18 @@ mod tests {
let directory = tempfile::tempdir()?;
fs::write(
directory.path().join("guide.md"),
"# Guide\n[Implementation](documented.rs)\n",
"# Guide\n[Implementation](documented.rs)\n[Details](details#usage)\n",
)?;
fs::write(
directory.path().join("details.md"),
"# Details\n\n## Usage\n\nExact project documentation.\n",
)?;
fs::write(
directory.path().join("documented.rs"),
"pub fn documented() {}\n",
)?;
let mut options = BuildOptions::new(directory.path());
options.inference_level = InferenceLevel::Max;
options.inference_level = InferenceLevel::Low;
options.no_cluster = true;
options.no_viz = true;
options.force = true;
Expand All @@ -8251,6 +8255,16 @@ mod tests {
.any(|edge| edge.kind.as_str() == "documents"),
"graph={graph:#?}"
);
assert!(
graph.links.iter().any(|edge| {
edge.kind.as_str() == "references"
&& edge
.relationship_site
.as_ref()
.is_some_and(|site| site.file == "guide.md")
}),
"exact document resolution must survive default-low publication: {graph:#?}"
);
Ok(())
}

Expand Down
2 changes: 1 addition & 1 deletion crates/compass-languages/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ mod fortran;
pub mod frameworks;

/// Version of the extraction contract consumed by graph publication.
pub const EXTRACTION_SEMANTICS_VERSION: &str = "compass.languages.extraction/11";
pub const EXTRACTION_SEMANTICS_VERSION: &str = "compass.languages.extraction/12";
mod go;
mod groovy;
mod html;
Expand Down
140 changes: 122 additions & 18 deletions crates/compass-languages/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub(crate) fn extract_source(
seen_nodes: HashSet::new(),
heading_stack: Vec::new(),
heading_occurrences: HashMap::new(),
heading_slug_occurrences: HashMap::new(),
heading_targets: HashMap::new(),
reference_definitions: HashMap::new(),
footnote_definitions: HashMap::new(),
Expand Down Expand Up @@ -167,6 +168,7 @@ struct State<'source, 'path> {
seen_nodes: HashSet<String>,
heading_stack: Vec<HeadingFrame>,
heading_occurrences: HashMap<String, usize>,
heading_slug_occurrences: HashMap<String, usize>,
heading_targets: HashMap<String, Vec<String>>,
reference_definitions: HashMap<String, String>,
footnote_definitions: HashMap<String, Vec<String>>,
Expand Down Expand Up @@ -203,6 +205,12 @@ struct LinkSite {
line_start: usize,
}

struct DocumentTargetHint {
path: String,
extension_inferred: bool,
root_relative: bool,
}

impl State<'_, '_> {
fn add_root(&mut self, id: String, metadata: Option<Map<String, Value>>) {
self.seen_nodes.insert(id.clone());
Expand Down Expand Up @@ -328,10 +336,8 @@ impl State<'_, '_> {
.to_owned(),
),
);
extra.insert(
"anchor_slug".to_owned(),
Value::String(slugify(explicit_id.as_deref().unwrap_or(title))),
);
let anchor_slug = self.heading_anchor_slug(title, explicit_id.as_deref());
extra.insert("anchor_slug".to_owned(), Value::String(anchor_slug.clone()));
if let Some(explicit_id) = explicit_id.as_deref() {
extra.insert(
"explicit_id".to_owned(),
Expand All @@ -346,7 +352,7 @@ impl State<'_, '_> {
parent.as_deref(),
extra,
);
self.register_heading_target(title, explicit_id.as_deref(), &id);
self.register_heading_target(&anchor_slug, explicit_id.as_deref(), &id);
self.collect_inline_descendants(node, &id);
self.heading_stack.push(HeadingFrame {
level,
Expand Down Expand Up @@ -408,6 +414,24 @@ impl State<'_, '_> {
} else if kind == "pipe_table_cell" {
extra.insert("table_role".to_owned(), Value::String("cell".to_owned()));
}
if let Some(section) = self.heading_stack.last() {
extra.insert(
"document_section".to_owned(),
Value::String(section.qualified_name.clone()),
);
extra.insert(
"qualified_name".to_owned(),
Value::String(format!(
"{}::{kind}#{}",
section.qualified_name, self.next_block_index
)),
);
} else {
extra.insert(
"qualified_name".to_owned(),
Value::String(format!("{}::{kind}#{}", self.stem, self.next_block_index)),
);
}
let id = self.add_block_node(
id,
&label,
Expand Down Expand Up @@ -928,8 +952,26 @@ impl State<'_, '_> {
owner
}

fn register_heading_target(&mut self, title: &str, explicit_id: Option<&str>, id: &str) {
let mut keys = vec![slugify(title)];
fn heading_anchor_slug(&mut self, title: &str, explicit_id: Option<&str>) -> String {
let base = slugify(explicit_id.unwrap_or(title));
if explicit_id.is_some() || base.is_empty() {
return base;
}
let occurrence = self
.heading_slug_occurrences
.entry(base.clone())
.or_default();
let slug = if *occurrence == 0 {
base
} else {
format!("{base}-{occurrence}")
};
*occurrence = occurrence.saturating_add(1);
slug
}

fn register_heading_target(&mut self, anchor_slug: &str, explicit_id: Option<&str>, id: &str) {
let mut keys = vec![anchor_slug.to_ascii_lowercase()];
if let Some(explicit_id) = explicit_id {
keys.push(explicit_id.to_ascii_lowercase());
keys.push(slugify(explicit_id));
Expand Down Expand Up @@ -1004,13 +1046,13 @@ impl State<'_, '_> {
.split_once('?')
.map_or(path_part, |(path, _)| path)
.trim();
let target_path = if path_part.is_empty() {
let root_relative = path_part.starts_with('/');
let unresolved_path = if path_part.is_empty() {
lexical_normalize(self.path)
} else if root_relative {
lexical_normalize(Path::new(path_part.trim_start_matches('/')))
} else {
let mut target = PathBuf::from(path_part);
if target.extension().is_none() {
target.set_extension("md");
}
let target = PathBuf::from(path_part);
if target.is_absolute() {
target
} else {
Expand All @@ -1023,14 +1065,20 @@ impl State<'_, '_> {
)
}
};
let extension_inferred = unresolved_path.extension().is_none();
let mut target_path = unresolved_path.clone();
if extension_inferred {
target_path.set_extension("md");
}
let same_file = lexical_normalize(self.path) == target_path;
if same_file {
if let Some(fragment) = fragment.filter(|fragment| !fragment.is_empty()) {
let key = fragment.to_ascii_lowercase();
let fragment_key = decode_fragment(fragment);
let key = fragment_key.to_ascii_lowercase();
let candidates = self
.heading_targets
.get(&key)
.or_else(|| self.heading_targets.get(&slugify(fragment)));
.or_else(|| self.heading_targets.get(&slugify(&fragment_key)));
match candidates {
Some(candidates) if candidates.len() == 1 => {
if let Some(target_id) = candidates.first() {
Expand All @@ -1047,9 +1095,6 @@ impl State<'_, '_> {
}
continue;
}
if is_documentable_source(&target_path) && !target_path.is_file() {
continue;
}
if !is_supported_local_link(&target_path) {
continue;
}
Expand All @@ -1059,7 +1104,16 @@ impl State<'_, '_> {
} else {
"references"
};
self.add_link_edge(&pending, target_id, Some((relation, fragment)));
self.add_link_edge_with_hint(
&pending,
target_id,
Some((relation, fragment)),
DocumentTargetHint {
path: unresolved_path.to_string_lossy().replace('\\', "/"),
extension_inferred,
root_relative,
},
);
}
}

Expand All @@ -1080,6 +1134,28 @@ impl State<'_, '_> {
);
}

fn add_link_edge_with_hint(
&mut self,
pending: &PendingLink,
target: String,
relation: Option<(&str, Option<&str>)>,
hint: DocumentTargetHint,
) {
self.add_link_edge(pending, target, relation);
if let Some(edge) = self.extraction.edges.last_mut() {
edge.attributes
.insert("_document_target_path".to_owned(), Value::String(hint.path));
edge.attributes.insert(
"_document_target_extension_inferred".to_owned(),
Value::Bool(hint.extension_inferred),
);
edge.attributes.insert(
"_document_target_root_relative".to_owned(),
Value::Bool(hint.root_relative),
);
}
}

fn add_unresolved(&mut self, pending: &PendingLink, reason: &str, target: &str) {
if self.unresolved_links.len() >= MAX_DIAGNOSTICS {
return;
Expand Down Expand Up @@ -1648,6 +1724,34 @@ fn slugify(text: &str) -> String {
slug
}

fn decode_fragment(fragment: &str) -> String {
let bytes = fragment.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len().min(512));
let mut index = 0;
while index < bytes.len() && decoded.len() < 512 {
if bytes[index] == b'%'
&& let (Some(high), Some(low)) = (bytes.get(index + 1), bytes.get(index + 2))
&& let (Some(high), Some(low)) = (hex_digit(*high), hex_digit(*low))
{
decoded.push(high.saturating_mul(16).saturating_add(low));
index = index.saturating_add(3);
continue;
}
decoded.push(bytes[index]);
index = index.saturating_add(1);
}
String::from_utf8_lossy(&decoded).into_owned()
}

const fn hex_digit(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}

fn normalize_reference_label(label: &str) -> String {
label
.trim()
Expand Down
51 changes: 42 additions & 9 deletions crates/compass-languages/tests/markdown_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ let hidden = "[not a link](ignored.md)";
edge.attributes.get("fragment") == Some(&serde_json::json!("start"))
&& edge.target != root.id
}));
assert!(extraction.nodes.iter().any(|node| {
node.attributes.get("document_kind") == Some(&serde_json::json!("paragraph"))
&& node.string("qualified_name").contains("::paragraph#")
}));
assert!(extraction.nodes.iter().all(|node| {
node.attributes
.get("start_byte")
Expand All @@ -257,18 +261,47 @@ let hidden = "[not a link](ignored.md)";
}

#[test]
fn markdown_duplicate_fragments_are_explicitly_unresolved() -> Result<(), Box<dyn Error>> {
fn markdown_duplicate_heading_slugs_follow_source_order_and_explicit_ids_remain_ambiguous()
-> Result<(), Box<dyn Error>> {
let extraction = Engine::default().extract_source(
std::path::Path::new("guide.md"),
b"# Same\n\n## Same\n\n## Other\n\n[jump](#same)\n",
b"# Same\n\n## Same\n\n## Other\n\n[first](#same) [second](#same-1)\n",
)?;
assert!(
!extraction
.edges
.iter()
.any(|edge| edge.attributes.get("link_kind") == Some(&serde_json::json!("inline")))
);
let unresolved = extraction
let links = extraction
.edges
.iter()
.filter(|edge| edge.attributes.get("link_kind") == Some(&serde_json::json!("inline")))
.collect::<Vec<_>>();
assert_eq!(links.len(), 2);
assert_ne!(links[0].target, links[1].target);
let slugs = extraction
.nodes
.iter()
.filter_map(|node| node.attributes.get("anchor_slug"))
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>();
assert!(slugs.contains(&"same"));
assert!(slugs.contains(&"same-1"));

let encoded = Engine::default().extract_source(
std::path::Path::new("encoded.md"),
b"# Encoded {#agent:rules}\n\n[jump](#agent%3Arules)\n",
)?;
let encoded_heading = encoded
.nodes
.iter()
.find(|node| node.string("explicit_id") == "agent:rules")
.ok_or("missing encoded-fragment heading")?;
assert!(encoded.edges.iter().any(|edge| {
edge.attributes.get("fragment") == Some(&serde_json::json!("agent%3Arules"))
&& edge.target == encoded_heading.id
}));

let explicit = Engine::default().extract_source(
std::path::Path::new("explicit.md"),
b"# One {#shared}\n\n# Two {#shared}\n\n[jump](#shared)\n",
)?;
let unresolved = explicit
.extensions
.get("markdown_unresolved_links")
.and_then(serde_json::Value::as_array)
Expand Down
Loading
Loading