Skip to content

Commit 5e2f436

Browse files
authored
fix(cli): unbreak openkb remove — registry pruning + scoped lint pass (closes #58) (#62)
* fix(cli): prune the hash registry by file_hash so `openkb remove` drops legacy entries `HashRegistry.remove_by_doc_name` matched on `meta.get("doc_name") == doc_name`. Registry rows ingested before commit c504e26 carry only `{name, type}` — no `doc_name` key — so the comparison was always `None == "<slug>"` and the method silently returned False. The CLI never checked the return value, leaving an orphan hash in `hashes.json` that then re-bound the next `openkb add` of the same file via PageIndex / SHA dedup. Add `HashRegistry.remove_by_hash(file_hash) -> bool` and call it from `remove` with the hash already resolved by `_resolve_doc_identifier`. The hash key is always present, so pruning no longer depends on the metadata shape. Tests: `test_hash_registry_remove_by_hash` (modern + legacy entry shapes, idempotency, unknown-hash returns False) and the end-to-end `test_cli_remove_prunes_legacy_registry_entry_without_doc_name`, which seeds a registry entry in the exact pre-PR#51 shape from the issue. Refs #58 (Bug 1). * fix(cli): scope `openkb remove`'s post-pass lint to the files it touched `remove` ran `fix_broken_links(wiki_dir)` after every deletion. That helper was built for `openkb lint --fix` — an explicit, user-invoked wiki-wide cleanup — and strips *every* dangling `[[wikilink]]` in the KB. PR #51 reused it for the post-remove pass, whose declared intent (per its own PR description) was only to strip links pointing at the just-removed summary and deleted concept pages. That mismatch meant a single `openkb remove` reformatted unrelated pages KB-wide — 39-file / 1254-line diff in the repro — and silently demoted pre-existing ghost links the user meant to keep, e.g. a hand-written link to a concept they plan to add later. Add a keyword-only `restrict_to` parameter to `fix_broken_links`: when given, only those files are rewritten, while the valid-target whitelist is still computed wiki-wide so legitimate cross-links in the scoped files still resolve. `remove` now passes the union of the concept pages it modified (`concept_result["modified"]`) and `index.md`. `openkb lint --fix` is untouched — it omits the parameter and keeps the wiki-wide behavior, which is correct for that command. `test_cli_remove_lint_cleans_dangling_links` previously planted a stray link in an unrelated page and asserted it was stripped, which enshrined the over-zealous behavior. Renamed to `..._in_modified_page` and re-targeted at a page the removal actually modifies; the new `test_cli_remove_preserves_ghosts_in_unrelated_pages` locks in the opposite contract. `TestFixBrokenLinksRestrictTo` adds 5 unit cases covering default behavior, scoping, empty list, paths outside the wiki, and the wiki-wide target set. Closes #58.
1 parent c1fe2a0 commit 5e2f436

5 files changed

Lines changed: 317 additions & 18 deletions

File tree

openkb/cli.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,21 @@ def remove(ctx, identifier, keep_raw, keep_empty_concepts, dry_run, yes):
882882
# Strip dangling wikilinks now so a retry (after a PageIndex
883883
# failure below) finds a clean wiki — no point in re-running this
884884
# on every attempt.
885-
files_changed, ghosts = fix_broken_links(wiki_dir)
885+
#
886+
# Scope: only the pages this remove actually touched (modified
887+
# concept pages ∪ index.md). Previously this swept the whole wiki
888+
# via ``fix_broken_links(wiki_dir)``, which silently stripped
889+
# pre-existing dangling links in unrelated pages — see issue #58
890+
# (Bug 2). Users who want a wiki-wide sweep can still run
891+
# ``openkb lint --fix`` explicitly.
892+
lint_scope: list[Path] = [
893+
wiki_dir / "concepts" / f"{slug}.md"
894+
for slug in concept_result["modified"]
895+
]
896+
index_md = wiki_dir / "index.md"
897+
if index_md.exists():
898+
lint_scope.append(index_md)
899+
files_changed, ghosts = fix_broken_links(wiki_dir, restrict_to=lint_scope)
886900
if files_changed:
887901
click.echo(f" lint --fix cleaned {ghosts} dangling wikilink(s) in {files_changed} file(s)")
888902

@@ -909,7 +923,10 @@ def remove(ctx, identifier, keep_raw, keep_empty_concepts, dry_run, yes):
909923
return
910924

911925
# ----- Commit point -----
912-
registry.remove_by_doc_name(doc_name)
926+
# Prune by hash, not by ``doc_name``: legacy registry entries
927+
# (ingested before commit c504e26) carry only ``{name, type}`` and
928+
# would silently no-op under ``remove_by_doc_name``. See issue #58.
929+
registry.remove_by_hash(file_hash)
913930

914931
if raw_path is not None:
915932
raw_path.unlink(missing_ok=True)

openkb/lint.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ def list_existing_wiki_targets(wiki_dir: Path) -> set[str]:
173173
return targets
174174

175175

176-
def fix_broken_links(wiki: Path) -> tuple[int, int]:
176+
def fix_broken_links(
177+
wiki: Path,
178+
*,
179+
restrict_to: list[Path] | None = None,
180+
) -> tuple[int, int]:
177181
"""Rewrite or strip broken [[wikilinks]] across the wiki in place.
178182
179183
For each Markdown page under ``wiki`` (excluding ``reports/`` and
@@ -184,6 +188,16 @@ def fix_broken_links(wiki: Path) -> tuple[int, int]:
184188
185189
Args:
186190
wiki: Path to the wiki root directory.
191+
restrict_to: When provided, only rewrite these files (must live
192+
under ``wiki``). Paths outside the wiki and non-existent
193+
paths are silently skipped. An empty list is a no-op — the
194+
valid-target whitelist is still computed from the entire
195+
wiki, so links like ``[[concepts/sibling]]`` resolve
196+
correctly even when ``sibling.md`` is not in the scope.
197+
Used by ``openkb remove`` (issue #58 / Bug 2) to clean only
198+
the pages it actually touched instead of sweeping the
199+
whole wiki and stripping pre-existing dangling links the
200+
user may want to keep.
187201
188202
Returns:
189203
Tuple of ``(files_changed, ghosts_stripped)``.
@@ -200,14 +214,27 @@ def fix_broken_links(wiki: Path) -> tuple[int, int]:
200214
# otherwise strip_ghost_wikilinks would rebuild it per file (O(F·M)).
201215
norm_index = build_norm_index(known_targets)
202216

217+
if restrict_to is None:
218+
candidates: list[Path] = [
219+
md for md in wiki.rglob("*.md")
220+
if md.name not in _EXCLUDED_FILES
221+
and md.relative_to(wiki).parts[:1] not in (("reports",), ("sources",))
222+
]
223+
else:
224+
wiki_resolved = wiki.resolve()
225+
candidates = []
226+
for raw in restrict_to:
227+
if not raw.is_file():
228+
continue
229+
try:
230+
raw.resolve().relative_to(wiki_resolved)
231+
except ValueError:
232+
continue # outside wiki — skip silently
233+
candidates.append(raw)
234+
203235
files_changed = 0
204236
ghosts_stripped = 0
205-
for md in wiki.rglob("*.md"):
206-
if md.name in _EXCLUDED_FILES:
207-
continue
208-
rel_parts = md.relative_to(wiki).parts
209-
if rel_parts and rel_parts[0] in ("reports", "sources"):
210-
continue
237+
for md in candidates:
211238
text = _read_md(md)
212239
cleaned, ghosts = strip_ghost_wikilinks(
213240
text, known_targets, norm_index=norm_index,

openkb/state.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ def remove_by_doc_name(self, doc_name: str) -> bool:
5050
return True
5151
return False
5252

53+
def remove_by_hash(self, file_hash: str) -> bool:
54+
"""Remove the entry keyed by ``file_hash``. Returns True if removed.
55+
56+
Preferred over :meth:`remove_by_doc_name` when the caller already
57+
has the hash in hand — works regardless of whether the entry's
58+
metadata carries a ``doc_name`` field (legacy entries written
59+
before commit c504e26 do not).
60+
"""
61+
if file_hash not in self._data:
62+
return False
63+
del self._data[file_hash]
64+
self._persist()
65+
return True
66+
5367
# ------------------------------------------------------------------
5468
# Internal
5569
# ------------------------------------------------------------------

tests/test_lint.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
find_broken_links,
1212
find_missing_entries,
1313
find_orphans,
14+
fix_broken_links,
1415
run_structural_lint,
1516
strip_ghost_wikilinks,
1617
)
@@ -392,3 +393,111 @@ def test_returns_normalized_to_canonical_map(self):
392393

393394
def test_empty_set_returns_empty_dict(self):
394395
assert build_norm_index(set()) == {}
396+
397+
398+
class TestFixBrokenLinksRestrictTo:
399+
"""Issue #58 / Bug 2: ``fix_broken_links`` must support scoping the
400+
rewrite to a caller-supplied subset of files so ``openkb remove``
401+
can clean up only the pages it actually touched (modified concept
402+
pages ∪ index.md) instead of sweeping the entire wiki and stripping
403+
pre-existing dangling links the user may want to keep.
404+
"""
405+
406+
def test_default_behavior_scans_all_files(self, tmp_path):
407+
"""Calling ``fix_broken_links(wiki)`` without ``restrict_to``
408+
still processes every wiki file — the existing global behavior
409+
is preserved for callers that want it (e.g. ``openkb lint --fix``).
410+
"""
411+
wiki = _make_wiki(tmp_path)
412+
a = wiki / "concepts" / "a.md"
413+
b = wiki / "concepts" / "b.md"
414+
a.write_text("# A\n\nLink [[concepts/ghost]] here.\n", encoding="utf-8")
415+
b.write_text("# B\n\nLink [[concepts/ghost]] too.\n", encoding="utf-8")
416+
417+
files_changed, ghosts = fix_broken_links(wiki)
418+
419+
assert files_changed == 2
420+
assert ghosts == 2
421+
assert "[[concepts/ghost]]" not in a.read_text()
422+
assert "[[concepts/ghost]]" not in b.read_text()
423+
424+
def test_restrict_to_only_touches_listed_files(self, tmp_path):
425+
"""When ``restrict_to`` is provided, only those files are
426+
rewritten — even if pre-existing ghost links exist elsewhere
427+
in the wiki, those files are left alone.
428+
"""
429+
wiki = _make_wiki(tmp_path)
430+
touched = wiki / "concepts" / "touched.md"
431+
untouched = wiki / "concepts" / "untouched.md"
432+
touched.write_text(
433+
"# touched\n\nGhost [[concepts/ghost]] here.\n", encoding="utf-8",
434+
)
435+
untouched.write_text(
436+
"# untouched\n\nGhost [[concepts/ghost]] here.\n", encoding="utf-8",
437+
)
438+
439+
files_changed, ghosts = fix_broken_links(wiki, restrict_to=[touched])
440+
441+
assert files_changed == 1
442+
assert ghosts == 1
443+
assert "[[concepts/ghost]]" not in touched.read_text()
444+
# Untouched file keeps its pre-existing ghost link verbatim.
445+
assert "[[concepts/ghost]]" in untouched.read_text()
446+
447+
def test_restrict_to_empty_list_is_noop(self, tmp_path):
448+
"""An empty ``restrict_to`` means "process nothing" (not "fall
449+
back to wiki-wide"). The whole point of the parameter is letting
450+
the CLI say "I touched zero files; don't sweep the wiki on my
451+
behalf."
452+
"""
453+
wiki = _make_wiki(tmp_path)
454+
a = wiki / "concepts" / "a.md"
455+
a.write_text("# A\n\nGhost [[concepts/ghost]] here.\n", encoding="utf-8")
456+
457+
files_changed, ghosts = fix_broken_links(wiki, restrict_to=[])
458+
459+
assert files_changed == 0
460+
assert ghosts == 0
461+
assert "[[concepts/ghost]]" in a.read_text()
462+
463+
def test_restrict_to_skips_paths_not_under_wiki(self, tmp_path):
464+
"""Defensive: a path that doesn't live under ``wiki`` (e.g. a
465+
leftover absolute path from the caller) is silently skipped
466+
rather than rewriting an unrelated file.
467+
"""
468+
wiki = _make_wiki(tmp_path)
469+
stray = tmp_path / "stray.md"
470+
stray.write_text("# stray\n[[concepts/ghost]]\n", encoding="utf-8")
471+
before = stray.read_text()
472+
473+
files_changed, ghosts = fix_broken_links(wiki, restrict_to=[stray])
474+
475+
assert files_changed == 0
476+
assert ghosts == 0
477+
assert stray.read_text() == before
478+
479+
def test_restrict_to_uses_global_known_targets(self, tmp_path):
480+
"""The valid-target set must still be computed from the whole
481+
wiki — restricting only narrows which files get *rewritten*,
482+
not what counts as a valid link target. Without this,
483+
``[[concepts/sibling]]`` in the file under review would be
484+
misclassified as a ghost just because ``sibling.md`` is outside
485+
``restrict_to``.
486+
"""
487+
wiki = _make_wiki(tmp_path)
488+
(wiki / "concepts" / "sibling.md").write_text("# sibling", encoding="utf-8")
489+
target = wiki / "concepts" / "target.md"
490+
target.write_text(
491+
"Valid [[concepts/sibling]] and ghost [[concepts/ghost]]\n",
492+
encoding="utf-8",
493+
)
494+
495+
files_changed, ghosts = fix_broken_links(wiki, restrict_to=[target])
496+
497+
assert files_changed == 1
498+
assert ghosts == 1
499+
text = target.read_text()
500+
# Real sibling link survives unchanged.
501+
assert "[[concepts/sibling]]" in text
502+
# Ghost link gets demoted.
503+
assert "[[concepts/ghost]]" not in text

0 commit comments

Comments
 (0)