Skip to content

Commit 2ce69f8

Browse files
shirgoldbirdclaude
andcommitted
fix(pipeline): resolve nav entries to real files before reading or writing them
A gap's `path` is a docs.json nav entry. Nav entries carry no file extension, and a page can share its name with the directory holding its children (docs/resources/breaking-changes-change-notices is both a page and that folder). generate.py used those paths as filesystem paths, which broke every page-editing gap in three different ways: - Directory collision: open() got the directory and raised "[Errno 21] Is a directory", recorded as a generation error (PR #447). - Otherwise load_file() swallowed FileNotFoundError and returned "", so the model was told to expand a page whose current content was empty and wrote a replacement from scratch. Existing content would have been discarded. - The output path had no extension, so the draft was named `docs--a--b` and promote.py, which collects only *.mdx, dropped it silently. The page was reported as generated but never shipped -- which is why PR #447 lists an expanded page that isn't in its diff. So thin_page, missing_code_examples and missing_description have never produced a shipped page. - detect_gaps.page_file() is now the one way to turn a nav entry into a file, using is_file() so a directory can never masquerade as the page. - generate.load_existing_page() raises rather than returning nothing, so a failed read can't become "write this page from scratch". - determine_output_path() and apply_description() resolve through page_file(), so drafts keep their .mdx and promote picks them up. This also makes open-PR path matching work for these gaps, since PR file lists carry extensions. Also stops flagging hub pages as thin. breaking-changes-change-notices is short because its content is on its child pages; padding it out is the wrong fix, so pages with children are exempt. The Anthropic SDK is now imported lazily, so gap detection, --dry-run and the tests run without it. CI installs only pyyaml for the pipeline tests. Tests: pipeline/test_gap_paths.py (17), against a temp docs tree. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 78446f6 commit 2ce69f8

5 files changed

Lines changed: 285 additions & 41 deletions

File tree

‎.github/workflows/test-scripts.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,7 @@ jobs:
3030
- uses: actions/setup-python@v5
3131
with:
3232
python-version: "3.12"
33-
# Stdlib only — these tests stub out gh and never call the network.
33+
# pyyaml only: the tests stub out gh, never call the network, and the
34+
# Anthropic SDK is imported lazily so it isn't needed here.
35+
- run: pip install pyyaml
3436
- run: python -m unittest discover -s pipeline -p "test_*.py" -v

‎pipeline/README.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ python pipeline/detect_gaps.py --force # report requirement gap
9292

9393
**What it checks (per family, across surfaces):** missing product tab, missing overview / tutorial / how-to, missing hub link, narrative pages sitting in the API Reference tab, ungrouped OpenAPI tags, plus thin pages (< 100 words) and missing frontmatter descriptions.
9494

95+
Pages with children are exempt from the thin-page check: a hub is short because its
96+
content sits on the child pages, so padding it out is the wrong fix.
97+
98+
Gap `path` values are docs.json nav entries, which carry **no file extension** and can
99+
share a name with the directory holding their children (`docs/resources/breaking-changes-change-notices`
100+
is both a page and that folder). Resolve one to a file with `detect_gaps.page_file()`;
101+
never append to it by hand.
102+
95103
**What it doesn't check:** fine-grained judgment (is *this specific* endpoint missing a how-to, is a page the wrong Diataxis type) — that's the LLM audit (`audit_gaps.py`, follow-up); style/Diataxis prose compliance (the review step); code sample correctness (validation step).
96104

97105
### 2. Generate Drafts
@@ -353,7 +361,8 @@ The live site structure (which page sits in which tab) is read from `docs.json`
353361
## Dependencies
354362

355363
- Python 3.8+
356-
- `anthropic` (for generate.py, rework.py, review.py, post_review.py)
364+
- `anthropic` (for generate.py, rework.py, review.py, post_review.py). generate.py
365+
imports it lazily, so gap detection, `--dry-run` and the tests work without it
357366
- `pyyaml` (for all scripts)
358367
- `gh` CLI (for ship.py, post_review.py, and the open-PR check in generate.py)
359368
- `mint` / `npx` (optional, for broken-links check in promote.py)

‎pipeline/detect_gaps.py‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -155,23 +155,44 @@ def ungrouped_tags(endpoints, families):
155155
# Pages — frontmatter + coarse Diataxis classification #
156156
# --------------------------------------------------------------------------- #
157157

