docs: update documentation files and fix linting issues
This commit is contained in:
+152
-68
@@ -12,25 +12,23 @@ Exit codes:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
severity: str # "ERROR" | "WARN" | "INFO"
|
||||
source: str # which doc file
|
||||
section: str # which section
|
||||
source: str # which doc file
|
||||
section: str # which section
|
||||
message: str
|
||||
|
||||
|
||||
@@ -82,9 +80,20 @@ def all_source_files() -> list[Path]:
|
||||
"""Walk the source tree and return all .py files."""
|
||||
files = []
|
||||
for root, dirs, filenames in os.walk(ROOT):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")
|
||||
and d not in ("__pycache__", ".pytest_cache", ".mypy_cache",
|
||||
".ruff_cache", ".hypothesis", ".ipynb_checkpoints")]
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not d.startswith(".")
|
||||
and d
|
||||
not in (
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".hypothesis",
|
||||
".ipynb_checkpoints",
|
||||
)
|
||||
]
|
||||
if "egg-info" in root:
|
||||
continue
|
||||
for fn in filenames:
|
||||
@@ -97,6 +106,7 @@ def all_source_files() -> list[Path]:
|
||||
# Checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_version_consistency(report: Report):
|
||||
"""Check that __version__ matches pyproject.toml version."""
|
||||
pyproject = ROOT / "pyproject.toml"
|
||||
@@ -116,8 +126,12 @@ def check_version_consistency(report: Report):
|
||||
ver = ver_match.group(1)
|
||||
if ver != py_ver:
|
||||
rel = pyfile.relative_to(ROOT)
|
||||
report.add("ERROR", rel.name, "version",
|
||||
f"__version__ = '{ver}' but pyproject.toml says '{py_ver}'")
|
||||
report.add(
|
||||
"ERROR",
|
||||
rel.name,
|
||||
"version",
|
||||
f"__version__ = '{ver}' but pyproject.toml says '{py_ver}'",
|
||||
)
|
||||
|
||||
|
||||
def check_version_in_code(report: Report):
|
||||
@@ -134,16 +148,19 @@ def check_version_in_code(report: Report):
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bs_pnpi", "--version"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
["bs_pnpi", "--version"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
cli_ver_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout)
|
||||
cli_ver_match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
|
||||
if cli_ver_match:
|
||||
cli_ver = cli_ver_match.group(1)
|
||||
if cli_ver != py_ver:
|
||||
report.add("ERROR", "cli", "version",
|
||||
f"CLI reports version '{cli_ver}' but pyproject.toml says '{py_ver}'")
|
||||
report.add(
|
||||
"ERROR",
|
||||
"cli",
|
||||
"version",
|
||||
f"CLI reports version '{cli_ver}' but pyproject.toml says '{py_ver}'",
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
@@ -168,8 +185,10 @@ def check_file_references(report: Report):
|
||||
# Skip tree diagrams and multi-line code blocks.
|
||||
for line in content.split("\n"):
|
||||
# Find single-line inline code that looks like a file path
|
||||
refs = re.findall(r'`([^`\n]+\.py[^`]*)`', line)
|
||||
refs.extend(re.findall(r'`([^`\n]+\\.(csv|json|toml|md|yaml|png|log))`', line))
|
||||
refs = re.findall(r"`([^`\n]+\.py[^`]*)`", line)
|
||||
refs.extend(
|
||||
re.findall(r"`([^`\n]+\\.(csv|json|toml|md|yaml|png|log))`", line)
|
||||
)
|
||||
for ref in refs:
|
||||
if isinstance(ref, tuple):
|
||||
ref = ref[0]
|
||||
@@ -183,20 +202,40 @@ def check_file_references(report: Report):
|
||||
if target.exists():
|
||||
continue
|
||||
# Filter out tree-diagram artifacts
|
||||
if any(c in ref for c in ('\u2190', '\u2192', '\u2713', '\u2717',
|
||||
'[', ']', '|', '\u2502', '\u2514', '\u251c',
|
||||
'\u2500', '\u2502', '\u2560', '\u256c')):
|
||||
if any(
|
||||
c in ref
|
||||
for c in (
|
||||
"\u2190",
|
||||
"\u2192",
|
||||
"\u2713",
|
||||
"\u2717",
|
||||
"[",
|
||||
"]",
|
||||
"|",
|
||||
"\u2502",
|
||||
"\u2514",
|
||||
"\u251c",
|
||||
"\u2500",
|
||||
"\u2502",
|
||||
"\u2560",
|
||||
"\u256c",
|
||||
)
|
||||
):
|
||||
continue
|
||||
# Only warn if it looks like a real file path
|
||||
if re.search(r'[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+', ref):
|
||||
if re.search(r"[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+", ref):
|
||||
basename = ref.split("/")[-1]
|
||||
if basename in known_removed or basename in known_placeholders:
|
||||
continue
|
||||
# Skip bare __init__.py (common in tree diagrams, not actionable)
|
||||
if basename == "__init__.py":
|
||||
continue
|
||||
report.add("WARN", doc_name, "file-reference",
|
||||
f"Referenced file not found: `{ref}`")
|
||||
report.add(
|
||||
"WARN",
|
||||
doc_name,
|
||||
"file-reference",
|
||||
f"Referenced file not found: `{ref}`",
|
||||
)
|
||||
|
||||
|
||||
def check_cli_commands(report: Report):
|
||||
@@ -207,12 +246,15 @@ def check_cli_commands(report: Report):
|
||||
if "bs_pnpi" in agents_md:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bs_pnpi", "--help"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
["bs_pnpi", "--help"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
report.add("WARN", "AGENTS.md", "cli",
|
||||
f"`bs_pnpi --help` failed: {result.stderr.strip()[:100]}")
|
||||
report.add(
|
||||
"WARN",
|
||||
"AGENTS.md",
|
||||
"cli",
|
||||
f"`bs_pnpi --help` failed: {result.stderr.strip()[:100]}",
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
report.add("WARN", "AGENTS.md", "cli", "bs_pnpi not found in PATH")
|
||||
|
||||
@@ -220,12 +262,15 @@ def check_cli_commands(report: Report):
|
||||
if "bs_exp" in agents_md:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bs_exp", "--help"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
["bs_exp", "--help"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
report.add("WARN", "AGENTS.md", "cli",
|
||||
f"`bs_exp --help` failed: {result.stderr.strip()[:100]}")
|
||||
report.add(
|
||||
"WARN",
|
||||
"AGENTS.md",
|
||||
"cli",
|
||||
f"`bs_exp --help` failed: {result.stderr.strip()[:100]}",
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
report.add("WARN", "AGENTS.md", "cli", "bs_exp not found in PATH")
|
||||
|
||||
@@ -234,15 +279,15 @@ def check_imports_in_examples(report: Report):
|
||||
"""Check that import statements in README examples are valid."""
|
||||
readme = read_doc("README.md")
|
||||
|
||||
code_blocks = re.findall(r'```[^\n]*\n(.*?)```', readme, re.DOTALL)
|
||||
code_blocks = re.findall(r"```[^\n]*\n(.*?)```", readme, re.DOTALL)
|
||||
for block in code_blocks:
|
||||
for line in block.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line.startswith("from ") and not line.startswith("import "):
|
||||
continue
|
||||
match = re.match(r'from\s+(\S+)\s+import', line)
|
||||
match = re.match(r"from\s+(\S+)\s+import", line)
|
||||
if not match:
|
||||
match = re.match(r'import\s+(\S+)', line)
|
||||
match = re.match(r"import\s+(\S+)", line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
@@ -250,8 +295,12 @@ def check_imports_in_examples(report: Report):
|
||||
try:
|
||||
__import__(module)
|
||||
except ImportError:
|
||||
report.add("WARN", "README.md", "imports",
|
||||
f"Cannot import module in example: `{module}`")
|
||||
report.add(
|
||||
"WARN",
|
||||
"README.md",
|
||||
"imports",
|
||||
f"Cannot import module in example: `{module}`",
|
||||
)
|
||||
|
||||
|
||||
def check_directory_structure(report: Report):
|
||||
@@ -268,12 +317,14 @@ def check_directory_structure(report: Report):
|
||||
def _check_tree_in_doc(content: str, doc_name: str, report: Report):
|
||||
"""Extract file references from a markdown tree diagram and check existence."""
|
||||
# Find fenced code blocks that look like directory trees
|
||||
trees = re.findall(r'```\w*\n(.*?)```', content, re.DOTALL)
|
||||
trees = re.findall(r"```\w*\n(.*?)```", content, re.DOTALL)
|
||||
for tree in trees:
|
||||
lines = tree.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
# Match lines like: `├── filename.py` or `│ ├── filename.py`
|
||||
match = re.search(r'[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+\.\w+)', line)
|
||||
match = re.search(
|
||||
r"[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+\.\w+)", line
|
||||
)
|
||||
if match:
|
||||
filename = match.group(1)
|
||||
# Determine depth by counting leading tree chars
|
||||
@@ -288,20 +339,27 @@ def _check_tree_in_doc(content: str, doc_name: str, report: Report):
|
||||
seen_dir = False
|
||||
for j in range(i - 1, max(i - 10, -1), -1):
|
||||
bl = lines[j]
|
||||
dir_match = re.search(r'[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+)/', bl)
|
||||
dir_match = re.search(
|
||||
r"[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+)/",
|
||||
bl,
|
||||
)
|
||||
if dir_match:
|
||||
dir_depth = len(bl) - len(bl.lstrip())
|
||||
if dir_depth < depth:
|
||||
dirs.append(dir_match.group(1))
|
||||
seen_dir = True
|
||||
elif re.search(r'\w', bl): # non-empty, non-matching line
|
||||
elif re.search(r"\w", bl): # non-empty, non-matching line
|
||||
if seen_dir:
|
||||
break # stop looking after first non-matching content line
|
||||
if dirs and len(dirs) <= 3: # cap at 3 levels
|
||||
target = ROOT / "/".join(reversed(dirs)) / filename
|
||||
if not target.exists():
|
||||
report.add("WARN", doc_name, "structure",
|
||||
f"File in structure diagram not found: `{'/'.join(reversed(dirs))}/{filename}`")
|
||||
report.add(
|
||||
"WARN",
|
||||
doc_name,
|
||||
"structure",
|
||||
f"File in structure diagram not found: `{'/'.join(reversed(dirs))}/{filename}`",
|
||||
)
|
||||
|
||||
|
||||
def check_cli_args_consistency(report: Report):
|
||||
@@ -311,30 +369,44 @@ def check_cli_args_consistency(report: Report):
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bs_pnpi", "--help"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
["bs_pnpi", "--help"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
actual_flags = set()
|
||||
for line in result.stdout.split("\n"):
|
||||
flag_match = re.match(r'\s{0,2}(-\S+)', line)
|
||||
flag_match = re.match(r"\s{0,2}(-\S+)", line)
|
||||
if flag_match:
|
||||
actual_flags.add(flag_match.group(1))
|
||||
|
||||
known_flags = {"-v", "-vv", "-q", "--nuclide", "--output",
|
||||
"--input", "--dry-run", "--version", "--plot",
|
||||
"--log-file", "--intensity-cutoff", "--help",
|
||||
"-h"}
|
||||
known_flags = {
|
||||
"-v",
|
||||
"-vv",
|
||||
"-q",
|
||||
"--nuclide",
|
||||
"--output",
|
||||
"--input",
|
||||
"--dry-run",
|
||||
"--version",
|
||||
"--plot",
|
||||
"--log-file",
|
||||
"--intensity-cutoff",
|
||||
"--help",
|
||||
"-h",
|
||||
}
|
||||
mentioned = set()
|
||||
# Only match actual CLI flag patterns (--flag-name, not random words)
|
||||
for flag in re.findall(r'--[a-z][a-z0-9-]{2,}', agents_md):
|
||||
for flag in re.findall(r"--[a-z][a-z0-9-]{2,}", agents_md):
|
||||
mentioned.add(flag)
|
||||
for flag in re.findall(r'-[a-z][a-z0-9-]{2,}', readme):
|
||||
for flag in re.findall(r"-[a-z][a-z0-9-]{2,}", readme):
|
||||
mentioned.add(flag)
|
||||
unknown = mentioned - known_flags
|
||||
for flag in sorted(unknown):
|
||||
report.add("INFO", "AGENTS.md", "cli-args",
|
||||
f"Flag `{flag}` mentioned but not in --help (may be custom)")
|
||||
report.add(
|
||||
"INFO",
|
||||
"AGENTS.md",
|
||||
"cli-args",
|
||||
f"Flag `{flag}` mentioned but not in --help (may be custom)",
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
@@ -350,8 +422,9 @@ def check_markdown_formatting(report: Report):
|
||||
content = path.read_text()
|
||||
for i, line in enumerate(content.split("\n"), 1):
|
||||
if line != line.rstrip():
|
||||
report.add("INFO", doc_name, f"line {i}",
|
||||
"Trailing whitespace detected")
|
||||
report.add(
|
||||
"INFO", doc_name, f"line {i}", "Trailing whitespace detected"
|
||||
)
|
||||
break # one per file is enough
|
||||
|
||||
|
||||
@@ -361,18 +434,24 @@ def check_todo_completions(report: Report):
|
||||
|
||||
# Find completed items that claim specific files/methods exist
|
||||
# Pattern: "- [x] ... implemented: `SomeClass` or `method_name`"
|
||||
completed = re.findall(r'- \[x\].*?(?=- \[|$)', todo, re.DOTALL)
|
||||
completed = re.findall(r"- \[x\].*?(?=- \[|$)", todo, re.DOTALL)
|
||||
for block in completed:
|
||||
# Skip historical references (items about cleanup, moves, removals)
|
||||
if re.search(r'(?:remove|moved|replaced|deleted|clean up|cleanup)', block, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:remove|moved|replaced|deleted|clean up|cleanup)", block, re.IGNORECASE
|
||||
):
|
||||
continue
|
||||
# Look for specific file claims
|
||||
file_claims = re.findall(r'`([^`]+\.py)`', block)
|
||||
file_claims = re.findall(r"`([^`]+\.py)`", block)
|
||||
for fc in file_claims:
|
||||
target = ROOT / fc
|
||||
if not target.exists():
|
||||
report.add("WARN", "TODO.md", "completions",
|
||||
f"Completed item references non-existent file: `{fc}`")
|
||||
report.add(
|
||||
"WARN",
|
||||
"TODO.md",
|
||||
"completions",
|
||||
f"Completed item references non-existent file: `{fc}`",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -395,12 +474,10 @@ CHECKS = [
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check documentation consistency")
|
||||
parser.add_argument(
|
||||
"--strict", action="store_true",
|
||||
help="Treat warnings as errors (exit 1)"
|
||||
"--strict", action="store_true", help="Treat warnings as errors (exit 1)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", action="store_true", dest="as_json",
|
||||
help="Output findings as JSON"
|
||||
"--json", action="store_true", dest="as_json", help="Output findings as JSON"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -414,15 +491,22 @@ def main():
|
||||
# Output
|
||||
if args.as_json:
|
||||
import json as _json
|
||||
|
||||
output = [
|
||||
{"severity": f.severity, "source": f.source,
|
||||
"section": f.section, "message": f.message}
|
||||
{
|
||||
"severity": f.severity,
|
||||
"source": f.source,
|
||||
"section": f.section,
|
||||
"message": f.message,
|
||||
}
|
||||
for f in report.findings
|
||||
]
|
||||
print(_json.dumps(output, indent=2))
|
||||
else:
|
||||
print(f"docs_check: {len(report.findings)} findings "
|
||||
f"({len(report.errors)} errors, {len(report.warnings)} warnings)")
|
||||
print(
|
||||
f"docs_check: {len(report.findings)} findings "
|
||||
f"({len(report.errors)} errors, {len(report.warnings)} warnings)"
|
||||
)
|
||||
print(report.summary())
|
||||
|
||||
# Exit
|
||||
|
||||
Reference in New Issue
Block a user