diff --git a/CHANGELOG.md b/CHANGELOG.md index fae91e17..c197543f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,10 @@ neighbor focus controls, and clearer icon-backed Callers, Callees, and Impact actions while preserving accessible labels and stable ordering. +- Ensure multiple Markdown tables under one heading receive section-scoped + structural identities, preventing graph publication collisions when their + headers differ. + ## 0.3.21 - 2026-08-25 - Add source-proven Python graph intelligence with artifact-only SCIP diff --git a/crates/compass-languages/src/markdown.rs b/crates/compass-languages/src/markdown.rs index 833cbbbb..ae23ab7d 100644 --- a/crates/compass-languages/src/markdown.rs +++ b/crates/compass-languages/src/markdown.rs @@ -905,8 +905,13 @@ impl State<'_, '_> { .map(|cell| truncate_utf8(&normalize_table_text(&cell.text), MAX_TABLE_CELL_BYTES)) .collect::>() .join("\u{1f}"); - let table_key = format!("{source_section}\u{1e}{header_signature}"); - let occurrence = self.table_occurrences.entry(table_key).or_default(); + // Table ordinals are scoped to the containing section, not to the + // header signature. Different tables can share a section and must + // still receive distinct qualified names and structural identities. + let occurrence = self + .table_occurrences + .entry(source_section.clone()) + .or_default(); *occurrence = occurrence.saturating_add(1); let table_ordinal = *occurrence; let table_qualified_name = format!("{source_section}::pipe_table#{table_ordinal}"); diff --git a/crates/compass-languages/tests/markdown_coverage.rs b/crates/compass-languages/tests/markdown_coverage.rs index 60ffe033..1cfb22d3 100644 --- a/crates/compass-languages/tests/markdown_coverage.rs +++ b/crates/compass-languages/tests/markdown_coverage.rs @@ -522,6 +522,60 @@ fn markdown_tables_publish_semantic_rows_and_retain_cell_links() -> Result<(), B Ok(()) } +#[test] +fn markdown_tables_with_different_headers_keep_section_scoped_identities() +-> Result<(), Box> { + let source = br#"# Event Object + +| Field | Type | Description | +| --- | --- | --- | +| name | string | Event name | + +| Value | Name | Description | +| --- | --- | --- | +| 1 | ready | Ready event | +"#; + let extraction = Engine::default().extract_source( + std::path::Path::new("docs/architecture/external-agent-protocol.md"), + source, + )?; + let tables = extraction + .nodes + .iter() + .filter(|node| { + node.attributes.get("document_kind") == Some(&serde_json::json!("pipe_table")) + }) + .collect::>(); + assert_eq!(tables.len(), 2); + assert_eq!( + tables + .iter() + .map(|node| node.string("qualified_name")) + .collect::>(), + [ + "Event Object::pipe_table#1".to_owned(), + "Event Object::pipe_table#2".to_owned(), + ] + ); + assert_ne!(tables[0].id, tables[1].id); + let header_names = extraction + .nodes + .iter() + .filter(|node| { + node.attributes.get("document_kind") == Some(&serde_json::json!("pipe_table_header")) + }) + .map(|node| node.string("qualified_name")) + .collect::>(); + assert_eq!( + header_names, + [ + "Event Object::pipe_table#1::pipe_table_header#1".to_owned(), + "Event Object::pipe_table#2::pipe_table_header#1".to_owned(), + ] + ); + Ok(()) +} + #[test] fn markdown_table_limits_are_truthful_and_later_blocks_survive() -> Result<(), Box> { let directory = tempfile::tempdir()?;