repowiki-cli 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. repowiki/__init__.py +18 -0
  2. repowiki/catalog.py +193 -0
  3. repowiki/cli.py +164 -0
  4. repowiki/coverage.py +96 -0
  5. repowiki/dispatch.py +419 -0
  6. repowiki/errors.py +21 -0
  7. repowiki/gitutil.py +19 -0
  8. repowiki/i18n.py +161 -0
  9. repowiki/knowledge.py +226 -0
  10. repowiki/llms.py +52 -0
  11. repowiki/metadata.py +176 -0
  12. repowiki/output.py +34 -0
  13. repowiki/paths.py +176 -0
  14. repowiki/plan.py +140 -0
  15. repowiki/scanner.py +170 -0
  16. repowiki/site.py +384 -0
  17. repowiki/state.py +437 -0
  18. repowiki/tasks.py +354 -0
  19. repowiki/templates/en/STYLE.md +49 -0
  20. repowiki/templates/en/catalog_task.md +69 -0
  21. repowiki/templates/en/flow_template.md +87 -0
  22. repowiki/templates/en/knowledge_card_task.md +65 -0
  23. repowiki/templates/en/knowledge_card_update_task.md +49 -0
  24. repowiki/templates/en/knowledge_module_task.md +33 -0
  25. repowiki/templates/en/knowledge_module_update_task.md +36 -0
  26. repowiki/templates/en/knowledge_task.md +65 -0
  27. repowiki/templates/en/overview_task.md +35 -0
  28. repowiki/templates/en/overview_update_task.md +50 -0
  29. repowiki/templates/en/page_task.md +45 -0
  30. repowiki/templates/en/page_template.md +113 -0
  31. repowiki/templates/en/update_task.md +49 -0
  32. repowiki/templates/site/app.js +378 -0
  33. repowiki/templates/site.html +373 -0
  34. repowiki/templates/zh/STYLE.md +50 -0
  35. repowiki/templates/zh/catalog_task.md +66 -0
  36. repowiki/templates/zh/flow_template.md +87 -0
  37. repowiki/templates/zh/knowledge_card_task.md +65 -0
  38. repowiki/templates/zh/knowledge_card_update_task.md +46 -0
  39. repowiki/templates/zh/knowledge_module_task.md +33 -0
  40. repowiki/templates/zh/knowledge_module_update_task.md +35 -0
  41. repowiki/templates/zh/knowledge_task.md +62 -0
  42. repowiki/templates/zh/overview_task.md +35 -0
  43. repowiki/templates/zh/overview_update_task.md +44 -0
  44. repowiki/templates/zh/page_task.md +45 -0
  45. repowiki/templates/zh/page_template.md +113 -0
  46. repowiki/templates/zh/update_task.md +49 -0
  47. repowiki/templates.py +38 -0
  48. repowiki/updater.py +322 -0
  49. repowiki/validate.py +356 -0
  50. repowiki/vendor/marked.min.js +69 -0
  51. repowiki/vendor/mermaid.min.js +3636 -0
  52. repowiki_cli-0.5.0.dist-info/METADATA +349 -0
  53. repowiki_cli-0.5.0.dist-info/RECORD +57 -0
  54. repowiki_cli-0.5.0.dist-info/WHEEL +5 -0
  55. repowiki_cli-0.5.0.dist-info/entry_points.txt +2 -0
  56. repowiki_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
  57. repowiki_cli-0.5.0.dist-info/top_level.txt +1 -0
