diff --git a/scripts/pkc_common.py b/scripts/pkc_common.py index 773a52a..b0a5844 100644 --- a/scripts/pkc_common.py +++ b/scripts/pkc_common.py @@ -506,8 +506,25 @@ def write_concept( return path, "created" -def _escape_link_label(label: str) -> str: - return label.replace("[", "\\[").replace("]", "\\]") +def _escape_link_label(label: Any) -> str: + """Make a concept title safe to use as a Markdown link label. + + YAML titles may be typed scalars (for example integers or booleans), so + normalize to text at the rendering boundary. Kept as one function so the + catalog renderers cannot drift apart on escaping. + """ + return str(label).replace("[", "\\[").replace("]", "\\]") + + +def _catalog_label(fm_c: dict[str, Any], path: Path) -> str: + """Pick and escape the label for one catalog entry. + + Both renderers below call this, so the fallback rule cannot drift between + them. `or` alone would send a falsy-but-real title (`0`, `false`) to the + stem, so only a missing or empty title falls back. + """ + title = fm_c.get("title") + return _escape_link_label(path.stem if title is None or title == "" else title) def ensure_catalog_index(bundle: Path, catalog: str, title: str | None = None) -> Path: @@ -529,7 +546,7 @@ def ensure_catalog_index(bundle: Path, catalog: str, title: str | None = 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) + label = _catalog_label(fm_c, p) body += f"- [{label}](/{catalog}/{p.name})\n" if body.endswith(":\n\n"): body += "_None yet._\n" @@ -554,7 +571,7 @@ 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) + label = _catalog_label(fm_c, p) entries.append(f"- [{label}](/{catalog}/{p.name})") body += "\n".join(entries) + ("\n" if entries else "_None yet._\n") index.write_text(dump_frontmatter(fm) + "\n" + body, encoding="utf-8") diff --git a/scripts/pkc_pack.py b/scripts/pkc_pack.py index 9aa07d9..87eabed 100755 --- a/scripts/pkc_pack.py +++ b/scripts/pkc_pack.py @@ -107,7 +107,10 @@ def _inbound_via_rg( if not is_concept_path(bundle, path): 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 inbound edge. Canonicalize both. + src = "/" + path.resolve().relative_to(bundle.resolve()).as_posix() except ValueError: continue if src == target: diff --git a/tests/test_pkc.py b/tests/test_pkc.py index 37f2301..3c19624 100644 --- a/tests/test_pkc.py +++ b/tests/test_pkc.py @@ -158,6 +158,33 @@ def test_ensure_escapes_too(self): if lines: # ensure_catalog_index lists entries here self.assertIn(r"\[AREA\]", lines[0], f"label not escaped: {lines[0]!r}") + def test_yaml_scalar_titles_render_as_text(self): + """`title: 421` parses as an int. Both renderers used to call + str.replace on it and abort the whole catalog refresh.""" + cases = { + "integer": ("421", "421"), + "boolean": ("false", "False"), + "date-like": ("2026-08-31", "2026-08-31"), + "zero": ("0", "0"), + } + for renderer in (refresh_catalog_index, ensure_catalog_index): + with self.subTest(renderer=renderer.__name__): + with tempfile.TemporaryDirectory() as td: + bundle = Path(td) + ensure_bundle(bundle) + decisions = bundle / "decisions" + decisions.mkdir(exist_ok=True) + for slug, (yaml_title, _expected) in cases.items(): + (decisions / f"{slug}.md").write_text( + f"---\ntype: Decision\ntitle: {yaml_title}\n---\n\n# {yaml_title}\n", + encoding="utf-8", + ) + (decisions / "index.md").unlink(missing_ok=True) + renderer(bundle, "decisions") + index = (decisions / "index.md").read_text(encoding="utf-8") + for slug, (_yaml_title, expected) in cases.items(): + self.assertIn(f"- [{expected}](/decisions/{slug}.md)", index) + def test_refuses_a_catalog_this_plugin_does_not_declare(self): self.assertNotIn("lakehouses", CATALOGS) with tempfile.TemporaryDirectory() as td: @@ -1489,6 +1516,42 @@ def test_pack_rg_matches_scan_graph(self): self.assertEqual(accel["reverse_index"], "rg") self.assertEqual(scan["reverse_index"], "scan") + def test_inbound_pack_survives_a_symlink_aliased_bundle(self): + """rg_list_files resolves its hits, so relative_to raised on an aliased + bundle and the inbound edge 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 made 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() + alias = tmp / "alias" + alias.symlink_to(real, target_is_directory=True) + ensure_bundle(real) + self.assertNotEqual(alias.resolve(), alias) + features = alias / "features" + features.mkdir(exist_ok=True) + (features / "root.md").write_text( + "---\ntype: Feature\ntitle: Root\n---\n\n# Root\n", encoding="utf-8" + ) + (features / "caller.md").write_text( + "---\ntype: Feature\ntitle: Caller\nlinks:\n" + " - target: /features/root.md\n rel: relates_to\n---\n\n# Caller\n", + encoding="utf-8", + ) + seed = features / "root.md" + scan = pack_bundle(alias, seed, hops=1, max_nodes=8, use_rg=False, use_index=False) + accel = pack_bundle(alias, seed, 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("/features/caller.md", scan_paths) + self.assertEqual(scan_paths, accel_paths) + self.assertEqual(accel["reverse_index"], "rg") + def test_doctor_reports_toolchain(self): report = doctor_bundle(ROOT / "sample-knowledge") self.assertIn("toolchain", report)