-
Notifications
You must be signed in to change notification settings - Fork 1
generation
- src/repodocs/generate.py
- tests/test_fix_generate.py
repodocs.generate implements cmd_generate, the command that turns a validated wiki plan into Markdown pages. It re-hashes each candidate source file with SHA-256, skips pages whose inputs are unchanged since the last run, and dispatches the remaining pages to the LLM backend through a bounded parallel pool, persisting hash state after every completed page so an interrupted run can resume.
Sources: src/repodocs/generate.py:L1-L14, src/repodocs/generate.py:L118-L136
cmd_generate(repo, out, only, dry_run, force) runs in five stages:
- Load the plan via
load_plan(repo, out, allow_omp=not dry_run)and drop any page whose slug is unsafe with_safe_page, sinceplan.jsonmay be hand-edited and is treated as untrusted input. - If
onlyis given, filter to the requested slugs, callingdie(...)if none match. - Load the on-disk hash store (
load_hashes) and, fordry_run, print the[action] slug (reason) -> pathdecision plus the full generation prompt for every page without invoking the backend. - Otherwise, create
outand run Phase 1 (main thread): calldecide_pagefor every page against a sharedhcache, printing[skip] slug (unchanged)immediately for pages that don't need work and collecting the rest intotodo. -
Phase 2: build
(slug, prompt)items fortodoand hand them toparallel_llm; only the main thread writes files and persists hashes as results arrive.
Sources: src/repodocs/generate.py:L118-L149

