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.
Files changed (47) hide show
  1. agent_wiki_cli-0.3.28.dist-info/METADATA +425 -0
  2. agent_wiki_cli-0.3.28.dist-info/RECORD +47 -0
  3. agent_wiki_cli-0.3.28.dist-info/WHEEL +5 -0
  4. agent_wiki_cli-0.3.28.dist-info/entry_points.txt +2 -0
  5. agent_wiki_cli-0.3.28.dist-info/licenses/LICENSE +21 -0
  6. agent_wiki_cli-0.3.28.dist-info/top_level.txt +1 -0
  7. llm_wiki_cli/__init__.py +7 -0
  8. llm_wiki_cli/cli.py +231 -0
  9. llm_wiki_cli/commands/__init__.py +1 -0
  10. llm_wiki_cli/commands/bootstrap_cmd.py +1072 -0
  11. llm_wiki_cli/commands/bump_cmd.py +55 -0
  12. llm_wiki_cli/commands/context_cmd.py +427 -0
  13. llm_wiki_cli/commands/extract_cmd.py +745 -0
  14. llm_wiki_cli/commands/generate_prompt_cmd.py +89 -0
  15. llm_wiki_cli/commands/hook_cmd.py +161 -0
  16. llm_wiki_cli/commands/init_cmd.py +92 -0
  17. llm_wiki_cli/commands/lint_cmd.py +294 -0
  18. llm_wiki_cli/commands/migrate_cmd.py +892 -0
  19. llm_wiki_cli/commands/release_cmd.py +163 -0
  20. llm_wiki_cli/commands/status_cmd.py +70 -0
  21. llm_wiki_cli/commands/sync_cmd.py +521 -0
  22. llm_wiki_cli/commands/trigger_cmd.py +205 -0
  23. llm_wiki_cli/commands/uninstall_cmd.py +221 -0
  24. llm_wiki_cli/commands/upgrade_cmd.py +196 -0
  25. llm_wiki_cli/config.py +318 -0
  26. llm_wiki_cli/extractors/__init__.py +46 -0
  27. llm_wiki_cli/extractors/common.py +90 -0
  28. llm_wiki_cli/extractors/go_extractor.py +143 -0
  29. llm_wiki_cli/extractors/go_scripts/go.mod +3 -0
  30. llm_wiki_cli/extractors/go_scripts/main.go +668 -0
  31. llm_wiki_cli/extractors/python_extractor.py +346 -0
  32. llm_wiki_cli/extractors/rust_extractor.py +143 -0
  33. llm_wiki_cli/extractors/rust_scripts/Cargo.lock +110 -0
  34. llm_wiki_cli/extractors/rust_scripts/Cargo.toml +11 -0
  35. llm_wiki_cli/extractors/rust_scripts/src/main.rs +803 -0
  36. llm_wiki_cli/extractors/ts_extractor.py +206 -0
  37. llm_wiki_cli/extractors/ts_scripts/extract.js +485 -0
  38. llm_wiki_cli/extractors/ts_scripts/package.json +10 -0
  39. llm_wiki_cli/services/__init__.py +0 -0
  40. llm_wiki_cli/services/circuit_breaker.py +79 -0
  41. llm_wiki_cli/services/io.py +47 -0
  42. llm_wiki_cli/services/lockfile.py +60 -0
  43. llm_wiki_cli/services/packages.py +173 -0
  44. llm_wiki_cli/services/paths.py +31 -0
  45. llm_wiki_cli/services/schema.py +214 -0
  46. llm_wiki_cli/services/secure_file.py +22 -0
  47. 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)}")