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,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)
|