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/dispatch.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Command orchestration: next / check / release / status.
|
|
2
|
+
|
|
3
|
+
``check`` is the heart of the loop: it validates agent output per task kind,
|
|
4
|
+
auto-fixes deterministic defects in place, flips status, and — for planning
|
|
5
|
+
tasks (catalog, knowledge-plan) — expands the follow-up task set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
|
|
14
|
+
from .catalog import flatten, validate_catalog
|
|
15
|
+
from .errors import ConflictError, UsageError
|
|
16
|
+
from .output import emit
|
|
17
|
+
from .paths import WikiPaths
|
|
18
|
+
from . import tasks as task_builders
|
|
19
|
+
from .scanner import scan
|
|
20
|
+
from .state import TaskStore
|
|
21
|
+
from .validate import (
|
|
22
|
+
check_knowledge_card,
|
|
23
|
+
check_knowledge_module,
|
|
24
|
+
check_knowledge_plan,
|
|
25
|
+
check_overview,
|
|
26
|
+
check_page,
|
|
27
|
+
)
|
|
28
|
+
from .knowledge import effective_categories
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _worker_id() -> str:
|
|
32
|
+
return f"{socket.gethostname()}:{os.getpid()}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _task_payload(paths: WikiPaths, task: dict) -> dict:
|
|
36
|
+
spec_path = paths.task_spec(task["id"])
|
|
37
|
+
instructions = spec_path.read_text(encoding="utf-8") if spec_path.exists() else ""
|
|
38
|
+
return {
|
|
39
|
+
"id": task["id"],
|
|
40
|
+
"kind": task["kind"],
|
|
41
|
+
"phase": task["phase"],
|
|
42
|
+
"title": task["title"],
|
|
43
|
+
"status": task["status"],
|
|
44
|
+
"output": task["output"],
|
|
45
|
+
"spec_path": str(spec_path),
|
|
46
|
+
"instructions": instructions,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_next(paths: WikiPaths, claim: bool, worker: str | None, as_json: bool) -> int:
|
|
51
|
+
store = TaskStore(paths)
|
|
52
|
+
if not paths.index_file.exists():
|
|
53
|
+
raise UsageError("尚未规划任务,请先运行 `repowiki plan <repo>`")
|
|
54
|
+
worker = worker or _worker_id()
|
|
55
|
+
result_tasks: list[dict] = []
|
|
56
|
+
if claim:
|
|
57
|
+
# 队列是纯 FIFO 拉取,一次只发放一个任务(worker 契约:一次只持有一个认领)
|
|
58
|
+
for task in store.ready_tasks(limit=1):
|
|
59
|
+
try:
|
|
60
|
+
result_tasks.append(store.claim(task["id"], worker))
|
|
61
|
+
except ConflictError:
|
|
62
|
+
pass # lost the race; the next `next` will re-pick
|
|
63
|
+
else:
|
|
64
|
+
result_tasks = store.ready_tasks(limit=1)
|
|
65
|
+
|
|
66
|
+
payload = [_task_payload(paths, t) for t in result_tasks]
|
|
67
|
+
stats = store.stats()
|
|
68
|
+
out = {
|
|
69
|
+
"ok": True,
|
|
70
|
+
"claimed": claim,
|
|
71
|
+
"tasks": payload,
|
|
72
|
+
"busy": stats["busy"],
|
|
73
|
+
"progress": {"total": stats["total"], "by_status": stats["by_status"], "current_phase": stats["current_phase"]},
|
|
74
|
+
}
|
|
75
|
+
emit(out, _next_human, as_json)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _next_human(out: dict) -> str:
|
|
80
|
+
lines = []
|
|
81
|
+
if not out["tasks"]:
|
|
82
|
+
hint = f",{out['busy']} 个任务执行中(稍后重试)" if out["busy"] else ""
|
|
83
|
+
lines.append(f"当前无可领取任务{hint}(共 {out['progress']['total']} 个任务,状态 {out['progress']['by_status']})")
|
|
84
|
+
for t in out["tasks"]:
|
|
85
|
+
lines += [f"[{t['kind']}] {t['id']} {t['title']}",
|
|
86
|
+
f" 规格: {t['spec_path']}",
|
|
87
|
+
f" 输出: {t['output']}"]
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_release(paths: WikiPaths, task_id: str, force: bool, as_json: bool) -> int:
|
|
92
|
+
store = TaskStore(paths)
|
|
93
|
+
task = store.release(task_id, force=force)
|
|
94
|
+
emit({"ok": True, "released": task_id, "status": task["status"]},
|
|
95
|
+
lambda r: f"已释放任务 {r['released']} → pending", as_json)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_status(paths: WikiPaths, as_json: bool) -> int:
|
|
100
|
+
store = TaskStore(paths)
|
|
101
|
+
stats = store.stats()
|
|
102
|
+
emit({"ok": True, **stats}, _status_human, as_json)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _status_human(out: dict) -> str:
|
|
107
|
+
lines = [f"任务总数 {out['total']} 当前阶段 {out['current_phase']}"]
|
|
108
|
+
for status, n in sorted(out["by_status"].items()):
|
|
109
|
+
lines.append(f" {status}: {n}")
|
|
110
|
+
for f in out["failed"]:
|
|
111
|
+
lines.append(f" ✗ failed: {f['id']} {f['title']}")
|
|
112
|
+
for e in out["exhausted"]:
|
|
113
|
+
lines.append(f" ⛔ exhausted: {e['id']} {e['title']}(已试 {e['attempts']} 次,`release --task {e['id']} --force` 可重置)")
|
|
114
|
+
for s in out["stale_claims"]:
|
|
115
|
+
lines.append(f" ⏰ stale: {s['id']}(worker {s['worker']},心跳 {s['heartbeat_at']})")
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_touch(paths: WikiPaths, task_id: str, worker: str | None, as_json: bool) -> int:
|
|
120
|
+
store = TaskStore(paths)
|
|
121
|
+
store.touch(task_id, worker=worker)
|
|
122
|
+
emit({"ok": True, "touched": task_id},
|
|
123
|
+
lambda r: f"✓ 已续期任务 {r['touched']} 的认领", as_json)
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_watch(paths: WikiPaths, interval: float, timeout: float, as_json: bool) -> int:
|
|
128
|
+
"""Block until all tasks are done, the pipeline stalls, or timeout.
|
|
129
|
+
|
|
130
|
+
Designed for the driving session: spawn background workers, then run
|
|
131
|
+
watch (foreground or background) — its exit code tells the session what
|
|
132
|
+
to do next without any polling logic of its own.
|
|
133
|
+
"""
|
|
134
|
+
import time as _time
|
|
135
|
+
|
|
136
|
+
store = TaskStore(paths)
|
|
137
|
+
data = store.load()
|
|
138
|
+
if not data["tasks"]:
|
|
139
|
+
raise UsageError("没有任务,请先运行 `repowiki plan <repo>`")
|
|
140
|
+
|
|
141
|
+
started = _time.monotonic()
|
|
142
|
+
last_line = ""
|
|
143
|
+
stats = store.stats()
|
|
144
|
+
|
|
145
|
+
while True:
|
|
146
|
+
stats = store.stats()
|
|
147
|
+
done = stats["by_status"].get("done", 0)
|
|
148
|
+
total = stats["total"]
|
|
149
|
+
stale_ids = {s["id"] for s in stats["stale_claims"]}
|
|
150
|
+
# stale claims are not "in flight": their worker is gone, the queue
|
|
151
|
+
# will hand those tasks back out — counting them hides real stalls
|
|
152
|
+
in_flight = [
|
|
153
|
+
t for t in store.load()["tasks"].values()
|
|
154
|
+
if t["status"] == "in_progress" and t["id"] not in stale_ids
|
|
155
|
+
]
|
|
156
|
+
ready = store.ready_tasks(limit=1)
|
|
157
|
+
|
|
158
|
+
line = (
|
|
159
|
+
f"[{_time.strftime('%H:%M:%S')}] {done}/{total} done"
|
|
160
|
+
f" · 阶段{stats['current_phase']}"
|
|
161
|
+
f" · 进行中: {', '.join(t['id'] + '(' + (t['worker'] or '?') + ')' for t in in_flight) or '无'}"
|
|
162
|
+
f" · failed {len(stats['failed'])} · exhausted {len(stats['exhausted'])} · stale {len(stale_ids)}"
|
|
163
|
+
)
|
|
164
|
+
if line != last_line:
|
|
165
|
+
if not as_json:
|
|
166
|
+
print(line, flush=True)
|
|
167
|
+
last_line = line
|
|
168
|
+
|
|
169
|
+
# terminal: everything done
|
|
170
|
+
if total > 0 and done == total:
|
|
171
|
+
emit({"reason": "completed", "stats": stats},
|
|
172
|
+
lambda r: f"✓ 全部 {r['stats']['total']} 个任务完成", as_json)
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
# terminal: stalled — work remains but nothing is running or claimable.
|
|
176
|
+
# Re-check with fresh stats first: a task can complete between the
|
|
177
|
+
# top-of-loop snapshot and this branch, and a stale snapshot must not
|
|
178
|
+
# fake a stall.
|
|
179
|
+
if not in_flight and not ready:
|
|
180
|
+
fresh = store.stats()
|
|
181
|
+
done = fresh["by_status"].get("done", 0)
|
|
182
|
+
if total > 0 and done == total:
|
|
183
|
+
emit({"reason": "completed", "stats": fresh},
|
|
184
|
+
lambda r: f"✓ 全部 {r['stats']['total']} 个任务完成", as_json)
|
|
185
|
+
return 0
|
|
186
|
+
reason = (
|
|
187
|
+
f"停滞:剩余 {total - done} 个任务未完成,但无执行中且无可领取任务"
|
|
188
|
+
"(通常是 exhausted 毒任务;`repowiki status` 查看详情,`release --force` 可重置)"
|
|
189
|
+
)
|
|
190
|
+
emit({"reason": "stalled", "detail": reason, "stats": fresh},
|
|
191
|
+
lambda r: f"⏹ {r['detail']}", as_json)
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
if _time.monotonic() - started >= timeout:
|
|
195
|
+
reason = f"超时({int(timeout)}s):剩余 {total - done} 个任务未完成"
|
|
196
|
+
emit({"reason": "timeout", "detail": reason, "stats": stats},
|
|
197
|
+
lambda r: f"⏰ {r['detail']}", as_json)
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
_time.sleep(interval)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- check ---
|
|
204
|
+
|
|
205
|
+
def run_check(paths: WikiPaths, task_id: str | None, as_json: bool,
|
|
206
|
+
select_all: bool = False, worker: str | None = None,
|
|
207
|
+
force: bool = False) -> int:
|
|
208
|
+
store = TaskStore(paths)
|
|
209
|
+
data = store.load()
|
|
210
|
+
if not data["tasks"]:
|
|
211
|
+
raise UsageError("没有任务,请先运行 `repowiki plan <repo>`")
|
|
212
|
+
|
|
213
|
+
if task_id:
|
|
214
|
+
if task_id not in data["tasks"]:
|
|
215
|
+
raise UsageError(f"任务不存在: {task_id}")
|
|
216
|
+
targets = [task_id]
|
|
217
|
+
elif select_all:
|
|
218
|
+
targets = [
|
|
219
|
+
tid for tid, t in data["tasks"].items()
|
|
220
|
+
if t["status"] in ("in_progress", "failed")
|
|
221
|
+
]
|
|
222
|
+
else:
|
|
223
|
+
raise UsageError("请指定 --task <id>(单任务校验)或 --all(检查全部 in_progress/failed,用于崩溃恢复)")
|
|
224
|
+
|
|
225
|
+
if not targets:
|
|
226
|
+
_emit_check(as_json, ok=True, results=[], note="没有待检查任务(无 in_progress/failed)")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
# claim ownership guard: refuse to flip tasks held by a different live worker
|
|
230
|
+
if worker and not force:
|
|
231
|
+
for tid in targets:
|
|
232
|
+
t = data["tasks"][tid]
|
|
233
|
+
if t["status"] == "in_progress":
|
|
234
|
+
try:
|
|
235
|
+
held = (store._claim_dir(tid) / "worker").read_text(encoding="utf-8").strip()
|
|
236
|
+
except OSError:
|
|
237
|
+
held = ""
|
|
238
|
+
if held and held != worker:
|
|
239
|
+
raise ConflictError(
|
|
240
|
+
f"任务 {tid} 由 {held} 认领;如确要代为校验请加 --force"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
inv = scan(paths.repo_root)
|
|
244
|
+
results = []
|
|
245
|
+
all_ok = True
|
|
246
|
+
for tid in targets:
|
|
247
|
+
task = data["tasks"][tid]
|
|
248
|
+
if task["status"] == "done":
|
|
249
|
+
# done is terminal (its spec may already be purged). Report only —
|
|
250
|
+
# never flip status, otherwise the task becomes unexecutable.
|
|
251
|
+
r = _check_readonly(paths, task, inv)
|
|
252
|
+
results.append(r)
|
|
253
|
+
all_ok = all_ok and r["ok"]
|
|
254
|
+
continue
|
|
255
|
+
r = _check_one(paths, store, task, inv)
|
|
256
|
+
if r["status"] == "in_progress":
|
|
257
|
+
store.heartbeat(tid) # validating a task also refreshes its claim
|
|
258
|
+
results.append(r)
|
|
259
|
+
all_ok = all_ok and r["ok"]
|
|
260
|
+
|
|
261
|
+
_emit_check(as_json, ok=all_ok, results=results)
|
|
262
|
+
return 0 if all_ok else 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _node_archetype(paths: WikiPaths, node_id: str) -> str:
|
|
266
|
+
"""Archetype of the catalog node behind a page task (default "module")."""
|
|
267
|
+
try:
|
|
268
|
+
catalog = json.loads(paths.catalog_file.read_text(encoding="utf-8"))
|
|
269
|
+
except (json.JSONDecodeError, OSError):
|
|
270
|
+
return "module"
|
|
271
|
+
stack = list(catalog.get("chapters") or []) if isinstance(catalog, dict) else []
|
|
272
|
+
while stack:
|
|
273
|
+
n = stack.pop()
|
|
274
|
+
if not isinstance(n, dict):
|
|
275
|
+
continue
|
|
276
|
+
if n.get("id") == node_id:
|
|
277
|
+
return n.get("archetype") if n.get("archetype") in ("module", "flow") else "module"
|
|
278
|
+
stack.extend(n.get("children") or [])
|
|
279
|
+
return "module"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _check_readonly(paths: WikiPaths, task: dict, inv) -> dict:
|
|
283
|
+
"""Validate a done task without touching its status."""
|
|
284
|
+
base = {"id": task["id"], "kind": task["kind"], "title": task["title"], "status": "done"}
|
|
285
|
+
out = paths.root / task["output"]
|
|
286
|
+
exists = out.is_dir() if task["kind"] == "knowledge_module" else out.is_file()
|
|
287
|
+
if not exists:
|
|
288
|
+
return {**base, "ok": False, "readonly": True,
|
|
289
|
+
"errors": [f"产出已不存在: {task['output']}(如需重建请用 update 或 plan --replan)"]}
|
|
290
|
+
if task["kind"] in ("page", "page_update"):
|
|
291
|
+
raw = out.read_text(encoding="utf-8")
|
|
292
|
+
node_id = task["id"][:-len("-update")] if task["id"].endswith("-update") else task["id"]
|
|
293
|
+
res = check_page(raw, task["title"].replace("(增量更新)", ""), paths.repo_root,
|
|
294
|
+
is_update=(task["kind"] == "page_update"), locale=paths.locale,
|
|
295
|
+
archetype=_node_archetype(paths, node_id))
|
|
296
|
+
return {**base, "ok": res.ok, "readonly": True, "errors": res.errors,
|
|
297
|
+
"fixed": [], "warnings": res.warnings,
|
|
298
|
+
"note": "done 为终态,此结果仅供参考,状态未改变"}
|
|
299
|
+
return {**base, "ok": True, "readonly": True, "errors": [], "fixed": [],
|
|
300
|
+
"warnings": [], "note": "done 为终态,产出存在(不做内容重校验)"}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _emit_check(as_json: bool, ok: bool, results: list[dict], note: str = "") -> None:
|
|
304
|
+
emit({"ok": ok, "results": results, "note": note}, _check_human, as_json)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _check_human(r: dict) -> str:
|
|
308
|
+
lines = [r["note"]] if r["note"] else []
|
|
309
|
+
for res in r["results"]:
|
|
310
|
+
mark = "✓" if res["ok"] else "✗"
|
|
311
|
+
lines.append(f"{mark} {res['id']} {res['title']} → {res['status']}")
|
|
312
|
+
lines += [f" 错误: {e}" for e in res.get("errors", [])]
|
|
313
|
+
lines += [f" 已修复: {f}" for f in res.get("fixed", [])]
|
|
314
|
+
lines += [f" 警告: {w}" for w in res.get("warnings", [])]
|
|
315
|
+
return "\n".join(lines)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _check_one(paths: WikiPaths, store: TaskStore, task: dict, inv) -> dict:
|
|
319
|
+
kind = task["kind"]
|
|
320
|
+
tid = task["id"]
|
|
321
|
+
base = {"id": tid, "kind": kind, "title": task["title"]}
|
|
322
|
+
|
|
323
|
+
if kind in ("catalog", "knowledge_plan"):
|
|
324
|
+
return _check_plan_task(paths, store, task, inv, base)
|
|
325
|
+
|
|
326
|
+
if kind in ("page", "page_update", "overview", "overview_update", "knowledge_card"):
|
|
327
|
+
out_file = paths.root / task["output"]
|
|
328
|
+
if not out_file.is_file():
|
|
329
|
+
store.update(tid, status="failed")
|
|
330
|
+
return {**base, "ok": False, "status": "failed",
|
|
331
|
+
"errors": [f"输出文件不存在: {task['output']}(按任务规格写入该路径)"]}
|
|
332
|
+
raw = out_file.read_text(encoding="utf-8")
|
|
333
|
+
if kind in ("overview", "overview_update"):
|
|
334
|
+
try:
|
|
335
|
+
repo_name = json.loads(paths.catalog_file.read_text(encoding="utf-8")).get("repo_name", "")
|
|
336
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
337
|
+
store.update(tid, status="failed")
|
|
338
|
+
return {**base, "ok": False, "status": "failed",
|
|
339
|
+
"errors": [f"state/catalog.json 无法读取({e}),无法校验 overview"]}
|
|
340
|
+
res = check_overview(raw, repo_name, locale=paths.locale)
|
|
341
|
+
elif kind == "knowledge_card":
|
|
342
|
+
plan = _load_json(paths.knowledge_plan_file) or {}
|
|
343
|
+
card_id = tid[:-len("-update")] if tid.endswith("-update") else tid
|
|
344
|
+
card = next(
|
|
345
|
+
(c for c in plan.get("cards", []) if c.get("id") == card_id), {}
|
|
346
|
+
)
|
|
347
|
+
res = check_knowledge_card(raw, card.get("title", task["title"]), card.get("category", ""),
|
|
348
|
+
paths.repo_root, locale=paths.locale,
|
|
349
|
+
is_update=tid.endswith("-update"),
|
|
350
|
+
categories={c["id"] for c in effective_categories(paths)})
|
|
351
|
+
else:
|
|
352
|
+
node_id = tid[:-len("-update")] if tid.endswith("-update") else tid
|
|
353
|
+
res = check_page(raw, task["title"].replace("(增量更新)", ""), paths.repo_root,
|
|
354
|
+
is_update=(kind == "page_update"), locale=paths.locale,
|
|
355
|
+
archetype=_node_archetype(paths, node_id))
|
|
356
|
+
if res.fixed and res.text != raw:
|
|
357
|
+
out_file.write_text(res.text, encoding="utf-8")
|
|
358
|
+
status = "done" if res.ok else "failed"
|
|
359
|
+
store.update(tid, status=status)
|
|
360
|
+
return {**base, "ok": res.ok, "status": status, "errors": res.errors, "fixed": res.fixed, "warnings": res.warnings}
|
|
361
|
+
|
|
362
|
+
if kind == "knowledge_module":
|
|
363
|
+
out_dir = paths.root / task["output"]
|
|
364
|
+
res = check_knowledge_module(out_dir, locale=paths.locale)
|
|
365
|
+
status = "done" if res.ok else "failed"
|
|
366
|
+
store.update(tid, status=status)
|
|
367
|
+
return {**base, "ok": res.ok, "status": status, "errors": res.errors, "fixed": res.fixed}
|
|
368
|
+
|
|
369
|
+
store.update(tid, status="failed")
|
|
370
|
+
return {**base, "ok": False, "status": "failed", "errors": [f"未知任务类型 {kind}"]}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _load_json(path) -> dict | None:
|
|
374
|
+
if not path.exists():
|
|
375
|
+
return None
|
|
376
|
+
try:
|
|
377
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
378
|
+
except json.JSONDecodeError as e:
|
|
379
|
+
return {"__parse_error__": str(e)}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _check_plan_task(paths: WikiPaths, store: TaskStore, task: dict, inv, base: dict) -> dict:
|
|
383
|
+
tid = task["id"]
|
|
384
|
+
plan_file = paths.catalog_file if tid == "catalog" else paths.knowledge_plan_file
|
|
385
|
+
|
|
386
|
+
data = _load_json(plan_file)
|
|
387
|
+
if data is None:
|
|
388
|
+
store.update(tid, status="failed")
|
|
389
|
+
return {**base, "ok": False, "status": "failed", "errors": [f"规划文件不存在或 JSON 解析失败: {plan_file}"]}
|
|
390
|
+
if "__parse_error__" in data:
|
|
391
|
+
store.update(tid, status="failed")
|
|
392
|
+
return {**base, "ok": False, "status": "failed", "errors": [f"JSON 解析失败: {data['__parse_error__']}"]}
|
|
393
|
+
|
|
394
|
+
if tid == "catalog":
|
|
395
|
+
errors, warnings = validate_catalog(data, inv.known_paths())
|
|
396
|
+
else:
|
|
397
|
+
cats = {c["id"] for c in effective_categories(paths)}
|
|
398
|
+
errors, warnings = check_knowledge_plan(data, inv.known_paths(), categories=cats)
|
|
399
|
+
|
|
400
|
+
if errors:
|
|
401
|
+
store.update(tid, status="failed")
|
|
402
|
+
return {**base, "ok": False, "status": "failed", "errors": errors, "warnings": warnings}
|
|
403
|
+
|
|
404
|
+
if tid == "catalog":
|
|
405
|
+
added = _expand_pages(paths, store, data, inv)
|
|
406
|
+
else:
|
|
407
|
+
added = _expand_knowledge(paths, store, data)
|
|
408
|
+
store.update(tid, status="done")
|
|
409
|
+
return {**base, "ok": True, "status": "done", "warnings": warnings,
|
|
410
|
+
"expanded_tasks": added}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _expand_pages(paths: WikiPaths, store: TaskStore, catalog: dict, inv) -> list[str]:
|
|
414
|
+
nodes = flatten(catalog, paths.locale)
|
|
415
|
+
return store.add_tasks(task_builders.build_page_tasks(paths, nodes, inv))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _expand_knowledge(paths: WikiPaths, store: TaskStore, plan: dict) -> list[str]:
|
|
419
|
+
return store.add_tasks(task_builders.build_knowledge_tasks(paths, plan))
|
repowiki/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Error vocabulary shared by every module (exit codes: 1 = usage/state, 2 = conflict).
|
|
2
|
+
|
|
3
|
+
Lives in a leaf module on purpose: nothing here imports anything, so command
|
|
4
|
+
modules can raise these without importing back into :mod:`repowiki.cli`.
|
|
5
|
+
:class:`UsageError` and :class:`StateError` map to exit code 1,
|
|
6
|
+
:class:`ConflictError` to exit code 2 (the mapping lives in cli.main).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConflictError(Exception):
|
|
13
|
+
"""State conflict, e.g. task already claimed by a live worker."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StateError(Exception):
|
|
17
|
+
"""Persisted state is unreadable (e.g. corrupt index.json); data-preserving abort."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UsageError(Exception):
|
|
21
|
+
"""User/input error reported with exit code 1."""
|
repowiki/gitutil.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared git subprocess helper: raw stdout on success, None on any failure.
|
|
2
|
+
|
|
3
|
+
Callers strip/split the output themselves — some call sites (e.g. ``diff
|
|
4
|
+
--name-only``) must distinguish "empty output" from "command failed".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_git(repo: Path | str, *args: str, timeout: int = 30) -> str | None:
|
|
14
|
+
try:
|
|
15
|
+
return subprocess.run(
|
|
16
|
+
["git", *args], cwd=str(repo), capture_output=True, check=True, timeout=timeout,
|
|
17
|
+
).stdout.decode("utf-8", "replace")
|
|
18
|
+
except (subprocess.SubprocessError, OSError):
|
|
19
|
+
return None
|
repowiki/i18n.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Locale support: per-language UI strings for generated output + detection.
|
|
2
|
+
|
|
3
|
+
The wiki output language follows the target repository: ``plan`` detects it
|
|
4
|
+
deterministically (CJK ratio over README + source samples, zero network),
|
|
5
|
+
persists it in ``state/locale``, and every later command honors it. Adding a
|
|
6
|
+
language = one entry in ``STRINGS`` plus a template directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SUPPORTED = ("zh", "en")
|
|
15
|
+
DEFAULT_LOCALE = "zh"
|
|
16
|
+
|
|
17
|
+
# Section/file names the validator enforces, per locale. "prefix" sections
|
|
18
|
+
# accept variants (e.g. 性能与一致性考量 / 性能考量).
|
|
19
|
+
STRINGS: dict[str, dict] = {
|
|
20
|
+
"zh": {
|
|
21
|
+
"name": "简体中文",
|
|
22
|
+
"toc": "目录",
|
|
23
|
+
"required_sections": [
|
|
24
|
+
("简介", "exact"),
|
|
25
|
+
("项目结构", "exact"),
|
|
26
|
+
("核心组件", "exact"),
|
|
27
|
+
("架构总览", "exact"),
|
|
28
|
+
("详细组件分析", "exact"),
|
|
29
|
+
("依赖关系分析", "exact"),
|
|
30
|
+
("性能", "prefix"),
|
|
31
|
+
("故障", "prefix"),
|
|
32
|
+
("结论", "exact"),
|
|
33
|
+
],
|
|
34
|
+
"update_extra": ("更新摘要", "exact"),
|
|
35
|
+
"flow_sections": [
|
|
36
|
+
("简介", "exact"),
|
|
37
|
+
("流程总览", "exact"),
|
|
38
|
+
("关键步骤", "exact"),
|
|
39
|
+
("参与组件", "exact"),
|
|
40
|
+
("数据与状态变化", "exact"),
|
|
41
|
+
("故障", "prefix"),
|
|
42
|
+
("结论", "exact"),
|
|
43
|
+
],
|
|
44
|
+
"overview_h1_suffix": "Wiki 总览",
|
|
45
|
+
"overview_sections": ("章节导航", "如何使用本 Wiki"),
|
|
46
|
+
"module_required_files": ["概述.md", "技术栈.md", "架构设计.md"],
|
|
47
|
+
"card_sections": ["体系概览", "关键文件与包", "架构与设计约定", "开发者应遵循的规则"],
|
|
48
|
+
"card_update_extra": "更新摘要",
|
|
49
|
+
"site": {
|
|
50
|
+
"overview_label": "总览",
|
|
51
|
+
"knowledge_label": "知识库",
|
|
52
|
+
"llms_desc": "repowiki 为仓库 {repo} 生成的结构化 Wiki:以下链接按章节索引全部 markdown 页面,llms-full.txt 为全文合并版。",
|
|
53
|
+
"search_placeholder": "搜索 Wiki…",
|
|
54
|
+
"no_results": "无匹配结果",
|
|
55
|
+
"snippet_missing": "源文件不存在或已删除,无法展示片段。",
|
|
56
|
+
"theme_label": "切换深色/浅色主题",
|
|
57
|
+
"menu_label": "打开/收起目录",
|
|
58
|
+
"close_label": "关闭 (Esc)",
|
|
59
|
+
"lines_label": "行",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
"en": {
|
|
63
|
+
"name": "English",
|
|
64
|
+
"toc": "Contents",
|
|
65
|
+
"required_sections": [
|
|
66
|
+
("Introduction", "exact"),
|
|
67
|
+
("Project Structure", "exact"),
|
|
68
|
+
("Core Components", "exact"),
|
|
69
|
+
("Architecture Overview", "exact"),
|
|
70
|
+
("Detailed Component Analysis", "exact"),
|
|
71
|
+
("Dependency Analysis", "exact"),
|
|
72
|
+
("Performance", "prefix"),
|
|
73
|
+
("Troubleshooting", "prefix"),
|
|
74
|
+
("Conclusion", "exact"),
|
|
75
|
+
],
|
|
76
|
+
"update_extra": ("Update Summary", "exact"),
|
|
77
|
+
"flow_sections": [
|
|
78
|
+
("Introduction", "exact"),
|
|
79
|
+
("Flow Overview", "exact"),
|
|
80
|
+
("Key Steps", "exact"),
|
|
81
|
+
("Involved Components", "exact"),
|
|
82
|
+
("Data and State Changes", "exact"),
|
|
83
|
+
("Troubleshooting", "prefix"),
|
|
84
|
+
("Conclusion", "exact"),
|
|
85
|
+
],
|
|
86
|
+
"overview_h1_suffix": "Wiki Overview",
|
|
87
|
+
"overview_sections": ("Section Navigation", "How to Use This Wiki"),
|
|
88
|
+
"module_required_files": ["overview.md", "tech-stack.md", "architecture.md"],
|
|
89
|
+
"card_sections": [
|
|
90
|
+
"System Overview", "Key Files and Packages",
|
|
91
|
+
"Architecture and Design Conventions", "Rules for Developers",
|
|
92
|
+
],
|
|
93
|
+
"card_update_extra": "Update Summary",
|
|
94
|
+
"site": {
|
|
95
|
+
"overview_label": "Overview",
|
|
96
|
+
"knowledge_label": "Knowledge Base",
|
|
97
|
+
"llms_desc": "Structured wiki for the repository '{repo}', generated by repowiki: the links index every markdown page by chapter; llms-full.txt holds the concatenated full text.",
|
|
98
|
+
"search_placeholder": "Search wiki…",
|
|
99
|
+
"no_results": "No matches",
|
|
100
|
+
"snippet_missing": "Source file missing or deleted; snippet unavailable.",
|
|
101
|
+
"theme_label": "Toggle dark/light theme",
|
|
102
|
+
"menu_label": "Toggle table of contents",
|
|
103
|
+
"close_label": "Close (Esc)",
|
|
104
|
+
"lines_label": "lines",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
110
|
+
_LATIN_RE = re.compile(r"[A-Za-z]")
|
|
111
|
+
_SAMPLE_SUFFIXES = {
|
|
112
|
+
".md", ".txt", ".rst", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs",
|
|
113
|
+
".java", ".kt", ".rb", ".php", ".c", ".h", ".cpp", ".cs", ".swift", ".sh",
|
|
114
|
+
}
|
|
115
|
+
_SAMPLE_BYTES = 8192
|
|
116
|
+
_CODE_SAMPLES = 24
|
|
117
|
+
_MIN_README_SIGNAL = 20 # letters (CJK included) before the README is decisive
|
|
118
|
+
_MIN_CODE_CJK = 30 # CJK chars in one source file => non-English comments
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def strings(locale: str) -> dict:
|
|
122
|
+
return STRINGS.get(locale) or STRINGS[DEFAULT_LOCALE]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_head(path: Path) -> str:
|
|
126
|
+
try:
|
|
127
|
+
data = path.read_bytes()[:_SAMPLE_BYTES]
|
|
128
|
+
except OSError:
|
|
129
|
+
return ""
|
|
130
|
+
if b"\0" in data:
|
|
131
|
+
return ""
|
|
132
|
+
return data.decode("utf-8", "ignore")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def detect_locale(repo_root: Path, code_files: list[str] | None = None) -> str:
|
|
136
|
+
"""Decide zh vs en from repository text. The README decides when it
|
|
137
|
+
carries enough natural text (identifiers in code are always latin, so
|
|
138
|
+
they must not dilute the docs-language signal); without a decisive README,
|
|
139
|
+
any source file with substantial CJK text (comments/docstrings) picks zh.
|
|
140
|
+
README.md is preferred when present — a translated sibling (README.en.md,
|
|
141
|
+
README.zh.md) must not outvote the primary one (sorted order would put
|
|
142
|
+
"README.en.md" first). Deterministic, no network."""
|
|
143
|
+
root = Path(repo_root)
|
|
144
|
+
primary = root / "README.md"
|
|
145
|
+
readme = primary if primary.is_file() else next(
|
|
146
|
+
(p for p in sorted(root.glob("README*")) if p.is_file()), None)
|
|
147
|
+
if readme is not None:
|
|
148
|
+
text = _read_head(readme)
|
|
149
|
+
cjk = len(_CJK_RE.findall(text))
|
|
150
|
+
latin = len(_LATIN_RE.findall(text))
|
|
151
|
+
if cjk + latin >= _MIN_README_SIGNAL:
|
|
152
|
+
return "zh" if cjk / (cjk + latin) >= 0.15 else "en"
|
|
153
|
+
|
|
154
|
+
candidates = [root / rel for rel in sorted(code_files or [])[:_CODE_SAMPLES]]
|
|
155
|
+
candidates = [p for p in candidates if p.suffix in _SAMPLE_SUFFIXES and p.is_file()]
|
|
156
|
+
if readme is not None:
|
|
157
|
+
candidates.append(readme)
|
|
158
|
+
for p in candidates:
|
|
159
|
+
if len(_CJK_RE.findall(_read_head(p))) >= _MIN_CODE_CJK:
|
|
160
|
+
return "zh"
|
|
161
|
+
return "en"
|