diff --git a/tests/test_cli.py b/tests/test_cli.py index 0a55eb6..cd404db 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_wiki.py b/tests/test_wiki.py index 300950f..3ec3110 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -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 @@ -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 == [] diff --git a/wiki_toolkit/cli.py b/wiki_toolkit/cli.py index 170c765..4ad094f 100644 --- a/wiki_toolkit/cli.py +++ b/wiki_toolkit/cli.py @@ -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 @@ -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: diff --git a/wiki_toolkit/wiki.py b/wiki_toolkit/wiki.py index 2e4c7f9..0f83927 100644 --- a/wiki_toolkit/wiki.py +++ b/wiki_toolkit/wiki.py @@ -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)\]") @@ -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"(? 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)