158-
def page_frontmatter(page_path):
159-
"""Return (frontmatter_dict, raw_content). (None, None) if the file is missing."""
158+
def page_file(page_path):
159+
"""The file backing a docs.json page entry, or None if nothing does.
160+
161+
Nav entries carry no extension, so a nav path is never a usable file path.
162+
Worse, a page can share its name with the directory holding its children
163+
(docs/resources/breaking-changes-change-notices is both a page and the folder
164+
its notices live in), so appending nothing and opening the result hands you a
165+
directory. Resolve through here; never build a path from a nav entry by hand.
166+
"""
160167
for ext in (".mdx", ".md"):
161168
fp = REPO_ROOT / (page_path + ext)
162-
if fp.exists():
163-
try:
164-
content = fp.read_text(encoding="utf-8")
165-
except Exception:
166-
return {}, ""
167-
if content.startswith("---"):
168-
end = content.find("---", 3)
169-
if end != -1:
170-
try:
171-
return (yaml.safe_load(content[3:end]) or {}), content
172-
except yaml.YAMLError:
173-
return {}, content
174-
return {}, content
169+
if fp.is_file():
170+
return fp
171+
return None
172+
173+
174+
def has_child_pages(all_pages, page_path):
175+
"""Whether other nav entries sit under this one, making it a parent page."""
176+
prefix = page_path + "/"
177+
return any(p.startswith(prefix) for p in all_pages)
178+
179+
180+
def page_frontmatter(page_path):
181+
"""Return (frontmatter_dict, raw_content). (None, None) if the file is missing."""
182+
fp = page_file(page_path)
183+
if fp:
184+
try:
185+
content = fp.read_text(encoding="utf-8")
186+
except OSError:
187+
return {}, ""
188+
if content.startswith("---"):
189+
end = content.find("---", 3)
190+
if end != -1:
191+
try:
192+
return (yaml.safe_load(content[3:end]) or {}), content
193+
except yaml.YAMLError:
194+
return {}, content
195+
return {}, content
175196
return None, None # page listed in nav but file missing
176197

177198

@@ -329,14 +350,17 @@ def detect_gaps(docs_json, endpoints, families, section=None, force=False):
329350

330351
# --- Doc-quality checks on non-reference pages ------------------------ #
331352
if not section:
332-
for p in all_doc_pages(docs_json, apiref_tab):
353+
doc_pages = all_doc_pages(docs_json, apiref_tab)
354+
for p in doc_pages:
333355
fm, content = page_frontmatter(p)
334356
if fm is None:
335357
continue
336358
if not fm.get("description"):
337359
gaps.append(gap("missing_description", "low", None, path=p,
338360
desc=f"{p} has no frontmatter description"))
339-
if len(strip_frontmatter(content).split()) < 100:
361+
# A page with children is a hub: it's short because the content sits
362+
# on the child pages. Padding it out is the wrong fix, so don't ask.
363+
if len(strip_frontmatter(content).split()) < 100 and not has_child_pages(doc_pages, p):
340364
gaps.append(gap("thin_page", "medium", None, path=p,
341365
desc=f"{p} has under 100 words"))
342366

‎pipeline/generate.py‎

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,9 @@
3030

3131
import yaml
3232

33-
try:
34-
import anthropic
35-
except ImportError:
36-
print("Install the Anthropic SDK: pip install anthropic")
37-
sys.exit(1)
38-
39-
4033
from util import build_authoring_system_prompt
4134
from open_prs import EXIT_NOTHING_TO_DO, fetch_open_prs, split_claimed_gaps
35+
from detect_gaps import page_file
4236

4337
REPO_ROOT = Path(__file__).resolve().parent.parent
4438
OPENAPI_PATH = REPO_ROOT / "api-reference" / "openapi.yaml"
@@ -74,6 +68,25 @@ def load_file(path):
7468
return ""
7569

7670

71+
def load_existing_page(gap):
72+
"""Content of the page a gap edits, resolved from its nav entry.
73+
74+
Raises instead of returning nothing. A gap's `path` is a docs.json nav entry
75+
with no extension, so opening it directly either fails or (when a directory
76+
shares the page's name) hands back a directory. Both used to surface as an
77+
empty page, which turned "expand this page" into "write one from scratch"
78+
and silently discarded the real content.
79+
"""
80+
page_path = gap.get("path", "")
81+
resolved = page_file(page_path)
82+
if not resolved:
83+
raise FileNotFoundError(f"no .mdx or .md file backs the nav entry '{page_path}'")
84+
content = load_file(resolved)
85+
if not content.strip():
86+
raise ValueError(f"{resolved.relative_to(REPO_ROOT)} is empty, refusing to rewrite it from nothing")
87+
return content
88+
89+
7790
def load_openapi_for_tags(tags):
7891
"""Extract the portion of the OpenAPI spec whose endpoints carry any of `tags`."""
7992
with open(OPENAPI_PATH) as f:
@@ -128,14 +141,26 @@ def find_existing_docs_for_family(family_name):
128141

