doc-drift-py 0.1.1__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.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: doc-drift-py
3
+ Version: 0.1.1
4
+ Summary: Find the docs that lie: env vars and CLI flags your documentation mentions that the code no longer has, and code nobody documented.
5
+ Author: mgkvibez
6
+ License: MIT
7
+ Keywords: documentation,docs,ci,static-analysis,drift,developer-tools
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Classifier: Topic :: Documentation
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8; extra == "dev"
18
+ Requires-Dist: build>=1.0; extra == "dev"
19
+
20
+ # doc-drift
21
+
22
+ **Find the docs that lie.** `docdrift` reports the environment variables and CLI flags your documentation promises that the code no longer has, and the ones the code has that nobody documented.
23
+
24
+ [![ci](https://github.com/mgkvibez/doc-drift/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/doc-drift/actions/workflows/ci.yml)
25
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
26
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
27
+
28
+ ```console docdrift-ignore
29
+ $ docdrift check
30
+ Docs that are wrong now
31
+ stale-env LEGACY_TOKEN README.md:41
32
+ the docs promise it and the code never reads it: rename the doc, or read the variable
33
+
34
+ Code nobody is told about
35
+ undocumented-flag --secret-mode src/cli.py:22
36
+ the flag exists and no one can find it
37
+
38
+ 1 stale in docs, 1 missing from docs (3 docs, 11 source files)
39
+ ```
40
+
41
+ ## Why this exists
42
+
43
+ Docs drift in a specific, boring way: someone renames a variable or deletes a flag, the code stays green, and the README keeps instructing people to use something that no longer exists. Nothing catches it, because tests exercise code and nothing exercises prose.
44
+
45
+ <!-- docdrift: ignore-next-line -->
46
+ The usual version of this tool greps. Grep lies in both directions: it finds `DATABASE_URL` inside a comment or a deleted branch, and it misses a name that appears with different spacing. `docdrift` reads the source with `ast`, so a variable inside a comment is not a variable, and a renamed function is really renamed.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pipx install doc-drift-py
52
+ ```
53
+
54
+ The distribution is `doc-drift-py` because PyPI refused `doc-drift` as too similar to an
55
+ existing project. The command stays `docdrift`: nothing you type changes.
56
+
57
+ ## Use
58
+
59
+ ```bash
60
+ docdrift check # README.md + docs/ against src/ (or .)
61
+ docdrift check --docs docs --src app # explicit paths
62
+ docdrift check --fail-on stale # only fail on docs that are wrong, not gaps
63
+ docdrift check --symbols # also check backticked function/class names
64
+ docdrift check --json # machine-readable
65
+ docdrift check --ignore LEGACY_* # skip a name, exact or prefix
66
+ ```
67
+
68
+ Exit codes are the contract:
69
+
70
+ | code | meaning |
71
+ |---|---|
72
+ | 0 | nothing drifted |
73
+ | 1 | drift found (the useful failure) |
74
+ | 2 | usage or configuration problem: bad path, no docs, no source |
75
+
76
+ In CI:
77
+
78
+ ```yaml docdrift-ignore
79
+ - name: Docs tell the truth
80
+ run: pipx run --spec git+https://github.com/mgkvibez/doc-drift.git docdrift check --fail-on stale
81
+ ```
82
+
83
+ ## What counts as a claim
84
+
85
+ Precision matters more than coverage here, because a checker that cries wolf gets deleted. So a documentation mention only counts when it is written the way a user would read it:
86
+
87
+ <!-- docdrift: ignore-next-line -->
88
+ - an environment variable in backticks, after `export`, inside `os.environ.get(...)`, or anywhere inside a fenced code block
89
+ <!-- docdrift: ignore-next-line -->
90
+ - a `--flag` written as a flag, not a hyphenated word and not a version range like `1.0--2.0`
91
+
92
+ Plain uppercase prose is ignored on purpose: `This speaks HTTP and JSON over TCP` is not a claim about your environment.
93
+
94
+ <!-- docdrift: ignore-next-line -->
95
+ On the code side, only real reads count: `os.environ["X"]`, `os.environ.get("X")`, `os.getenv("X")`, `argparse.add_argument("--flag")`. Files that fail to parse are skipped silently and cannot create findings.
96
+
97
+ ## Suppressing a false positive
98
+
99
+ Some docs legitimately discuss names they do not promise: a migration guide, an example of another tool's command, a paragraph about how the check works. Two markers handle that, and both keep line numbers honest by blanking rather than deleting.
100
+
101
+ <!-- docdrift: ignore-next-line -->
102
+ - `<!-- docdrift: ignore-next-line -->` blanks the line that follows it
103
+ - a fence tagged `docdrift-ignore` (` ```console docdrift-ignore `) is treated as an illustration, never as a claim
104
+
105
+ argparse's own `-h`, `--help` and `--version` are excluded automatically, because no project documents those.
106
+
107
+ ## What it deliberately does not do
108
+
109
+ - **Not every framework.** Flags are read from `argparse`. Click, Typer and docopt are not parsed yet, and a project using them will report its flags as undocumented. That is a known gap, not a silent one.
110
+ - **Not every language.** Source scanning is Python only. Markdown, RST and plain text docs are read.
111
+ <!-- docdrift: ignore-next-line -->
112
+ - **A flag in an example may not be yours.** `gh run view --log-failed` in your README is a flag of `gh`, not of your project, and it will be reported. Use `--ignore` or tag that fence `docdrift-ignore`. Found by running this on its own sibling project.
113
+ - **Not a prose checker.** It says a name is missing, not that a sentence is wrong.
114
+ - **Symbols are opt-in.** Backticked `function_name` references are heuristic, so they are behind `--symbols` and off by default.
115
+
116
+ ## Dogfooding
117
+
118
+ This repo runs `docdrift` on itself in the gate, so the README above cannot quietly drift from the code that ships it.
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,11 @@
1
+ docdrift/__init__.py,sha256=C_Enausv6mzM_nxR6u16fMEF36rsEEyCx0ertaN2CVo,53
2
+ docdrift/cli.py,sha256=T-gLr2SMQO4ZJ42un_DTi1nDIEzXNPjCQSmGWGQBBWc,3798
3
+ docdrift/code.py,sha256=kOWtrdZb_i7Qqv2xYQHzol44VSnoRjhpnKbbMXobAlY,4366
4
+ docdrift/compare.py,sha256=NE-PTBK7R6OPkYJw1yojHptLPqgl3665tBIdcRGULhE,2042
5
+ docdrift/docs.py,sha256=qB64cb35O1xt0BLyJu6mVD-ECjQb1t4ZFpy-Pvat-dc,5190
6
+ docdrift/report.py,sha256=GG9dQGh1ZDbzWaA4x1GZ5brTW2b9HmZowVhrkgT5utA,4108
7
+ doc_drift_py-0.1.1.dist-info/METADATA,sha256=BzoOE0DrUimuxeGBCJHv2ScrWNcbLuw-iJAM7eg-5HA,5871
8
+ doc_drift_py-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ doc_drift_py-0.1.1.dist-info/entry_points.txt,sha256=vLNHjC9sdHzy9imyIEC60oY9MpI6qHjibTXtK33lPew,47
10
+ doc_drift_py-0.1.1.dist-info/top_level.txt,sha256=wcmBTBnmnLgshMXanJnF_YEtZgFCVsCFykfvg1tAt9A,9
11
+ doc_drift_py-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docdrift = docdrift.cli:main
@@ -0,0 +1 @@
1
+ docdrift
docdrift/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Find the docs that lie."""
2
+
3
+ __version__ = "0.1.1"
docdrift/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ """Command line. Exit codes are part of the contract:
2
+
3
+ 0 nothing drifted
4
+ 1 drift found (docdrift is a check, so this is the useful failure)
5
+ 2 usage or configuration problem: bad path, nothing to scan, bad flag
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import List, Optional, Sequence
14
+
15
+ from . import __version__, code, docs
16
+ from .compare import compare
17
+ from .report import ENV, FLAG, SYMBOL, Report, summarise, render_json, render_text
18
+
19
+ EXIT_CLEAN = 0
20
+ EXIT_DRIFT = 1
21
+ EXIT_USAGE = 2
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="docdrift",
27
+ description="Find env vars and CLI flags that documentation mentions but code does not have, and vice versa.",
28
+ )
29
+ parser.add_argument("--version", action="version", version="docdrift %s" % __version__)
30
+ sub = parser.add_subparsers(dest="cmd", required=True)
31
+
32
+ check = sub.add_parser("check", help="compare docs against source")
33
+ check.add_argument("--docs", action="extend", nargs="+", metavar="PATH", default=None,
34
+ help="doc file(s) or dir(s); default README.md and docs/")
35
+ check.add_argument("--src", action="extend", nargs="+", metavar="PATH", default=None,
36
+ help="source file(s) or dir(s); default src/ then .")
37
+ check.add_argument("--ignore", action="extend", nargs="+", metavar="NAME", default=[],
38
+ help="names to skip, exact or prefix with *")
39
+ check.add_argument("--symbols", action="store_true",
40
+ help="also check backticked function/class names (heuristic, off by default)")
41
+ check.add_argument("--json", action="store_true", help="machine-readable output")
42
+ check.add_argument("--fail-on", choices=["stale", "any", "none"], default="any",
43
+ help="which drift fails the build (default: any)")
44
+ return parser
45
+
46
+
47
+ def _default_docs() -> List[Path]:
48
+ candidates = [Path("README.md"), Path("README.rst"), Path("docs")]
49
+ return [p for p in candidates if p.exists()]
50
+
51
+
52
+ def _default_src() -> List[Path]:
53
+ for candidate in (Path("src"), Path(".")):
54
+ if candidate.exists():
55
+ return [candidate]
56
+ return []
57
+
58
+
59
+ def main(argv: Optional[Sequence[str]] = None) -> int:
60
+ args = build_parser().parse_args(list(sys.argv[1:] if argv is None else argv))
61
+
62
+ doc_paths = [Path(p) for p in (args.docs or [])] or _default_docs()
63
+ src_paths = [Path(p) for p in (args.src or [])] or _default_src()
64
+
65
+ missing = [str(p) for p in doc_paths + src_paths if not p.exists()]
66
+ if missing:
67
+ print("error: path not found: %s" % ", ".join(missing), file=sys.stderr)
68
+ return EXIT_USAGE
69
+ if not doc_paths:
70
+ print("error: no docs found; pass --docs or add a README.md", file=sys.stderr)
71
+ return EXIT_USAGE
72
+ if not src_paths:
73
+ print("error: no source found; pass --src", file=sys.stderr)
74
+ return EXIT_USAGE
75
+
76
+ code_facts = code.scan(src_paths)
77
+ doc_facts = docs.scan(doc_paths, symbols=args.symbols)
78
+
79
+ subjects = [ENV, FLAG] + ([SYMBOL] if args.symbols else [])
80
+ report = Report(
81
+ docs=[str(p) for p in docs.iter_doc_files(doc_paths)],
82
+ sources=[str(p) for p in code.iter_python_files(src_paths)],
83
+ findings=compare(code_facts, doc_facts, subjects=subjects, ignore=args.ignore),
84
+ )
85
+
86
+ print(render_json(report) if args.json else render_text(report))
87
+
88
+ if report.ok or args.fail_on == "none":
89
+ return EXIT_CLEAN
90
+ if args.fail_on == "stale" and not report.stale():
91
+ return EXIT_CLEAN
92
+ line = summarise(report)
93
+ if line and not args.json:
94
+ print(line, file=sys.stderr)
95
+ return EXIT_DRIFT
96
+
97
+
98
+ if __name__ == "__main__": # pragma: no cover
99
+ sys.exit(main())
docdrift/code.py ADDED
@@ -0,0 +1,112 @@
1
+ """What the code actually has, read with ast rather than regex.
2
+
3
+ Grep over source finds names in comments, in dead strings, and in the tests of a
4
+ library you forgot to delete. Parsing means a variable inside a comment is not a
5
+ variable, and a renamed function is really renamed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
14
+
15
+ SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "build", "dist", "__pycache__",
16
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", "site-packages"}
17
+
18
+
19
+ @dataclass
20
+ class CodeFacts:
21
+ """Where each thing lives, so a finding can point at a line."""
22
+
23
+ env: Dict[str, Tuple[str, int]] = field(default_factory=dict)
24
+ flags: Dict[str, Tuple[str, int]] = field(default_factory=dict)
25
+ symbols: Dict[str, Tuple[str, int]] = field(default_factory=dict)
26
+
27
+ def merge(self, other: "CodeFacts") -> None:
28
+ for attr in ("env", "flags", "symbols"):
29
+ mine = getattr(self, attr)
30
+ for key, value in getattr(other, attr).items():
31
+ mine.setdefault(key, value)
32
+
33
+
34
+ class _Visitor(ast.NodeVisitor):
35
+ def __init__(self, path: str) -> None:
36
+ self.path = path
37
+ self.facts = CodeFacts()
38
+
39
+ # os.environ["X"], os.environ.get("X"), os.getenv("X")
40
+ def _env_name(self, node: ast.AST) -> Optional[str]:
41
+ if isinstance(node, ast.Subscript):
42
+ target = node.value
43
+ if isinstance(target, ast.Attribute) and target.attr == "environ":
44
+ key = node.slice
45
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
46
+ return key.value
47
+ return None
48
+
49
+ def visit_Subscript(self, node: ast.Subscript) -> None:
50
+ name = self._env_name(node)
51
+ if name:
52
+ self.facts.env.setdefault(name, (self.path, getattr(node, "lineno", 0)))
53
+ self.generic_visit(node)
54
+
55
+ def visit_Call(self, node: ast.Call) -> None:
56
+ func = node.func
57
+ # os.environ.get("X") / os.getenv("X")
58
+ if isinstance(func, ast.Attribute) and func.attr in ("get", "getenv"):
59
+ owner = func.value
60
+ is_environ = (
61
+ (isinstance(owner, ast.Attribute) and owner.attr == "environ")
62
+ or (isinstance(owner, ast.Name) and owner.id == "os" and func.attr == "getenv")
63
+ )
64
+ if is_environ and node.args:
65
+ first = node.args[0]
66
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
67
+ self.facts.env.setdefault(first.value, (self.path, node.lineno))
68
+ # argparse: add_argument("--flag") / add_argument("-f", "--flag")
69
+ if isinstance(func, ast.Attribute) and func.attr == "add_argument":
70
+ for arg in node.args:
71
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
72
+ value = arg.value
73
+ if value.startswith("-") and len(value) > 1:
74
+ self.facts.flags.setdefault(value, (self.path, node.lineno))
75
+ self.generic_visit(node)
76
+
77
+ def _define(self, node) -> None:
78
+ name = getattr(node, "name", None)
79
+ if name and not name.startswith("_"):
80
+ self.facts.symbols.setdefault(name, (self.path, node.lineno))
81
+ self.generic_visit(node)
82
+
83
+ visit_FunctionDef = _define
84
+ visit_AsyncFunctionDef = _define
85
+ visit_ClassDef = _define
86
+
87
+
88
+ def scan_file(path: Path) -> CodeFacts:
89
+ try:
90
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path))
91
+ except SyntaxError:
92
+ return CodeFacts() # a file we cannot parse contributes nothing, and says nothing
93
+ visitor = _Visitor(str(path))
94
+ visitor.visit(tree)
95
+ return visitor.facts
96
+
97
+
98
+ def iter_python_files(paths: Iterable[Path]) -> Iterable[Path]:
99
+ for path in paths:
100
+ if path.is_file() and path.suffix == ".py":
101
+ yield path
102
+ elif path.is_dir():
103
+ for child in sorted(path.rglob("*.py")):
104
+ if not SKIP_DIRS.intersection(child.parts):
105
+ yield child
106
+
107
+
108
+ def scan(paths: Sequence[Path]) -> CodeFacts:
109
+ facts = CodeFacts()
110
+ for file_path in iter_python_files(paths):
111
+ facts.merge(scan_file(file_path))
112
+ return facts
docdrift/compare.py ADDED
@@ -0,0 +1,53 @@
1
+ """Match what the docs claim against what the code does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fnmatch import fnmatchcase
6
+ from typing import Iterable, List, Sequence, Set
7
+
8
+ from .code import CodeFacts
9
+ from .docs import DocFacts
10
+ from .report import ENV, FLAG, STALE, SYMBOL, UNDOCUMENTED, Finding
11
+
12
+ # argparse generates these; nobody documents them per project
13
+ BUILTIN_FLAGS = ("-h", "--help", "--version")
14
+
15
+
16
+ def _ignored(name: str, patterns: Sequence[str]) -> bool:
17
+ for pattern in patterns:
18
+ if pattern.endswith("*"):
19
+ if name.startswith(pattern[:-1]):
20
+ return True
21
+ elif fnmatchcase(name, pattern) or name == pattern:
22
+ return True
23
+ return False
24
+
25
+
26
+ def _pair(doc_map, code_map, subject: str, ignore: Sequence[str]) -> List[Finding]:
27
+ findings: List[Finding] = []
28
+ for name, (path, line) in sorted(doc_map.items()):
29
+ if _ignored(name, ignore):
30
+ continue
31
+ if name not in code_map:
32
+ findings.append(Finding(kind=STALE, subject=subject, name=name, doc_path=path, doc_line=line))
33
+ for name, (path, line) in sorted(code_map.items()):
34
+ if _ignored(name, ignore):
35
+ continue
36
+ if name not in doc_map:
37
+ findings.append(Finding(kind=UNDOCUMENTED, subject=subject, name=name, code_path=path, code_line=line))
38
+ return findings
39
+
40
+
41
+ def compare(code: CodeFacts, docs: DocFacts, *, subjects: Iterable[str] = (ENV, FLAG),
42
+ ignore: Sequence[str] = ()) -> List[Finding]:
43
+ findings: List[Finding] = []
44
+ for subject in subjects:
45
+ if subject == ENV:
46
+ findings.extend(_pair(docs.env, code.env, ENV, ignore))
47
+ elif subject == FLAG:
48
+ findings.extend(_pair(docs.flags, code.flags, FLAG, tuple(ignore) + BUILTIN_FLAGS))
49
+ elif subject == SYMBOL:
50
+ findings.extend(_pair(docs.symbols, code.symbols, SYMBOL, ignore))
51
+ # stale first: those break the reader right now
52
+ findings.sort(key=lambda f: (f.kind != STALE, f.subject, f.name))
53
+ return findings
docdrift/docs.py ADDED
@@ -0,0 +1,128 @@
1
+ """What the documentation claims, read conservatively on purpose.
2
+
3
+ A doc checker loses trust the first time it reports a false positive, so the rules
4
+ here are narrow: an environment variable counts when it is backticked or written in
5
+ a shell context, and a flag counts when it is written the way a user would type it.
6
+ A name that merely looks uppercase is prose, not a claim about the code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Dict, Iterable, List, Sequence, Set, Tuple
15
+
16
+ DOC_SUFFIXES = {".md", ".markdown", ".rst", ".txt"}
17
+
18
+ # --flag, not a hyphenated word and not a range like 1--2
19
+ FLAG_RE = re.compile(r"(?<![\w-])--([a-z0-9][a-z0-9-]*)")
20
+ # backticked token
21
+ BACKTICK_RE = re.compile(r"`([^`\n]{1,120})`")
22
+ # ENV_STYLE_NAME with at least one underscore, or a well-known bare name
23
+ ENV_RE = re.compile(r"(?<!\w)([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)(?!\w)")
24
+ SHELL_CONTEXT_RE = re.compile(r"(?:export\s+$|\bos\.environ\.get\(\s*['\"]|\bgetenv\(\s*['\"])")
25
+ HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
26
+ CODE_SPAN_RE = re.compile(r"`[^`\n]*`")
27
+ FENCE_RE = re.compile(r"^(```|~~~).*?^\1", re.S | re.M)
28
+ # ```console docdrift-ignore ... ``` content is an illustration, never a claim
29
+ IGNORE_FENCE_RE = re.compile(r"^(```|~~~)[^\n]*docdrift-ignore[^\n]*\n.*?^\1", re.S | re.M)
30
+ # <!-- docdrift: ignore-next-line --> blanks the line after it
31
+ IGNORE_NEXT_RE = re.compile(r"<!--\s*docdrift:\s*ignore-next-line\s*-->[^\n]*\n[^\n]*")
32
+
33
+ # words that are capitalised and underscored but are not variables
34
+ ENV_STOPWORDS = {
35
+ "README", "CHANGELOG", "TODO", "FIXME", "NOTE", "WARNING", "USAGE", "MIT",
36
+ "HTTP_200", "HTTP_404", "HTTP_500", "SOC", "SOC_2", "AI_ML", "CI_CD",
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class DocFacts:
42
+ """name -> (path, line)"""
43
+
44
+ env: Dict[str, Tuple[str, int]] = field(default_factory=dict)
45
+ flags: Dict[str, Tuple[str, int]] = field(default_factory=dict)
46
+ symbols: Dict[str, Tuple[str, int]] = field(default_factory=dict)
47
+
48
+ def merge(self, other: "DocFacts") -> None:
49
+ for attr in ("env", "flags", "symbols"):
50
+ mine = getattr(self, attr)
51
+ for key, value in getattr(other, attr).items():
52
+ mine.setdefault(key, value)
53
+
54
+
55
+ def _strip_noise(text: str) -> str:
56
+ """Blank out anything that is not a claim, keeping line numbers intact.
57
+
58
+ Blanking rather than deleting is deliberate: every reported line number has to
59
+ match the file a human will open.
60
+ """
61
+ def blank(match: "re.Match[str]") -> str:
62
+ return re.sub(r"[^\n]", " ", match.group(0))
63
+
64
+ text = IGNORE_NEXT_RE.sub(blank, text)
65
+ text = IGNORE_FENCE_RE.sub(blank, text)
66
+ return HTML_COMMENT_RE.sub(blank, text)
67
+
68
+
69
+ def _line_of(text: str, offset: int) -> int:
70
+ return text.count("\n", 0, offset) + 1
71
+
72
+
73
+ def scan_doc(path: Path, *, symbols: bool = False) -> DocFacts:
74
+ raw = path.read_text(encoding="utf-8", errors="replace")
75
+ text = _strip_noise(raw)
76
+ facts = DocFacts()
77
+ name = str(path)
78
+
79
+ for match in FLAG_RE.finditer(text):
80
+ facts.flags.setdefault("--" + match.group(1), (name, _line_of(text, match.start())))
81
+
82
+ for match in ENV_RE.finditer(text):
83
+ candidate = match.group(1)
84
+ if candidate in ENV_STOPWORDS:
85
+ continue
86
+ line_start = text.rfind("\n", 0, match.start()) + 1
87
+ prefix = text[line_start:match.start()]
88
+ backticked = prefix.count("`") % 2 == 1 or text[match.end():match.end() + 1] == "`"
89
+ shellish = bool(SHELL_CONTEXT_RE.search(prefix))
90
+ # inside a code fence counts as a claim, since that is where commands live
91
+ fenced = any(f.start() < match.start() < f.end() for f in FENCE_RE.finditer(text))
92
+ if backticked or shellish or fenced:
93
+ facts.env.setdefault(candidate, (name, _line_of(text, match.start())))
94
+
95
+ for match in BACKTICK_RE.finditer(text):
96
+ token = match.group(1).strip()
97
+ if token.isupper() and len(token) >= 2 and token not in ENV_STOPWORDS:
98
+ facts.env.setdefault(token, (name, _line_of(text, match.start())))
99
+ elif symbols and _looks_like_symbol(token):
100
+ facts.symbols.setdefault(token.split("(")[0].strip(), (name, _line_of(text, match.start())))
101
+
102
+ return facts
103
+
104
+
105
+ def _looks_like_symbol(token: str) -> bool:
106
+ token = token.split("(")[0].strip()
107
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*", token):
108
+ return False
109
+ if token.isupper():
110
+ return False # that is an env var or a constant, handled above
111
+ return "_" in token or token[:1].isupper()
112
+
113
+
114
+ def iter_doc_files(paths: Iterable[Path]) -> Iterable[Path]:
115
+ for path in paths:
116
+ if path.is_file() and path.suffix.lower() in DOC_SUFFIXES:
117
+ yield path
118
+ elif path.is_dir():
119
+ for child in sorted(path.rglob("*")):
120
+ if child.is_file() and child.suffix.lower() in DOC_SUFFIXES:
121
+ yield child
122
+
123
+
124
+ def scan(paths: Sequence[Path], *, symbols: bool = False) -> DocFacts:
125
+ facts = DocFacts()
126
+ for file_path in iter_doc_files(paths):
127
+ facts.merge(scan_doc(file_path, symbols=symbols))
128
+ return facts
docdrift/report.py ADDED
@@ -0,0 +1,121 @@
1
+ """Findings and their rendering.
2
+
3
+ Two kinds of drift, in both directions: docs that mention something the code no
4
+ longer has, and code that nothing in the docs mentions. The first breaks whoever
5
+ follows the instructions, the second hides a feature from them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, List, Optional
13
+
14
+ STALE = "stale" # docs mention it, code does not have it
15
+ UNDOCUMENTED = "undocumented" # code has it, docs do not mention it
16
+
17
+ ENV = "env"
18
+ FLAG = "flag"
19
+ SYMBOL = "symbol"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Finding:
24
+ kind: str # STALE | UNDOCUMENTED
25
+ subject: str # ENV | FLAG | SYMBOL
26
+ name: str
27
+ doc_path: str = ""
28
+ doc_line: int = 0
29
+ code_path: str = ""
30
+ code_line: int = 0
31
+
32
+ @property
33
+ def label(self) -> str:
34
+ return "%s-%s" % (self.kind, self.subject)
35
+
36
+ def as_dict(self) -> Dict[str, object]:
37
+ data = {"kind": self.kind, "subject": self.subject, "name": self.name}
38
+ if self.doc_path:
39
+ data["doc"] = {"path": self.doc_path, "line": self.doc_line}
40
+ if self.code_path:
41
+ data["code"] = {"path": self.code_path, "line": self.code_line}
42
+ return data
43
+
44
+
45
+ @dataclass
46
+ class Report:
47
+ docs: List[str] = field(default_factory=list)
48
+ sources: List[str] = field(default_factory=list)
49
+ findings: List[Finding] = field(default_factory=list)
50
+
51
+ def stale(self) -> List[Finding]:
52
+ return [f for f in self.findings if f.kind == STALE]
53
+
54
+ def undocumented(self) -> List[Finding]:
55
+ return [f for f in self.findings if f.kind == UNDOCUMENTED]
56
+
57
+ @property
58
+ def ok(self) -> bool:
59
+ return not self.findings
60
+
61
+ def counts(self) -> Dict[str, int]:
62
+ return {"stale": len(self.stale()), "undocumented": len(self.undocumented())}
63
+
64
+
65
+ _ADVICE = {
66
+ (STALE, ENV): "the docs promise it and the code never reads it: rename the doc, or read the variable",
67
+ (STALE, FLAG): "the docs document a flag the parser does not define, so following them fails",
68
+ (STALE, SYMBOL): "the docs name something no longer defined, so the example will not run",
69
+ (UNDOCUMENTED, ENV): "the code reads a variable nobody is told to set",
70
+ (UNDOCUMENTED, FLAG): "the flag exists and no one can find it",
71
+ (UNDOCUMENTED, SYMBOL): "public surface with no mention anywhere",
72
+ }
73
+
74
+
75
+ def render_text(report: Report) -> str:
76
+ if report.ok:
77
+ return "no issues found by docdrift v%s across %d doc(s) and %d source file(s)" % (
78
+ _version(), len(report.docs), len(report.sources))
79
+
80
+ lines = []
81
+ for group, title in ((STALE, "Docs that are wrong now"), (UNDOCUMENTED, "Code nobody is told about")):
82
+ items = [f for f in report.findings if f.kind == group]
83
+ if not items:
84
+ continue
85
+ lines.append(title)
86
+ for f in items:
87
+ where = "%s:%d" % (f.doc_path, f.doc_line) if f.doc_path else "%s:%d" % (f.code_path, f.code_line)
88
+ lines.append(" %-14s %-28s %s" % (f.label, f.name, where))
89
+ advice = _ADVICE.get((f.kind, f.subject))
90
+ if advice:
91
+ lines.append(" %-14s %s" % ("", advice))
92
+ lines.append("")
93
+
94
+ c = report.counts()
95
+ lines.append("%d stale in docs, %d missing from docs (%d docs, %d source files)" % (
96
+ c["stale"], c["undocumented"], len(report.docs), len(report.sources)))
97
+ return "\n".join(lines).rstrip()
98
+
99
+
100
+ def render_json(report: Report) -> str:
101
+ return json.dumps({
102
+ "tool": "docdrift",
103
+ "version": _version(),
104
+ "docs": report.docs,
105
+ "sources": report.sources,
106
+ "counts": report.counts(),
107
+ "findings": [f.as_dict() for f in report.findings],
108
+ }, indent=2)
109
+
110
+
111
+ def _version() -> str:
112
+ from . import __version__
113
+ return __version__
114
+
115
+
116
+ def summarise(report: Report) -> Optional[str]:
117
+ """One line for CI logs, or None when clean."""
118
+ if report.ok:
119
+ return None
120
+ c = report.counts()
121
+ return "docdrift: %d stale reference(s), %d undocumented item(s)" % (c["stale"], c["undocumented"])