Sources: src/repodocs/generate.py:L118-L185
Each candidate file is hashed with compute_file_hash, which reads the file in 64KB chunks through hashlib.sha256(). Hashes are cached per run in hcache so files shared by multiple pages are hashed only once.
Sources: src/repodocs/generate.py:L40-L46, src/repodocs/generate.py:L89-L91
decide_page(repo, out, page, hashes, force, hcache) resolves each page's action:
- For every file in
page["files"], it resolves the path withsafe_repo_file(repo, f), skipping candidates that escape the repository (e.g. symlinks pointing outside, or../-relative paths) before hashing them. - If
forceis set, the action is always("generate", "forced", current). - Otherwise it looks up the stored record for the slug in
hashes, defensively coercing a non-dict record or a non-dictfilesvalue to{}, and calls the pure decision functiongenerate_decision.
Sources: src/repodocs/generate.py:L79-L99
generate_decision(md_exists, stored_files, current_hashes) is a pure function with no I/O:
| Condition | Result |
|---|---|
.md file does not exist |
("generate", "new") |
| a file was added to the page's set | ("generate", "changed: <path> (added)") |
| a file was removed from the page's set | ("generate", "changed: <path> (removed)") |
| a shared file's hash differs from the stored hash | ("generate", "changed: <path>") |
| none of the above | ("skip", "unchanged") |
The comparison is set-based (cur - sto, sto - cur) over the current and stored file dictionaries, then a per-file hash diff for files present in both.
Sources: src/repodocs/generate.py:L63-L76
The .hashes.json store maps slug -> {"files": {relpath: sha256hex}}. load_hashes treats a missing file, an OSError, or a JSONDecodeError as an empty store, forcing regeneration of everything rather than crashing; save_hashes writes it back with json.dumps(..., indent=2).
Sources: src/repodocs/generate.py:L49-L60
test_decide_page_corrupt_hash_record_falls_back_to_regenerate exercises this defensiveness directly: passing {"p": "not-a-dict"} or {"p": {"files": "not-a-dict"}} as the stored hashes both yield action == "generate" instead of raising.
Sources: tests/test_fix_generate.py:L101-L117
test_decide_page_skips_escaping_candidate_before_hashing verifies that a symlink pointing outside the repo (escape.py) and a literal ../outside/secret.py path are both excluded from current, leaving only real.py, while the page still generates because it's new.
Sources: tests/test_fix_generate.py:L51-L69
_safe_page(page, out) guards against a hand-edited plan.json supplying a malicious slug. It requires slug to match SLUG_RE and requires the resolved destination out / f"{slug}.md" to remain under out (via Path.relative_to), printing skip: unsafe slug {slug!r} in plan.json to stderr and returning False otherwise. cmd_generate applies this filter to all_pages immediately after loading the plan, before any --pages filtering or hashing.
Sources: src/repodocs/generate.py:L102-L120
test_safe_page_rejects_traversal_slug confirms ../evil is rejected with an "unsafe slug" stderr message while good-page passes. test_reject_unsafe_slug_writes_nothing_outside_out runs cmd_generate end-to-end with a plan containing both an unsafe and a safe page (bypassing plan.py's own validation by monkeypatching load_plan), and confirms no evil.md is written anywhere under the repo while good-page.md is written normally.
Sources: tests/test_fix_generate.py:L17-L48
page_prompt(page, repo) builds the exact prompt sent to the backend for a page: the slug, title, purpose, a bullet list of candidate files (or (none detected)), a "context economy" instruction to read in slices, and a reference to the AGENTS.md contract. If repo is given and safe_repo_file(repo, "graphify-out/graph.json") resolves to a real in-repo file, an extra paragraph is inserted directing the writer to consult the tree-sitter knowledge graph first. safe_repo_file again ensures a symlinked graph.json that escapes the repo is not surfaced.
Sources: src/repodocs/generate.py:L16-L37
test_page_prompt_omits_hint_for_escaping_graph_symlink and test_page_prompt_includes_hint_for_real_in_repo_graph confirm the hint text graphify-out/graph.json appears in the prompt only when the file is a genuine in-repo file, not an escaping symlink.
Sources: tests/test_fix_generate.py:L74-L98
items = [(s, page_prompt(pagemap[s], repo)) for s in todo] is passed to parallel_llm(repo, items, on_submit), which yields (slug, result) pairs as jobs complete; on_submit logs [start] slug (reason) when a job is dispatched. For each result:
-
FileNotFoundErrortriggersdie(missing_resource_message(res), 1)(backend executable missing). -
subprocess.TimeoutExpiredprints a timeout message referencingREPODOCS_TIMEOUTand incrementsfailed. - A generic
Exceptionprints its message and incrementsfailed. - A nonzero
returncodeprints{backend_name()} exit {code}: {failure_detail(res)}and incrementsfailed. - Otherwise the page is written:
(out / f"{slug}.md").write_text(repair_citations(repo, res.stdout)), the hash record is updated (hashes[slug] = {"files": curmap[slug]}), a[done n/total]log line is emitted, andsave_hashes(out, hashes)is called immediately — persisting progress after each individual page so an interrupted run does not lose completed work.
save_hashes is called once more after the loop as a final flush.
Sources: src/repodocs/generate.py:L150-L177
test_index_includes_full_plan_when_pages_filtered exercises this pipeline through _fake_parallel_llm, a stand-in that never spawns a real subprocess and instead yields a CompletedProcess with returncode=0 directly.
Sources: tests/test_fix_generate.py:L10-L14, tests/test_fix_generate.py:L121-L140
After the generation loop, cmd_generate computes written (pages whose .md exists) and calls lint_citations(repo, out, written). It then computes all_written from all_pages (not the --pages-filtered subset) and calls write_index(repo, out, all_written), so index.md always reflects the full plan regardless of a --pages restriction. _gitignore_notice(repo, out) runs last, and if no pages were written at all, a diagnostic is printed pointing at the backend's errors.
Sources: src/repodocs/generate.py:L177-L185
write_index(repo, out, pages) writes # <repo name> Wiki, a ## Pages heading, and one Markdown link per page (- [title](slug.md)).
Sources: src/repodocs/generate.py:L207-L211
test_index_includes_full_plan_when_pages_filtered generates only beta via only={"beta"} while alpha.md already exists from an earlier run, and confirms index.md lists both Alpha/alpha.md and Beta/beta.md.
Sources: tests/test_fix_generate.py:L119-L140
_gitignore_notice(repo, out) computes out's path relative to repo; if out is outside repo (ValueError) it returns silently. If out == repo, rel.parts is empty and the function returns without indexing into it. Otherwise, if the repo has a .git directory and out's top-level directory entry ("<dir>/") is not already present in .gitignore, it runs git -C repo ls-files <rel>; if the output dir is already tracked by git, no nag is printed (the docs are deliberately committed), otherwise it prints a note: suggesting .gitignore or a deliberate commit.
Sources: src/repodocs/generate.py:L188-L204
test_gitignore_notice_out_equals_repo_does_not_raise confirms calling _gitignore_notice(tmp_path, tmp_path) returns rather than raising an IndexError on the empty rel.parts.
Sources: tests/test_fix_generate.py:L143-L146
render_plan_table(pages) formats a fixed-width text table with columns SLUG, TITLE, FILES (count of page.get("files", [])), and PURPOSE, padding each column to the widest value and inserting a ----style separator row after the header.
Sources: src/repodocs/generate.py:L214-L224
- Home
- Installation & Setup
- Architecture
- CLI Reference
- LLM Backend Selection
- Repository Scanning
- Wiki Page Planning
- Page Generation
- Source Citations
- Translation
- HTML Rendering
- Diagram Rendering
- Diagram Poster Tool
- Git Remote Link Resolution
- Publishing
- GitHub Wiki Integration
- Shared Utilities
- Environment Configuration
- Security & Trust Boundaries
- Limitations & Non-goals
- Testing
- Development
- Contributing
- Upgrading
- Changelog