Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,38 @@ def test_search_catalog_requires_query_option(tmp_path: Path, monkeypatch, make_
assert result.exit_code != 0


def test_cross_link_candidates_parses_pages_and_prints_jsonl(
tmp_path: Path, monkeypatch, make_docs_tree, make_wiki_note
) -> None:
"""cross-link-candidates parses PAGE_PATHS args, delegates to wiki.py, and prints one JSON object per line."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="This note discusses Target Page in detail.")
entry = {"path": "docs/wiki/target.md", "title": "Target Page", "aliases": []}
(docs_dir / "catalog.jsonl").write_text(orjson.dumps(entry).decode() + "\n")
monkeypatch.chdir(tmp_path)

result = CliRunner().invoke(cli, ["cross-link-candidates", "docs/wiki/session.md"])

assert result.exit_code == 0
line = orjson.loads(result.output.strip())
assert line == {
"page": "docs/wiki/session.md",
"target": "docs/wiki/target.md",
"mention_text": "Target Page",
"match_type": "title",
}


def test_cross_link_candidates_requires_at_least_one_page(tmp_path: Path, monkeypatch, make_docs_tree) -> None:
"""Omitting PAGE_PATHS is a usage error, not a crash."""
make_docs_tree()
monkeypatch.chdir(tmp_path)

result = CliRunner().invoke(cli, ["cross-link-candidates"])

assert result.exit_code != 0


def test_log_honors_docs_dir_flag(tmp_path: Path, monkeypatch, make_docs_tree) -> None:
"""Log --docs-dir writes to the overridden tree, not cwd/docs (which doesn't exist here)."""
docs_dir = make_docs_tree()
Expand Down
125 changes: 124 additions & 1 deletion tests/test_wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import orjson

from wiki_toolkit.wiki import build_catalog, lint_wiki, parse_tag_taxonomy, search_catalog
from wiki_toolkit.wiki import build_catalog, find_cross_link_candidates, lint_wiki, parse_tag_taxonomy, search_catalog

if TYPE_CHECKING:
from collections.abc import Callable
Expand Down Expand Up @@ -472,3 +472,126 @@ def test_search_catalog_no_match_returns_empty_list() -> None:
entries = [{"title": "Auth Middleware", "path": "docs/wiki/auth.md"}]

assert search_catalog("nonexistent", entries) == []


def _write_catalog(docs_dir: Path, entries: list[dict]) -> None:
lines = [orjson.dumps(entry).decode() for entry in entries]
(docs_dir / "catalog.jsonl").write_text("\n".join(lines) + ("\n" if lines else ""))


def test_cross_link_candidates_matches_title(make_docs_tree: Callable[[], Path], make_wiki_note) -> None:
"""A literal, unlinked mention of another page's title yields a `title`-type candidate."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="This note discusses Target Page in detail.")
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": []}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert len(candidates) == 1
candidate = candidates[0]
assert candidate.page == "docs/wiki/session.md"
assert candidate.target == "docs/wiki/target.md"
assert candidate.mention_text == "Target Page"
assert candidate.match_type == "title"


def test_cross_link_candidates_matches_alias(make_docs_tree: Callable[[], Path], make_wiki_note) -> None:
"""A mention matching an alias (not the title) yields an `alias`-type candidate."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="We use the TP acronym here.")
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": ["TP"]}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert len(candidates) == 1
assert candidates[0].mention_text == "TP"
assert candidates[0].match_type == "alias"


def test_cross_link_candidates_case_insensitive(make_docs_tree: Callable[[], Path], make_wiki_note) -> None:
"""Matching ignores case, but reports the mention text as it actually appears in the body."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="see target page over there.")
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": []}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert len(candidates) == 1
assert candidates[0].mention_text == "target page"


def test_cross_link_candidates_skips_code_blocks(make_docs_tree: Callable[[], Path], make_wiki_note) -> None:
"""A mention inside a fenced code block is not reported."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="Body text.\n\n```\nTarget Page\n```\n")
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": []}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert candidates == []


def test_cross_link_candidates_skips_already_linked_mentions(
make_docs_tree: Callable[[], Path], make_wiki_note
) -> None:
"""A mention already wrapped in `[[...]]` is not reported as a new candidate."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="See [[Target Page]] for details.")
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": []}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert candidates == []


def test_cross_link_candidates_skips_frontmatter(make_docs_tree: Callable[[], Path], make_wiki_note) -> None:
"""A mention appearing only in the session page's frontmatter (not its body) is not reported."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="Unrelated body.", tags=["Target Page"])
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": []}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert candidates == []


def test_cross_link_candidates_excludes_own_session_pages_as_targets(
make_docs_tree: Callable[[], Path], make_wiki_note
) -> None:
"""A catalog entry that is itself one of the session's own pages is never a match target."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="Mentions Other Session Page here.")
make_wiki_note(docs_dir, "other.md", content="")
_write_catalog(
docs_dir,
[{"path": "docs/wiki/other.md", "title": "Other Session Page", "aliases": []}],
)

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md", "docs/wiki/other.md"])

assert candidates == []


def test_cross_link_candidates_one_per_page_target_pair(make_docs_tree: Callable[[], Path], make_wiki_note) -> None:
"""A target mentioned via both title and alias yields exactly one candidate, preferring the title match."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="Target Page also known as TP.")
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": ["TP"]}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert len(candidates) == 1
assert candidates[0].match_type == "title"


def test_cross_link_candidates_no_matches_returns_empty_list(
make_docs_tree: Callable[[], Path], make_wiki_note
) -> None:
"""A session page with no matches against the registry returns an empty list, not an error."""
docs_dir = make_docs_tree()
make_wiki_note(docs_dir, "session.md", content="Nothing relevant here.")
_write_catalog(docs_dir, [{"path": "docs/wiki/target.md", "title": "Target Page", "aliases": []}])

candidates = find_cross_link_candidates(docs_dir, ["docs/wiki/session.md"])

assert candidates == []
16 changes: 15 additions & 1 deletion wiki_toolkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
suggest_dedupe,
write_source_snapshot,
)
from wiki_toolkit.wiki import build_catalog, lint_wiki, search_catalog
from wiki_toolkit.wiki import build_catalog, find_cross_link_candidates, lint_wiki, search_catalog
from wiki_toolkit.write_gate import ALLOWED_FRAMES, commit_pages, propose_pr, start_wiki_branch


Expand Down Expand Up @@ -297,6 +297,20 @@ def search_catalog_cmd(query: str, docs_dir: Path | None) -> None:
click.echo(f"{entry.get('title', '')} ({entry.get('path', '')})")


@cli.command("cross-link-candidates")
@click.argument("page_paths", nargs=-1, required=True)
@click.option(
"--docs-dir", type=click.Path(path_type=Path), default=None, help="Override the resolved docs/ directory."
)
def cross_link_candidates_cmd(page_paths: tuple[str, ...], docs_dir: Path | None) -> None:
"""Find literal title/alias mentions of other catalog pages inside PAGE_PATHS' bodies (JSONL output)."""
docs_dir = resolve_docs_dir(flag=docs_dir).docs_dir
candidates = find_cross_link_candidates(docs_dir, list(page_paths))

for candidate in candidates:
click.echo(orjson.dumps(asdict(candidate)).decode())


@cli.command("start-branch")
@click.option("--frame", type=click.Choice(ALLOWED_FRAMES), required=True, help="Reviewer framing for this session.")
def start_branch_cmd(frame: str) -> None:
Expand Down
84 changes: 82 additions & 2 deletions wiki_toolkit/wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
from decimal import ROUND_HALF_UP, Decimal
from typing import TYPE_CHECKING, Literal

from wiki_toolkit._io import read_jsonl
from wiki_toolkit.frontmatter import Post
from wiki_toolkit.sources import SOURCE_MANIFEST_FILENAME, LintViolation, LoadError, _iter_markdown, _read_manifest

if TYPE_CHECKING:
from pathlib import Path

from wiki_toolkit.frontmatter import Post

_BULLET_RE = re.compile(r"^\s*[-*]\s+(.*)$")
_WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]")
_CONFIDENCE_MARKER_RE = re.compile(r"\^\[(inferred|ambiguous)\]")
Expand Down Expand Up @@ -275,3 +275,83 @@ def search_catalog(query: str, entries: list[dict]) -> list[dict]:
"""Return catalog entries whose title or path contains `query`, case-insensitively."""
needle = query.lower()
return [e for e in entries if needle in e.get("title", "").lower() or needle in e.get("path", "").lower()]


