From 8e44a3dfe751a2eeec9fc4015fb43bf9f0249323 Mon Sep 17 00:00:00 2001 From: Benjamin Kapner Date: Mon, 17 Aug 2026 08:46:45 +0300 Subject: [PATCH 1/3] feat(linkage): parse doc-to-code front-matter declarations File selection is currently an LLM guess on every run. Where a maintainer declares which source files a doc covers, selection should be deterministic, free, and correct. Add a parser for YAML front-matter in .md, comment directives in .rst, and comment directives in .adoc. Parser only; no selection changes yet. --- src/linkage.py | 107 ++++++++++++++++++++++++++++++++++++++++++ tests/test_linkage.py | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/linkage.py create mode 100644 tests/test_linkage.py 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/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)) == [] From 732b2c7416f86ad7fdc05960da2b94812818c529 Mon Sep 17 00:00:00 2001 From: Benjamin Kapner Date: Mon, 17 Aug 2026 08:47:26 +0300 Subject: [PATCH 2/3] feat(linkage): select declared docs deterministically Any doc whose declared covers paths intersect the diff is included with no LLM call for that decision. The LLM selection pass still runs for undeclared docs. This is additive, not a replacement. --- src/suggest_docs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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: From 9b08e5c657d24a7a99f9d760e0f77558d6d72da6 Mon Sep 17 00:00:00 2001 From: Benjamin Kapner Date: Mon, 17 Aug 2026 08:47:45 +0300 Subject: [PATCH 3/3] docs(readme): document doc-to-code linkage with examples Add a README section showing front-matter syntax for all three formats and positioning linkage as an incremental adoption path. --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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