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.
- doc_drift_py-0.1.1.dist-info/METADATA +122 -0
- doc_drift_py-0.1.1.dist-info/RECORD +11 -0
- doc_drift_py-0.1.1.dist-info/WHEEL +5 -0
- doc_drift_py-0.1.1.dist-info/entry_points.txt +2 -0
- doc_drift_py-0.1.1.dist-info/top_level.txt +1 -0
- docdrift/__init__.py +3 -0
- docdrift/cli.py +99 -0
- docdrift/code.py +112 -0
- docdrift/compare.py +53 -0
- docdrift/docs.py +128 -0
- docdrift/report.py +121 -0
|
@@ -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
|
+
[](https://github.com/mgkvibez/doc-drift/actions/workflows/ci.yml)
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
[](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 @@
|
|
|
1
|
+
docdrift
|
docdrift/__init__.py
ADDED
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"])
|