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/knowledge.py ADDED
@@ -0,0 +1,226 @@
1
+ """``repowiki knowledge``: append the knowledge-card task set, and aggregate
2
+ knowledge outputs (_index.yaml / _module.yaml) at finalize time.
3
+
4
+ Card categories default to the built-in six; ``--categories <file>`` replaces
5
+ them wholesale with a repo-specific list (YAML/JSON), persisted in
6
+ ``state/knowledge_categories.json`` so later ``check``/``update`` rounds
7
+ validate against the same set.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+
15
+ import yaml
16
+
17
+ from .errors import UsageError
18
+ from .gitutil import run_git
19
+ from .output import emit
20
+ from .paths import WikiPaths, sanitize_component
21
+ from . import tasks as task_builders
22
+ from .scanner import scan
23
+ from .state import TaskStore, now_iso
24
+
25
+ _CATEGORY_ID_RE = re.compile(r"^[a-z][a-z0-9_]{1,39}$")
26
+ MAX_CATEGORIES = 12
27
+
28
+
29
+ def run_knowledge(paths: WikiPaths, as_json: bool, categories: str | None = None) -> int:
30
+ if categories:
31
+ custom = _load_category_file(paths.repo_root / categories)
32
+ paths.knowledge_categories_file.write_text(
33
+ json.dumps({"categories": custom}, ensure_ascii=False, indent=2),
34
+ encoding="utf-8",
35
+ )
36
+ store = TaskStore(paths)
37
+ data = store.load()
38
+ if not data["tasks"]:
39
+ raise UsageError("请先运行 `repowiki plan <repo>`")
40
+ effective = effective_categories(paths)
41
+ added = store.add_tasks([
42
+ task_builders.build_knowledge_plan_task(paths, scan(paths.repo_root), effective)
43
+ ])
44
+ result = {
45
+ "ok": True,
46
+ "added": added,
47
+ "categories": [c["id"] for c in effective],
48
+ "note": "knowledge-plan 任务已就绪,领取执行后 check 会自动展开模块/卡片任务",
49
+ }
50
+ emit(result, _knowledge_human, as_json)
51
+ return 0
52
+
53
+
54
+ def _knowledge_human(r: dict) -> str:
55
+ head = "已添加 knowledge-plan 任务" if r["added"] else "knowledge 任务集已存在"
56
+ cats = "、".join(r["categories"])
57
+ return f"{head}(类别清单:{cats})\n{r['note']}"
58
+
59
+
60
+ def _load_category_file(path) -> list[dict]:
61
+ """Parse + validate a user-supplied category list (YAML or JSON).
62
+
63
+ Entries are ``id`` strings or objects with ``id`` and optional
64
+ ``name``/``guidance``. The list replaces the built-in categories.
65
+ """
66
+ if not path.is_file():
67
+ raise UsageError(f"类别文件不存在: {path}")
68
+ raw = path.read_text(encoding="utf-8")
69
+ try:
70
+ data = (yaml.safe_load(raw) if path.suffix in (".yaml", ".yml")
71
+ else json.loads(raw))
72
+ except (yaml.YAMLError, json.JSONDecodeError) as e:
73
+ raise UsageError(f"类别文件解析失败({path.name}): {e}") from e
74
+ if not isinstance(data, list) or not data:
75
+ raise UsageError("类别文件必须是非空数组(字符串 id,或含 id/name/guidance 的对象)")
76
+ seen: set[str] = set()
77
+ cats: list[dict] = []
78
+ for i, item in enumerate(data):
79
+ where = f"categories[{i}]"
80
+ if isinstance(item, str):
81
+ item = {"id": item}
82
+ if not isinstance(item, dict):
83
+ raise UsageError(f"{where}: 必须是字符串或对象")
84
+ cid = str(item.get("id", "")).strip()
85
+ if not _CATEGORY_ID_RE.match(cid):
86
+ raise UsageError(
87
+ f"{where}: id 非法 `{cid}`(小写字母开头,仅小写字母/数字/下划线)"
88
+ )
89
+ if cid in seen:
90
+ raise UsageError(f"{where}: id 重复 `{cid}`")
91
+ seen.add(cid)
92
+ cats.append({
93
+ "id": cid,
94
+ "name": str(item.get("name", "")).strip() or cid,
95
+ "guidance": str(item.get("guidance", "")).strip(),
96
+ })
97
+ if len(cats) > MAX_CATEGORIES:
98
+ raise UsageError(f"类别过多({len(cats)} > {MAX_CATEGORIES}):机制卡片贵精不贵多")
99
+ return cats
100
+
101
+
102
+ def effective_categories(paths: WikiPaths) -> list[dict]:
103
+ """The category list in force: user-supplied (persisted) or built-in."""
104
+ f = paths.knowledge_categories_file
105
+ if f.exists():
106
+ try:
107
+ data = json.loads(f.read_text(encoding="utf-8"))
108
+ except json.JSONDecodeError:
109
+ data = None
110
+ if isinstance(data, dict) and isinstance(data.get("categories"), list) and data["categories"]:
111
+ return data["categories"]
112
+ return [
113
+ {"id": cid, "name": guide}
114
+ for cid, guide in task_builders.DEFAULT_KNOWLEDGE_CATEGORIES
115
+ ]
116
+
117
+
118
+ def scope_covers(scope_entry: str, path: str) -> bool:
119
+ """True when ``path`` falls under a plan scope entry like ``src/core/``,
120
+ ``src/core/**`` or ``**``."""
121
+ if scope_entry in ("**", "", "*"):
122
+ return True
123
+ prefix = scope_entry
124
+ for suffix in ("/**", "/*", "/"):
125
+ if prefix.endswith(suffix):
126
+ prefix = prefix[: -len(suffix)]
127
+ break
128
+ if not prefix:
129
+ return True
130
+ return path == prefix or path.startswith(prefix + "/")
131
+
132
+
133
+ def _module_source_files(plan: dict, mod: dict) -> list[str]:
134
+ """Card-declared source files that fall under this module's scope — the
135
+ module docs describe structure, so their key files come from the cards."""
136
+ scopes = [s for s in (mod.get("scope") or []) if isinstance(s, str)]
137
+ files: set[str] = set()
138
+ for c in plan.get("cards", []):
139
+ if not isinstance(c, dict):
140
+ continue
141
+ for p in c.get("source_files") or []:
142
+ if any(scope_covers(s, p) for s in scopes):
143
+ files.add(p)
144
+ return sorted(files)
145
+
146
+
147
+ def aggregate_knowledge(paths: WikiPaths, plan: dict, task_records: dict) -> str:
148
+ """Write knowledge/<locale>/_index.yaml and per-module _module.yaml.
149
+
150
+ Returns a human summary. Module -> directory mapping comes from the
151
+ knowledge_module task outputs (recorded when the task set was expanded).
152
+ """
153
+ mod_dirs: dict[str, str] = {}
154
+ used: set[str] = set()
155
+ for tid, t in task_records.items():
156
+ if t["kind"] == "knowledge_module":
157
+ dir_name = t["output"].rstrip("/").split("/")[-1]
158
+ mod_dirs[tid] = dir_name
159
+ used.add(dir_name)
160
+
161
+ if not mod_dirs:
162
+ return ""
163
+
164
+ def dir_of(mid: str) -> str | None:
165
+ return mod_dirs.get(mid)
166
+
167
+ modules_by_id = {m["id"]: m for m in plan.get("modules", []) if isinstance(m, dict)}
168
+ branch = ((run_git(paths.repo_root, "rev-parse", "--abbrev-ref", "HEAD") or "").strip()) or "main"
169
+
170
+ # per-module _module.yaml
171
+ for mid, mod in modules_by_id.items():
172
+ d = dir_of(mid)
173
+ if not d:
174
+ continue
175
+ mod_path = (mod.get("scope") or [""])[0]
176
+ content = {
177
+ "schema_version": 1,
178
+ "module_path": mod_path if mod_path != "**" else "",
179
+ "title": mod.get("title", ""),
180
+ "scope": mod.get("scope") or [],
181
+ "source_files": _module_source_files(plan, mod),
182
+ "depends_on": [dir_of(x) for x in (mod.get("depends_on") or []) if dir_of(x)],
183
+ "related_to": [dir_of(x) for x in (mod.get("related_to") or []) if dir_of(x)],
184
+ }
185
+ f = paths.knowledge_dir / d / "_module.yaml"
186
+ f.parent.mkdir(parents=True, exist_ok=True)
187
+ f.write_text(
188
+ "# 知识卡导出模块文件\n" + yaml.safe_dump(content, allow_unicode=True, sort_keys=False),
189
+ encoding="utf-8",
190
+ )
191
+
192
+ # _index.yaml with a module tree keyed by module_path
193
+ index_modules: dict[str, dict] = {}
194
+ for mid, mod in modules_by_id.items():
195
+ d = dir_of(mid)
196
+ if not d:
197
+ continue
198
+ key = (mod.get("scope") or [""])[0]
199
+ key = "" if key in ("**", "") else key
200
+ index_modules[key] = {
201
+ "dir_name": d,
202
+ "title": mod.get("title", ""),
203
+ "scope": mod.get("scope") or [],
204
+ "source_files": _module_source_files(plan, mod),
205
+ "children": [
206
+ (modules_by_id[c].get("scope") or [""])[0] or ""
207
+ for c in (mod.get("children") or []) if c in modules_by_id
208
+ ],
209
+ "depends_on": [dir_of(x) for x in (mod.get("depends_on") or []) if dir_of(x)],
210
+ "related_to": [dir_of(x) for x in (mod.get("related_to") or []) if dir_of(x)],
211
+ }
212
+ index = {
213
+ "schema_version": 1,
214
+ "locale": "zh-CN" if paths.locale == "zh" else "en",
215
+ "branch": branch,
216
+ "nodes_managed": True,
217
+ "exported_at": now_iso(),
218
+ "modules": index_modules,
219
+ }
220
+ paths.knowledge_dir.mkdir(parents=True, exist_ok=True)
221
+ (paths.knowledge_dir / "_index.yaml").write_text(
222
+ "# 知识卡导出索引文件\n" + yaml.safe_dump(index, allow_unicode=True, sort_keys=False),
223
+ encoding="utf-8",
224
+ )
225
+ cards = len(plan.get("cards", []))
226
+ return f"_index.yaml + {len(index_modules)} 个 _module.yaml(卡片 {cards} 张)"
repowiki/llms.py ADDED
@@ -0,0 +1,52 @@
1
+ """llms.txt / llms-full.txt export: the agent-consumption face of the wiki.
2
+
3
+ The llms.txt convention (llmstxt.org) gives coding agents a linked index of a
4
+ documentation set plus an optional full-text companion. repowiki's pages are
5
+ plain markdown already, so both files are a deterministic byproduct of the same
6
+ page collection the HTML site builds: links are relative paths into
7
+ ``.repowiki/`` — no agent, network or MCP server involved.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ from .i18n import strings
16
+ from .paths import WikiPaths
17
+
18
+
19
+ def write_llms(paths: WikiPaths, repo_name: str, pages: list[dict], nav: list[dict]) -> None:
20
+ """Write ``<locale>/llms.txt`` and ``<locale>/llms-full.txt``.
21
+
22
+ ``pages``/``nav`` are the collections assembled for the HTML site: page
23
+ dicts carry ``title``/``path`` (posix, relative to ``.repowiki/``)/``md``,
24
+ nav entries are ``{"title", "page"}`` singles or ``{"title", "children"}``
25
+ chapters whose leaves reference page indexes.
26
+ """
27
+ desc = strings(paths.locale)["site"]["llms_desc"].format(repo=repo_name)
28
+
29
+ lines = [f"# {repo_name} Wiki", "", f"> {desc}"]
30
+ for entry in nav:
31
+ lines += ["", f"## {entry['title']}", ""]
32
+ for leaf in entry.get("children", [entry]):
33
+ p = pages[leaf["page"]]
34
+ lines.append(f"- [{leaf['title']}]({_rel(paths, p['path'])})")
35
+
36
+ full = [f"# {repo_name} Wiki", "", f"> {desc}"]
37
+ for p in pages:
38
+ full += ["", "---", "", p["md"].rstrip()]
39
+
40
+ for name, text in (("llms.txt", "\n".join(lines) + "\n"),
41
+ ("llms-full.txt", "\n".join(full) + "\n")):
42
+ out = paths.root / paths.locale / name
43
+ out.parent.mkdir(parents=True, exist_ok=True)
44
+ tmp = out.with_name(f".{out.name}.{os.getpid()}.tmp")
45
+ tmp.write_text(text, encoding="utf-8")
46
+ os.replace(tmp, out)
47
+
48
+
49
+ def _rel(paths: WikiPaths, page_path: str) -> str:
50
+ """Link target for a page, relative to the directory holding llms.txt."""
51
+ rel = os.path.relpath(paths.root / page_path, paths.root / paths.locale)
52
+ return Path(rel).as_posix()
repowiki/metadata.py ADDED
@@ -0,0 +1,176 @@
1
+ """``repowiki finalize``: assemble <locale>/meta/repowiki-metadata.json.
2
+
3
+ Parses every finished page for file:// references and builds the metadata
4
+ document (source_files / code_snippets / knowledge_relations); runtime state
5
+ stays in ``state/``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+
14
+ from .catalog import flatten
15
+ from .errors import UsageError
16
+ from .gitutil import run_git
17
+ from .output import emit
18
+ from .paths import WikiPaths
19
+ from .state import TaskStore, now_iso
20
+ from .tasks import build_overview_task
21
+ from .validate import extract_refs
22
+
23
+
24
+ def _md5(s: str) -> str:
25
+ return hashlib.md5(s.encode("utf-8")).hexdigest()
26
+
27
+
28
+ def run_finalize(paths: WikiPaths, as_json: bool) -> int:
29
+ store = TaskStore(paths)
30
+ data = store.load()
31
+ if not data["tasks"]:
32
+ raise UsageError("没有任务,请先运行 `repowiki plan <repo>`")
33
+
34
+ if "overview" not in data["tasks"]:
35
+ catalog = _require_catalog(paths)
36
+ nodes = flatten(catalog, paths.locale)
37
+ store.add_tasks([build_overview_task(paths, catalog.get("repo_name", ""), nodes)])
38
+ msg = "已创建阶段3 overview 任务,请领取执行后再次运行 finalize"
39
+ _emit_progress(as_json, msg)
40
+ return 3 # progress, not error: waiting for the overview task
41
+
42
+ catalog = _require_catalog(paths)
43
+ nodes = flatten(catalog, paths.locale)
44
+ missing_pages = [f"{n.id}({n.title})" for n in nodes if not (paths.root / n.output).is_file()]
45
+ if missing_pages:
46
+ raise UsageError(
47
+ f"catalog 中有 {len(missing_pages)} 个页面尚未生成: "
48
+ + ", ".join(missing_pages[:8])
49
+ + ("…" if len(missing_pages) > 8 else "")
50
+ + "(--max-pages 试跑后请补齐页面再 finalize,或 plan --replan 重来)"
51
+ )
52
+
53
+ unfinished = {tid: t["status"] for tid, t in data["tasks"].items() if t["status"] != "done"}
54
+ if unfinished:
55
+ raise UsageError(
56
+ f"仍有 {len(unfinished)} 个任务未完成: "
57
+ + ", ".join(f"{k}({v})" for k, v in list(unfinished.items())[:8])
58
+ )
59
+
60
+ by_id = {n.id: n for n in nodes}
61
+
62
+ source_files: dict[str, dict] = {}
63
+ snippets: dict[str, dict] = {}
64
+ relations: list[dict] = []
65
+ for n in nodes:
66
+ page_file = paths.root / n.output
67
+ refs = extract_refs(page_file.read_text(encoding="utf-8")) if page_file.is_file() else []
68
+ for path, start, end in refs:
69
+ sf_id = _md5(path)
70
+ if sf_id not in source_files:
71
+ source_files[sf_id] = {"id": sf_id, "path": path, "filename": path.rsplit("/", 1)[-1]}
72
+ relations.append({"type": "CONTAINS", "from": n.id, "to": sf_id})
73
+ if start is not None:
74
+ rng = f"{start}-{end}" if end and end != start else f"{start}-{start}"
75
+ sn_id = _md5(f"{path}:{rng}")
76
+ if sn_id not in snippets:
77
+ snippets[sn_id] = {"id": sn_id, "path": path, "line_range": rng}
78
+ relations.append({"type": "REFERENCED_BY", "from": sn_id, "to": n.id})
79
+
80
+ repo_name = catalog.get("repo_name") or paths.repo_root.name
81
+ metadata = {
82
+ "wiki_repo": {
83
+ "id": _md5(str(paths.repo_root)),
84
+ "name": repo_name,
85
+ "progress_status": "completed",
86
+ "wiki_present_status": "COMPLETED",
87
+ "locale": paths.locale,
88
+ "last_commit_id": (run_git(paths.repo_root, "rev-parse", "HEAD") or "").strip() or None,
89
+ "generated_at": now_iso(),
90
+ },
91
+ "wiki_catalogs": [
92
+ {
93
+ "id": n.id,
94
+ "repo_id": _md5(str(paths.repo_root)),
95
+ "name": n.title,
96
+ "description": n.slug,
97
+ "prompt": n.page_brief,
98
+ "parent_id": n.parent_id,
99
+ "dependent_files": ", ".join(n.dependent_files),
100
+ "progress_status": "completed",
101
+ }
102
+ for n in nodes
103
+ ],
104
+ "wiki_items": [{"catalog_id": n.id, "title": n.title} for n in nodes],
105
+ "source_files": sorted(source_files.values(), key=lambda x: x["path"]),
106
+ "code_snippets": sorted(snippets.values(), key=lambda x: (x["path"], x["line_range"])),
107
+ "knowledge_relations": relations,
108
+ }
109
+
110
+ overview = paths.overview_file
111
+ if overview.is_file():
112
+ metadata["wiki_overview"] = overview.read_text(encoding="utf-8")
113
+ else:
114
+ metadata["wiki_overview"] = "No overview yet."
115
+
116
+ knowledge_summary = _aggregate_knowledge_if_present(paths, store)
117
+
118
+ paths.meta_dir.mkdir(parents=True, exist_ok=True)
119
+ tmp = paths.metadata_file.with_name(".metadata.tmp")
120
+ tmp.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
121
+ os.replace(tmp, paths.metadata_file)
122
+
123
+ summary = {
124
+ "ok": True,
125
+ "metadata": str(paths.metadata_file),
126
+ "pages": len(nodes),
127
+ "source_files": len(source_files),
128
+ "code_snippets": len(snippets),
129
+ "knowledge_relations": len(relations),
130
+ "knowledge": knowledge_summary,
131
+ }
132
+ removed = store.cleanup_runtime()
133
+ summary["cleaned_runtime"] = removed
134
+ emit(summary, _fmt_summary, as_json)
135
+ return 0
136
+
137
+
138
+ def _fmt_summary(s: dict) -> str:
139
+ lines = [
140
+ f"✓ metadata 已生成: {s['metadata']}",
141
+ f" 页面 {s['pages']} · 引用文件 {s['source_files']} · 代码片段 {s['code_snippets']} · 知识关系 {s['knowledge_relations']}",
142
+ ]
143
+ if s["knowledge"]:
144
+ lines.append(f" 知识库: {s['knowledge']}")
145
+ if s.get("cleaned_runtime"):
146
+ lines.append(f" 已清理运行时产物: state/{', state/'.join(s['cleaned_runtime'])}(保留 index/catalog/knowledge 供增量更新)")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def _emit_progress(as_json: bool, msg: str) -> None:
151
+ emit({"ok": True, "waiting": True, "detail": msg,
152
+ "next_action": "执行 overview 任务后再次运行 finalize"},
153
+ lambda r: r["detail"], as_json)
154
+
155
+
156
+ def _require_catalog(paths: WikiPaths) -> dict:
157
+ if not paths.catalog_file.exists():
158
+ raise UsageError("state/catalog.json 不存在:请先完成 catalog 任务")
159
+ try:
160
+ return json.loads(paths.catalog_file.read_text(encoding="utf-8"))
161
+ except json.JSONDecodeError as e:
162
+ raise UsageError(
163
+ f"state/catalog.json 损坏({e}):可手工修复该文件,或 `repowiki plan --replan` 重新规划"
164
+ ) from e
165
+
166
+
167
+ def _aggregate_knowledge_if_present(paths: WikiPaths, store: TaskStore) -> str:
168
+ if not paths.knowledge_plan_file.exists():
169
+ return ""
170
+ try:
171
+ plan = json.loads(paths.knowledge_plan_file.read_text(encoding="utf-8"))
172
+ except json.JSONDecodeError:
173
+ return "knowledge.json 解析失败,已跳过聚合"
174
+ from .knowledge import aggregate_knowledge
175
+
176
+ return aggregate_knowledge(paths, plan, store.load()["tasks"])
repowiki/output.py ADDED
@@ -0,0 +1,34 @@
1
+ """Dual-format output: the one seam every command prints through.
2
+
3
+ ``--json`` mode dumps the result dict verbatim; human mode renders it with a
4
+ per-command formatter. Routing both representations through one seam keeps
5
+ them in lockstep (a new field lands in both or neither) and gives the human
6
+ branch — historically untested copy — a callable test surface.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import Callable
14
+
15
+ Human = Callable[[dict], str]
16
+
17
+
18
+ def emit(result: dict, human: Human, as_json: bool, file=None) -> None:
19
+ """Print ``result`` as JSON, or via ``human(result)`` (skipped when empty)."""
20
+ if as_json:
21
+ print(json.dumps(result, ensure_ascii=False, indent=2), file=file)
22
+ else:
23
+ text = human(result)
24
+ if text:
25
+ print(text, file=file)
26
+
27
+
28
+ def emit_error(kind: str, detail: str, as_json: bool) -> None:
29
+ """Report a CLI error: JSON contract on stdout, human copy on stderr."""
30
+ prefix = {"conflict": "conflict: ", "state_corrupt": "state error: "}.get(kind, "error: ")
31
+ emit({"ok": False, "error": kind, "detail": detail},
32
+ lambda r: prefix + r["detail"],
33
+ as_json,
34
+ file=None if as_json else sys.stderr)
repowiki/paths.py ADDED
@@ -0,0 +1,176 @@
1
+ """Path derivation and normalization rules (all deterministic).
2
+
3
+ - Chapter titles become directory/file names: NFC normalize, replace
4
+ filesystem-illegal characters with full-width counterparts, strip
5
+ control characters and leading/trailing dots or spaces.
6
+ - Name collisions get a ``__2`` style suffix.
7
+ - GitHub-style heading anchors keep CJK characters, drop punctuation
8
+ (including ``:`` and ``:``) and turn spaces into ``-``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import unicodedata
15
+ from pathlib import Path
16
+
17
+ from .i18n import DEFAULT_LOCALE, SUPPORTED
18
+
19
+ _ILLEGAL_MAP = {
20
+ "/": "/",
21
+ "\\": "\",
22
+ ":": ":",
23
+ "*": "*",
24
+ "?": "?",
25
+ '"': """,
26
+ "<": "<",
27
+ ">": ">",
28
+ "|": "|",
29
+ }
30
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
31
+
32
+ _GITHUB_PUNCT_RE = re.compile(
33
+ r"[" + re.escape("`~!#$%^&*()+=[]{}\\|:;'\",.<>/?_-—─·•„“”«»…!¥()【】《》「」:;‘’\",。、?") + r"]"
34
+ )
35
+ _GITHUB_SPACE_RE = re.compile(r"\s+")
36
+
37
+
38
+ def nfc(s: str) -> str:
39
+ return unicodedata.normalize("NFC", s)
40
+
41
+
42
+ def sanitize_component(title: str) -> str:
43
+ """Title -> single path component (directory or file stem)."""
44
+ s = nfc(title).strip()
45
+ s = _CONTROL_RE.sub("", s)
46
+ for bad, full in _ILLEGAL_MAP.items():
47
+ s = s.replace(bad, full)
48
+ s = s.strip(" .")
49
+ return s or "untitled"
50
+
51
+
52
+ def unique_name(name: str, used: set[str]) -> str:
53
+ """Return ``name`` or ``name__2``, ``name__3``... so it is unique in ``used``."""
54
+ if name not in used:
55
+ used.add(name)
56
+ return name
57
+ n = 2
58
+ while f"{name}__{n}" in used:
59
+ n += 1
60
+ candidate = f"{name}__{n}"
61
+ used.add(candidate)
62
+ return candidate
63
+
64
+
65
+ def github_anchor(heading: str) -> str:
66
+ """GitHub-style anchor: lowercase, delete punctuation (CJK included),
67
+ whitespace -> '-'. ``附录:一键运行清单`` -> ``附录一键运行清单``;
68
+ ``BM25 关键词`` -> ``bm25-关键词``.
69
+ """
70
+ s = nfc(heading).strip().lower()
71
+ s = _GITHUB_PUNCT_RE.sub("", s)
72
+ s = _GITHUB_SPACE_RE.sub("-", s)
73
+ s = re.sub(r"-{2,}", "-", s).strip("-")
74
+ return s
75
+
76
+
77
+ class WikiPaths:
78
+ """Resolves every location the tool reads/writes under a repository.
79
+
80
+ The output locale follows the target repository: explicit argument wins,
81
+ else the persisted ``state/locale`` file, else the default (zh).
82
+ """
83
+
84
+ def __init__(self, repo_root: str | Path, locale: str | None = None):
85
+ self.repo_root = Path(repo_root).resolve()
86
+ self._locale = locale
87
+
88
+ @property
89
+ def locale(self) -> str:
90
+ if self._locale is None:
91
+ f = self.state_dir / "locale"
92
+ try:
93
+ value = f.read_text(encoding="utf-8").strip()
94
+ except OSError:
95
+ value = ""
96
+ self._locale = value if value in SUPPORTED else DEFAULT_LOCALE
97
+ return self._locale
98
+
99
+ def persist_locale(self, locale: str) -> None:
100
+ """Pin the output locale for this and all later commands."""
101
+ if locale not in SUPPORTED:
102
+ raise ValueError(f"unsupported locale: {locale}")
103
+ self.state_dir.mkdir(parents=True, exist_ok=True)
104
+ (self.state_dir / "locale").write_text(locale, encoding="utf-8")
105
+ self._locale = locale
106
+
107
+ @property
108
+ def root(self) -> Path:
109
+ return self.repo_root / ".repowiki"
110
+
111
+ @property
112
+ def state_dir(self) -> Path:
113
+ return self.root / "state"
114
+
115
+ @property
116
+ def tasks_dir(self) -> Path:
117
+ return self.state_dir / "tasks"
118
+
119
+ @property
120
+ def claims_dir(self) -> Path:
121
+ return self.state_dir / "claims"
122
+
123
+ @property
124
+ def catalog_file(self) -> Path:
125
+ return self.state_dir / "catalog.json"
126
+
127
+ @property
128
+ def knowledge_plan_file(self) -> Path:
129
+ return self.state_dir / "knowledge.json"
130
+
131
+ @property
132
+ def knowledge_categories_file(self) -> Path:
133
+ return self.state_dir / "knowledge_categories.json"
134
+
135
+ @property
136
+ def index_file(self) -> Path:
137
+ return self.state_dir / "index.json"
138
+
139
+ @property
140
+ def content_dir(self) -> Path:
141
+ return self.root / self.locale / "content"
142
+
143
+ @property
144
+ def meta_dir(self) -> Path:
145
+ return self.root / self.locale / "meta"
146
+
147
+ @property
148
+ def knowledge_dir(self) -> Path:
149
+ return self.root / "knowledge" / self.locale
150
+
151
+ @property
152
+ def metadata_file(self) -> Path:
153
+ return self.meta_dir / "repowiki-metadata.json"
154
+
155
+ @property
156
+ def site_file(self) -> Path:
157
+ return self.root / self.locale / "wiki.html"
158
+
159
+ @property
160
+ def llms_file(self) -> Path:
161
+ return self.root / self.locale / "llms.txt"
162
+
163
+ @property
164
+ def llms_full_file(self) -> Path:
165
+ return self.root / self.locale / "llms-full.txt"
166
+
167
+ @property
168
+ def overview_file(self) -> Path:
169
+ return self.meta_dir / "wiki-overview.md"
170
+
171
+ def ensure(self) -> None:
172
+ for d in (self.state_dir, self.tasks_dir, self.claims_dir, self.content_dir, self.meta_dir):
173
+ d.mkdir(parents=True, exist_ok=True)
174
+
175
+ def task_spec(self, task_id: str) -> Path:
176
+ return self.tasks_dir / f"{task_id}.md"