diff --git a/README.md b/README.md index 60a7e46..247d84d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,38 @@ With this config, generated PRs will be titled `:book: docs: update documentatio This file is optional — if missing, the tool uses default titles with no prefix. +## Doc-to-Code Linkage + +You can declare which source files a doc covers. Declared docs are selected deterministically with no LLM call, making selection free and reliable for the files you care about most. + +**Markdown** (YAML front-matter): +```markdown +--- +code-to-docs: + covers: + - src/cli/flags.py + - src/config.py +--- +# CLI Reference +``` + +**reStructuredText** (directive comment): +```rst +.. code-to-docs:: covers: src/cli/flags.py, src/config.py + +CLI Reference +============= +``` + +**AsciiDoc** (comment): +```adoc +// code-to-docs: covers: src/cli/flags.py, src/config.py + += CLI Reference +``` + +Linkage is additive. The LLM selection pass still runs for docs without declarations. Adopt it incrementally, starting with your most important reference pages. + ## How It Works 1. **Triggered by PR Comments** - When someone comments `[review-docs]`, `[update-docs]`, or `[review-feature]` on a Pull Request diff --git a/src/linkage.py b/src/linkage.py new file mode 100644 index 0000000..6f903d5 --- /dev/null +++ b/src/linkage.py @@ -0,0 +1,107 @@ +"""Doc-to-code linkage: deterministic file selection from front-matter declarations.""" + +import re +from pathlib import Path + +import yaml + + +def parse_doc_frontmatter(file_path): + """Extract code-to-docs front-matter from a documentation file. + + Supports YAML front-matter (--- delimiters) for .md files, and + comment-based declarations for .rst and .adoc files. + + Returns a dict with a "covers" key (list of source paths), or + empty dict if no declaration is found. + """ + try: + content = Path(file_path).read_text(encoding="utf-8") + except Exception: + return {} + + suffix = Path(file_path).suffix + + if suffix == ".md": + return _parse_yaml_frontmatter(content) + elif suffix == ".rst": + return _parse_rst_directive(content) + elif suffix == ".adoc": + return _parse_adoc_comment(content) + return {} + + +def _parse_yaml_frontmatter(content): + """Parse YAML front-matter between --- delimiters.""" + if not content.startswith("---"): + return {} + parts = content.split("---", 2) + if len(parts) < 3: + return {} + try: + fm = yaml.safe_load(parts[1]) + if isinstance(fm, dict) and "code-to-docs" in fm: + ctd = fm["code-to-docs"] + if isinstance(ctd, dict) and "covers" in ctd: + covers = ctd["covers"] + if isinstance(covers, list): + return {"covers": [str(p) for p in covers]} + except yaml.YAMLError: + pass + return {} + + +def _parse_rst_directive(content): + """Parse .. code-to-docs:: covers: path1, path2 from rst.""" + match = re.search(r"^\.\.\s+code-to-docs::\s*covers:\s*(.+)$", content, re.MULTILINE) + if match: + paths = [p.strip() for p in match.group(1).split(",") if p.strip()] + return {"covers": paths} if paths else {} + return {} + + +def _parse_adoc_comment(content): + """Parse // code-to-docs: covers: path1, path2 from adoc.""" + match = re.search(r"^//\s*code-to-docs:\s*covers:\s*(.+)$", content, re.MULTILINE) + if match: + paths = [p.strip() for p in match.group(1).split(",") if p.strip()] + return {"covers": paths} if paths else {} + return {} + + +def extract_changed_paths(diff_text): + """Extract all file paths changed in a unified diff.""" + paths = set() + for match in re.finditer(r"^diff --git a/(.+?) b/", diff_text, re.MULTILINE): + paths.add(match.group(1)) + return paths + + +def find_declared_docs(diff_text, doc_root="."): + """Find doc files whose declared covers paths intersect with the diff. + + Returns a list of (doc_path, "declared") tuples for docs that match, + and scans all doc files in doc_root. + """ + changed = extract_changed_paths(diff_text) + if not changed: + return [] + + doc_extensions = {".md", ".rst", ".adoc"} + declared = [] + + for doc in Path(doc_root).rglob("*"): + if not doc.is_file() or doc.suffix not in doc_extensions: + continue + if ".doc-index" in str(doc): + continue + fm = parse_doc_frontmatter(str(doc)) + covers = fm.get("covers", []) + if not covers: + continue + for covered_path in covers: + if any(c == covered_path or c.startswith(covered_path + "/") for c in changed): + declared.append((str(doc), "declared")) + break + + return declared diff --git a/src/suggest_docs.py b/src/suggest_docs.py index 805f09d..29065c1 100644 --- a/src/suggest_docs.py +++ b/src/suggest_docs.py @@ -514,6 +514,16 @@ def main(): print("Asking AI for relevant files...") relevant_files = ask_ai_for_relevant_files(diff, file_previews) + # Merge in declared docs (front-matter linkage). These skip the LLM call. + from linkage import find_declared_docs + + declared_docs = find_declared_docs(diff) + declared_paths = {d[0] for d in declared_docs} + if declared_paths: + print(f"Declared docs (front-matter linkage): {sorted(declared_paths)}") + existing = set(relevant_files) if relevant_files else set() + relevant_files = sorted(existing | declared_paths) + if not relevant_files: print("AI did not suggest any files.") if review_mode or update_mode or feature_mode: diff --git a/tests/test_linkage.py b/tests/test_linkage.py new file mode 100644 index 0000000..38ddec5 --- /dev/null +++ b/tests/test_linkage.py @@ -0,0 +1,86 @@ +"""Tests for doc-to-code linkage front-matter parsing.""" + +from linkage import ( + extract_changed_paths, + find_declared_docs, + parse_doc_frontmatter, +) + + +class TestParseDocFrontmatter: + def test_md_yaml_frontmatter(self, tmp_path): + doc = tmp_path / "guide.md" + doc.write_text( + "---\ncode-to-docs:\n covers:\n - src/cli.py\n - src/config.py\n---\n# Guide\n", + encoding="utf-8", + ) + result = parse_doc_frontmatter(str(doc)) + assert result == {"covers": ["src/cli.py", "src/config.py"]} + + def test_md_no_frontmatter(self, tmp_path): + doc = tmp_path / "guide.md" + doc.write_text("# Guide\n\nNo front-matter here.\n", encoding="utf-8") + assert parse_doc_frontmatter(str(doc)) == {} + + def test_rst_directive(self, tmp_path): + doc = tmp_path / "guide.rst" + doc.write_text( + ".. code-to-docs:: covers: src/cli.py, src/config.py\n\nGuide\n=====\n", + encoding="utf-8", + ) + result = parse_doc_frontmatter(str(doc)) + assert result == {"covers": ["src/cli.py", "src/config.py"]} + + def test_adoc_comment(self, tmp_path): + doc = tmp_path / "guide.adoc" + doc.write_text( + "// code-to-docs: covers: src/cli.py, src/config.py\n= Guide\n", + encoding="utf-8", + ) + result = parse_doc_frontmatter(str(doc)) + assert result == {"covers": ["src/cli.py", "src/config.py"]} + + def test_missing_file(self): + assert parse_doc_frontmatter("/nonexistent/file.md") == {} + + def test_no_covers_key(self, tmp_path): + doc = tmp_path / "guide.md" + doc.write_text("---\ntitle: Guide\n---\n# Guide\n", encoding="utf-8") + assert parse_doc_frontmatter(str(doc)) == {} + + +class TestExtractChangedPaths: + def test_extracts_paths(self): + diff = "diff --git a/src/cli.py b/src/cli.py\n+new\ndiff --git a/src/config.py b/src/config.py\n+new\n" + assert extract_changed_paths(diff) == {"src/cli.py", "src/config.py"} + + def test_empty_diff(self): + assert extract_changed_paths("") == set() + + +class TestFindDeclaredDocs: + def test_finds_matching_doc(self, tmp_path): + doc = tmp_path / "guide.md" + doc.write_text( + "---\ncode-to-docs:\n covers:\n - src/cli.py\n---\n# Guide\n", + encoding="utf-8", + ) + diff = "diff --git a/src/cli.py b/src/cli.py\n+new\n" + result = find_declared_docs(diff, str(tmp_path)) + assert len(result) == 1 + assert result[0][1] == "declared" + + def test_no_match(self, tmp_path): + doc = tmp_path / "guide.md" + doc.write_text( + "---\ncode-to-docs:\n covers:\n - src/other.py\n---\n# Guide\n", + encoding="utf-8", + ) + diff = "diff --git a/src/cli.py b/src/cli.py\n+new\n" + assert find_declared_docs(diff, str(tmp_path)) == [] + + def test_skips_undeclared_docs(self, tmp_path): + doc = tmp_path / "guide.md" + doc.write_text("# Guide\n\nNo declaration.\n", encoding="utf-8") + diff = "diff --git a/src/cli.py b/src/cli.py\n+new\n" + assert find_declared_docs(diff, str(tmp_path)) == []