129142
context_docs = {}
130143
for pp in dict.fromkeys(page_paths): # dedupe, preserve order
131-
for ext in (".mdx", ".md"):
132-
full_path = REPO_ROOT / (pp + ext)
133-
if full_path.exists():
134-
context_docs[pp + ext] = load_file(full_path)
135-
break
144+
full_path = page_file(pp)
145+
if full_path:
146+
context_docs[str(full_path.relative_to(REPO_ROOT))] = load_file(full_path)
136147
return context_docs
137148

138149

150+
def make_client():
151+
"""Build the API client, importing the SDK only when a call is imminent.
152+
153+
Kept lazy so --dry-run, gap detection and the tests all work without the
154+
SDK installed — none of them talk to the model.
155+
"""
156+
try:
157+
import anthropic
158+
except ImportError:
159+
print("Install the Anthropic SDK: pip install anthropic")
160+
sys.exit(1)
161+
return anthropic.Anthropic()
162+
163+
139164
def build_system_prompt():
140165
# All writing rules live in the agent files, assembled in one place.
141166
return build_authoring_system_prompt(
@@ -208,7 +233,7 @@ def build_generation_prompt(gap, family_name, openapi_context, existing_docs_con
208233

209234
elif gap_type == "thin_page":
210235
page_path = gap.get("path", "")
211-
page_content = load_file(REPO_ROOT / page_path)
236+
page_content = load_existing_page(gap)
212237
return f"""Expand this thin page. It currently has only {gap.get('word_count', 0)} words.
213238
214239
Current content of {page_path}:
@@ -225,7 +250,7 @@ def build_generation_prompt(gap, family_name, openapi_context, existing_docs_con
225250

226251
elif gap_type == "missing_code_examples":
227252
page_path = gap.get("path", "")
228-
page_content = load_file(REPO_ROOT / page_path)
253+
page_content = load_existing_page(gap)
229254
return f"""Add code examples to this page. It's a guide but has no runnable code.
230255
231256
Current content of {page_path}:
@@ -242,7 +267,7 @@ def build_generation_prompt(gap, family_name, openapi_context, existing_docs_con
242267

243268
elif gap_type == "missing_description":
244269
page_path = gap.get("path", "")
245-
page_content = load_file(REPO_ROOT / page_path)[:500]
270+
page_content = load_existing_page(gap)[:500]
246271
return f"""Generate a frontmatter description for this page.
247272
248273
Current content of {page_path} (first 500 chars):
@@ -292,10 +317,13 @@ def determine_output_path(gap, family_name, content=None):
292317
slug = slugify(title_from_content(content))
293318
return DOCS_DIR / family_name.lower() / f"{slug}.mdx"
294319

295-
elif gap_type in ("thin_page", "missing_code_examples"):
296-
return REPO_ROOT / gap["path"]
320+
elif gap_type in ("thin_page", "missing_code_examples", "missing_description"):
321+
# Resolve the nav entry to the real file. Without the extension the draft
322+
# is written as `docs--a--b` and promote.py, which only collects *.mdx,
323+
# drops it without a word — the page looks generated but never ships.
324+
return page_file(gap.get("path", ""))
297325

298-
return None # missing_description handled inline; non-generative types skipped
326+
return None # non-generative types skipped
299327

300328

301329
def _predicted_path(gap):
@@ -323,7 +351,9 @@ def _pr_review_hint(claimed):
323351

324352
def apply_description(gap, description):
325353
"""Insert a frontmatter description into an existing page."""
326-
page_path = REPO_ROOT / gap["path"]
354+
page_path = page_file(gap.get("path", ""))
355+
if not page_path:
356+
return False
327357
content = page_path.read_text(encoding="utf-8")
328358

329359
if not content.startswith("---"):
@@ -423,10 +453,14 @@ def main():
423453
out_path = determine_output_path(g, family)
424454
if out_path:
425455
print(f" -> {out_path.relative_to(REPO_ROOT)}")
456+
elif g.get("path"):
457+
print(f" -> skip (no file backs the nav entry '{g['path']}')")
458+
else:
459+
print(f" -> filename chosen from the generated title")
426460
print()
427461
return 0
428462

429-
client = anthropic.Anthropic()
463+
client = make_client()
430464
system_prompt = build_system_prompt()
431465

432466
run_id = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
@@ -476,8 +510,12 @@ def main():
476510
print(f" Wrote {draft_path.relative_to(REPO_ROOT)}")
477511
generated.append({"gap": gap, "action": "file_written", "path": str(rel), "draft": str(draft_path.relative_to(REPO_ROOT))})
478512
else:
479-
print(f" No output path determined, skipping")
480-
errors.append({"gap": gap, "error": "No output path"})
513+
detail = (
514+
f"no file backs the nav entry '{gap['path']}'"
515+
if gap.get("path") else "no output path could be determined"
516+
)
517+
print(f" Skipping: {detail}")
518+
errors.append({"gap": gap, "error": detail})
481519

482520
except Exception as e:
483521
print(f" Error: {e}")

0 commit comments

Comments
 (0)