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.
- repowiki/__init__.py +18 -0
- repowiki/catalog.py +193 -0
- repowiki/cli.py +164 -0
- repowiki/coverage.py +96 -0
- repowiki/dispatch.py +419 -0
- repowiki/errors.py +21 -0
- repowiki/gitutil.py +19 -0
- repowiki/i18n.py +161 -0
- repowiki/knowledge.py +226 -0
- repowiki/llms.py +52 -0
- repowiki/metadata.py +176 -0
- repowiki/output.py +34 -0
- repowiki/paths.py +176 -0
- repowiki/plan.py +140 -0
- repowiki/scanner.py +170 -0
- repowiki/site.py +384 -0
- repowiki/state.py +437 -0
- repowiki/tasks.py +354 -0
- repowiki/templates/en/STYLE.md +49 -0
- repowiki/templates/en/catalog_task.md +69 -0
- repowiki/templates/en/flow_template.md +87 -0
- repowiki/templates/en/knowledge_card_task.md +65 -0
- repowiki/templates/en/knowledge_card_update_task.md +49 -0
- repowiki/templates/en/knowledge_module_task.md +33 -0
- repowiki/templates/en/knowledge_module_update_task.md +36 -0
- repowiki/templates/en/knowledge_task.md +65 -0
- repowiki/templates/en/overview_task.md +35 -0
- repowiki/templates/en/overview_update_task.md +50 -0
- repowiki/templates/en/page_task.md +45 -0
- repowiki/templates/en/page_template.md +113 -0
- repowiki/templates/en/update_task.md +49 -0
- repowiki/templates/site/app.js +378 -0
- repowiki/templates/site.html +373 -0
- repowiki/templates/zh/STYLE.md +50 -0
- repowiki/templates/zh/catalog_task.md +66 -0
- repowiki/templates/zh/flow_template.md +87 -0
- repowiki/templates/zh/knowledge_card_task.md +65 -0
- repowiki/templates/zh/knowledge_card_update_task.md +46 -0
- repowiki/templates/zh/knowledge_module_task.md +33 -0
- repowiki/templates/zh/knowledge_module_update_task.md +35 -0
- repowiki/templates/zh/knowledge_task.md +62 -0
- repowiki/templates/zh/overview_task.md +35 -0
- repowiki/templates/zh/overview_update_task.md +44 -0
- repowiki/templates/zh/page_task.md +45 -0
- repowiki/templates/zh/page_template.md +113 -0
- repowiki/templates/zh/update_task.md +49 -0
- repowiki/templates.py +38 -0
- repowiki/updater.py +322 -0
- repowiki/validate.py +356 -0
- repowiki/vendor/marked.min.js +69 -0
- repowiki/vendor/mermaid.min.js +3636 -0
- repowiki_cli-0.5.0.dist-info/METADATA +349 -0
- repowiki_cli-0.5.0.dist-info/RECORD +57 -0
- repowiki_cli-0.5.0.dist-info/WHEEL +5 -0
- repowiki_cli-0.5.0.dist-info/entry_points.txt +2 -0
- repowiki_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- 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)
|