agent-wiki-cli 0.3.28__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.
- agent_wiki_cli-0.3.28.dist-info/METADATA +425 -0
- agent_wiki_cli-0.3.28.dist-info/RECORD +47 -0
- agent_wiki_cli-0.3.28.dist-info/WHEEL +5 -0
- agent_wiki_cli-0.3.28.dist-info/entry_points.txt +2 -0
- agent_wiki_cli-0.3.28.dist-info/licenses/LICENSE +21 -0
- agent_wiki_cli-0.3.28.dist-info/top_level.txt +1 -0
- llm_wiki_cli/__init__.py +7 -0
- llm_wiki_cli/cli.py +231 -0
- llm_wiki_cli/commands/__init__.py +1 -0
- llm_wiki_cli/commands/bootstrap_cmd.py +1072 -0
- llm_wiki_cli/commands/bump_cmd.py +55 -0
- llm_wiki_cli/commands/context_cmd.py +427 -0
- llm_wiki_cli/commands/extract_cmd.py +745 -0
- llm_wiki_cli/commands/generate_prompt_cmd.py +89 -0
- llm_wiki_cli/commands/hook_cmd.py +161 -0
- llm_wiki_cli/commands/init_cmd.py +92 -0
- llm_wiki_cli/commands/lint_cmd.py +294 -0
- llm_wiki_cli/commands/migrate_cmd.py +892 -0
- llm_wiki_cli/commands/release_cmd.py +163 -0
- llm_wiki_cli/commands/status_cmd.py +70 -0
- llm_wiki_cli/commands/sync_cmd.py +521 -0
- llm_wiki_cli/commands/trigger_cmd.py +205 -0
- llm_wiki_cli/commands/uninstall_cmd.py +221 -0
- llm_wiki_cli/commands/upgrade_cmd.py +196 -0
- llm_wiki_cli/config.py +318 -0
- llm_wiki_cli/extractors/__init__.py +46 -0
- llm_wiki_cli/extractors/common.py +90 -0
- llm_wiki_cli/extractors/go_extractor.py +143 -0
- llm_wiki_cli/extractors/go_scripts/go.mod +3 -0
- llm_wiki_cli/extractors/go_scripts/main.go +668 -0
- llm_wiki_cli/extractors/python_extractor.py +346 -0
- llm_wiki_cli/extractors/rust_extractor.py +143 -0
- llm_wiki_cli/extractors/rust_scripts/Cargo.lock +110 -0
- llm_wiki_cli/extractors/rust_scripts/Cargo.toml +11 -0
- llm_wiki_cli/extractors/rust_scripts/src/main.rs +803 -0
- llm_wiki_cli/extractors/ts_extractor.py +206 -0
- llm_wiki_cli/extractors/ts_scripts/extract.js +485 -0
- llm_wiki_cli/extractors/ts_scripts/package.json +10 -0
- llm_wiki_cli/services/__init__.py +0 -0
- llm_wiki_cli/services/circuit_breaker.py +79 -0
- llm_wiki_cli/services/io.py +47 -0
- llm_wiki_cli/services/lockfile.py +60 -0
- llm_wiki_cli/services/packages.py +173 -0
- llm_wiki_cli/services/paths.py +31 -0
- llm_wiki_cli/services/schema.py +214 -0
- llm_wiki_cli/services/secure_file.py +22 -0
- llm_wiki_cli/services/versioning.py +193 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Incremental wiki sync — update only pages whose source has changed.
|
|
2
|
+
|
|
3
|
+
Workflow:
|
|
4
|
+
1. Load ``wiki_dir/.llm-wiki-manifest.json`` (error if missing — run bootstrap first).
|
|
5
|
+
2. Hash every source file in the current AST inventory.
|
|
6
|
+
3. Compute a diff: new / changed / unchanged / removed files, moved classes.
|
|
7
|
+
4. Apply changes surgically: regenerate pages for new/changed files, add a
|
|
8
|
+
deprecation warning to pages whose source was removed, skip everything else.
|
|
9
|
+
5. Rebuild index.md and append a log entry if anything changed.
|
|
10
|
+
6. Save the updated manifest.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import date
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from .extract_cmd import get_inventory_result, infer_language_from_path, print_inventory_failures
|
|
24
|
+
from .bootstrap_cmd import (
|
|
25
|
+
_build_relationships,
|
|
26
|
+
_generate_entity_md,
|
|
27
|
+
_generate_index_md,
|
|
28
|
+
_generate_module_md,
|
|
29
|
+
_module_name_from_path,
|
|
30
|
+
_page_name_for_entity,
|
|
31
|
+
_page_name_for_module,
|
|
32
|
+
build_entity_page_map,
|
|
33
|
+
build_module_page_map,
|
|
34
|
+
)
|
|
35
|
+
from ..config import validate_path
|
|
36
|
+
from ..services.io import read_md, write_md
|
|
37
|
+
|
|
38
|
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
MANIFEST_FILENAME = ".llm-wiki-manifest.json"
|
|
41
|
+
MANIFEST_VERSION = 2
|
|
42
|
+
_DEPRECATION_HEADER = (
|
|
43
|
+
"> ⚠️ **Stale:** Source no longer found in codebase. "
|
|
44
|
+
"Run `llm-wiki lint` to audit.\n\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ── Manifest ──────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SyncManifest:
|
|
52
|
+
"""Persistent record of what the wiki was generated from.
|
|
53
|
+
|
|
54
|
+
Schema v2::
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
"version": 1,
|
|
58
|
+
"sources": {
|
|
59
|
+
"src/models.py": {
|
|
60
|
+
"hash": "sha256:<hex>",
|
|
61
|
+
"language": "python",
|
|
62
|
+
"entities": ["User", "Role"],
|
|
63
|
+
"module_page": "models"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
sources: dict[str, dict] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
# ── Persistence ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def load(cls, wiki_dir: Path) -> "SyncManifest":
|
|
75
|
+
"""Load manifest from *wiki_dir*; raise ``FileNotFoundError`` if absent."""
|
|
76
|
+
manifest_path = wiki_dir / MANIFEST_FILENAME
|
|
77
|
+
if not manifest_path.exists():
|
|
78
|
+
raise FileNotFoundError(manifest_path)
|
|
79
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
80
|
+
sources = data.get("sources", {})
|
|
81
|
+
for filepath, info in sources.items():
|
|
82
|
+
if "language" not in info:
|
|
83
|
+
info["language"] = infer_language_from_path(filepath)
|
|
84
|
+
return cls(sources=sources)
|
|
85
|
+
|
|
86
|
+
def save(self, wiki_dir: Path) -> None:
|
|
87
|
+
"""Write manifest to *wiki_dir* atomically (write + rename)."""
|
|
88
|
+
manifest_path = wiki_dir / MANIFEST_FILENAME
|
|
89
|
+
tmp_path = manifest_path.with_suffix(".json.tmp")
|
|
90
|
+
payload = json.dumps(
|
|
91
|
+
{"version": MANIFEST_VERSION, "sources": self.sources},
|
|
92
|
+
indent=2,
|
|
93
|
+
sort_keys=True,
|
|
94
|
+
)
|
|
95
|
+
tmp_path.write_text(payload, encoding="utf-8")
|
|
96
|
+
tmp_path.replace(manifest_path)
|
|
97
|
+
|
|
98
|
+
# ── Factory ───────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def build_from_inventory(
|
|
102
|
+
cls,
|
|
103
|
+
inventory: dict,
|
|
104
|
+
src_dir: str,
|
|
105
|
+
entity_page_cache: dict[tuple[str, str], str],
|
|
106
|
+
module_page_map: dict[str, str],
|
|
107
|
+
) -> "SyncManifest":
|
|
108
|
+
"""Create a manifest that reflects the current inventory state."""
|
|
109
|
+
sources: dict[str, dict] = {}
|
|
110
|
+
for filepath, file_data in inventory.items():
|
|
111
|
+
sources[filepath] = {
|
|
112
|
+
"hash": _hash_file(Path(src_dir) / filepath),
|
|
113
|
+
"language": file_data.get("language") or infer_language_from_path(filepath),
|
|
114
|
+
"entities": [c["name"] for c in file_data.get("classes", [])],
|
|
115
|
+
"entity_pages": {
|
|
116
|
+
c["name"]: entity_page_cache.get((c["name"], filepath), c["name"])
|
|
117
|
+
for c in file_data.get("classes", [])
|
|
118
|
+
},
|
|
119
|
+
"module_page": module_page_map.get(filepath, _module_name_from_path(filepath)),
|
|
120
|
+
}
|
|
121
|
+
return cls(sources=sources)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _hash_file(path: Path) -> str:
|
|
128
|
+
"""Return a ``"sha256:<hexdigest>"`` fingerprint of *path*'s raw bytes.
|
|
129
|
+
|
|
130
|
+
If the file cannot be read (deleted between inventory scan and hash)
|
|
131
|
+
return an empty sentinel so the caller treats it as changed.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
135
|
+
return f"sha256:{digest}"
|
|
136
|
+
except OSError:
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Diff ──────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class SyncDiff:
|
|
145
|
+
"""Categorised difference between the persisted manifest and live inventory."""
|
|
146
|
+
|
|
147
|
+
new_files: list[str] = field(default_factory=list)
|
|
148
|
+
changed_files: list[str] = field(default_factory=list)
|
|
149
|
+
unchanged_files: list[str] = field(default_factory=list)
|
|
150
|
+
removed_files: list[str] = field(default_factory=list)
|
|
151
|
+
# {class_name: (old_filepath, new_filepath)}
|
|
152
|
+
moved_entities: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def has_changes(self) -> bool:
|
|
156
|
+
return bool(
|
|
157
|
+
self.new_files
|
|
158
|
+
or self.changed_files
|
|
159
|
+
or self.removed_files
|
|
160
|
+
or self.moved_entities
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _compute_diff(manifest: SyncManifest, inventory: dict, src_dir: str) -> SyncDiff:
|
|
165
|
+
"""Compare *manifest* against the live *inventory*.
|
|
166
|
+
|
|
167
|
+
Move detection: a class that appears in the manifest under one filepath
|
|
168
|
+
but now lives in a *different* filepath is considered moved rather than
|
|
169
|
+
deleted+created. Its source-file hash is therefore refreshed from the
|
|
170
|
+
*new* filepath.
|
|
171
|
+
"""
|
|
172
|
+
diff = SyncDiff()
|
|
173
|
+
|
|
174
|
+
# Build reverse lookup: class_name → filepath (from old manifest)
|
|
175
|
+
old_cls_to_file: dict[str, str] = {}
|
|
176
|
+
for fp, info in manifest.sources.items():
|
|
177
|
+
for cls_name in info.get("entities", []):
|
|
178
|
+
old_cls_to_file[cls_name] = fp
|
|
179
|
+
|
|
180
|
+
# Build reverse lookup: class_name → filepath (from new inventory)
|
|
181
|
+
new_cls_to_file: dict[str, str] = {}
|
|
182
|
+
for fp, file_data in inventory.items():
|
|
183
|
+
for cls in file_data.get("classes", []):
|
|
184
|
+
new_cls_to_file[cls["name"]] = fp
|
|
185
|
+
|
|
186
|
+
# Detect moves: same class name, different file
|
|
187
|
+
for cls_name, old_fp in old_cls_to_file.items():
|
|
188
|
+
new_fp = new_cls_to_file.get(cls_name)
|
|
189
|
+
if new_fp is not None and new_fp != old_fp:
|
|
190
|
+
diff.moved_entities[cls_name] = (old_fp, new_fp)
|
|
191
|
+
|
|
192
|
+
# Categorise each file in the new inventory
|
|
193
|
+
for filepath, file_data in inventory.items():
|
|
194
|
+
if filepath not in manifest.sources:
|
|
195
|
+
diff.new_files.append(filepath)
|
|
196
|
+
else:
|
|
197
|
+
# Re-hash to detect content changes
|
|
198
|
+
current_hash = _hash_file(Path(src_dir) / filepath)
|
|
199
|
+
if current_hash != manifest.sources[filepath].get("hash", ""):
|
|
200
|
+
diff.changed_files.append(filepath)
|
|
201
|
+
else:
|
|
202
|
+
diff.unchanged_files.append(filepath)
|
|
203
|
+
|
|
204
|
+
# Detect removals: in manifest but not in new inventory
|
|
205
|
+
for filepath in manifest.sources:
|
|
206
|
+
if filepath not in inventory:
|
|
207
|
+
diff.removed_files.append(filepath)
|
|
208
|
+
|
|
209
|
+
return diff
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── Apply ─────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class SyncResult:
|
|
217
|
+
created: int = 0
|
|
218
|
+
updated: int = 0
|
|
219
|
+
skipped: int = 0
|
|
220
|
+
deprecated: int = 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _collision_maps(
|
|
224
|
+
inventory: dict, src_dir: str
|
|
225
|
+
) -> tuple[set[str], set[str], dict[tuple[str, str], str]]:
|
|
226
|
+
"""Return (colliding_stems, colliding_cls, entity_page_name_cache).
|
|
227
|
+
|
|
228
|
+
Uses :func:`build_entity_page_map` for collision-aware entity names.
|
|
229
|
+
The first two sets are retained for API compatibility but are no
|
|
230
|
+
longer consumed directly.
|
|
231
|
+
"""
|
|
232
|
+
entity_page_cache = build_entity_page_map(inventory)
|
|
233
|
+
return set(), set(), entity_page_cache
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _apply_diff(
|
|
237
|
+
diff: SyncDiff,
|
|
238
|
+
wiki_dir: Path,
|
|
239
|
+
inventory: dict,
|
|
240
|
+
src_dir: str,
|
|
241
|
+
manifest: SyncManifest,
|
|
242
|
+
) -> SyncResult:
|
|
243
|
+
"""Regenerate pages for new/changed files, deprecate pages for removed files."""
|
|
244
|
+
result = SyncResult()
|
|
245
|
+
|
|
246
|
+
# Full collision maps over the *entire* inventory
|
|
247
|
+
colliding_stems, colliding_cls, entity_page_cache = _collision_maps(inventory, src_dir)
|
|
248
|
+
|
|
249
|
+
# Module page map for the manifest builder: filepath → module_page_name
|
|
250
|
+
module_page_map: dict[str, str] = build_module_page_map(inventory)
|
|
251
|
+
|
|
252
|
+
# Re-build relationships from the full inventory using the same
|
|
253
|
+
# collision-aware module page names as bootstrap/migrate.
|
|
254
|
+
relationships = _build_relationships(inventory, module_page_map)
|
|
255
|
+
|
|
256
|
+
# ── New + changed files ────────────────────────────────────────────────────
|
|
257
|
+
for filepath in diff.new_files + diff.changed_files:
|
|
258
|
+
file_data = inventory[filepath]
|
|
259
|
+
mod_page_name = module_page_map.get(filepath, _page_name_for_module(filepath))
|
|
260
|
+
|
|
261
|
+
file_entity_page_map = {
|
|
262
|
+
cls["name"]: entity_page_cache[(cls["name"], filepath)]
|
|
263
|
+
for cls in file_data.get("classes", [])
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# Entity pages
|
|
267
|
+
for cls in file_data.get("classes", []):
|
|
268
|
+
entity_page_name = file_entity_page_map[cls["name"]]
|
|
269
|
+
entity_path = wiki_dir / "entities" / f"{entity_page_name}.md"
|
|
270
|
+
content = _generate_entity_md(cls, filepath, relationships, mod_page_name)
|
|
271
|
+
is_new = not entity_path.exists()
|
|
272
|
+
write_md(entity_path, content)
|
|
273
|
+
if is_new:
|
|
274
|
+
result.created += 1
|
|
275
|
+
print(f" CREATE entity: {entity_page_name}")
|
|
276
|
+
else:
|
|
277
|
+
result.updated += 1
|
|
278
|
+
print(f" UPDATE entity: {entity_page_name}")
|
|
279
|
+
|
|
280
|
+
# Module page
|
|
281
|
+
module_path = wiki_dir / "modules" / f"{mod_page_name}.md"
|
|
282
|
+
content = _generate_module_md(filepath, file_data, file_entity_page_map)
|
|
283
|
+
is_new = not module_path.exists()
|
|
284
|
+
write_md(module_path, content)
|
|
285
|
+
if is_new:
|
|
286
|
+
result.created += 1
|
|
287
|
+
print(f" CREATE module: {mod_page_name}")
|
|
288
|
+
else:
|
|
289
|
+
result.updated += 1
|
|
290
|
+
print(f" UPDATE module: {mod_page_name}")
|
|
291
|
+
|
|
292
|
+
# ── Unchanged files ────────────────────────────────────────────────────────
|
|
293
|
+
for filepath in diff.unchanged_files:
|
|
294
|
+
mod_page_name = module_page_map.get(filepath, _page_name_for_module(filepath))
|
|
295
|
+
entity_count = len(inventory[filepath].get("classes", []))
|
|
296
|
+
result.skipped += 1 + entity_count # 1 module page + N entity pages
|
|
297
|
+
print(f" SKIP (unchanged): {_module_name_from_path(filepath)}")
|
|
298
|
+
|
|
299
|
+
# ── Removed files ──────────────────────────────────────────────────────────
|
|
300
|
+
for filepath in diff.removed_files:
|
|
301
|
+
old_info = manifest.sources[filepath]
|
|
302
|
+
deprecated_count = 0
|
|
303
|
+
|
|
304
|
+
for cls_name in old_info.get("entities", []):
|
|
305
|
+
entity_page_name = _removed_entity_page_name(wiki_dir, cls_name, filepath, old_info)
|
|
306
|
+
|
|
307
|
+
if entity_page_name:
|
|
308
|
+
entity_path = wiki_dir / "entities" / f"{entity_page_name}.md"
|
|
309
|
+
text = read_md(entity_path)
|
|
310
|
+
if _DEPRECATION_HEADER not in text:
|
|
311
|
+
write_md(entity_path, _DEPRECATION_HEADER + text)
|
|
312
|
+
deprecated_count += 1
|
|
313
|
+
result.deprecated += 1
|
|
314
|
+
print(f" DEPRECATE entity: {entity_page_name}")
|
|
315
|
+
|
|
316
|
+
# Module page deprecation
|
|
317
|
+
old_mod_page = old_info.get("module_page", _module_name_from_path(filepath))
|
|
318
|
+
mod_page_path = wiki_dir / "modules" / f"{old_mod_page}.md"
|
|
319
|
+
|
|
320
|
+
if mod_page_path.exists():
|
|
321
|
+
text = read_md(mod_page_path)
|
|
322
|
+
if _DEPRECATION_HEADER not in text:
|
|
323
|
+
write_md(mod_page_path, _DEPRECATION_HEADER + text)
|
|
324
|
+
result.deprecated += 1
|
|
325
|
+
print(f" DEPRECATE module: {mod_page_path.stem}")
|
|
326
|
+
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _removed_entity_page_name(
|
|
331
|
+
wiki_dir: Path,
|
|
332
|
+
cls_name: str,
|
|
333
|
+
filepath: str,
|
|
334
|
+
old_info: dict,
|
|
335
|
+
) -> Optional[str]:
|
|
336
|
+
"""Resolve the existing entity page for a class whose source file was removed."""
|
|
337
|
+
entity_pages = old_info.get("entity_pages", {})
|
|
338
|
+
candidates: list[str] = []
|
|
339
|
+
if isinstance(entity_pages, dict) and entity_pages.get(cls_name):
|
|
340
|
+
candidates.append(str(entity_pages[cls_name]))
|
|
341
|
+
|
|
342
|
+
old_mod_page = old_info.get("module_page", _module_name_from_path(filepath))
|
|
343
|
+
if old_mod_page:
|
|
344
|
+
candidates.append(f"{old_mod_page}_{cls_name}")
|
|
345
|
+
candidates.append(cls_name)
|
|
346
|
+
|
|
347
|
+
seen: set[str] = set()
|
|
348
|
+
for page_name in candidates:
|
|
349
|
+
if page_name in seen:
|
|
350
|
+
continue
|
|
351
|
+
seen.add(page_name)
|
|
352
|
+
if (wiki_dir / "entities" / f"{page_name}.md").exists():
|
|
353
|
+
return page_name
|
|
354
|
+
|
|
355
|
+
matches = sorted((wiki_dir / "entities").glob(f"*_{cls_name}.md"))
|
|
356
|
+
return matches[0].stem if matches else None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ── run ───────────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def run(args) -> None:
|
|
363
|
+
src_dir: str = getattr(args, "src_dir", ".")
|
|
364
|
+
wiki_dir = Path(getattr(args, "wiki_dir", "docs/llm_wiki"))
|
|
365
|
+
validate_path(src_dir, "--src-dir")
|
|
366
|
+
validate_path(str(wiki_dir), "--wiki-dir")
|
|
367
|
+
|
|
368
|
+
# 1. Load manifest — seed one if the wiki exists but the manifest doesn't
|
|
369
|
+
# (migration path for projects bootstrapped by older llm-wiki versions)
|
|
370
|
+
try:
|
|
371
|
+
manifest = SyncManifest.load(wiki_dir)
|
|
372
|
+
except FileNotFoundError:
|
|
373
|
+
if (wiki_dir / "index.md").exists():
|
|
374
|
+
# Wiki was bootstrapped before manifests existed → seed baseline
|
|
375
|
+
print(
|
|
376
|
+
f"No sync manifest found — seeding from current source state.\n"
|
|
377
|
+
f"Existing wiki pages will NOT be modified.\n"
|
|
378
|
+
f"Future `llm-wiki sync` runs will update incrementally.\n"
|
|
379
|
+
)
|
|
380
|
+
inventory_result = get_inventory_result(src_dir, deep=True)
|
|
381
|
+
if inventory_result.failed:
|
|
382
|
+
print_inventory_failures(inventory_result)
|
|
383
|
+
sys.exit(1)
|
|
384
|
+
inventory = inventory_result.inventory
|
|
385
|
+
if not inventory:
|
|
386
|
+
print("No supported source files found; manifest not written.")
|
|
387
|
+
return
|
|
388
|
+
colliding_stems, colliding_cls, entity_page_cache = _collision_maps(
|
|
389
|
+
inventory, src_dir,
|
|
390
|
+
)
|
|
391
|
+
module_page_map = build_module_page_map(inventory)
|
|
392
|
+
seed = SyncManifest.build_from_inventory(
|
|
393
|
+
inventory, src_dir, entity_page_cache, module_page_map,
|
|
394
|
+
)
|
|
395
|
+
seed.save(wiki_dir)
|
|
396
|
+
print(f"Manifest written to {wiki_dir / MANIFEST_FILENAME}")
|
|
397
|
+
return
|
|
398
|
+
print(
|
|
399
|
+
f"Error: no sync manifest found at {wiki_dir / MANIFEST_FILENAME}.\n"
|
|
400
|
+
"Run `llm-wiki bootstrap` first to create the initial wiki and manifest.",
|
|
401
|
+
file=sys.stderr,
|
|
402
|
+
)
|
|
403
|
+
sys.exit(1)
|
|
404
|
+
|
|
405
|
+
print(f"Syncing wiki from source: {src_dir}")
|
|
406
|
+
print(f"Wiki directory: {wiki_dir}")
|
|
407
|
+
|
|
408
|
+
# 2. Extract current AST inventory (always deep for full page content)
|
|
409
|
+
inventory_result = get_inventory_result(src_dir, deep=True)
|
|
410
|
+
if inventory_result.failed:
|
|
411
|
+
print_inventory_failures(inventory_result)
|
|
412
|
+
sys.exit(1)
|
|
413
|
+
inventory = inventory_result.inventory
|
|
414
|
+
|
|
415
|
+
if not inventory and not manifest.sources:
|
|
416
|
+
print("No supported source files with classes or functions found.")
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
# 3. Compute diff
|
|
420
|
+
diff = _compute_diff(manifest, inventory, src_dir)
|
|
421
|
+
|
|
422
|
+
if not diff.has_changes:
|
|
423
|
+
print("Wiki is up to date.")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# 4. Apply changes
|
|
427
|
+
result = _apply_diff(diff, wiki_dir, inventory, src_dir, manifest)
|
|
428
|
+
|
|
429
|
+
# 5. Rebuild index.md
|
|
430
|
+
_rebuild_index(wiki_dir, inventory, src_dir)
|
|
431
|
+
|
|
432
|
+
# 6. Append log entry
|
|
433
|
+
_append_log(wiki_dir, src_dir, diff, result)
|
|
434
|
+
|
|
435
|
+
# 7. Compute collision maps + module page map for manifest, then save
|
|
436
|
+
colliding_stems, colliding_cls, entity_page_cache = _collision_maps(inventory, src_dir)
|
|
437
|
+
module_page_map = build_module_page_map(inventory)
|
|
438
|
+
updated_manifest = SyncManifest.build_from_inventory(
|
|
439
|
+
inventory, src_dir, entity_page_cache, module_page_map
|
|
440
|
+
)
|
|
441
|
+
updated_manifest.save(wiki_dir)
|
|
442
|
+
|
|
443
|
+
print(
|
|
444
|
+
f"\nSync complete: {result.created} created, {result.updated} updated, "
|
|
445
|
+
f"{result.skipped} skipped, {result.deprecated} deprecated."
|
|
446
|
+
)
|
|
447
|
+
if diff.moved_entities:
|
|
448
|
+
names = ", ".join(diff.moved_entities.keys())
|
|
449
|
+
print(f"Moved entities detected (pages updated in-place): {names}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ── Index + log helpers ───────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _rebuild_index(wiki_dir: Path, inventory: dict, src_dir: str) -> None:
|
|
456
|
+
"""Regenerate index.md from the live inventory."""
|
|
457
|
+
colliding_stems, colliding_cls, entity_page_cache = _collision_maps(inventory, src_dir)
|
|
458
|
+
mod_page_map = build_module_page_map(inventory)
|
|
459
|
+
|
|
460
|
+
all_entity_names: list[str] = []
|
|
461
|
+
seen: set[str] = set()
|
|
462
|
+
module_entries: list[dict] = []
|
|
463
|
+
|
|
464
|
+
for filepath, file_data in inventory.items():
|
|
465
|
+
mod_page_name = mod_page_map.get(filepath, _page_name_for_module(filepath))
|
|
466
|
+
module_entries.append({
|
|
467
|
+
"name": mod_page_name,
|
|
468
|
+
"path": filepath,
|
|
469
|
+
"docstring": file_data.get("module_docstring", ""),
|
|
470
|
+
})
|
|
471
|
+
for cls in file_data.get("classes", []):
|
|
472
|
+
page_name = entity_page_cache[(cls["name"], filepath)]
|
|
473
|
+
if page_name not in seen:
|
|
474
|
+
all_entity_names.append(page_name)
|
|
475
|
+
seen.add(page_name)
|
|
476
|
+
|
|
477
|
+
# Collect any existing workflow + infrastructure entries from disk
|
|
478
|
+
workflow_entries = _list_existing_pages(wiki_dir / "workflows", "entry")
|
|
479
|
+
infra_entries = _list_existing_pages(wiki_dir / "infrastructure", "type")
|
|
480
|
+
|
|
481
|
+
index_path = wiki_dir / "index.md"
|
|
482
|
+
write_md(index_path,
|
|
483
|
+
_generate_index_md(all_entity_names, module_entries, workflow_entries or None, infra_entries or None)
|
|
484
|
+
)
|
|
485
|
+
print(" WRITE index.md")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _list_existing_pages(directory: Path, extra_key: str) -> list[dict]:
|
|
489
|
+
"""Return a list of ``{"name": stem}`` dicts for every .md file in *directory*."""
|
|
490
|
+
if not directory.exists():
|
|
491
|
+
return []
|
|
492
|
+
return [{"name": p.stem, extra_key: ""} for p in sorted(directory.glob("*.md"))]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _append_log(wiki_dir: Path, src_dir: str, diff: SyncDiff, result: SyncResult) -> None:
|
|
496
|
+
log_path = wiki_dir / "log.md"
|
|
497
|
+
today = date.today().isoformat()
|
|
498
|
+
moved_str = (
|
|
499
|
+
", ".join(
|
|
500
|
+
f"`{cls}` ({old} → {new})"
|
|
501
|
+
for cls, (old, new) in diff.moved_entities.items()
|
|
502
|
+
)
|
|
503
|
+
if diff.moved_entities
|
|
504
|
+
else "none"
|
|
505
|
+
)
|
|
506
|
+
entry = (
|
|
507
|
+
f"\n## {today}\n\n"
|
|
508
|
+
f"### feat: incremental sync\n"
|
|
509
|
+
f"- Source: `{src_dir}`\n"
|
|
510
|
+
f"- Pages created: {result.created}\n"
|
|
511
|
+
f"- Pages updated: {result.updated}\n"
|
|
512
|
+
f"- Pages skipped (unchanged): {result.skipped}\n"
|
|
513
|
+
f"- Pages deprecated: {result.deprecated}\n"
|
|
514
|
+
f"- Moved entities: {moved_str}\n"
|
|
515
|
+
)
|
|
516
|
+
if log_path.exists():
|
|
517
|
+
existing_log = read_md(log_path)
|
|
518
|
+
write_md(log_path, existing_log + entry)
|
|
519
|
+
else:
|
|
520
|
+
write_md(log_path, "# Architectural Log\n\nAppend-only chronological log.\n" + entry)
|
|
521
|
+
print(" APPEND log.md")
|