repowiki/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """repowiki: deterministic repo-wiki build system driven by coding agents.
2
+
3
+ It plans tasks, validates agent-produced output, and assembles metadata;
4
+ intelligence is supplied by whatever agent drives the worker loop:
5
+
6
+ loop: task = repowiki next --claim --json
7
+ if empty and busy > 0: wait and retry # others are mid-flight
8
+ if empty and busy == 0: exit # all done
9
+ execute the task spec (read source, write output)
10
+ repowiki check --task <id> --json # auto-fix + status flip
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version as _package_version
14
+
15
+ try:
16
+ __version__ = _package_version("repowiki")
17
+ except PackageNotFoundError: # running from an uninstalled source tree
18
+ __version__ = "0.0.0"
repowiki/catalog.py ADDED
@@ -0,0 +1,193 @@
1
+ """Catalog schema validation, flattening and output-path derivation.
2
+
3
+ The catalog is the chapter tree produced by the planning task
4
+ (``state/catalog.json``). This module is the single source of truth for
5
+ what a valid catalog looks like and where each page lands under
6
+ ``<locale>/content/``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ from .paths import sanitize_component, unique_name, nfc
15
+ from .validate import PLACEHOLDER_RE
16
+
17
+ SLUG_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
18
+ MAX_DEPTH = 4 # root chapters are depth 1
19
+ ARCHETYPES = ("module", "flow") # page templates; default "module"
20
+
21
+
22
+ @dataclass
23
+ class FlatNode:
24
+ id: str
25
+ title: str
26
+ slug: str
27
+ summary: str
28
+ kind: str # "chapter" | "page"
29
+ dependent_files: list[str]
30
+ page_brief: str
31
+ parent_id: str | None
32
+ depth: int
33
+ output: str # relative to .repowiki/, e.g. zh/content/Overview/Overview.md
34
+ archetype: str = "module" # page template shape: "module" | "flow"
35
+
36
+ def chapter_path(self, by_id: dict[str, "FlatNode"]) -> str:
37
+ parts = [self.title]
38
+ p = self.parent_id
39
+ while p and p in by_id:
40
+ parts.append(by_id[p].title)
41
+ p = by_id[p].parent_id
42
+ return " > ".join(reversed(parts))
43
+
44
+
45
+ def validate_catalog(data, known_paths: set[str]) -> tuple[list[str], list[str]]:
46
+ """Return (errors, warnings). Unknown dependent_files are dropped with a warning."""
47
+ errors: list[str] = []
48
+ warnings: list[str] = []
49
+ if not isinstance(data, dict):
50
+ return ["catalog 必须是 JSON 对象"], warnings
51
+ if not isinstance(data.get("repo_name"), str) or not data.get("repo_name", "").strip():
52
+ errors.append("缺少 repo_name(非空字符串)")
53
+ chapters = data.get("chapters")
54
+ if not isinstance(chapters, list) or not chapters:
55
+ errors.append("缺少 chapters(非空数组)")
56
+ return errors, warnings
57
+
58
+ seen_ids: dict[str, str] = {}
59
+ seen_titles: dict[str, str] = {}
60
+ seen_slugs: dict[str, str] = {}
61
+ dropped_files: list[str] = []
62
+
63
+ def walk(nodes, depth: int, path_desc: str) -> None:
64
+ if nodes is None:
65
+ nodes = []
66
+ if not isinstance(nodes, list):
67
+ errors.append(f"{path_desc}: children 必须是数组")
68
+ return
69
+ if depth > MAX_DEPTH:
70
+ errors.append(f"{path_desc}: 树深度超过 {MAX_DEPTH} 层")
71
+ return
72
+ for i, node in enumerate(nodes):
73
+ where = f"{path_desc}[{i}]"
74
+ if not isinstance(node, dict):
75
+ errors.append(f"{where}: 节点必须是对象")
76
+ continue
77
+ nid = node.get("id")
78
+ title = nfc(node.get("title", "")).strip()
79
+ slug = node.get("slug", "")
80
+ kind = node.get("kind")
81
+ if not isinstance(nid, str) or not nid.strip():
82
+ errors.append(f"{where}: 缺少 id")
83
+ elif nid in seen_ids:
84
+ errors.append(f"{where}: id 重复 `{nid}`(首次出现于 {seen_ids[nid]})")
85
+ else:
86
+ seen_ids[nid] = where
87
+ if not title:
88
+ errors.append(f"{where}: 缺少 title")
89
+ elif title in seen_titles:
90
+ errors.append(f"{where}: title 重复 `{title}`(首次出现于 {seen_titles[title]})")
91
+ elif PLACEHOLDER_RE.search(title):
92
+ errors.append(
93
+ f"{where}: title 含模板占位符形态 `{title}`"
94
+ "(标题会写进页面 H1 并触发「未替换占位符」误判,请改用普通措辞)"
95
+ )
96
+ else:
97
+ seen_titles[title] = where
98
+ if not isinstance(slug, str) or not SLUG_RE.match(slug or ""):
99
+ errors.append(f"{where}: slug 非法 `{slug}`(应为小写英文与连字符,如 project-overview)")
100
+ elif slug in seen_slugs:
101
+ errors.append(f"{where}: slug 重复 `{slug}`(首次出现于 {seen_slugs[slug]})")
102
+ else:
103
+ seen_slugs[slug] = where
104
+ if kind not in ("chapter", "page"):
105
+ errors.append(f"{where}: kind 必须是 chapter 或 page,得到 {kind!r}")
106
+ kind = "page"
107
+ archetype = node.get("archetype", "module")
108
+ if archetype not in ARCHETYPES:
109
+ errors.append(
110
+ f"{where}({title or nid}): archetype 必须是 module 或 flow(可省略,默认 module),得到 {archetype!r}"
111
+ )
112
+ archetype = "module"
113
+ brief = node.get("page_brief")
114
+ if not isinstance(brief, str) or not brief.strip():
115
+ errors.append(f"{where}({title or nid}): 缺少 page_brief(页面要点提示词)")
116
+ deps = node.get("dependent_files")
117
+ if deps is None:
118
+ deps = []
119
+ if not isinstance(deps, list):
120
+ errors.append(f"{where}({title or nid}): dependent_files 必须是数组")
121
+ deps = []
122
+ else:
123
+ bad = [d for d in deps if not isinstance(d, str) or d not in known_paths]
124
+ for d in bad:
125
+ dropped_files.append(f"{title or nid}: {d}")
126
+ deps = [d for d in deps if isinstance(d, str) and d in known_paths]
127
+ node["dependent_files"] = deps
128
+ if kind == "page" and node.get("children"):
129
+ errors.append(f"{where}({title or nid}): kind=page 不能有 children")
130
+ walk(node.get("children") if kind == "chapter" else None, depth + 1, f"{where}/children")
131
+
132
+ walk(chapters, 1, "chapters")
133
+ if dropped_files:
134
+ warnings.append("以下 dependent_files 不在仓库清单中,已剔除: " + "; ".join(dropped_files))
135
+ return errors, warnings
136
+
137
+
138
+ def flatten(data: dict, locale: str = "zh") -> list[FlatNode]:
139
+ """Flatten validated catalog into nodes with derived output paths.
140
+
141
+ Path rules: every chapter gets a directory named after its title plus an
142
+ index page with the same name; root-level pages are standalone files at
143
+ ``<locale>/content/``. Collisions among siblings get ``__2`` suffixes.
144
+ """
145
+ content_root = f"{locale}/content"
146
+ by_id: dict[str, FlatNode] = {}
147
+ out: list[FlatNode] = []
148
+
149
+ def walk(nodes, parent: FlatNode | None, dir_chain: list[str], used_here: set[str]) -> None:
150
+ for node in nodes:
151
+ kind = node.get("kind", "page")
152
+ comp = unique_name(sanitize_component(node["title"]), used_here)
153
+ if kind == "chapter":
154
+ chain = dir_chain + [comp]
155
+ rel_dir = "/".join(chain)
156
+ output = f"{content_root}/{rel_dir}/{comp}.md"
157
+ else:
158
+ rel_dir = "/".join(dir_chain)
159
+ output = f"{content_root}/{rel_dir}/{comp}.md" if rel_dir else f"{content_root}/{comp}.md"
160
+ flat = FlatNode(
161
+ id=node["id"],
162
+ title=nfc(node["title"]).strip(),
163
+ slug=node.get("slug", ""),
164
+ summary=node.get("summary", ""),
165
+ kind=kind,
166
+ dependent_files=list(node.get("dependent_files", [])),
167
+ page_brief=node.get("page_brief", ""),
168
+ parent_id=parent.id if parent else None,
169
+ depth=(parent.depth + 1) if parent else 1,
170
+ output=output,
171
+ archetype=node.get("archetype", "module") if node.get("archetype") in ARCHETYPES else "module",
172
+ )
173
+ by_id[flat.id] = flat
174
+ out.append(flat)
175
+ if kind == "chapter":
176
+ walk(node.get("children") or [], flat, chain, set())
177
+
178
+ walk(data.get("chapters") or [], None, [], set())
179
+ return out
180
+
181
+
182
+ def catalog_tree_text(flat: list[FlatNode]) -> str:
183
+ """Render the flattened catalog as an indented tree with page_briefs."""
184
+ by_id = {n.id: n for n in flat}
185
+ lines: list[str] = []
186
+ for n in flat:
187
+ indent = " " * (n.depth - 1)
188
+ marker = "章" if n.kind == "chapter" else "页"
189
+ lines.append(f"{indent}- [{marker}] {n.title} ({n.output})")
190
+ if n.page_brief:
191
+ brief = n.page_brief.replace("\n", " ")
192
+ lines.append(f"{indent} · {brief}")
193
+ return "\n".join(lines)
repowiki/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """Command-line interface.
2
+
3
+ Exit codes: 0 = ok, 1 = validation failure / usage error / corrupted state,
4
+ 2 = state conflict (e.g. task already claimed by a live worker).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from . import __version__
13
+ from .dispatch import run_check, run_next, run_release, run_status, run_touch, run_watch
14
+ from .errors import ConflictError, StateError, UsageError # noqa: F401 (re-exported)
15
+ from .coverage import run_coverage
16
+ from .knowledge import run_knowledge
17
+ from .metadata import run_finalize
18
+ from .output import emit_error
19
+ from .paths import WikiPaths
20
+ from .plan import run_plan
21
+ from .site import run_site
22
+ from .state import run_clean
23
+ from .updater import run_stale, run_update
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="repowiki",
29
+ description="Deterministic repo-wiki build system driven by coding agents.",
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"repowiki {__version__}")
32
+ sub = parser.add_subparsers(dest="command", required=True)
33
+
34
+ p = sub.add_parser("plan", help="scan repo and create the task manifest (phase 1: catalog task)")
35
+ p.add_argument("repo", help="path to the repository")
36
+ p.add_argument("--replan", action="store_true", help="discard existing catalog and plan again")
37
+ p.add_argument("--force", action="store_true", help="with --replan: proceed even if tasks are in flight")
38
+ p.add_argument("--max-pages", type=int, default=None, help="cap number of page tasks (for cheap trial runs)")
39
+ p.add_argument("--knowledge", action="store_true", help="also append the knowledge-card task set")
40
+ p.add_argument("--locale", default="auto", choices=["auto", "zh", "en"],
41
+ help="output language: auto-detect from the repo (README-weighted) or force zh/en")
42
+ p.add_argument("--json", action="store_true")
43
+ p.set_defaults(func=lambda a, paths: run_plan(
44
+ paths, replan=a.replan, max_pages=a.max_pages, knowledge=a.knowledge,
45
+ force=a.force, as_json=a.json, locale=a.locale))
46
+
47
+ p = sub.add_parser("next", help="list (and optionally claim) ready tasks")
48
+ p.add_argument("repo")
49
+ p.add_argument("--claim", action="store_true", help="atomically claim the returned tasks")
50
+ p.add_argument("--worker", default=None, help="worker identifier recorded on claim")
51
+ p.add_argument("--json", action="store_true")
52
+ p.set_defaults(func=lambda a, paths: run_next(paths, claim=a.claim, worker=a.worker, as_json=a.json))
53
+
54
+ p = sub.add_parser("check", help="validate task output, auto-fix deterministic defects, update status")
55
+ p.add_argument("repo")
56
+ p.add_argument("--task", default=None, help="check a single task id")
57
+ p.add_argument("--all", dest="select_all", action="store_true",
58
+ help="check all in_progress/failed tasks (crash recovery / main agent)")
59
+ p.add_argument("--worker", default=None, help="caller identity; in_progress tasks held by others are refused")
60
+ p.add_argument("--force", action="store_true", help="check even if claimed by another worker")
61
+ p.add_argument("--json", action="store_true")
62
+ p.set_defaults(func=lambda a, paths: run_check(
63
+ paths, task_id=a.task, as_json=a.json, select_all=a.select_all,
64
+ worker=a.worker, force=a.force))
65
+
66
+ p = sub.add_parser("touch", help="refresh a task's claim while executing (heartbeat)")
67
+ p.add_argument("repo")
68
+ p.add_argument("--task", required=True)
69
+ p.add_argument("--worker", default=None)
70
+ p.add_argument("--json", action="store_true")
71
+ p.set_defaults(func=lambda a, paths: run_touch(paths, task_id=a.task, worker=a.worker, as_json=a.json))
72
+
73
+ p = sub.add_parser("watch", help="block until all tasks are done (or stalled/timeout); exit 0=completed, 1=stalled/timeout")
74
+ p.add_argument("repo")
75
+ p.add_argument("--interval", type=float, default=10.0, help="poll interval seconds (default 10)")
76
+ p.add_argument("--timeout", type=float, default=3600.0, help="give up after this many seconds (default 3600)")
77
+ p.add_argument("--json", action="store_true")
78
+ p.set_defaults(func=lambda a, paths: run_watch(
79
+ paths, interval=a.interval, timeout=a.timeout, as_json=a.json))
80
+
81
+ p = sub.add_parser("release", help="return an in_progress task to pending")
82
+ p.add_argument("repo")
83
+ p.add_argument("--task", required=True)
84
+ p.add_argument("--force", action="store_true", help="release even if claimed by another worker")
85
+ p.add_argument("--json", action="store_true")
86
+ p.set_defaults(func=lambda a, paths: run_release(
87
+ paths, task_id=a.task, force=a.force, as_json=a.json))
88
+
89
+ p = sub.add_parser("finalize", help="assemble zh/meta/repowiki-metadata.json (requires all tasks done)")
90
+ p.add_argument("repo")
91
+ p.add_argument("--json", action="store_true")
92
+ p.set_defaults(func=lambda a, paths: run_finalize(paths, as_json=a.json))
93
+
94
+ p = sub.add_parser("site", help="render the finished wiki into one offline HTML file (.repowiki/<locale>/wiki.html) plus llms.txt / llms-full.txt agent indexes")
95
+ p.add_argument("repo")
96
+ p.add_argument("--open", dest="open_browser", action="store_true",
97
+ help="open the generated file in the default browser")
98
+ p.add_argument("--json", action="store_true")
99
+ p.set_defaults(func=lambda a, paths: run_site(
100
+ paths, open_browser=a.open_browser, as_json=a.json))
101
+
102
+ p = sub.add_parser("update", help="map git changes to page_update tasks (incremental regeneration)")
103
+ p.add_argument("repo")
104
+ p.add_argument("--since", default=None, help="commit sha to diff from (default: last_commit_id in metadata)")
105
+ p.add_argument("--dirty", action="store_true",
106
+ help="also see uncommitted (staged+unstaged) and untracked changes, not just commits")
107
+ p.add_argument("--json", action="store_true")
108
+ p.set_defaults(func=lambda a, paths: run_update(paths, since=a.since, as_json=a.json, dirty=a.dirty))
109
+
110
+ p = sub.add_parser("stale", help="read-only: report which pages `update` would affect for since..HEAD (CI staleness gate)")
111
+ p.add_argument("repo")
112
+ p.add_argument("--since", default=None, help="git ref to diff from (default: last_commit_id in metadata)")
113
+ p.add_argument("--dirty", action="store_true",
114
+ help="also see uncommitted (staged+unstaged) and untracked changes, not just commits")
115
+ p.add_argument("--fail-if-stale", dest="fail_if_stale", action="store_true",
116
+ help="exit 1 when any page/card/module is affected (for CI gates)")
117
+ p.add_argument("--json", action="store_true")
118
+ p.set_defaults(func=lambda a, paths: run_stale(
119
+ paths, since=a.since, fail_if_stale=a.fail_if_stale, as_json=a.json, dirty=a.dirty))
120
+
121
+ p = sub.add_parser("knowledge", help="append the knowledge-card task set (planning + cards)")
122
+ p.add_argument("repo")
123
+ p.add_argument("--categories", default=None, metavar="FILE",
124
+ help="YAML/JSON file with a custom mechanism-card category list "
125
+ "(replaces the built-in six; persisted in state/knowledge_categories.json)")
126
+ p.add_argument("--json", action="store_true")
127
+ p.set_defaults(func=lambda a, paths: run_knowledge(paths, as_json=a.json, categories=a.categories))
128
+
129
+ p = sub.add_parser("status", help="show task statistics, failures and stale claims")
130
+ p.add_argument("repo")
131
+ p.add_argument("--json", action="store_true")
132
+ p.set_defaults(func=lambda a, paths: run_status(paths, as_json=a.json))
133
+
134
+ p = sub.add_parser("coverage", help="read-only: which repo files has the wiki never cited (coverage report)")
135
+ p.add_argument("repo")
136
+ p.add_argument("--json", action="store_true")
137
+ p.set_defaults(func=lambda a, paths: run_coverage(paths, as_json=a.json))
138
+
139
+ p = sub.add_parser("clean", help="remove .repowiki/state entirely (wiki output is kept)")
140
+ p.add_argument("repo")
141
+ p.add_argument("--json", action="store_true")
142
+ p.set_defaults(func=lambda a, paths: run_clean(paths, as_json=a.json))
143
+
144
+ return parser
145
+
146
+
147
+ def main(argv: list[str] | None = None) -> int:
148
+ args = build_parser().parse_args(argv)
149
+ paths = WikiPaths(args.repo)
150
+ try:
151
+ return args.func(args, paths)
152
+ except ConflictError as e:
153
+ emit_error("conflict", str(e), args.json)
154
+ return 2
155
+ except StateError as e:
156
+ emit_error("state_corrupt", str(e), args.json)
157
+ return 1
158
+ except UsageError as e:
159
+ emit_error("usage", str(e), args.json)
160
+ return 1
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main())
repowiki/coverage.py ADDED
@@ -0,0 +1,96 @@
1
+ """``repowiki coverage``: which repo files has the wiki never cited?
2
+
3
+ A read-only quality report in repowiki's deterministic spirit: compare the
4
+ repository inventory against the union of ``file://`` citations across all
5
+ wiki pages and the overview, plus knowledge-card source files. No agent, no
6
+ LLM — the numbers are computable facts, useful as a writing guide ("these
7
+ modules have no page yet") and as a provable quality metric.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from .catalog import flatten
15
+ from .errors import UsageError
16
+ from .output import emit
17
+ from .paths import WikiPaths
18
+ from .scanner import scan
19
+ from .validate import extract_refs
20
+
21
+ MAX_LISTING = 50 # human output caps the uncited listing; JSON is complete
22
+
23
+
24
+ def run_coverage(paths: WikiPaths, as_json: bool) -> int:
25
+ if not paths.catalog_file.exists():
26
+ raise UsageError("state/catalog.json 不存在,请先完成首次生成")
27
+ try:
28
+ catalog = json.loads(paths.catalog_file.read_text(encoding="utf-8"))
29
+ except json.JSONDecodeError as e:
30
+ raise UsageError(
31
+ f"state/catalog.json 损坏({e}):可手工修复该文件,或 `repowiki plan --replan` 重新规划"
32
+ ) from e
33
+
34
+ inv = scan(paths.repo_root)
35
+ known = {f.path for f in inv.files}
36
+
37
+ cited: set[str] = set()
38
+ pages: list[dict] = []
39
+ for n in flatten(catalog, paths.locale):
40
+ f = paths.root / n.output
41
+ refs = extract_refs(f.read_text(encoding="utf-8")) if f.is_file() else []
42
+ page_cited = {path for path, _s, _e in refs if path in known}
43
+ cited |= page_cited
44
+ pages.append({"id": n.id, "title": n.title, "cited_files": len(page_cited)})
45
+
46
+ if paths.overview_file.is_file():
47
+ for path, _s, _e in extract_refs(paths.overview_file.read_text(encoding="utf-8")):
48
+ if path in known:
49
+ cited.add(path)
50
+
51
+ knowledge_files: set[str] = set()
52
+ if paths.knowledge_plan_file.exists():
53
+ try:
54
+ plan = json.loads(paths.knowledge_plan_file.read_text(encoding="utf-8"))
55
+ except json.JSONDecodeError:
56
+ plan = {}
57
+ if isinstance(plan, dict):
58
+ for card in plan.get("cards") or []:
59
+ if isinstance(card, dict):
60
+ knowledge_files |= {p for p in card.get("source_files") or [] if p in known}
61
+ cited |= knowledge_files
62
+
63
+ uncited = sorted(known - cited)
64
+ total = len(known)
65
+ covered = total - len(uncited)
66
+ result = {
67
+ "ok": True,
68
+ "repo_files": total,
69
+ "cited_files": covered,
70
+ "coverage": round(covered / total, 4) if total else 1.0,
71
+ "uncited_files": uncited,
72
+ "knowledge_files": sorted(knowledge_files),
73
+ "pages": pages,
74
+ }
75
+ emit(result, _coverage_human, as_json)
76
+ return 0
77
+
78
+
79
+ def _coverage_human(r: dict) -> str:
80
+ lines = [
81
+ f"覆盖率 {r['cited_files']}/{r['repo_files']}({r['coverage'] * 100:.1f}%)"
82
+ "——被 wiki 页面/总览/知识卡片引用过的仓库文件占比"
83
+ ]
84
+ uncited = r["uncited_files"]
85
+ if uncited:
86
+ lines.append(f"未被引用 {len(uncited)} 个(至多列出 {MAX_LISTING} 个,JSON 输出含全量):")
87
+ lines += [f" → {p}" for p in uncited[:MAX_LISTING]]
88
+ else:
89
+ lines.append("仓库全部文件都被引用 ✓")
90
+ zero = [p for p in r["pages"] if p["cited_files"] == 0]
91
+ if zero:
92
+ lines.append(
93
+ f"⚠ {len(zero)} 个页面没有任何 file:// 引用: "
94
+ + ", ".join(f"{p['id']}({p['title']})" for p in zero[:5])
95
+ )
96
+ return "\n".join(lines)