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,89 @@
1
+ from __future__ import annotations
2
+
3
+ from ..config import DEFAULT_WIKI_DIR, validate_path
4
+ from ..services.paths import shell_quote
5
+ from ..services.secure_file import write_private_text
6
+
7
+ _DEFAULT_PROMPT_FILE = ".git/llm-wiki-prompt.txt"
8
+
9
+
10
+ def _build_prompt(wiki_dir: str, src_dir: str) -> str:
11
+ quoted_wiki_dir = shell_quote(wiki_dir)
12
+ quoted_wiki_dir_slash = shell_quote(f"{wiki_dir}/")
13
+ quoted_src_dir = shell_quote(src_dir)
14
+ return f"""\
15
+ You are a Wiki synchronizer for this project.
16
+ The project's wiki lives at `{wiki_dir}/`.
17
+
18
+ ## Context
19
+
20
+ Run these commands to understand what changed:
21
+
22
+ ```bash
23
+ # Changed files — compact inventory of what was modified in the last commit
24
+ llm-wiki extract --src-dir {quoted_src_dir} --changed --summary
25
+
26
+ # Full diff of the last commit
27
+ git diff HEAD~1..HEAD
28
+
29
+ # Current wiki health — shows what's already broken vs. what you need to fix
30
+ llm-wiki lint --wiki-dir {quoted_wiki_dir} --src-dir {quoted_src_dir}
31
+ ```
32
+
33
+ For full detail (methods, params, docstrings) on a specific file:
34
+ ```bash
35
+ llm-wiki extract --src-dir {quoted_src_dir} --paths path/to/file.py
36
+ ```
37
+
38
+ ## Success Criteria
39
+
40
+ Your work is done when **all** of the following are true:
41
+
42
+ 1. **`llm-wiki lint` exits 0** — no broken links, no orphan pages, no undocumented \
43
+ classes, no stale entities, no missing modules, no broken workflow links, \
44
+ no undocumented infrastructure files.
45
+ 2. **Only affected pages changed** — modify wiki pages that correspond to code \
46
+ touched in the diff. Do not edit unrelated pages or reformat existing content.
47
+ 3. **`{wiki_dir}/log.md` has a new entry** — one concise line describing what changed, \
48
+ appended at the bottom.
49
+ 4. **`CHANGELOG.md` updated** (if applicable) — add an entry under `## [Unreleased]` \
50
+ for user-facing changes. Skip for pure refactors, test-only, or doc-only commits. \
51
+ *(Not verified by lint.)*
52
+
53
+ ## Verify & Commit
54
+
55
+ After making your changes, run:
56
+
57
+ ```bash
58
+ llm-wiki lint --wiki-dir {quoted_wiki_dir} --src-dir {quoted_src_dir}
59
+ ```
60
+
61
+ If lint reports issues, fix them and re-run until it exits 0. Then commit:
62
+
63
+ ```bash
64
+ git add {quoted_wiki_dir_slash} CHANGELOG.md
65
+ LLM_WIKI_AUTO_COMMIT=1 git commit -m "docs(wiki): auto-update [bot]"
66
+ ```
67
+ """
68
+
69
+
70
+ def run(args) -> None:
71
+ wiki_dir: str = getattr(args, "wiki_dir", DEFAULT_WIKI_DIR)
72
+ src_dir: str = getattr(args, "src_dir", ".")
73
+ validate_path(wiki_dir, "--wiki-dir")
74
+ validate_path(src_dir, "--src-dir")
75
+ output: str = getattr(args, "output", _DEFAULT_PROMPT_FILE)
76
+ print_only: bool = getattr(args, "print_prompt", False)
77
+
78
+ prompt = _build_prompt(wiki_dir, src_dir)
79
+
80
+ if print_only:
81
+ print(prompt)
82
+ return
83
+
84
+ out_path = write_private_text(output, prompt)
85
+
86
+ print(f"Wiki sync prompt written to: {out_path}")
87
+ print()
88
+ print("Paste the contents into your IDE agent chat to trigger a wiki sync.")
89
+ print(f" cat {shell_quote(out_path)}")
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from ..config import CLI_AGENTS, DEFAULT_WIKI_DIR, IDE_AGENTS, get_agent_config_path, read_config, validate_path
9
+ from ..services.paths import shell_quote
10
+
11
+ # Agents that support headless CLI execution (can be used in post-commit hook)
12
+ _CLI_AGENTS = set(CLI_AGENTS)
13
+ # Agents that are IDE-only and cannot run headlessly
14
+ _UI_ONLY_AGENTS = IDE_AGENTS
15
+ HOOK_SIGNATURE = "LLM Wiki"
16
+
17
+
18
+ def _read_agent_config(wiki_dir: str) -> str | None:
19
+ """Read the agent name persisted by `llm-wiki init`."""
20
+ config_path = get_agent_config_path(wiki_dir)
21
+ if config_path.exists():
22
+ config = read_config(wiki_dir)
23
+ return config.get("agent")
24
+ return None
25
+
26
+
27
+ def _build_post_commit(agent: str, wiki_dir: str) -> str:
28
+ quoted_agent = shell_quote(agent)
29
+ quoted_wiki_dir = shell_quote(wiki_dir)
30
+ return f"""#!/bin/sh
31
+
32
+ # LLM Wiki Auto-Sync Post-Commit Hook
33
+ # Triggers the wiki update in the background so it doesn't block the developer
34
+
35
+ # Skip if this commit was made by the pre-push auto-bump
36
+ if [ -n "$LLM_WIKI_AUTO_COMMIT" ]; then
37
+ exit 0
38
+ fi
39
+
40
+ echo "Triggering LLM Wiki subagent sync in the background..."
41
+
42
+ # Configurable via environment variables (no need to re-install hook)
43
+ LLM_WIKI_TIMEOUT="${{LLM_WIKI_TIMEOUT:-300}}"
44
+ LLM_WIKI_MAX_DIFF="${{LLM_WIKI_MAX_DIFF:-1000}}"
45
+ LLM_WIKI_MAX_PROMPT_BYTES="${{LLM_WIKI_MAX_PROMPT_BYTES:-2000000}}"
46
+
47
+ # Find the virtual environment if it exists, or run globally
48
+ if [ -f ".venv/bin/llm-wiki" ]; then
49
+ CLI=".venv/bin/llm-wiki"
50
+ else
51
+ CLI="llm-wiki"
52
+ fi
53
+
54
+ nohup "$CLI" trigger-agent --agent {quoted_agent} --wiki-dir {quoted_wiki_dir} --timeout "$LLM_WIKI_TIMEOUT" --max-diff-lines "$LLM_WIKI_MAX_DIFF" --max-prompt-bytes "$LLM_WIKI_MAX_PROMPT_BYTES" > .git/llm-wiki-sync.log 2>&1 &
55
+ """
56
+
57
+
58
+ def _build_ide_post_commit(wiki_dir: str) -> str:
59
+ quoted_wiki_dir = shell_quote(wiki_dir)
60
+ return f"""#!/bin/sh
61
+
62
+ # LLM Wiki -- IDE Agent Prompt Helper (Post-Commit Hook)
63
+ # Generates a ready-to-paste sync prompt for IDE agents (Copilot, Cursor, etc.)
64
+ # The agent cannot run headlessly, so this hook prepares the work for you.
65
+
66
+ # Skip if this commit was made by the pre-push auto-bump
67
+ if [ -n "$LLM_WIKI_AUTO_COMMIT" ]; then
68
+ exit 0
69
+ fi
70
+
71
+ if [ -f ".venv/bin/llm-wiki" ]; then
72
+ CLI=".venv/bin/llm-wiki"
73
+ else
74
+ CLI="llm-wiki"
75
+ fi
76
+
77
+ "$CLI" generate-prompt --wiki-dir {quoted_wiki_dir} --output .git/llm-wiki-prompt.txt
78
+
79
+ echo ""
80
+ echo "+--------------------------------------------------------------+"
81
+ echo "| LLM Wiki: paste the sync prompt into your IDE agent chat. |"
82
+ echo "| File: .git/llm-wiki-prompt.txt |"
83
+ echo "+--------------------------------------------------------------+"
84
+
85
+ # Auto-open in VS Code only when explicitly enabled
86
+ if [ "${{LLM_WIKI_OPEN_PROMPT:-0}}" = "1" ] && [ "$TERM_PROGRAM" = "vscode" ]; then
87
+ code .git/llm-wiki-prompt.txt 2>/dev/null || true
88
+ fi
89
+ """
90
+
91
+
92
+
93
+
94
+ def _install_hook(hooks_dir: Path, name: str, content: str, *, force: bool = False) -> None:
95
+ """Write a hook file and make it executable."""
96
+ hook_path = hooks_dir / name
97
+ if hook_path.exists():
98
+ existing = hook_path.read_text(encoding="utf-8", errors="replace")
99
+ if HOOK_SIGNATURE not in existing and not force:
100
+ print(
101
+ f"Error: {hook_path} already exists and does not look like an LLM Wiki hook.\n"
102
+ "Use --force to replace it intentionally.",
103
+ file=sys.stderr,
104
+ )
105
+ sys.exit(1)
106
+ with open(hook_path, "w", encoding="utf-8") as f:
107
+ f.write(content)
108
+ st = os.stat(hook_path)
109
+ os.chmod(hook_path, st.st_mode | stat.S_IEXEC)
110
+ print(f" Installed: {hook_path}")
111
+
112
+
113
+ def run(args):
114
+ git_dir = Path(".git")
115
+ if not git_dir.exists():
116
+ print("Error: No .git directory found. Are you in the root of a git repository?")
117
+ sys.exit(1)
118
+
119
+ hooks_dir = git_dir / "hooks"
120
+ hooks_dir.mkdir(exist_ok=True)
121
+
122
+ wiki_dir = getattr(args, "wiki_dir", DEFAULT_WIKI_DIR)
123
+ validate_path(wiki_dir, "--wiki-dir")
124
+
125
+ # Resolve agent: CLI override > config file > fallback
126
+ agent = getattr(args, "agent", None)
127
+ if not agent:
128
+ agent = _read_agent_config(wiki_dir)
129
+ if not agent:
130
+ print(
131
+ f"Warning: No agent config found at .git/.llm-wiki-agent.\n"
132
+ f"Run `llm-wiki init --agent <agent>` first, or pass --agent to this command.\n"
133
+ f"Defaulting to 'claude'."
134
+ )
135
+ agent = "claude"
136
+
137
+ # IDE-only agent: install the prompt-generation hook instead of the headless sync hook
138
+ if agent in _UI_ONLY_AGENTS:
139
+ _install_hook(
140
+ hooks_dir, "post-commit", _build_ide_post_commit(wiki_dir),
141
+ force=getattr(args, "force", False),
142
+ )
143
+ print(f" Agent: {agent} (IDE mode -- prompt-generation hook)")
144
+ print(
145
+ f"\nIDE sync hook installed. After each commit, a prompt file will be generated at\n"
146
+ f" .git/llm-wiki-prompt.txt\n"
147
+ f"Paste its contents into your {agent} chat to sync the wiki.\n"
148
+ f"You can also generate it manually at any time:\n"
149
+ f" llm-wiki generate-prompt"
150
+ )
151
+ print("\nHook installation complete.")
152
+ return
153
+
154
+ # CLI agent: install headless auto-sync hook with agent baked in
155
+ _install_hook(
156
+ hooks_dir, "post-commit", _build_post_commit(agent, wiki_dir),
157
+ force=getattr(args, "force", False),
158
+ )
159
+ print(f" Agent: {agent}")
160
+
161
+ print("\nHook installation complete.")
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from ..config import CLI_AGENTS, DEFAULT_WIKI_DIR, get_agent_config_path, validate_path, write_config
9
+ from ..services.io import read_md, write_md
10
+ from ..services.schema import (
11
+ CONSTRAINT_START as _CONSTRAINT_START,
12
+ SCHEMA_FILENAMES,
13
+ build_schema_content as _build_schema_content,
14
+ )
15
+
16
+
17
+ # Agents that have a real CLI executable (used by trigger-agent / install-hook)
18
+ _CLI_AGENTS = CLI_AGENTS
19
+
20
+
21
+ def run(args):
22
+ wiki_dir = getattr(args, "wiki_dir", DEFAULT_WIKI_DIR)
23
+ validate_path(wiki_dir, "--wiki-dir")
24
+ print(f"Initializing LLM Wiki with {args.agent} schema...")
25
+
26
+ # Warn if the agent has a CLI executable that isn't installed
27
+ executable = _CLI_AGENTS.get(args.agent)
28
+ if executable and not shutil.which(executable):
29
+ print(
30
+ f"\nWarning: '{executable}' is not installed or not on PATH.\n"
31
+ f"The schema file will be created, but background auto-sync\n"
32
+ f"(`llm-wiki trigger-agent --agent {args.agent}`) will not work\n"
33
+ f"until '{executable}' is installed.\n"
34
+ )
35
+
36
+
37
+ # 1. Create directory structure
38
+ base_dir = Path(wiki_dir)
39
+ directories = [
40
+ base_dir,
41
+ base_dir / "entities",
42
+ base_dir / "modules",
43
+ base_dir / "workflows",
44
+ base_dir / "infrastructure",
45
+ ]
46
+
47
+ try:
48
+ for d in directories:
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ # Create empty .gitkeep so git tracks empty dirs
51
+ (d / ".gitkeep").touch()
52
+ except OSError as exc:
53
+ print(f"Error creating wiki directories: {exc}")
54
+ sys.exit(1)
55
+
56
+ print(f"Created wiki directories in {base_dir}/")
57
+
58
+ # 2. Create core files if they don't exist
59
+ index_path = base_dir / "index.md"
60
+ if not index_path.exists():
61
+ write_md(index_path, "# LLM Wiki Index\n\nCatalog of project modules and entities.\n\n## Entities\n\n## Modules\n\n## Workflows\n\n## Infrastructure\n")
62
+
63
+ log_path = base_dir / "log.md"
64
+ if not log_path.exists():
65
+ write_md(log_path, "# Architectural Log\n\nAppend-only chronological log.\n\n")
66
+
67
+ # 3. Create or Append to Agent Schema
68
+ quality_hints = not getattr(args, "no_quality_hints", False)
69
+ filename = SCHEMA_FILENAMES.get(args.agent)
70
+ if filename:
71
+ schema_path = Path(filename)
72
+ # ensure parent exists (e.g. for .github/)
73
+ schema_path.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ content_to_add = _build_schema_content(args.agent, wiki_dir, quality_hints=quality_hints)
76
+
77
+ if schema_path.exists():
78
+ existing_content = read_md(schema_path)
79
+
80
+ if _CONSTRAINT_START not in existing_content:
81
+ write_md(schema_path, existing_content + "\n\n" + content_to_add)
82
+ print(f"Appended agent constraints to existing file: {schema_path}")
83
+ else:
84
+ print(f"Agent constraints already exist in {schema_path}, skipping append.")
85
+ else:
86
+ write_md(schema_path, content_to_add)
87
+ print(f"Created agent schema file: {schema_path}")
88
+
89
+ # 4. Persist the chosen agent so install-hook can read it
90
+ write_config(base_dir, {"agent": args.agent, "quality_hints": quality_hints})
91
+
92
+ print("LLM Wiki initialized successfully.")
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .extract_cmd import get_call_graph, get_docker_inventory, get_inventory_result, print_inventory_failures
9
+ from .bootstrap_cmd import build_module_page_map, build_entity_page_map
10
+ from ..config import validate_path
11
+ from ..services.io import read_md
12
+
13
+ # basic regex for [text](url)
14
+ LINK_RE = re.compile(r'\[.+?\]\((.+?)\)')
15
+
16
+
17
+ def _local_link_path(link: str) -> str | None:
18
+ """Return the file portion of a local markdown link, or None if ignored."""
19
+ if link.startswith(("http://", "https://", "mailto:", "#")):
20
+ return None
21
+ base, _sep, _anchor = link.partition("#")
22
+ if not base:
23
+ return None
24
+ return base
25
+
26
+
27
+ def _is_legacy_page(path: Path, wiki_dir: Path) -> bool:
28
+ """Return True for archived migration pages that lint should ignore."""
29
+ try:
30
+ return path.relative_to(wiki_dir).parts[:1] == ("legacy",)
31
+ except ValueError:
32
+ try:
33
+ return path.resolve().relative_to(wiki_dir.resolve()).parts[:1] == ("legacy",)
34
+ except ValueError:
35
+ return False
36
+
37
+
38
+ def _collect_documented_entities(wiki_dir: Path) -> set[str]:
39
+ """Return the set of entity names that have wiki pages."""
40
+ entities_dir = wiki_dir / "entities"
41
+ if not entities_dir.exists():
42
+ return set()
43
+ return {p.stem for p in entities_dir.glob("*.md")}
44
+
45
+
46
+ def _collect_code_classes(inventory_or_src_dir) -> set[str]:
47
+ """Return the set of entity page names found by AST scanning.
48
+
49
+ Uses collision-aware naming so that duplicate class names across
50
+ different modules are qualified (e.g. ``parser_Parser``).
51
+ """
52
+ inventory = (
53
+ inventory_or_src_dir
54
+ if isinstance(inventory_or_src_dir, dict)
55
+ else get_inventory_result(inventory_or_src_dir).inventory
56
+ )
57
+ entity_map = build_entity_page_map(inventory)
58
+ return set(entity_map.values())
59
+
60
+
61
+ def _collect_documented_modules(wiki_dir: Path) -> set[str]:
62
+ """Return the set of module names that have wiki pages."""
63
+ modules_dir = wiki_dir / "modules"
64
+ if not modules_dir.exists():
65
+ return set()
66
+ return {p.stem for p in modules_dir.glob("*.md")}
67
+
68
+
69
+ def _collect_code_modules(inventory_or_src_dir) -> set[str]:
70
+ """Return the set of module page names with tracked components.
71
+
72
+ Uses collision-aware naming so that duplicate file stems across
73
+ different directories are qualified (e.g. ``pkg_a_cli``).
74
+ """
75
+ inventory = (
76
+ inventory_or_src_dir
77
+ if isinstance(inventory_or_src_dir, dict)
78
+ else get_inventory_result(inventory_or_src_dir).inventory
79
+ )
80
+ mod_map = build_module_page_map(inventory)
81
+ return set(mod_map.values())
82
+
83
+
84
+ def _collect_documented_workflows(wiki_dir: Path) -> set[str]:
85
+ """Return the set of workflow names that have wiki pages."""
86
+ workflows_dir = wiki_dir / "workflows"
87
+ if not workflows_dir.exists():
88
+ return set()
89
+ return {p.stem for p in workflows_dir.glob("*.md")}
90
+
91
+
92
+ def _collect_documented_infrastructure(wiki_dir: Path) -> set[str]:
93
+ """Return the set of infrastructure page names that have wiki pages."""
94
+ infra_dir = wiki_dir / "infrastructure"
95
+ if not infra_dir.exists():
96
+ return set()
97
+ return {p.stem for p in infra_dir.glob("*.md")}
98
+
99
+
100
+ def _collect_docker_files(docker_inventory_or_src_dir) -> set[str]:
101
+ """Return the set of Docker/Compose file page-names found in source."""
102
+ docker_inv = (
103
+ docker_inventory_or_src_dir
104
+ if isinstance(docker_inventory_or_src_dir, dict)
105
+ else get_docker_inventory(docker_inventory_or_src_dir)
106
+ )
107
+ return {f.replace("\\", "/").replace("/", "_").replace(".", "_") for f in docker_inv}
108
+
109
+
110
+ def run(args):
111
+ wiki_dir = Path(args.wiki_dir)
112
+ src_dir = getattr(args, "src_dir", ".")
113
+ validate_path(str(wiki_dir), "--wiki-dir")
114
+ validate_path(src_dir, "--src-dir")
115
+ issues = 0
116
+
117
+ print(f"Linting Wiki at: {wiki_dir}")
118
+
119
+ if not wiki_dir.exists():
120
+ print(f"Error: Directory {wiki_dir} does not exist.")
121
+ sys.exit(1)
122
+
123
+ inventory_result = get_inventory_result(src_dir, deep=True)
124
+ if inventory_result.failed:
125
+ print_inventory_failures(inventory_result)
126
+ sys.exit(1)
127
+ deep_inventory = inventory_result.inventory
128
+ docker_inventory = get_docker_inventory(src_dir)
129
+
130
+ pages = [
131
+ page for page in wiki_dir.rglob("*.md")
132
+ if not _is_legacy_page(page, wiki_dir)
133
+ ]
134
+
135
+ # ── 1. Broken Links ──────────────────────────────────────────────
136
+ broken_links = 0
137
+ for page in pages:
138
+ content = read_md(page)
139
+ links = LINK_RE.findall(content)
140
+
141
+ for link in links:
142
+ local_path = _local_link_path(link)
143
+ if local_path is None:
144
+ continue
145
+ target = (page.parent / local_path).resolve()
146
+ if not target.exists():
147
+ print(f" ❌ Broken link in {page.relative_to(wiki_dir)} -> {link}")
148
+ broken_links += 1
149
+
150
+ issues += broken_links
151
+ if broken_links:
152
+ print(f" Found {broken_links} broken link(s).\n")
153
+ else:
154
+ print(" ✅ No broken links.\n")
155
+
156
+ # ── 2. Orphan Pages (not referenced in index.md) ─────────────────
157
+ orphan_count = 0
158
+ index_path = wiki_dir / "index.md"
159
+ referenced_files: list[Path] = []
160
+ if index_path.exists():
161
+ index_content = read_md(index_path)
162
+ index_links = LINK_RE.findall(index_content)
163
+
164
+ for link in index_links:
165
+ local_path = _local_link_path(link)
166
+ if local_path is not None:
167
+ target = (index_path.parent / local_path).resolve()
168
+ referenced_files.append(target)
169
+
170
+ for page in pages:
171
+ if page.name in ["index.md", "log.md"]:
172
+ continue
173
+ if page.resolve() not in referenced_files:
174
+ print(f" ⚠️ Orphan page (not in index.md): {page.relative_to(wiki_dir)}")
175
+ orphan_count += 1
176
+
177
+ issues += orphan_count
178
+ if orphan_count:
179
+ print(f" Found {orphan_count} orphan page(s).\n")
180
+ else:
181
+ print(" ✅ No orphan pages.\n")
182
+
183
+ # ── 3. AST ↔ Wiki Cross-Reference (entities) ─────────────────────
184
+ documented_entities = _collect_documented_entities(wiki_dir)
185
+ code_classes = _collect_code_classes(deep_inventory)
186
+
187
+ undocumented = code_classes - documented_entities
188
+ stale = documented_entities - code_classes
189
+
190
+ if undocumented:
191
+ for name in sorted(undocumented):
192
+ print(f" ⚠️ Undocumented class (in code, not in wiki): {name}")
193
+ issues += len(undocumented)
194
+ print(f" Found {len(undocumented)} undocumented class(es).\n")
195
+ else:
196
+ print(" ✅ All classes documented.\n")
197
+
198
+ if stale:
199
+ for name in sorted(stale):
200
+ print(f" ⚠️ Stale entity (in wiki, not in code): {name}")
201
+ issues += len(stale)
202
+ print(f" Found {len(stale)} stale entity page(s).\n")
203
+ else:
204
+ print(" ✅ No stale entity pages.\n")
205
+
206
+ # ── 4. AST ↔ Wiki Cross-Reference (modules) ──────────────────────
207
+ documented_modules = _collect_documented_modules(wiki_dir)
208
+ code_modules = _collect_code_modules(deep_inventory)
209
+
210
+ undoc_mods = code_modules - documented_modules
211
+ stale_mods = documented_modules - code_modules
212
+
213
+ if undoc_mods:
214
+ for name in sorted(undoc_mods):
215
+ print(f" ⚠️ Undocumented module (in code, not in wiki): {name}")
216
+ issues += len(undoc_mods)
217
+ print(f" Found {len(undoc_mods)} undocumented module(s).\n")
218
+ else:
219
+ print(" ✅ All modules documented.\n")
220
+
221
+ if stale_mods:
222
+ for name in sorted(stale_mods):
223
+ print(f" ⚠️ Stale module (in wiki, not in code): {name}")
224
+ issues += len(stale_mods)
225
+ print(f" Found {len(stale_mods)} stale module page(s).\n")
226
+ else:
227
+ print(" ✅ No stale module pages.\n")
228
+
229
+ # ── 5. Workflow checks ────────────────────────────────────────────
230
+ documented_workflows = _collect_documented_workflows(wiki_dir)
231
+
232
+ # 5a. Check workflow pages reference existing modules
233
+ workflows_dir = wiki_dir / "workflows"
234
+ stale_wf = 0
235
+ if workflows_dir.exists():
236
+ for wf_page in workflows_dir.glob("*.md"):
237
+ content = read_md(wf_page)
238
+ links = LINK_RE.findall(content)
239
+ for link in links:
240
+ local_path = _local_link_path(link)
241
+ if local_path is None:
242
+ continue
243
+ target = (wf_page.parent / local_path).resolve()
244
+ if not target.exists():
245
+ print(f" ⚠️ Broken link in workflow {wf_page.stem} -> {link}")
246
+ stale_wf += 1
247
+
248
+ issues += stale_wf
249
+ if stale_wf:
250
+ print(f" Found {stale_wf} broken workflow link(s).\n")
251
+ else:
252
+ print(" ✅ No broken workflow links.\n")
253
+
254
+ # 5b. Detect missing workflows (call chains with 3+ modules but no page)
255
+ detected_workflows = set(get_call_graph(deep_inventory).keys())
256
+
257
+ missing_wf = detected_workflows - documented_workflows
258
+ if missing_wf:
259
+ for name in sorted(missing_wf):
260
+ print(f" ⚠️ Missing workflow (detected in code, no wiki page): {name}")
261
+ issues += len(missing_wf)
262
+ print(f" Found {len(missing_wf)} missing workflow(s).\n")
263
+ else:
264
+ print(" ✅ All detected workflows documented.\n")
265
+
266
+ # ── 6. Infrastructure checks (Docker/Compose) ────────────────────
267
+ documented_infra = _collect_documented_infrastructure(wiki_dir)
268
+ code_docker = _collect_docker_files(docker_inventory)
269
+
270
+ undoc_infra = code_docker - documented_infra
271
+ stale_infra = documented_infra - code_docker
272
+
273
+ if undoc_infra:
274
+ for name in sorted(undoc_infra):
275
+ print(f" ⚠️ Undocumented Docker file (in source, not in wiki): {name}")
276
+ issues += len(undoc_infra)
277
+ print(f" Found {len(undoc_infra)} undocumented Docker file(s).\n")
278
+ else:
279
+ print(" ✅ All Docker/Compose files documented.\n")
280
+
281
+ if stale_infra:
282
+ for name in sorted(stale_infra):
283
+ print(f" ⚠️ Stale infrastructure page (in wiki, source file removed): {name}")
284
+ issues += len(stale_infra)
285
+ print(f" Found {len(stale_infra)} stale infrastructure page(s).\n")
286
+ else:
287
+ print(" ✅ No stale infrastructure pages.\n")
288
+
289
+ # ── Summary ───────────────────────────────────────────────────────
290
+ if issues == 0:
291
+ print("✅ Lint passed: wiki is fully consistent.")
292
+ else:
293
+ print(f"❌ Lint found {issues} issue(s).")
294
+ sys.exit(1)