diff --git a/scripts/dekc_common.py b/scripts/dekc_common.py index 3fa564c..a3041a7 100755 --- a/scripts/dekc_common.py +++ b/scripts/dekc_common.py @@ -1035,15 +1035,19 @@ def ensure_catalog_index(bundle: Path, catalog: str, title: str | None = None) - ) -def _escape_link_label(label: str) -> str: +def _escape_link_label(label: Any) -> str: """Make a concept title safe to use as a Markdown link label. An unescaped `[AREA]` title renders as `[[AREA]](/cat/x.md)`, which the OKF graph reader's link regex cannot match. That yields a MISSING edge rather than a broken one, and validate reports only broken edges -- so the concept silently loses its catalog backlink. + + YAML titles may also be typed scalars (for example integers or booleans), + so normalize to text at this rendering boundary. Untyped, one bad title + aborts the whole catalog refresh after ingestion already wrote concepts. """ - return label.replace("[", "\\[").replace("]", "\\]") + return str(label).replace("[", "\\[").replace("]", "\\]") def refresh_catalog_index(bundle: Path, catalog: str) -> None: @@ -1067,7 +1071,11 @@ def refresh_catalog_index(bundle: Path, catalog: str) -> None: if p.name == "index.md": continue fm_c, _ = parse_frontmatter(p.read_text(encoding="utf-8")) - label = _escape_link_label(fm_c.get("title") or p.stem) + # `or` alone sends a falsy-but-real title (`0`, `false`) to the stem. + title_value = fm_c.get("title") + label = _escape_link_label( + p.stem if title_value is None or title_value == "" else title_value + ) layer = fm_c.get("layer") # Only annotate catalogs no sibling plugin renders. On a shared catalog # the annotation is what makes the file churn back and forth. diff --git a/scripts/dekc_pack.py b/scripts/dekc_pack.py index 2a9c030..9a4e8f0 100755 --- a/scripts/dekc_pack.py +++ b/scripts/dekc_pack.py @@ -154,7 +154,11 @@ def _consider(src: str, nxt: str) -> None: if not is_concept_path(bundle, path) and path.resolve() != target_path.resolve(): continue try: - src = "/" + path.relative_to(bundle).as_posix() + # rg_list_files resolves its hits, so a bundle reached through a + # symlink alias (macOS /var -> /private/var) makes relative_to + # raise and silently drop a real lineage neighbor. Canonicalize + # both operands, as is_concept_path already does. + src = "/" + path.resolve().relative_to(bundle.resolve()).as_posix() except ValueError: continue fm, body = _parse_rel(bundle, src) diff --git a/tests/test_dekc.py b/tests/test_dekc.py index ba101d3..17eb70a 100644 --- a/tests/test_dekc.py +++ b/tests/test_dekc.py @@ -90,6 +90,16 @@ def test_layer_annotation_only_on_catalogs_we_alone_own(self): own = self._bundle(td, "layers", "Gold tier", layer="gold") self.assertIn("\u00b7 gold", own, f"lost the annotation on our own catalog: {own!r}") + def test_yaml_scalar_titles_render_as_text(self): + """`title: 421` parses as an int. str.replace on it aborted the whole + catalog refresh, after capture had already written concepts.""" + cases = {"integer": ("421", "421"), "boolean": ("false", "False"), + "date-like": ("2026-08-31", "2026-08-31"), "zero": ("0", "0")} + for slug, (yaml_title, expected) in cases.items(): + with self.subTest(title=slug), tempfile.TemporaryDirectory() as td: + line = self._bundle(td, "tables", yaml_title) + self.assertEqual(line, f"- [{expected}](/tables/a.md)") + def test_refuses_a_catalog_this_plugin_does_not_declare(self): self.assertNotIn("adrs", CATALOGS) with tempfile.TemporaryDirectory() as td: diff --git a/tests/test_retrieval_ladder.py b/tests/test_retrieval_ladder.py index c5200aa..c2dbb56 100644 --- a/tests/test_retrieval_ladder.py +++ b/tests/test_retrieval_ladder.py @@ -69,6 +69,43 @@ def test_pack_rg_matches_scan_graph(self): self.assertEqual(scan["reverse_index"], "scan") self.assertGreaterEqual(scan["node_count"], 5) + def test_lineage_pack_survives_a_symlink_aliased_bundle(self): + """rg_list_files resolves its hits, so relative_to raised on an aliased + bundle and the lineage neighbor was dropped — while the pack still + reported `reverse_index: rg`. + + The symlink is built here rather than leaned on: mkdtemp yields the + /var alias on macOS but a plain /tmp path on Linux, which would make an + alias-dependent test inert on CI — the platform this must not regress on. + """ + tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, tmp, True) + real = tmp / "real" + real.mkdir() + bundle = tmp / "alias" + bundle.symlink_to(real, target_is_directory=True) + self.assertNotEqual(bundle.resolve(), bundle) + (bundle / "index.md").write_text( + "---\ntype: Bundle\ntitle: T\n---\n\n# T\n", encoding="utf-8" + ) + tables = bundle / "tables" + tables.mkdir() + (tables / "root.md").write_text( + "---\ntype: Table\ntitle: Root\n---\n\n# Root\n", encoding="utf-8" + ) + (tables / "caller.md").write_text( + "---\ntype: Table\ntitle: Caller\nlinks:\n" + " - target: /tables/root.md\n rel: reads_from\n---\n\n# Caller\n", + encoding="utf-8", + ) + scan = pack(bundle, "tables/root.md", hops=1, max_nodes=8, use_rg=False, use_index=False) + accel = pack(bundle, "tables/root.md", hops=1, max_nodes=8, use_rg=True, use_index=False) + scan_paths = {n["path"] for n in scan["nodes"]} + accel_paths = {n["path"] for n in accel["nodes"]} + self.assertIn("/tables/caller.md", scan_paths) + self.assertEqual(scan_paths, accel_paths) + self.assertEqual(accel["reverse_index"], "rg") + class TestSqliteIndex(unittest.TestCase): def setUp(self):