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,1072 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import posixpath
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from datetime import date
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .extract_cmd import get_call_graph, get_docker_inventory, get_inventory_result, print_inventory_failures
|
|
14
|
+
from ..config import validate_path
|
|
15
|
+
from ..services.io import read_md, write_md
|
|
16
|
+
from ..services.paths import normalize_source_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _module_name_from_path(filepath: str) -> str:
|
|
20
|
+
"""Derive a short module name from a file path."""
|
|
21
|
+
return Path(filepath).stem
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _page_name_for_module(filepath: str) -> str:
|
|
25
|
+
"""Return the wiki page stem for a module.
|
|
26
|
+
|
|
27
|
+
For collision-aware naming use :func:`build_module_page_map` instead.
|
|
28
|
+
"""
|
|
29
|
+
return Path(filepath).stem
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _page_name_for_entity(cls_name: str) -> str:
|
|
33
|
+
"""Return the wiki page stem for an entity.
|
|
34
|
+
|
|
35
|
+
For collision-aware naming use :func:`build_entity_page_map` instead.
|
|
36
|
+
"""
|
|
37
|
+
return cls_name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Collision-aware page-name builders ────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _disambiguate_paths(fps: list[str], stem: str) -> dict[str, str]:
|
|
44
|
+
"""Given filepaths sharing *stem*, return ``{filepath: unique_name}``.
|
|
45
|
+
|
|
46
|
+
Progressively adds parent directory components until every name is
|
|
47
|
+
unique. Falls back to the full path (sans extension) if needed.
|
|
48
|
+
"""
|
|
49
|
+
max_depth = max(len(Path(fp).parts) for fp in fps)
|
|
50
|
+
for depth in range(1, max_depth):
|
|
51
|
+
candidates: dict[str, str] = {}
|
|
52
|
+
for fp in fps:
|
|
53
|
+
dir_parts = Path(fp).parts[:-1] # directories only
|
|
54
|
+
prefix_parts = dir_parts[-depth:] if len(dir_parts) >= depth else dir_parts
|
|
55
|
+
candidates[fp] = "_".join(prefix_parts) + "_" + stem
|
|
56
|
+
if len(set(candidates.values())) == len(fps):
|
|
57
|
+
return candidates
|
|
58
|
+
# Fallback: full path plus extension, with a final numeric guard.
|
|
59
|
+
candidates = {fp: _page_name_with_extension(fp) for fp in fps}
|
|
60
|
+
if len(set(candidates.values())) == len(fps):
|
|
61
|
+
return candidates
|
|
62
|
+
|
|
63
|
+
seen: dict[str, int] = defaultdict(int)
|
|
64
|
+
unique: dict[str, str] = {}
|
|
65
|
+
for fp in sorted(fps):
|
|
66
|
+
name = candidates[fp]
|
|
67
|
+
seen[name] += 1
|
|
68
|
+
unique[fp] = name if seen[name] == 1 else f"{name}_{seen[name]}"
|
|
69
|
+
return unique
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _page_name_with_extension(filepath: str) -> str:
|
|
73
|
+
"""Return a page-safe path stem that includes the source extension."""
|
|
74
|
+
path = Path(filepath)
|
|
75
|
+
base = path.with_suffix("").as_posix()
|
|
76
|
+
base = base.replace("/", "_").replace("\\", "_").replace(".", "_")
|
|
77
|
+
ext = path.suffix.lower().lstrip(".") or "file"
|
|
78
|
+
ext = ext.replace(".", "_")
|
|
79
|
+
return f"{base}_{ext}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_module_page_map(inventory: dict) -> dict[str, str]:
|
|
83
|
+
"""Return ``{filepath: page_stem}`` qualifying colliding stems.
|
|
84
|
+
|
|
85
|
+
When two files share the same stem (e.g. ``pkg_a/cli.py`` and
|
|
86
|
+
``pkg_b/cli.py``) parent directory components are prepended to
|
|
87
|
+
disambiguate. Non-colliding stems keep their short name.
|
|
88
|
+
"""
|
|
89
|
+
from collections import defaultdict
|
|
90
|
+
|
|
91
|
+
stem_groups: defaultdict[str, list[str]] = defaultdict(list)
|
|
92
|
+
for fp in inventory:
|
|
93
|
+
stem_groups[Path(fp).stem].append(fp)
|
|
94
|
+
|
|
95
|
+
page_map: dict[str, str] = {}
|
|
96
|
+
for stem, fps in stem_groups.items():
|
|
97
|
+
if len(fps) == 1:
|
|
98
|
+
page_map[fps[0]] = stem
|
|
99
|
+
else:
|
|
100
|
+
page_map.update(_disambiguate_paths(fps, stem))
|
|
101
|
+
return page_map
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_entity_page_map(inventory: dict) -> dict[tuple[str, str], str]:
|
|
105
|
+
"""Return ``{(class_name, filepath): page_stem}`` qualifying collisions.
|
|
106
|
+
|
|
107
|
+
Uses the already-disambiguated module page name as prefix when two
|
|
108
|
+
classes share the same name across different files. This guarantees
|
|
109
|
+
uniqueness because module page names are themselves unique.
|
|
110
|
+
"""
|
|
111
|
+
from collections import Counter
|
|
112
|
+
|
|
113
|
+
cls_count: Counter[str] = Counter()
|
|
114
|
+
for fp, data in inventory.items():
|
|
115
|
+
for cls in data.get("classes", []):
|
|
116
|
+
cls_count[cls["name"]] += 1
|
|
117
|
+
|
|
118
|
+
mod_page_map = build_module_page_map(inventory)
|
|
119
|
+
|
|
120
|
+
page_map: dict[tuple[str, str], str] = {}
|
|
121
|
+
for fp, data in inventory.items():
|
|
122
|
+
for cls in data.get("classes", []):
|
|
123
|
+
name = cls["name"]
|
|
124
|
+
if cls_count[name] > 1:
|
|
125
|
+
page_map[(name, fp)] = f"{mod_page_map[fp]}_{name}"
|
|
126
|
+
else:
|
|
127
|
+
page_map[(name, fp)] = name
|
|
128
|
+
return page_map
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _module_path_candidates(module: str, importer_filepath: str, inventory: dict) -> set[str]:
|
|
132
|
+
"""Resolve an import module string to inventory file paths when possible."""
|
|
133
|
+
if not module:
|
|
134
|
+
return set()
|
|
135
|
+
|
|
136
|
+
normalized = module.strip().strip('"').strip("'").replace("\\", "/")
|
|
137
|
+
if not normalized:
|
|
138
|
+
return set()
|
|
139
|
+
|
|
140
|
+
importer_parent = Path(importer_filepath).parent
|
|
141
|
+
candidate_stems: set[str] = set()
|
|
142
|
+
has_relative_candidate = False
|
|
143
|
+
|
|
144
|
+
if normalized.startswith(("./", "../")):
|
|
145
|
+
rel = normalized
|
|
146
|
+
while rel.startswith("./"):
|
|
147
|
+
rel = rel[2:]
|
|
148
|
+
if rel.startswith("../") or rel:
|
|
149
|
+
candidate_stems.add(
|
|
150
|
+
posixpath.normpath((importer_parent / rel).as_posix()).strip("/")
|
|
151
|
+
)
|
|
152
|
+
has_relative_candidate = True
|
|
153
|
+
elif normalized.startswith("."):
|
|
154
|
+
dot_count = len(normalized) - len(normalized.lstrip("."))
|
|
155
|
+
remainder = normalized[dot_count:]
|
|
156
|
+
base = importer_parent
|
|
157
|
+
for _ in range(max(dot_count - 1, 0)):
|
|
158
|
+
base = base.parent
|
|
159
|
+
if remainder:
|
|
160
|
+
candidate_stems.add(
|
|
161
|
+
posixpath.normpath((base / remainder.replace(".", "/")).as_posix()).strip("/")
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
base_candidate = posixpath.normpath(base.as_posix()).strip("/")
|
|
165
|
+
if base_candidate:
|
|
166
|
+
candidate_stems.add(base_candidate)
|
|
167
|
+
candidate_stems.add(f"{base_candidate}/__init__")
|
|
168
|
+
else:
|
|
169
|
+
candidate_stems.add("__init__")
|
|
170
|
+
has_relative_candidate = True
|
|
171
|
+
|
|
172
|
+
if not has_relative_candidate:
|
|
173
|
+
module_path = normalized.replace("::", "/").replace(".", "/")
|
|
174
|
+
clean_module_path = module_path.strip("/")
|
|
175
|
+
if clean_module_path:
|
|
176
|
+
candidate_stems.add(clean_module_path)
|
|
177
|
+
if "/" not in clean_module_path:
|
|
178
|
+
candidate_stems.add(Path(clean_module_path).name)
|
|
179
|
+
|
|
180
|
+
matches: set[str] = set()
|
|
181
|
+
for filepath in inventory:
|
|
182
|
+
path_no_suffix = Path(filepath).with_suffix("").as_posix()
|
|
183
|
+
path_parts = Path(filepath).parts
|
|
184
|
+
stripped_src = (
|
|
185
|
+
"/".join(path_parts[1:])
|
|
186
|
+
if path_parts and path_parts[0] == "src"
|
|
187
|
+
else filepath
|
|
188
|
+
)
|
|
189
|
+
stripped_src_no_suffix = Path(stripped_src).with_suffix("").as_posix()
|
|
190
|
+
comparable = {path_no_suffix, stripped_src_no_suffix, Path(filepath).stem}
|
|
191
|
+
for candidate in candidate_stems:
|
|
192
|
+
candidate = candidate.strip("/")
|
|
193
|
+
if not candidate:
|
|
194
|
+
continue
|
|
195
|
+
if (
|
|
196
|
+
candidate in comparable
|
|
197
|
+
or path_no_suffix.endswith(f"/{candidate}")
|
|
198
|
+
or stripped_src_no_suffix.endswith(f"/{candidate}")
|
|
199
|
+
):
|
|
200
|
+
matches.add(filepath)
|
|
201
|
+
return matches
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _build_relationships(inventory: dict, module_page_map: dict[str, str] | None = None) -> dict:
|
|
205
|
+
"""Cross-reference imports against known entity identities to build a usage graph.
|
|
206
|
+
|
|
207
|
+
Returns a dict mapping ``(entity_name, defining_filepath)`` to a list of
|
|
208
|
+
{module, module_page, function, relationship} records. Duplicate entity names
|
|
209
|
+
are only linked when the import module resolves to exactly one defining file.
|
|
210
|
+
|
|
211
|
+
*module_page_map*: optional mapping of filepath -> wiki page stem produced by
|
|
212
|
+
``_page_name_for_module``. When provided every relationship record carries
|
|
213
|
+
``module_page`` so that generated links point to the correct page even when
|
|
214
|
+
the module stem was qualified to resolve a collision.
|
|
215
|
+
"""
|
|
216
|
+
entity_to_files: dict[str, set[str]] = defaultdict(set)
|
|
217
|
+
for filepath, data in inventory.items():
|
|
218
|
+
for cls in data.get("classes", []):
|
|
219
|
+
entity_to_files[cls["name"]].add(filepath)
|
|
220
|
+
|
|
221
|
+
# relationship map: (entity_name, defining_filepath) -> relationship records
|
|
222
|
+
relationships = defaultdict(list)
|
|
223
|
+
_mod_page_map = module_page_map or {}
|
|
224
|
+
|
|
225
|
+
for filepath, data in inventory.items():
|
|
226
|
+
mod_name = _module_name_from_path(filepath)
|
|
227
|
+
mod_page = _mod_page_map.get(filepath, mod_name)
|
|
228
|
+
imported_entities: dict[tuple[str, str], set[str]] = {}
|
|
229
|
+
for imp in data.get("imports", []):
|
|
230
|
+
entity_name = imp.get("name")
|
|
231
|
+
if not entity_name or entity_name not in entity_to_files:
|
|
232
|
+
continue
|
|
233
|
+
candidates = set(entity_to_files[entity_name])
|
|
234
|
+
module_candidates = _module_path_candidates(
|
|
235
|
+
imp.get("module", ""), filepath, inventory
|
|
236
|
+
)
|
|
237
|
+
if module_candidates:
|
|
238
|
+
candidates &= module_candidates
|
|
239
|
+
candidates.discard(filepath)
|
|
240
|
+
if len(candidates) != 1:
|
|
241
|
+
continue
|
|
242
|
+
defining_filepath = next(iter(candidates))
|
|
243
|
+
visible_names = {entity_name}
|
|
244
|
+
if imp.get("alias"):
|
|
245
|
+
visible_names.add(imp["alias"])
|
|
246
|
+
imported_entities[(entity_name, defining_filepath)] = visible_names
|
|
247
|
+
|
|
248
|
+
for entity_key, visible_names in imported_entities.items():
|
|
249
|
+
for fn in data.get("functions", []):
|
|
250
|
+
mentions_entity = False
|
|
251
|
+
for p in fn.get("params", []):
|
|
252
|
+
if any(name in p.get("type", "") for name in visible_names):
|
|
253
|
+
mentions_entity = True
|
|
254
|
+
if any(name in fn.get("return_type", "") for name in visible_names):
|
|
255
|
+
mentions_entity = True
|
|
256
|
+
for dec in fn.get("decorators", []):
|
|
257
|
+
if any(name in dec for name in visible_names):
|
|
258
|
+
mentions_entity = True
|
|
259
|
+
|
|
260
|
+
if mentions_entity:
|
|
261
|
+
relationships[entity_key].append({
|
|
262
|
+
"module": mod_name,
|
|
263
|
+
"module_page": mod_page,
|
|
264
|
+
"module_path": filepath,
|
|
265
|
+
"function": fn["name"],
|
|
266
|
+
"rel": "used_by",
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
# If imported but not found in any specific function, still note the import
|
|
270
|
+
if not any(r["module_path"] == filepath for r in relationships[entity_key]):
|
|
271
|
+
relationships[entity_key].append({
|
|
272
|
+
"module": mod_name,
|
|
273
|
+
"module_page": mod_page,
|
|
274
|
+
"module_path": filepath,
|
|
275
|
+
"function": None,
|
|
276
|
+
"rel": "imported_by",
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
return dict(relationships)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _format_signature(fn: dict) -> str:
|
|
283
|
+
"""Build a readable function signature string."""
|
|
284
|
+
params = []
|
|
285
|
+
for p in fn.get("params", []):
|
|
286
|
+
part = p["name"]
|
|
287
|
+
if p.get("type"):
|
|
288
|
+
part += f": {p['type']}"
|
|
289
|
+
if p.get("default"):
|
|
290
|
+
part += f" = {p['default']}"
|
|
291
|
+
params.append(part)
|
|
292
|
+
|
|
293
|
+
ret = fn.get("return_type", "")
|
|
294
|
+
sig = f"({', '.join(params)})"
|
|
295
|
+
if ret:
|
|
296
|
+
sig += f" -> {ret}"
|
|
297
|
+
return sig
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _generate_entity_md(class_info: dict, filepath: str, relationships: dict, mod_page_name: str | None = None) -> str:
|
|
301
|
+
"""Generate comprehensive markdown for a class entity."""
|
|
302
|
+
name = class_info["name"]
|
|
303
|
+
bases = class_info.get("bases", [])
|
|
304
|
+
line = class_info.get("line", "?")
|
|
305
|
+
docstring = class_info.get("docstring", "")
|
|
306
|
+
decorators = class_info.get("decorators", [])
|
|
307
|
+
attributes = class_info.get("attributes", [])
|
|
308
|
+
methods = class_info.get("methods", [])
|
|
309
|
+
mod_name = mod_page_name if mod_page_name is not None else _module_name_from_path(filepath)
|
|
310
|
+
|
|
311
|
+
bases_str = ", ".join(f"`{b}`" for b in bases) if bases else "—"
|
|
312
|
+
|
|
313
|
+
lines = [
|
|
314
|
+
f"# {name}",
|
|
315
|
+
"",
|
|
316
|
+
f"**Location:** `{filepath}:{line}`",
|
|
317
|
+
f"**Bases:** {bases_str}",
|
|
318
|
+
f"**Module:** [{mod_name}](../modules/{mod_name}.md)",
|
|
319
|
+
"",
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
if decorators:
|
|
323
|
+
lines.append(f"**Decorators:** {', '.join(f'`@{d}`' for d in decorators)}")
|
|
324
|
+
lines.append("")
|
|
325
|
+
|
|
326
|
+
# Description
|
|
327
|
+
lines.append("## Description")
|
|
328
|
+
lines.append("")
|
|
329
|
+
if docstring:
|
|
330
|
+
lines.append(docstring)
|
|
331
|
+
else:
|
|
332
|
+
lines.append(f"_Auto-generated from `{name}` in `{filepath}`._")
|
|
333
|
+
lines.append("")
|
|
334
|
+
|
|
335
|
+
# Attributes
|
|
336
|
+
lines.append("## Attributes")
|
|
337
|
+
lines.append("")
|
|
338
|
+
if attributes:
|
|
339
|
+
lines.append("| Name | Type | Default | Description |")
|
|
340
|
+
lines.append("|------|------|---------|-------------|")
|
|
341
|
+
for attr in attributes:
|
|
342
|
+
default = f"`{attr['default']}`" if attr.get("default") else "*required*"
|
|
343
|
+
lines.append(f"| `{attr['name']}` | `{attr.get('type', '—')}` | {default} | — |")
|
|
344
|
+
else:
|
|
345
|
+
lines.append("*No annotated attributes found.*")
|
|
346
|
+
lines.append("")
|
|
347
|
+
|
|
348
|
+
# Methods
|
|
349
|
+
lines.append("## Methods")
|
|
350
|
+
lines.append("")
|
|
351
|
+
if methods:
|
|
352
|
+
lines.append("| Method | Signature | Decorators | Description |")
|
|
353
|
+
lines.append("|--------|-----------|------------|-------------|")
|
|
354
|
+
for m in methods:
|
|
355
|
+
sig = _format_signature(m)
|
|
356
|
+
decs = ", ".join(f"`@{d}`" for d in m.get("decorators", [])) or "—"
|
|
357
|
+
doc = m.get("docstring", "").split("\n")[0] if m.get("docstring") else "—"
|
|
358
|
+
async_tag = "*(async)* " if m.get("is_async") else ""
|
|
359
|
+
lines.append(f"| `{m['name']}` | `{async_tag}{sig}` | {decs} | {doc} |")
|
|
360
|
+
else:
|
|
361
|
+
lines.append("*No public methods. Inherits from base classes.*")
|
|
362
|
+
lines.append("")
|
|
363
|
+
|
|
364
|
+
# Relationships
|
|
365
|
+
rels = relationships.get((name, filepath), relationships.get(name, []))
|
|
366
|
+
lines.append("## Relationships")
|
|
367
|
+
lines.append("")
|
|
368
|
+
if rels:
|
|
369
|
+
for r in rels:
|
|
370
|
+
page = r.get("module_page", r["module"])
|
|
371
|
+
mod_link = f"[{r['module']}](../modules/{page}.md)"
|
|
372
|
+
if r.get("function"):
|
|
373
|
+
lines.append(f"- **{r['rel']}**: `{r['function']}()` in {mod_link}")
|
|
374
|
+
else:
|
|
375
|
+
lines.append(f"- **{r['rel']}**: {mod_link}")
|
|
376
|
+
else:
|
|
377
|
+
lines.append("*No cross-module references detected.*")
|
|
378
|
+
lines.append("")
|
|
379
|
+
|
|
380
|
+
return "\n".join(lines)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _generate_module_md(filepath: str, file_data: dict, entity_page_map: dict | None = None) -> str:
|
|
384
|
+
"""Generate comprehensive markdown for a module page."""
|
|
385
|
+
mod_name = _module_name_from_path(filepath)
|
|
386
|
+
classes = file_data.get("classes", [])
|
|
387
|
+
functions = file_data.get("functions", [])
|
|
388
|
+
imports = file_data.get("imports", [])
|
|
389
|
+
module_docstring = file_data.get("module_docstring", "")
|
|
390
|
+
|
|
391
|
+
lines = [
|
|
392
|
+
f"# {mod_name} Module",
|
|
393
|
+
"",
|
|
394
|
+
f"**Path:** `{filepath}`",
|
|
395
|
+
"",
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
# Description
|
|
399
|
+
lines.append("## Description")
|
|
400
|
+
lines.append("")
|
|
401
|
+
if module_docstring:
|
|
402
|
+
lines.append(module_docstring)
|
|
403
|
+
else:
|
|
404
|
+
lines.append(f"_Auto-generated from `{filepath}`._")
|
|
405
|
+
lines.append("")
|
|
406
|
+
|
|
407
|
+
# Imports
|
|
408
|
+
if imports:
|
|
409
|
+
# Group imports by source module
|
|
410
|
+
grouped: dict[str, list[str]] = defaultdict(list)
|
|
411
|
+
for imp in imports:
|
|
412
|
+
grouped[imp["module"]].append(imp["name"])
|
|
413
|
+
|
|
414
|
+
lines.append("## Imports")
|
|
415
|
+
lines.append("")
|
|
416
|
+
lines.append("| Source | Symbols |")
|
|
417
|
+
lines.append("|--------|---------|")
|
|
418
|
+
for module, names in sorted(grouped.items()):
|
|
419
|
+
lines.append(f"| `{module}` | {', '.join(f'`{n}`' for n in names)} |")
|
|
420
|
+
lines.append("")
|
|
421
|
+
|
|
422
|
+
# Classes
|
|
423
|
+
if classes:
|
|
424
|
+
lines.append("## Classes")
|
|
425
|
+
lines.append("")
|
|
426
|
+
lines.append("| Class | Line | Bases | Description |")
|
|
427
|
+
lines.append("|-------|------|-------|-------------|")
|
|
428
|
+
for c in classes:
|
|
429
|
+
page_name = (entity_page_map or {}).get(c["name"], c["name"])
|
|
430
|
+
entity_link = f"[{c['name']}](../entities/{page_name}.md)"
|
|
431
|
+
bases = ", ".join(f"`{b}`" for b in c.get("bases", [])) or "—"
|
|
432
|
+
doc = c.get("docstring", "").split("\n")[0] if c.get("docstring") else "—"
|
|
433
|
+
lines.append(f"| {entity_link} | {c.get('line', '?')} | {bases} | {doc} |")
|
|
434
|
+
lines.append("")
|
|
435
|
+
|
|
436
|
+
# Functions
|
|
437
|
+
if functions:
|
|
438
|
+
lines.append("## Functions")
|
|
439
|
+
lines.append("")
|
|
440
|
+
lines.append("| Function | Signature | Decorators | Description |")
|
|
441
|
+
lines.append("|----------|-----------|------------|-------------|")
|
|
442
|
+
for fn in functions:
|
|
443
|
+
sig = _format_signature(fn)
|
|
444
|
+
decs = ", ".join(f"`@{d}`" for d in fn.get("decorators", [])) or "—"
|
|
445
|
+
doc = fn.get("docstring", "").split("\n")[0] if fn.get("docstring") else "—"
|
|
446
|
+
async_tag = "*(async)* " if fn.get("is_async") else ""
|
|
447
|
+
lines.append(f"| `{fn['name']}` | `{async_tag}{sig}` | {decs} | {doc} |")
|
|
448
|
+
lines.append("")
|
|
449
|
+
|
|
450
|
+
return "\n".join(lines)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _generate_index_md(entity_names: list[str], module_entries: list[dict], workflow_entries: list[dict] | None = None, infra_entries: list[dict] | None = None) -> str:
|
|
454
|
+
"""Generate the full index.md content."""
|
|
455
|
+
lines = [
|
|
456
|
+
"# LLM Wiki Index",
|
|
457
|
+
"",
|
|
458
|
+
"Catalog of project modules and entities.",
|
|
459
|
+
"",
|
|
460
|
+
"## Entities",
|
|
461
|
+
"",
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
for name in sorted(entity_names):
|
|
465
|
+
lines.append(f"- [{name}](entities/{name}.md)")
|
|
466
|
+
|
|
467
|
+
lines.append("")
|
|
468
|
+
lines.append("## Modules")
|
|
469
|
+
lines.append("")
|
|
470
|
+
|
|
471
|
+
for entry in sorted(module_entries, key=lambda e: e["name"]):
|
|
472
|
+
desc = entry.get("docstring", "")
|
|
473
|
+
suffix = f" - {desc}" if desc else f" - `{entry['path']}`"
|
|
474
|
+
lines.append(f"- [{entry['name']}](modules/{entry['name']}.md){suffix}")
|
|
475
|
+
|
|
476
|
+
lines.append("")
|
|
477
|
+
lines.append("## Workflows")
|
|
478
|
+
lines.append("")
|
|
479
|
+
|
|
480
|
+
if workflow_entries:
|
|
481
|
+
for wf in sorted(workflow_entries, key=lambda w: w["name"]):
|
|
482
|
+
entry_point = wf.get("entry", "")
|
|
483
|
+
lines.append(f"- [{wf['name']}](workflows/{wf['name']}.md) - entry: `{entry_point}`")
|
|
484
|
+
lines.append("")
|
|
485
|
+
|
|
486
|
+
lines.append("## Infrastructure")
|
|
487
|
+
lines.append("")
|
|
488
|
+
|
|
489
|
+
if infra_entries:
|
|
490
|
+
for entry in sorted(infra_entries, key=lambda e: e["name"]):
|
|
491
|
+
desc = entry.get("type", "")
|
|
492
|
+
suffix = f" - {desc}" if desc else ""
|
|
493
|
+
lines.append(f"- [{entry['name']}](infrastructure/{entry['name']}.md){suffix}")
|
|
494
|
+
lines.append("")
|
|
495
|
+
|
|
496
|
+
return "\n".join(lines)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _generate_workflow_md(name: str, wf: dict) -> str:
|
|
500
|
+
"""Generate a skeleton workflow page from call-graph data."""
|
|
501
|
+
entry = wf["entry"]
|
|
502
|
+
modules = wf["modules_touched"]
|
|
503
|
+
chain = wf.get("chain", [])
|
|
504
|
+
docstring = wf.get("docstring", "")
|
|
505
|
+
|
|
506
|
+
lines = [
|
|
507
|
+
f"# {name}",
|
|
508
|
+
"",
|
|
509
|
+
f"**Entry point:** `{entry}`",
|
|
510
|
+
f"**Modules involved:** {', '.join(f'[{m}](../modules/{m}.md)' for m in modules)}",
|
|
511
|
+
"",
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
if docstring:
|
|
515
|
+
lines.append(f"> {docstring}")
|
|
516
|
+
lines.append("")
|
|
517
|
+
|
|
518
|
+
lines.append("## Sequence")
|
|
519
|
+
lines.append("")
|
|
520
|
+
lines.append("<!-- Auto-detected call chain. Refine order and conditions after review. -->")
|
|
521
|
+
if chain:
|
|
522
|
+
for i, step in enumerate(chain, 1):
|
|
523
|
+
lines.append(f"{i}. `{step}`")
|
|
524
|
+
else:
|
|
525
|
+
lines.append("*No detailed chain extracted — refine manually.*")
|
|
526
|
+
lines.append("")
|
|
527
|
+
|
|
528
|
+
lines.append("## Touches")
|
|
529
|
+
lines.append("")
|
|
530
|
+
for m in modules:
|
|
531
|
+
lines.append(f"- [{m}](../modules/{m}.md)")
|
|
532
|
+
lines.append("")
|
|
533
|
+
|
|
534
|
+
return "\n".join(lines)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
_SOURCE_EXTS = (".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _normalize_source_path(path: str) -> str:
|
|
541
|
+
"""Normalize Docker COPY source paths for comparison with inventory keys."""
|
|
542
|
+
return normalize_source_path(path) or ""
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _coerce_module_links(module_links: Mapping[str, str] | set[str] | None) -> dict[str, str]:
|
|
546
|
+
"""Return ``{source_path: module_page_stem}`` for Docker COPY linking.
|
|
547
|
+
|
|
548
|
+
``set[str]`` is accepted for backward compatibility with older tests that
|
|
549
|
+
passed raw module stems.
|
|
550
|
+
"""
|
|
551
|
+
if not module_links:
|
|
552
|
+
return {}
|
|
553
|
+
if isinstance(module_links, Mapping):
|
|
554
|
+
return {
|
|
555
|
+
_normalize_source_path(source_path): page_name
|
|
556
|
+
for source_path, page_name in module_links.items()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
coerced: dict[str, str] = {}
|
|
560
|
+
for stem in module_links:
|
|
561
|
+
for ext in _SOURCE_EXTS:
|
|
562
|
+
coerced[f"{stem}{ext}"] = stem
|
|
563
|
+
return coerced
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _copy_source_candidates(source: str, docker_filename: str) -> list[str]:
|
|
567
|
+
"""Return likely project-relative source paths for a Docker COPY source."""
|
|
568
|
+
source = _normalize_source_path(source)
|
|
569
|
+
if not source:
|
|
570
|
+
return []
|
|
571
|
+
|
|
572
|
+
docker_path = Path(docker_filename.replace("\\", "/"))
|
|
573
|
+
docker_parent = docker_path.parent
|
|
574
|
+
|
|
575
|
+
candidates: list[str] = []
|
|
576
|
+
if str(docker_parent) not in ("", "."):
|
|
577
|
+
# Common repo layout: Dockerfiles live in ./docker while the build
|
|
578
|
+
# context is the project/worktree root one level above that directory.
|
|
579
|
+
if docker_parent.name == "docker":
|
|
580
|
+
candidates.append((docker_parent.parent / source).as_posix())
|
|
581
|
+
candidates.append((docker_parent / source).as_posix())
|
|
582
|
+
candidates.append(source)
|
|
583
|
+
|
|
584
|
+
seen: set[str] = set()
|
|
585
|
+
result: list[str] = []
|
|
586
|
+
for candidate in candidates:
|
|
587
|
+
normalized = _normalize_source_path(candidate)
|
|
588
|
+
if normalized and normalized not in seen:
|
|
589
|
+
result.append(normalized)
|
|
590
|
+
seen.add(normalized)
|
|
591
|
+
return result
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _module_page_for_copy_source(
|
|
595
|
+
source: str,
|
|
596
|
+
docker_filename: str,
|
|
597
|
+
module_links: Mapping[str, str] | set[str] | None,
|
|
598
|
+
) -> str | None:
|
|
599
|
+
"""Resolve a Docker COPY source to a module page stem if unambiguous."""
|
|
600
|
+
source = _normalize_source_path(source)
|
|
601
|
+
if not source or "*" in source or source.endswith("/"):
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
links = _coerce_module_links(module_links)
|
|
605
|
+
if not links:
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
for candidate in _copy_source_candidates(source, docker_filename):
|
|
609
|
+
if candidate in links:
|
|
610
|
+
return links[candidate]
|
|
611
|
+
|
|
612
|
+
suffix_matches = {
|
|
613
|
+
page_name
|
|
614
|
+
for source_path, page_name in links.items()
|
|
615
|
+
if source_path == source or source_path.endswith(f"/{source}")
|
|
616
|
+
}
|
|
617
|
+
if len(suffix_matches) == 1:
|
|
618
|
+
return next(iter(suffix_matches))
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _split_copy_sources(source: str) -> list[str]:
|
|
623
|
+
"""Split a Docker COPY source field while preserving a safe fallback."""
|
|
624
|
+
source = source.strip()
|
|
625
|
+
if not source:
|
|
626
|
+
return []
|
|
627
|
+
try:
|
|
628
|
+
parts = shlex.split(source)
|
|
629
|
+
except ValueError:
|
|
630
|
+
return [source]
|
|
631
|
+
return parts or [source]
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _format_copy_source_links(
|
|
635
|
+
source: str,
|
|
636
|
+
docker_filename: str,
|
|
637
|
+
module_links: Mapping[str, str] | set[str] | None,
|
|
638
|
+
) -> str:
|
|
639
|
+
"""Format a Docker COPY source cell with module links where safe."""
|
|
640
|
+
sources = _split_copy_sources(source)
|
|
641
|
+
if not sources:
|
|
642
|
+
return "—"
|
|
643
|
+
|
|
644
|
+
formatted: list[str] = []
|
|
645
|
+
for item in sources:
|
|
646
|
+
page_name = _module_page_for_copy_source(item, docker_filename, module_links)
|
|
647
|
+
if page_name:
|
|
648
|
+
formatted.append(f"[`{item}`](../modules/{page_name}.md)")
|
|
649
|
+
else:
|
|
650
|
+
formatted.append(f"`{item}`")
|
|
651
|
+
return ", ".join(formatted)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _generate_docker_md(
|
|
655
|
+
filename: str,
|
|
656
|
+
info: dict,
|
|
657
|
+
module_links: Mapping[str, str] | set[str] | None = None,
|
|
658
|
+
*,
|
|
659
|
+
module_stems: set[str] | None = None,
|
|
660
|
+
) -> str:
|
|
661
|
+
"""Generate a wiki page for a Dockerfile or docker-compose file."""
|
|
662
|
+
if module_links is None and module_stems is not None:
|
|
663
|
+
module_links = module_stems
|
|
664
|
+
if info["type"] == "dockerfile":
|
|
665
|
+
return _generate_dockerfile_md(filename, info, module_links)
|
|
666
|
+
return _generate_compose_md(filename, info, module_links)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _generate_dockerfile_md(filename: str, info: dict, module_links: Mapping[str, str] | set[str] | None = None) -> str:
|
|
670
|
+
"""Generate markdown for a Dockerfile."""
|
|
671
|
+
stages = info.get("stages", [])
|
|
672
|
+
ports = info.get("ports", [])
|
|
673
|
+
env_vars = info.get("env_vars", [])
|
|
674
|
+
volumes = info.get("volumes", [])
|
|
675
|
+
copies = info.get("copies", [])
|
|
676
|
+
build_args = info.get("build_args", [])
|
|
677
|
+
labels = info.get("labels", {})
|
|
678
|
+
entrypoint = info.get("entrypoint", "")
|
|
679
|
+
cmd = info.get("cmd", "")
|
|
680
|
+
workdir = info.get("workdir", "")
|
|
681
|
+
healthcheck = info.get("healthcheck", "")
|
|
682
|
+
|
|
683
|
+
base_images = [s["image"] for s in stages] if stages else ["unknown"]
|
|
684
|
+
|
|
685
|
+
lines = [
|
|
686
|
+
f"# {filename}",
|
|
687
|
+
"",
|
|
688
|
+
f"**Path:** `{filename}`",
|
|
689
|
+
f"**Base Image(s):** {', '.join(f'`{img}`' for img in base_images)}",
|
|
690
|
+
"",
|
|
691
|
+
]
|
|
692
|
+
|
|
693
|
+
# Build stages
|
|
694
|
+
if len(stages) > 1 or (stages and stages[0].get("alias")):
|
|
695
|
+
lines.append("## Build Stages")
|
|
696
|
+
lines.append("")
|
|
697
|
+
lines.append("| Stage | Base Image |")
|
|
698
|
+
lines.append("|-------|-----------|")
|
|
699
|
+
for s in stages:
|
|
700
|
+
alias = f"`{s['alias']}`" if s.get("alias") else "*(final)*"
|
|
701
|
+
lines.append(f"| {alias} | `{s['image']}` |")
|
|
702
|
+
lines.append("")
|
|
703
|
+
|
|
704
|
+
# Exposed ports
|
|
705
|
+
if ports:
|
|
706
|
+
lines.append("## Exposed Ports")
|
|
707
|
+
lines.append("")
|
|
708
|
+
for p in ports:
|
|
709
|
+
lines.append(f"- `{p}`")
|
|
710
|
+
lines.append("")
|
|
711
|
+
|
|
712
|
+
# Build args
|
|
713
|
+
if build_args:
|
|
714
|
+
lines.append("## Build Arguments")
|
|
715
|
+
lines.append("")
|
|
716
|
+
lines.append("| Argument | Default |")
|
|
717
|
+
lines.append("|----------|---------|")
|
|
718
|
+
for a in build_args:
|
|
719
|
+
default = f"`{a['default']}`" if a["default"] else "—"
|
|
720
|
+
lines.append(f"| `{a['name']}` | {default} |")
|
|
721
|
+
lines.append("")
|
|
722
|
+
|
|
723
|
+
# Environment variables
|
|
724
|
+
if env_vars:
|
|
725
|
+
lines.append("## Environment Variables")
|
|
726
|
+
lines.append("")
|
|
727
|
+
lines.append("| Variable | Default |")
|
|
728
|
+
lines.append("|----------|---------|")
|
|
729
|
+
for e in env_vars:
|
|
730
|
+
default = f"`{e['default']}`" if e["default"] else "—"
|
|
731
|
+
lines.append(f"| `{e['name']}` | {default} |")
|
|
732
|
+
lines.append("")
|
|
733
|
+
|
|
734
|
+
# Volumes
|
|
735
|
+
if volumes:
|
|
736
|
+
lines.append("## Volumes")
|
|
737
|
+
lines.append("")
|
|
738
|
+
for v in volumes:
|
|
739
|
+
lines.append(f"- `{v}`")
|
|
740
|
+
lines.append("")
|
|
741
|
+
|
|
742
|
+
# Working directory
|
|
743
|
+
if workdir:
|
|
744
|
+
lines.append(f"**Working Directory:** `{workdir}`")
|
|
745
|
+
lines.append("")
|
|
746
|
+
|
|
747
|
+
# Entry point / CMD
|
|
748
|
+
if entrypoint or cmd:
|
|
749
|
+
lines.append("## Entry Point")
|
|
750
|
+
lines.append("")
|
|
751
|
+
if entrypoint:
|
|
752
|
+
lines.append(f"**ENTRYPOINT:** `{entrypoint}`")
|
|
753
|
+
if cmd:
|
|
754
|
+
lines.append(f"**CMD:** `{cmd}`")
|
|
755
|
+
lines.append("")
|
|
756
|
+
|
|
757
|
+
# File copies
|
|
758
|
+
if copies:
|
|
759
|
+
lines.append("## File Copies")
|
|
760
|
+
lines.append("")
|
|
761
|
+
lines.append("| Instruction | Source | Destination | From Stage |")
|
|
762
|
+
lines.append("|-------------|--------|-------------|------------|")
|
|
763
|
+
for c in copies:
|
|
764
|
+
stage = f"`{c['from_stage']}`" if c.get("from_stage") else "—"
|
|
765
|
+
src_text = _format_copy_source_links(c["src"], filename, module_links)
|
|
766
|
+
lines.append(f"| `{c['instruction']}` | {src_text} | `{c['dest']}` | {stage} |")
|
|
767
|
+
lines.append("")
|
|
768
|
+
|
|
769
|
+
# Labels
|
|
770
|
+
if labels:
|
|
771
|
+
lines.append("## Labels")
|
|
772
|
+
lines.append("")
|
|
773
|
+
lines.append("| Key | Value |")
|
|
774
|
+
lines.append("|-----|-------|")
|
|
775
|
+
for k, v in labels.items():
|
|
776
|
+
lines.append(f"| `{k}` | `{v}` |")
|
|
777
|
+
lines.append("")
|
|
778
|
+
|
|
779
|
+
# Healthcheck
|
|
780
|
+
if healthcheck:
|
|
781
|
+
lines.append(f"**Healthcheck:** `{healthcheck}`")
|
|
782
|
+
lines.append("")
|
|
783
|
+
|
|
784
|
+
return "\n".join(lines)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _generate_compose_md(filename: str, info: dict, module_links: Mapping[str, str] | set[str] | None = None) -> str:
|
|
788
|
+
"""Generate markdown for a docker-compose / compose file."""
|
|
789
|
+
services = info.get("services", {})
|
|
790
|
+
networks = info.get("networks", [])
|
|
791
|
+
named_volumes = info.get("volumes", [])
|
|
792
|
+
|
|
793
|
+
lines = [
|
|
794
|
+
f"# {filename}",
|
|
795
|
+
"",
|
|
796
|
+
f"**Path:** `{filename}`",
|
|
797
|
+
"",
|
|
798
|
+
]
|
|
799
|
+
|
|
800
|
+
# Services summary table
|
|
801
|
+
if services:
|
|
802
|
+
lines.append("## Services")
|
|
803
|
+
lines.append("")
|
|
804
|
+
lines.append("| Service | Image / Build | Ports | Depends On |")
|
|
805
|
+
lines.append("|---------|---------------|-------|------------|")
|
|
806
|
+
for name, svc in services.items():
|
|
807
|
+
image = svc.get("image", "")
|
|
808
|
+
build = svc.get("build", "")
|
|
809
|
+
if isinstance(build, dict):
|
|
810
|
+
build = build.get("context", ".")
|
|
811
|
+
img_str = f"build: `{build}`" if build else (f"`{image}`" if image else "—")
|
|
812
|
+
ports = svc.get("ports", [])
|
|
813
|
+
ports_str = ", ".join(f"`{p}`" for p in ports) if isinstance(ports, list) else (f"`{ports}`" if ports else "—")
|
|
814
|
+
depends = svc.get("depends_on", [])
|
|
815
|
+
deps_str = ", ".join(f"`{d}`" for d in depends) if isinstance(depends, list) else (f"`{depends}`" if depends else "—")
|
|
816
|
+
lines.append(f"| `{name}` | {img_str} | {ports_str} | {deps_str} |")
|
|
817
|
+
lines.append("")
|
|
818
|
+
|
|
819
|
+
# Per-service detail
|
|
820
|
+
for name, svc in services.items():
|
|
821
|
+
lines.append(f"### {name}")
|
|
822
|
+
lines.append("")
|
|
823
|
+
image = svc.get("image", "")
|
|
824
|
+
build = svc.get("build", "")
|
|
825
|
+
if build:
|
|
826
|
+
ctx = build if isinstance(build, str) else build.get("context", ".")
|
|
827
|
+
lines.append(f"- **Build context:** `{ctx}`")
|
|
828
|
+
if image:
|
|
829
|
+
lines.append(f"- **Image:** `{image}`")
|
|
830
|
+
ports = svc.get("ports", [])
|
|
831
|
+
if ports:
|
|
832
|
+
ports_list = ports if isinstance(ports, list) else [ports]
|
|
833
|
+
lines.append(f"- **Ports:** {', '.join(f'`{p}`' for p in ports_list)}")
|
|
834
|
+
vols = svc.get("volumes", [])
|
|
835
|
+
if vols:
|
|
836
|
+
vols_list = vols if isinstance(vols, list) else [vols]
|
|
837
|
+
lines.append(f"- **Volumes:** {', '.join(f'`{v}`' for v in vols_list)}")
|
|
838
|
+
env = svc.get("environment", [])
|
|
839
|
+
if env:
|
|
840
|
+
env_list = env if isinstance(env, list) else [env]
|
|
841
|
+
lines.append(f"- **Environment:** {', '.join(f'`{e}`' for e in env_list)}")
|
|
842
|
+
depends = svc.get("depends_on", [])
|
|
843
|
+
if depends:
|
|
844
|
+
deps_list = depends if isinstance(depends, list) else [depends]
|
|
845
|
+
lines.append(f"- **Depends on:** {', '.join(f'`{d}`' for d in deps_list)}")
|
|
846
|
+
command = svc.get("command", "")
|
|
847
|
+
if command:
|
|
848
|
+
lines.append(f"- **Command:** `{command}`")
|
|
849
|
+
lines.append("")
|
|
850
|
+
|
|
851
|
+
# Networks
|
|
852
|
+
if networks:
|
|
853
|
+
lines.append("## Networks")
|
|
854
|
+
lines.append("")
|
|
855
|
+
for n in networks:
|
|
856
|
+
lines.append(f"- `{n}`")
|
|
857
|
+
lines.append("")
|
|
858
|
+
|
|
859
|
+
# Named volumes
|
|
860
|
+
if named_volumes:
|
|
861
|
+
lines.append("## Named Volumes")
|
|
862
|
+
lines.append("")
|
|
863
|
+
for v in named_volumes:
|
|
864
|
+
lines.append(f"- `{v}`")
|
|
865
|
+
lines.append("")
|
|
866
|
+
|
|
867
|
+
return "\n".join(lines)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def run(args):
|
|
871
|
+
src_dir = args.src_dir
|
|
872
|
+
wiki_dir = Path(args.wiki_dir)
|
|
873
|
+
validate_path(str(wiki_dir), "--wiki-dir")
|
|
874
|
+
validate_path(src_dir, "--src-dir")
|
|
875
|
+
depth = getattr(args, "depth", "full")
|
|
876
|
+
deep = depth == "full"
|
|
877
|
+
skip_workflows = getattr(args, "skip_workflows", False)
|
|
878
|
+
|
|
879
|
+
print(f"Bootstrapping wiki from source: {src_dir} (depth={depth})")
|
|
880
|
+
print(f"Wiki output directory: {wiki_dir}")
|
|
881
|
+
|
|
882
|
+
# Ensure wiki structure exists
|
|
883
|
+
for subdir in ["entities", "modules", "workflows", "infrastructure"]:
|
|
884
|
+
(wiki_dir / subdir).mkdir(parents=True, exist_ok=True)
|
|
885
|
+
|
|
886
|
+
# 1. Extract full AST inventory
|
|
887
|
+
inventory_result = get_inventory_result(src_dir, deep=deep)
|
|
888
|
+
if inventory_result.failed:
|
|
889
|
+
print_inventory_failures(inventory_result)
|
|
890
|
+
sys.exit(1)
|
|
891
|
+
inventory = inventory_result.inventory
|
|
892
|
+
|
|
893
|
+
if not inventory:
|
|
894
|
+
print("No supported source files with classes or functions found. Nothing to bootstrap.")
|
|
895
|
+
return
|
|
896
|
+
|
|
897
|
+
all_entity_names = []
|
|
898
|
+
module_entries = []
|
|
899
|
+
entities_created = 0
|
|
900
|
+
modules_created = 0
|
|
901
|
+
_seen_entity_pages: set[str] = set() # dedup index entries
|
|
902
|
+
|
|
903
|
+
# Precompute module page name map: filepath -> page_stem
|
|
904
|
+
_module_page_map: dict[str, str] = build_module_page_map(inventory)
|
|
905
|
+
|
|
906
|
+
# Precompute per-file entity page names: (cls_name, filepath) -> page_stem
|
|
907
|
+
_entity_page_name_cache: dict = build_entity_page_map(inventory)
|
|
908
|
+
|
|
909
|
+
# 2. Build cross-reference relationships (only meaningful in deep mode)
|
|
910
|
+
relationships = _build_relationships(inventory, _module_page_map) if deep else {}
|
|
911
|
+
|
|
912
|
+
for filepath, file_data in inventory.items():
|
|
913
|
+
mod_page_name = _module_page_map[filepath]
|
|
914
|
+
# Map cls_name -> page_stem for classes in this file (used by module page links)
|
|
915
|
+
file_entity_page_map = {
|
|
916
|
+
cls["name"]: _entity_page_name_cache[(cls["name"], filepath)]
|
|
917
|
+
for cls in file_data.get("classes", [])
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
# Generate entity pages for each class
|
|
921
|
+
for cls in file_data.get("classes", []):
|
|
922
|
+
entity_page_name = file_entity_page_map[cls["name"]]
|
|
923
|
+
entity_path = wiki_dir / "entities" / f"{entity_page_name}.md"
|
|
924
|
+
if entity_path.exists() and not args.overwrite:
|
|
925
|
+
print(f" SKIP entity (exists): {entity_page_name}")
|
|
926
|
+
else:
|
|
927
|
+
write_md(entity_path, _generate_entity_md(cls, filepath, relationships, mod_page_name))
|
|
928
|
+
entities_created += 1
|
|
929
|
+
print(f" CREATE entity: {entity_page_name}")
|
|
930
|
+
if entity_page_name not in _seen_entity_pages:
|
|
931
|
+
all_entity_names.append(entity_page_name)
|
|
932
|
+
_seen_entity_pages.add(entity_page_name)
|
|
933
|
+
|
|
934
|
+
# Generate module page
|
|
935
|
+
module_path = wiki_dir / "modules" / f"{mod_page_name}.md"
|
|
936
|
+
if module_path.exists() and not args.overwrite:
|
|
937
|
+
print(f" SKIP module (exists): {mod_page_name}")
|
|
938
|
+
else:
|
|
939
|
+
write_md(module_path, _generate_module_md(filepath, file_data, file_entity_page_map))
|
|
940
|
+
modules_created += 1
|
|
941
|
+
print(f" CREATE module: {mod_page_name}")
|
|
942
|
+
|
|
943
|
+
module_entries.append({
|
|
944
|
+
"name": mod_page_name,
|
|
945
|
+
"path": filepath,
|
|
946
|
+
"docstring": file_data.get("module_docstring", ""),
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
# 3. Generate workflow pages from call graph (deep mode only)
|
|
950
|
+
workflow_entries = []
|
|
951
|
+
workflows_created = 0
|
|
952
|
+
if deep and not skip_workflows:
|
|
953
|
+
call_graph = get_call_graph(inventory)
|
|
954
|
+
for wf_name, wf_data in call_graph.items():
|
|
955
|
+
wf_path = wiki_dir / "workflows" / f"{wf_name}.md"
|
|
956
|
+
if wf_path.exists() and not args.overwrite:
|
|
957
|
+
print(f" SKIP workflow (exists): {wf_name}")
|
|
958
|
+
else:
|
|
959
|
+
write_md(wf_path, _generate_workflow_md(wf_name, wf_data))
|
|
960
|
+
workflows_created += 1
|
|
961
|
+
print(f" CREATE workflow: {wf_name}")
|
|
962
|
+
workflow_entries.append({"name": wf_name, "entry": wf_data["entry"]})
|
|
963
|
+
|
|
964
|
+
# 4. Generate infrastructure pages (Dockerfile, docker-compose, etc.)
|
|
965
|
+
infra_entries = []
|
|
966
|
+
infra_created = 0
|
|
967
|
+
docker_inventory = get_docker_inventory(src_dir)
|
|
968
|
+
for docker_file, docker_info in docker_inventory.items():
|
|
969
|
+
page_name = docker_file.replace("\\", "/").replace("/", "_").replace(".", "_")
|
|
970
|
+
infra_path = wiki_dir / "infrastructure" / f"{page_name}.md"
|
|
971
|
+
if infra_path.exists() and not args.overwrite:
|
|
972
|
+
print(f" SKIP infrastructure (exists): {page_name}")
|
|
973
|
+
else:
|
|
974
|
+
write_md(infra_path, _generate_docker_md(docker_file, docker_info, _module_page_map))
|
|
975
|
+
infra_created += 1
|
|
976
|
+
print(f" CREATE infrastructure: {page_name}")
|
|
977
|
+
infra_entries.append({"name": page_name, "type": docker_info["type"]})
|
|
978
|
+
|
|
979
|
+
# 5. Rebuild index.md
|
|
980
|
+
index_path = wiki_dir / "index.md"
|
|
981
|
+
write_md(index_path, _generate_index_md(all_entity_names, module_entries, workflow_entries or None, infra_entries or None))
|
|
982
|
+
print(f" WRITE index.md")
|
|
983
|
+
|
|
984
|
+
# 6. Append log entry
|
|
985
|
+
log_path = wiki_dir / "log.md"
|
|
986
|
+
today = date.today().isoformat()
|
|
987
|
+
log_entry = (
|
|
988
|
+
f"\n## {today}\n\n"
|
|
989
|
+
f"### feat: bootstrap wiki from existing codebase\n"
|
|
990
|
+
f"- Source: `{src_dir}`\n"
|
|
991
|
+
f"- Depth: `{depth}`\n"
|
|
992
|
+
f"- Entities created: {entities_created}\n"
|
|
993
|
+
f"- Modules created: {modules_created}\n"
|
|
994
|
+
f"- Workflows created: {workflows_created}\n"
|
|
995
|
+
f"- Infrastructure created: {infra_created}\n"
|
|
996
|
+
f"- Total classes tracked: {len(all_entity_names)}\n"
|
|
997
|
+
f"- Total files scanned: {len(inventory)}\n"
|
|
998
|
+
f"- Docker/Compose files: {len(docker_inventory)}\n"
|
|
999
|
+
f"- Cross-references resolved: {sum(len(v) for v in relationships.values())}\n"
|
|
1000
|
+
)
|
|
1001
|
+
if log_path.exists():
|
|
1002
|
+
existing_log = read_md(log_path)
|
|
1003
|
+
write_md(log_path, existing_log + log_entry)
|
|
1004
|
+
else:
|
|
1005
|
+
write_md(log_path, "# Architectural Log\n\nAppend-only chronological log.\n" + log_entry)
|
|
1006
|
+
|
|
1007
|
+
print(
|
|
1008
|
+
f"\nBootstrap complete: {entities_created} entities, "
|
|
1009
|
+
f"{modules_created} modules, {workflows_created} workflows, "
|
|
1010
|
+
f"{infra_created} infrastructure "
|
|
1011
|
+
f"created from {len(inventory)} source files "
|
|
1012
|
+
f"({sum(len(v) for v in relationships.values())} cross-references)."
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
# 7. Update agent constraint files if wiki-dir differs from default
|
|
1016
|
+
_update_agent_constraints(str(wiki_dir))
|
|
1017
|
+
|
|
1018
|
+
# 8. Save sync manifest so `llm-wiki sync` can run incrementally
|
|
1019
|
+
from .sync_cmd import SyncManifest # local import to avoid circular dep
|
|
1020
|
+
|
|
1021
|
+
manifest = SyncManifest.build_from_inventory(
|
|
1022
|
+
inventory, src_dir, _entity_page_name_cache, _module_page_map,
|
|
1023
|
+
)
|
|
1024
|
+
manifest.save(wiki_dir)
|
|
1025
|
+
print(f" WRITE {wiki_dir / '.llm-wiki-manifest.json'}")
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
from ..config import DEFAULT_WIKI_DIR as _DEFAULT_WIKI_DIR
|
|
1029
|
+
from ..services.schema import (
|
|
1030
|
+
ALL_SCHEMA_FILES as _AGENT_SCHEMA_FILES,
|
|
1031
|
+
CONSTRAINT_START as _CONSTRAINT_START,
|
|
1032
|
+
CONSTRAINT_END as _CONSTRAINT_END,
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _update_agent_constraints(wiki_dir: str) -> None:
|
|
1037
|
+
"""Replace docs/llm_wiki path references inside the constraint block
|
|
1038
|
+
in any existing agent schema files to match the actual wiki_dir."""
|
|
1039
|
+
# Normalize: treat both as Path to allow absolute/relative comparison
|
|
1040
|
+
wiki_path = Path(wiki_dir)
|
|
1041
|
+
default_path = Path(_DEFAULT_WIKI_DIR)
|
|
1042
|
+
# Nothing to do if the resolved paths are the same or wiki_dir already
|
|
1043
|
+
# contains the default string (handles the relative == relative case)
|
|
1044
|
+
if wiki_path == default_path or wiki_dir == _DEFAULT_WIKI_DIR:
|
|
1045
|
+
return
|
|
1046
|
+
# Also skip if the resolved absolute paths are equivalent
|
|
1047
|
+
try:
|
|
1048
|
+
if wiki_path.resolve() == default_path.resolve():
|
|
1049
|
+
return
|
|
1050
|
+
except OSError:
|
|
1051
|
+
pass
|
|
1052
|
+
|
|
1053
|
+
updated = []
|
|
1054
|
+
for filename in _AGENT_SCHEMA_FILES:
|
|
1055
|
+
p = Path(filename)
|
|
1056
|
+
if not p.exists():
|
|
1057
|
+
continue
|
|
1058
|
+
text = read_md(p)
|
|
1059
|
+
if _CONSTRAINT_START not in text or _CONSTRAINT_END not in text:
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
# Replace only within the constraint block to avoid touching user content
|
|
1063
|
+
start_idx = text.index(_CONSTRAINT_START)
|
|
1064
|
+
end_idx = text.index(_CONSTRAINT_END, start_idx) + len(_CONSTRAINT_END)
|
|
1065
|
+
block = text[start_idx:end_idx]
|
|
1066
|
+
new_block = block.replace(_DEFAULT_WIKI_DIR, wiki_dir)
|
|
1067
|
+
if new_block != block:
|
|
1068
|
+
write_md(p, text[:start_idx] + new_block + text[end_idx:])
|
|
1069
|
+
updated.append(filename)
|
|
1070
|
+
|
|
1071
|
+
if updated:
|
|
1072
|
+
print(f"\nUpdated wiki path to `{wiki_dir}` in: {', '.join(updated)}")
|