_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)


@dataclass
class CrossLinkCandidate:
"""A single literal-match cross-link candidate found in a session page's body."""

page: str
target: str
mention_text: str
match_type: Literal["title", "alias"]


def _protected_spans(content: str) -> list[tuple[int, int]]:
"""Return `(start, end)` spans in `content` that matches must not start inside: code blocks and `[[...]]`."""
spans = [m.span() for m in _FENCE_RE.finditer(content)]
spans.extend(m.span() for m in _WIKILINK_RE.finditer(content))
return spans


def _build_cross_link_registry(
catalog_entries: list[dict], own_pages: set[str]
) -> list[tuple[re.Pattern[str], str, Literal["title", "alias"]]]:
"""Return `(pattern, target_path, match_type)` triples for every catalog entry outside `own_pages`."""

def compiled(match_string: str) -> re.Pattern[str]:
return re.compile(rf"(?<!\w){re.escape(match_string)}(?!\w)", re.IGNORECASE)

registry: list[tuple[re.Pattern[str], str, Literal["title", "alias"]]] = []
for entry in catalog_entries:
path = entry.get("path", "")
if not path or path in own_pages:
continue
title = entry.get("title") or ""
if title:
registry.append((compiled(title), path, "title"))
registry.extend((compiled(alias), path, "alias") for alias in entry.get("aliases") or [] if alias)
return registry


def find_cross_link_candidates(docs_dir: Path, page_paths: list[str]) -> list[CrossLinkCandidate]:
"""Find literal title/alias mentions of other catalog pages inside `page_paths`' bodies.

`page_paths` are the session's own pages (paths as stored in `catalog.jsonl`, relative to
`docs_dir.parent`) — the only bodies read. Every other catalog entry is a potential match
target, matched by `title` and `aliases`, never re-read as a source. A match inside a fenced
code block or an existing `[[...]]` wikilink is skipped; at most one candidate is reported
per `(page, target)` pair, preferring a `title` match over an `alias` match.
"""
own_pages = set(page_paths)
registry = _build_cross_link_registry(read_jsonl(docs_dir / "catalog.jsonl"), own_pages)

candidates: list[CrossLinkCandidate] = []
for page in page_paths:
full_path = docs_dir.parent / page
if not full_path.is_file():
continue
content = Post.loads(full_path.read_text(encoding="utf-8")).content
spans = _protected_spans(content)

matched_targets: set[str] = set()
for pattern, target, match_type in registry:
if target in matched_targets:
continue
match = next((m for m in pattern.finditer(content) if not _in_span(m.start(), spans)), None)
if match is None:
continue
matched_targets.add(target)
candidates.append(
CrossLinkCandidate(page=page, target=target, mention_text=match.group(0), match_type=match_type)
)

return candidates


def _in_span(pos: int, spans: list[tuple[int, int]]) -> bool:
"""Report whether `pos` falls inside any `(start, end)` span."""
return any(start <= pos < end for start, end in spans)
Loading