doc-drift-py 0.1.1__tar.gz

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,103 @@
1
+ # doc-drift
2
+
3
+ **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.
4
+
5
+ [![ci](https://github.com/mgkvibez/doc-drift/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/doc-drift/actions/workflows/ci.yml)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
8
+
9
+ ```console docdrift-ignore
10
+ $ docdrift check
11
+ Docs that are wrong now
12
+ stale-env LEGACY_TOKEN README.md:41
13
+ the docs promise it and the code never reads it: rename the doc, or read the variable
14
+
15
+ Code nobody is told about
16
+ undocumented-flag --secret-mode src/cli.py:22
17
+ the flag exists and no one can find it
18
+
19
+ 1 stale in docs, 1 missing from docs (3 docs, 11 source files)
20
+ ```
21
+
22
+ ## Why this exists
23
+
24
+ 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.
25
+
26
+ <!-- docdrift: ignore-next-line -->
27
+ 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.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install doc-drift-py
33
+ ```
34
+
35
+ The distribution is `doc-drift-py` because PyPI refused `doc-drift` as too similar to an
36
+ existing project. The command stays `docdrift`: nothing you type changes.
37
+
38
+ ## Use
39
+
40
+ ```bash
41
+ docdrift check # README.md + docs/ against src/ (or .)
42
+ docdrift check --docs docs --src app # explicit paths
43
+ docdrift check --fail-on stale # only fail on docs that are wrong, not gaps
44
+ docdrift check --symbols # also check backticked function/class names
45
+ docdrift check --json # machine-readable
46
+ docdrift check --ignore LEGACY_* # skip a name, exact or prefix
47
+ ```
48
+
49
+ Exit codes are the contract:
50
+
51
+ | code | meaning |
52
+ |---|---|
53
+ | 0 | nothing drifted |
54
+ | 1 | drift found (the useful failure) |
55
+ | 2 | usage or configuration problem: bad path, no docs, no source |
56
+
57
+ In CI:
58
+
59
+ ```yaml docdrift-ignore
60
+ - name: Docs tell the truth
61
+ run: pipx run --spec git+https://github.com/mgkvibez/doc-drift.git docdrift check --fail-on stale
62
+ ```
63
+
64
+ ## What counts as a claim
65
+
66
+ 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:
67
+
68
+ <!-- docdrift: ignore-next-line -->
69
+ - an environment variable in backticks, after `export`, inside `os.environ.get(...)`, or anywhere inside a fenced code block
70
+ <!-- docdrift: ignore-next-line -->
71
+ - a `--flag` written as a flag, not a hyphenated word and not a version range like `1.0--2.0`
72
+
73
+ Plain uppercase prose is ignored on purpose: `This speaks HTTP and JSON over TCP` is not a claim about your environment.
74
+
75
+ <!-- docdrift: ignore-next-line -->
76
+ 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.
77
+
78
+ ## Suppressing a false positive
79
+
80
+ 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.
81
+
82
+ <!-- docdrift: ignore-next-line -->
83
+ - `<!-- docdrift: ignore-next-line -->` blanks the line that follows it
84
+ - a fence tagged `docdrift-ignore` (` ```console docdrift-ignore `) is treated as an illustration, never as a claim
85
+
86
+ argparse's own `-h`, `--help` and `--version` are excluded automatically, because no project documents those.
87
+
88
+ ## What it deliberately does not do
89
+
90
+ - **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.
91
+ - **Not every language.** Source scanning is Python only. Markdown, RST and plain text docs are read.
92
+ <!-- docdrift: ignore-next-line -->
93
+ - **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.
94
+ - **Not a prose checker.** It says a name is missing, not that a sentence is wrong.
95
+ - **Symbols are opt-in.** Backticked `function_name` references are heuristic, so they are behind `--symbols` and off by default.
96
+
97
+ ## Dogfooding
98
+
99
+ This repo runs `docdrift` on itself in the gate, so the README above cannot quietly drift from the code that ships it.
100
+
101
+ ## License
102
+
103
+ MIT
@@ -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,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ doc_drift_py.egg-info/PKG-INFO
4
+ doc_drift_py.egg-info/SOURCES.txt
5
+ doc_drift_py.egg-info/dependency_links.txt
6
+ doc_drift_py.egg-info/entry_points.txt
7
+ doc_drift_py.egg-info/requires.txt
8
+ doc_drift_py.egg-info/top_level.txt
9
+ docdrift/__init__.py
10
+ docdrift/cli.py
11
+ docdrift/code.py
12
+ docdrift/compare.py
13
+ docdrift/docs.py
14
+ docdrift/report.py
15
+ tests/test_cli.py
16
+ tests/test_code.py
17
+ tests/test_docs.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docdrift = docdrift.cli:main
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ build>=1.0
@@ -0,0 +1 @@
1
+ docdrift
@@ -0,0 +1,3 @@
1
+ """Find the docs that lie."""
2
+
3
+ __version__ = "0.1.1"
@@ -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())
@@ -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
@@ -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
@@ -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
@@ -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"])
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "doc-drift-py"
7
+ version = "0.1.1"
8
+ description = "Find the docs that lie: env vars and CLI flags your documentation mentions that the code no longer has, and code nobody documented."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "mgkvibez"}]
13
+ keywords = ["documentation", "docs", "ci", "static-analysis", "drift", "developer-tools"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Environment :: Console",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ "Topic :: Documentation",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8", "build>=1.0"]
26
+
27
+ [project.scripts]
28
+ docdrift = "docdrift.cli:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["docdrift*"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,134 @@
1
+ """End to end: exit codes are the contract CI depends on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import textwrap
7
+
8
+ import pytest
9
+
10
+ from docdrift import __version__
11
+ from docdrift.cli import main
12
+
13
+
14
+ def project(tmp_path, readme, source):
15
+ (tmp_path / "README.md").write_text(textwrap.dedent(readme), encoding="utf-8")
16
+ src = tmp_path / "src"
17
+ src.mkdir(exist_ok=True)
18
+ (src / "app.py").write_text(textwrap.dedent(source), encoding="utf-8")
19
+ return tmp_path
20
+
21
+
22
+ @pytest.fixture
23
+ def clean(tmp_path, monkeypatch):
24
+ path = project(tmp_path, "Set `DATABASE_URL`.\n", """
25
+ import os
26
+ KEY = os.environ["DATABASE_URL"]
27
+ """)
28
+ monkeypatch.chdir(path)
29
+ return path
30
+
31
+
32
+ def test_clean_project_exits_zero(clean, capsys):
33
+ assert main(["check"]) == 0
34
+ out = capsys.readouterr().out
35
+ assert "no issues found by docdrift v%s" % __version__ in out
36
+
37
+
38
+ def test_stale_env_var_fails(tmp_path, monkeypatch, capsys):
39
+ path = project(tmp_path, "Set `DATABASE_URL` and `LEGACY_TOKEN`.\n", """
40
+ import os
41
+ KEY = os.environ["DATABASE_URL"]
42
+ """)
43
+ monkeypatch.chdir(path)
44
+ assert main(["check"]) == 1
45
+ out = capsys.readouterr().out
46
+ assert "stale-env" in out and "LEGACY_TOKEN" in out
47
+ assert "README.md:1" in out
48
+
49
+
50
+ def test_undocumented_flag_fails_by_default(tmp_path, monkeypatch, capsys):
51
+ path = project(tmp_path, "Just prose.\n", """
52
+ import argparse
53
+ p = argparse.ArgumentParser()
54
+ p.add_argument("--secret-mode", action="store_true")
55
+ """)
56
+ monkeypatch.chdir(path)
57
+ assert main(["check"]) == 1
58
+ assert "undocumented-flag" in capsys.readouterr().out
59
+
60
+
61
+ def test_fail_on_stale_ignores_undocumented_only(tmp_path, monkeypatch):
62
+ path = project(tmp_path, "Just prose.\n", """
63
+ import argparse
64
+ p = argparse.ArgumentParser()
65
+ p.add_argument("--secret-mode", action="store_true")
66
+ """)
67
+ monkeypatch.chdir(path)
68
+ assert main(["check", "--fail-on", "stale"]) == 0
69
+
70
+
71
+ def test_fail_on_none_never_fails(tmp_path, monkeypatch):
72
+ path = project(tmp_path, "Set `GONE`.\n", "")
73
+ monkeypatch.chdir(path)
74
+ assert main(["check", "--fail-on", "none"]) == 0
75
+
76
+
77
+ def test_ignore_exact_and_prefix(tmp_path, monkeypatch, capsys):
78
+ path = project(tmp_path, "Set `GONE` and `ALSO_GONE`.\n", "")
79
+ monkeypatch.chdir(path)
80
+ assert main(["check", "--ignore", "GONE", "--ignore", "ALSO_*"]) == 0
81
+ assert "no issues found" in capsys.readouterr().out
82
+
83
+
84
+ def test_json_output_shape(tmp_path, monkeypatch, capsys):
85
+ path = project(tmp_path, "Set `GONE`.\n", "")
86
+ monkeypatch.chdir(path)
87
+ assert main(["check", "--json"]) == 1
88
+ payload = json.loads(capsys.readouterr().out)
89
+ assert payload["counts"]["stale"] == 1
90
+ finding = payload["findings"][0]
91
+ assert finding["kind"] == "stale" and finding["subject"] == "env"
92
+ assert finding["doc"]["line"] == 1
93
+
94
+
95
+ def test_missing_path_is_a_usage_error(tmp_path, monkeypatch):
96
+ monkeypatch.chdir(tmp_path)
97
+ assert main(["check", "--docs", "nope.md"]) == 2
98
+
99
+
100
+ def test_no_docs_anywhere_is_a_usage_error(tmp_path, monkeypatch):
101
+ (tmp_path / "src").mkdir()
102
+ (tmp_path / "src" / "app.py").write_text("x = 1\n", encoding="utf-8")
103
+ monkeypatch.chdir(tmp_path)
104
+ assert main(["check"]) == 2
105
+
106
+
107
+ def test_symbols_flag_finds_renamed_function(tmp_path, monkeypatch, capsys):
108
+ path = project(tmp_path, "Call `old_helper()` to start.\n", """
109
+ def new_helper():
110
+ pass
111
+ """)
112
+ monkeypatch.chdir(path)
113
+ assert main(["check"]) == 0
114
+ assert main(["check", "--symbols"]) == 1
115
+ assert "stale-symbol" in capsys.readouterr().out
116
+
117
+
118
+ def test_version_flag(capsys):
119
+ with pytest.raises(SystemExit) as exc:
120
+ main(["--version"])
121
+ assert exc.value.code == 0
122
+ assert __version__ in capsys.readouterr().out
123
+
124
+
125
+ def test_builtin_argparse_flags_are_never_reported(tmp_path, monkeypatch, capsys):
126
+ path = project(tmp_path, "Just prose.\n", """
127
+ import argparse
128
+ p = argparse.ArgumentParser()
129
+ p.add_argument("--version", action="version", version="1")
130
+ p.add_argument("--help")
131
+ """)
132
+ monkeypatch.chdir(path)
133
+ assert main(["check"]) == 0
134
+ assert "undocumented-flag" not in capsys.readouterr().out
@@ -0,0 +1,115 @@
1
+ """Code-side extraction. ast, not grep, is the point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import textwrap
6
+
7
+ from docdrift.code import scan, scan_file
8
+
9
+
10
+ def write(tmp_path, name, body):
11
+ path = tmp_path / name
12
+ path.parent.mkdir(parents=True, exist_ok=True)
13
+ path.write_text(textwrap.dedent(body), encoding="utf-8")
14
+ return path
15
+
16
+
17
+ def test_os_environ_subscript(tmp_path):
18
+ path = write(tmp_path, "a.py", """
19
+ import os
20
+ key = os.environ["DATABASE_URL"]
21
+ """)
22
+ facts = scan_file(path)
23
+ assert "DATABASE_URL" in facts.env
24
+
25
+
26
+ def test_os_environ_get(tmp_path):
27
+ path = write(tmp_path, "a.py", """
28
+ import os
29
+ key = os.environ.get("API_TOKEN", "")
30
+ """)
31
+ assert "API_TOKEN" in scan_file(path).env
32
+
33
+
34
+ def test_os_getenv(tmp_path):
35
+ path = write(tmp_path, "a.py", """
36
+ import os
37
+ key = os.getenv("STRIPE_KEY")
38
+ """)
39
+ assert "STRIPE_KEY" in scan_file(path).env
40
+
41
+
42
+ def test_env_name_in_a_comment_is_not_a_read(tmp_path):
43
+ path = write(tmp_path, "a.py", """
44
+ # os.environ["GHOST_VAR"] is handled elsewhere
45
+ x = 1
46
+ """)
47
+ assert scan_file(path).env == {}
48
+
49
+
50
+ def test_env_name_in_a_dead_string_is_not_a_read(tmp_path):
51
+ path = write(tmp_path, "a.py", """
52
+ MESSAGE = 'set os.environ["GHOST_VAR"] first'
53
+ """)
54
+ assert scan_file(path).env == {}
55
+
56
+
57
+ def test_argparse_flags_including_short_forms(tmp_path):
58
+ path = write(tmp_path, "a.py", """
59
+ import argparse
60
+ p = argparse.ArgumentParser()
61
+ p.add_argument("--dry-run", action="store_true")
62
+ p.add_argument("-f", "--file")
63
+ """)
64
+ facts = scan_file(path)
65
+ assert "--dry-run" in facts.flags
66
+ assert "--file" in facts.flags
67
+ assert "-f" in facts.flags
68
+
69
+
70
+ def test_non_flag_add_argument_positionals_ignored(tmp_path):
71
+ path = write(tmp_path, "a.py", """
72
+ import argparse
73
+ p = argparse.ArgumentParser()
74
+ p.add_argument("target")
75
+ """)
76
+ assert scan_file(path).flags == {}
77
+
78
+
79
+ def test_symbols_defined_and_private_excluded(tmp_path):
80
+ path = write(tmp_path, "a.py", """
81
+ def public_thing():
82
+ pass
83
+
84
+ def _private_thing():
85
+ pass
86
+
87
+ class Widget:
88
+ pass
89
+
90
+ async def fetch_all():
91
+ pass
92
+ """)
93
+ facts = scan_file(path)
94
+ assert "public_thing" in facts.symbols
95
+ assert "Widget" in facts.symbols
96
+ assert "fetch_all" in facts.symbols
97
+ assert "_private_thing" not in facts.symbols
98
+
99
+
100
+ def test_unparseable_file_is_skipped_silently(tmp_path):
101
+ path = write(tmp_path, "broken.py", """
102
+ def oops(:
103
+ """)
104
+ facts = scan_file(path)
105
+ assert facts.env == {} and facts.flags == {} and facts.symbols == {}
106
+
107
+
108
+ def test_scan_merges_a_tree_and_skips_vendored_dirs(tmp_path):
109
+ write(tmp_path, "pkg/a.py", 'import os\nX = os.environ["ONE"]\n')
110
+ write(tmp_path, "pkg/deep/b.py", 'import os\nY = os.getenv("TWO")\n')
111
+ vendored = tmp_path / ".venv" / "lib"
112
+ vendored.mkdir(parents=True)
113
+ (vendored / "c.py").write_text('import os\nZ = os.getenv("VENDORED")\n', encoding="utf-8")
114
+ facts = scan([tmp_path])
115
+ assert set(facts.env) == {"ONE", "TWO"}
@@ -0,0 +1,137 @@
1
+ """Doc-side extraction. The hard requirement here is no false positives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import textwrap
6
+
7
+ from docdrift.docs import scan_doc
8
+
9
+
10
+ def write(tmp_path, name, body):
11
+ path = tmp_path / name
12
+ path.write_text(textwrap.dedent(body), encoding="utf-8")
13
+ return path
14
+
15
+
16
+ def test_backticked_env_var_is_a_claim(tmp_path):
17
+ path = write(tmp_path, "README.md", "Set `DATABASE_URL` before starting.\n")
18
+ assert "DATABASE_URL" in scan_doc(path).env
19
+
20
+
21
+ def test_export_line_is_a_claim(tmp_path):
22
+ path = write(tmp_path, "README.md", "export STRIPE_SECRET_KEY=sk_test\n")
23
+ assert "STRIPE_SECRET_KEY" in scan_doc(path).env
24
+
25
+
26
+ def test_fenced_block_is_a_claim(tmp_path):
27
+ path = write(tmp_path, "README.md", """
28
+ ```bash
29
+ API_TOKEN=abc uvicorn app:app
30
+ ```
31
+ """)
32
+ assert "API_TOKEN" in scan_doc(path).env
33
+
34
+
35
+ def test_plain_prose_acronyms_are_not_claims(tmp_path):
36
+ path = write(tmp_path, "README.md", "This speaks HTTP and JSON over TCP, not XML.\n")
37
+ facts = scan_doc(path)
38
+ assert facts.env == {}
39
+
40
+
41
+ def test_uppercase_words_without_underscores_are_not_claims(tmp_path):
42
+ path = write(tmp_path, "README.md", "Use the CLI, read the README, the MIT license applies.\n")
43
+ assert scan_doc(path).env == {}
44
+
45
+
46
+ def test_html_comment_is_ignored(tmp_path):
47
+ path = write(tmp_path, "README.md", """
48
+ Visible text.
49
+ <!-- Set `SECRET_FROM_COMMENT` when debugging. -->
50
+ More visible text.
51
+ """)
52
+ assert "SECRET_FROM_COMMENT" not in scan_doc(path).env
53
+
54
+
55
+ def test_long_flag_is_a_claim(tmp_path):
56
+ path = write(tmp_path, "README.md", "Run `docdrift check --fail-on stale`.\n")
57
+ assert "--fail-on" in scan_doc(path).flags
58
+
59
+
60
+ def test_number_range_is_not_a_flag(tmp_path):
61
+ path = write(tmp_path, "README.md", "Covers versions 1.0--2.0 only.\n")
62
+ assert scan_doc(path).flags == {}
63
+
64
+
65
+ def test_bare_double_dash_is_not_a_flag(tmp_path):
66
+ path = write(tmp_path, "README.md", "Then run `-- status`.\n")
67
+ assert scan_doc(path).flags == {}
68
+
69
+
70
+ def test_symbols_off_by_default(tmp_path):
71
+ path = write(tmp_path, "README.md", "Call `parse_flags()` yourself.\n")
72
+ assert scan_doc(path).symbols == {}
73
+
74
+
75
+ def test_backticked_snake_case_symbol_when_enabled(tmp_path):
76
+ path = write(tmp_path, "README.md", "Call `parse_flags()` yourself.\n")
77
+ assert "parse_flags" in scan_doc(path, symbols=True).symbols
78
+
79
+
80
+ def test_backticked_uppercase_is_not_a_symbol(tmp_path):
81
+ path = write(tmp_path, "README.md", "Set `DATABASE_URL` first.\n")
82
+ assert scan_doc(path, symbols=True).symbols == {}
83
+
84
+
85
+ def test_line_numbers_point_at_the_claim(tmp_path):
86
+ path = write(tmp_path, "README.md", """
87
+ # Title
88
+
89
+ Some text.
90
+
91
+ Set `MISSING_VAR` here.
92
+ """)
93
+ facts = scan_doc(path)
94
+ assert facts.env["MISSING_VAR"][1] == 6
95
+
96
+
97
+ # --- suppressions: the difference between a useful checker and a noisy one ---
98
+
99
+ def test_fenced_block_tagged_ignore_is_not_a_claim(tmp_path):
100
+ path = write(tmp_path, "README.md", """
101
+ ```console docdrift-ignore
102
+ $ tool --not-a-real-flag
103
+ Set `NOT_REAL_EITHER`.
104
+ ```
105
+ """)
106
+ facts = scan_doc(path)
107
+ assert facts.flags == {} and facts.env == {}
108
+
109
+
110
+ def test_untagged_fence_is_still_a_claim(tmp_path):
111
+ path = write(tmp_path, "README.md", """
112
+ ```console
113
+ $ tool --real-flag
114
+ ```
115
+ """)
116
+ assert "--real-flag" in scan_doc(path).flags
117
+
118
+
119
+ def test_ignore_next_line_only_skips_one_line(tmp_path):
120
+ path = write(tmp_path, "README.md", """
121
+ <!-- docdrift: ignore-next-line -->
122
+ Illustrative `SKIPPED_NAME` here.
123
+ But `REAL_NAME` here is a claim.
124
+ """)
125
+ facts = scan_doc(path)
126
+ assert "SKIPPED_NAME" not in facts.env
127
+ assert "REAL_NAME" in facts.env
128
+
129
+
130
+ def test_suppression_keeps_line_numbers_honest(tmp_path):
131
+ path = write(tmp_path, "README.md", """
132
+ ```console docdrift-ignore
133
+ $ pretend
134
+ ```
135
+ Set `REAL_NAME` here.
136
+ """)
137
+ assert scan_doc(path).env["REAL_NAME"][1] == 5