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.
- doc_drift_py-0.1.1/PKG-INFO +122 -0
- doc_drift_py-0.1.1/README.md +103 -0
- doc_drift_py-0.1.1/doc_drift_py.egg-info/PKG-INFO +122 -0
- doc_drift_py-0.1.1/doc_drift_py.egg-info/SOURCES.txt +17 -0
- doc_drift_py-0.1.1/doc_drift_py.egg-info/dependency_links.txt +1 -0
- doc_drift_py-0.1.1/doc_drift_py.egg-info/entry_points.txt +2 -0
- doc_drift_py-0.1.1/doc_drift_py.egg-info/requires.txt +4 -0
- doc_drift_py-0.1.1/doc_drift_py.egg-info/top_level.txt +1 -0
- doc_drift_py-0.1.1/docdrift/__init__.py +3 -0
- doc_drift_py-0.1.1/docdrift/cli.py +99 -0
- doc_drift_py-0.1.1/docdrift/code.py +112 -0
- doc_drift_py-0.1.1/docdrift/compare.py +53 -0
- doc_drift_py-0.1.1/docdrift/docs.py +128 -0
- doc_drift_py-0.1.1/docdrift/report.py +121 -0
- doc_drift_py-0.1.1/pyproject.toml +35 -0
- doc_drift_py-0.1.1/setup.cfg +4 -0
- doc_drift_py-0.1.1/tests/test_cli.py +134 -0
- doc_drift_py-0.1.1/tests/test_code.py +115 -0
- doc_drift_py-0.1.1/tests/test_docs.py +137 -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,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
|
+
[](https://github.com/mgkvibez/doc-drift/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](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
|
+
[](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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
docdrift
|
|
@@ -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,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
|