refactor: move the extraction fix out (split into a patch PR)

This commit is contained in:
Caminhar
2026-09-25 14:25:58 -03:00
parent 444170607d
commit 1b6ef53bb2
3 changed files with 15 additions and 58 deletions
+5 -12
View File
@@ -293,18 +293,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
instead of `%C3%A9`. An accented cwd reached the server as a different
path, and Cursor events and the session-start handoff lookup both use the
query `cwd`. (#877)
- Link extraction no longer mints a permanently unresolved row from a
directory target. A `relations:` value whose final component is empty
(`sessions/`) had the extension appended to nothing and was stored as the
literal `sessions/.md`; the same target in a body link or wikilink
(`[notes/](notes/)`) stayed extension-less. No page path can match either —
page paths carry `.md` and `latest_page_id_for_link` matches exactly — so
both sat in `links` with `to_page_id = NULL` and were visible only as
`unresolved:` in `ai-memory status`. Both routes now skip a directory
target, or a stem-less `.md`, with the existing warning. Unresolved
same-project links are reported by `memory_lint` now too: it only knew
cross-project dangling edges, so a broken internal link stayed invisible
outside the counter. (#911)
- `memory_lint` now reports unresolved same-project links. Only cross-project
dangling edges (`DanglingCrossLink`) were reported, so a link to a missing
page in the same project stayed invisible outside the status counter;
`ReaderPool::dangling_internal_links` now feeds the same `broken_link`
findings. (#911)
## [2.4.0] - 2026-09-21
+9 -44
View File
@@ -190,18 +190,6 @@ fn log_bounded(value: &str) -> String {
ai_memory_core::truncate_utf8_bytes(value, RELATION_LOG_FIELD_MAX_BYTES)
}
/// Whether a link target's final path segment can name a page.
///
/// A directory target (trailing `/`, so an empty last segment) and a
/// stem-less `.md` cannot: the first used to normalize to the literal
/// `notes/.md` in `relations:` frontmatter, the second to an
/// extension-less path — both permanently unresolved `links` rows that no
/// page write could ever repoint.
fn last_segment_names_a_page(target: &str) -> bool {
let last = target.rsplit_once('/').map_or(target, |(_, s)| s);
!last.is_empty() && last != ".md"
}
/// Extract typed relation edges from a page's `relations:` frontmatter
/// (2.0 item 3):
///
@@ -237,18 +225,17 @@ pub fn extract_relation_links(frontmatter: &serde_json::Value) -> Vec<LinkTarget
},
};
// Same terminal normalization as wikilinks: extension-less
// targets gain `.md`; a directory target, a stem-less `.md`, and
// anything with a non-md extension are not pages and are skipped.
// targets gain `.md`; anything with a non-md extension is
// not a page and is skipped.
let raw_path = raw_path.trim();
let last = raw_path.rsplit_once('/').map_or(raw_path, |(_, s)| s);
let names_a_page = last_segment_names_a_page(raw_path)
&& (!last.contains('.') || raw_path.ends_with(".md"));
if !names_a_page {
tracing::warn!(target = %log_bounded(target), "relation target is not a page; skipping");
continue;
}
let normalized = if last.contains('.') {
raw_path.to_string()
if raw_path.ends_with(".md") {
raw_path.to_string()
} else {
tracing::warn!(target = %log_bounded(target), "relation target is not a page; skipping");
continue;
}
} else {
format!("{raw_path}.md")
};
@@ -381,20 +368,13 @@ fn normalize_link_target(raw: &str, page_path: &PagePath, wikilink: bool) -> Opt
return None;
}
// A directory target (`notes/`) is not a page: kept, it minted an
// extension-less `to_path` (or, for a wikilink, the literal
// `notes/.md`) that no page write could ever resolve.
if !last_segment_names_a_page(target) {
return None;
}
let mut target = target.to_string();
let last_segment = target.rsplit_once('/').map_or(target.as_str(), |(_, s)| s);
if last_segment.contains('.') {
if !target.ends_with(".md") {
return None;
}
} else {
} else if wikilink || !last_segment.is_empty() {
target.push_str(".md");
}
@@ -516,21 +496,6 @@ mod tests {
assert_eq!(links[0].path.as_str(), "notes/ok.md");
}
#[test]
fn directory_and_stemless_targets_are_not_pages() {
// `sessions/` used to normalize to the literal `sessions/.md` — a
// link no page write could ever resolve; the same trailing-slash
// form in the body stayed extension-less, which cannot match a page
// path either. Both are dropped now.
let fm = serde_json::json!({
"relations": {"fixes": ["sessions/", "sessions/.md"]}
});
assert!(extract_relation_links(&fm).is_empty());
assert!(extract_links("- [notes/](notes/)\n", &page()).is_empty());
assert!(extract_links("- [[notes/]]\n", &page()).is_empty());
}
#[test]
fn pages_without_relations_extract_nothing() {
assert!(extract_relation_links(&serde_json::json!({})).is_empty());
+1 -2
View File
@@ -66,8 +66,7 @@ gain `.md`.
## Who writes them
- **You**, in any page's frontmatter (the wiki files are plain
markdown — edit them and let the watcher reindex the page; `ai-memory
reindex` only rebuilds a clean store from the markdown).
markdown — edit and `reindex`, or let the watcher pick it up).
- **The consolidator**, sparingly: both single-page consolidation and
`memory_consolidate` with `multi_page=true` can preserve a relation
when the session's evidence states it plainly (a fix