recursion-claim-audit 0.1.0__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.
- recursion_claim_audit-0.1.0/LICENSE +21 -0
- recursion_claim_audit-0.1.0/MANIFEST.in +2 -0
- recursion_claim_audit-0.1.0/PKG-INFO +165 -0
- recursion_claim_audit-0.1.0/README.md +155 -0
- recursion_claim_audit-0.1.0/pyproject.toml +18 -0
- recursion_claim_audit-0.1.0/setup.cfg +4 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit/__init__.py +2 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit/__main__.py +44 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit/audit.py +181 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/PKG-INFO +165 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/SOURCES.txt +25 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/dependency_links.txt +1 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/entry_points.txt +2 -0
- recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/top_level.txt +1 -0
- recursion_claim_audit-0.1.0/tests/fixtures/bad/recursive/README.md +1 -0
- recursion_claim_audit-0.1.0/tests/fixtures/bad/recursive/main.py +2 -0
- recursion_claim_audit-0.1.0/tests/fixtures/bad/shallow/README.md +1 -0
- recursion_claim_audit-0.1.0/tests/fixtures/bad/shallow/main.py +2 -0
- recursion_claim_audit-0.1.0/tests/fixtures/good/recursive/.env +1 -0
- recursion_claim_audit-0.1.0/tests/fixtures/good/recursive/README.md +1 -0
- recursion_claim_audit-0.1.0/tests/fixtures/good/recursive/main.py +2 -0
- recursion_claim_audit-0.1.0/tests/fixtures/good/shallow/README.md +1 -0
- recursion_claim_audit-0.1.0/tests/fixtures/good/shallow/main.py +2 -0
- recursion_claim_audit-0.1.0/tests/fixtures/testonly/README.md +1 -0
- recursion_claim_audit-0.1.0/tests/fixtures/testonly/main.py +2 -0
- recursion_claim_audit-0.1.0/tests/fixtures/testonly/tests/test_work.py +4 -0
- recursion_claim_audit-0.1.0/tests/test_audit.py +282 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 recursion-claim-audit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: recursion-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit documented directory traversal depth against Python source.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# recursion-claim-audit
|
|
12
|
+
|
|
13
|
+
Audit documented directory traversal depth against Python source.
|
|
14
|
+
|
|
15
|
+
Version 0.1.0. A stdlib-only, offline static auditor for Python projects.
|
|
16
|
+
It executes no audited code, uses no network, skips symlinks, and never reads
|
|
17
|
+
`.env` contents. Only root `README.md`, Markdown files beneath `docs/`, and
|
|
18
|
+
eligible Python source are opened. Files outside the target are not opened.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Requires Python 3.9 or newer. Install and run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python3 -m pip install .
|
|
26
|
+
recursion-claim-audit /path/to/project
|
|
27
|
+
python3 -m recursion_claim_audit /path/to/project --json
|
|
28
|
+
recursion-claim-audit . --verbose --ignore 'vendor/*' --fail-on HIGH
|
|
29
|
+
recursion-claim-audit --version
|
|
30
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
31
|
+
python3 -m build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The target defaults to the current directory. `--ignore GLOB` is repeatable
|
|
35
|
+
and matches relative paths; matching directories are pruned. `--verbose`
|
|
36
|
+
adds verified claims and source diagnostics to text output. `--json` emits
|
|
37
|
+
an object with `findings`, `verified_claims`, and `errors`. Findings include
|
|
38
|
+
rule, severity, claims (path, line, text, family), and evidence (path, line,
|
|
39
|
+
kind). Parse/read errors appear in diagnostics and do not themselves create
|
|
40
|
+
rules. Exit status is 0 for PASS, 2 for findings meeting the threshold, and
|
|
41
|
+
1 for usage errors. The default threshold is MEDIUM; `--fail-on HIGH` keeps
|
|
42
|
+
MEDIUM findings visible while permitting exit 0.
|
|
43
|
+
|
|
44
|
+
## Claim extraction
|
|
45
|
+
|
|
46
|
+
Claims come only from root README.md and docs Markdown. Fenced blocks are
|
|
47
|
+
excluded; inline code counts as prose. Matching is case-insensitive and uses
|
|
48
|
+
only the exact phrases below, with word boundaries. The families are
|
|
49
|
+
independent; contradictory documentation can produce separate findings.
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
RECURSIVE:
|
|
53
|
+
recursively
|
|
54
|
+
recursive scan
|
|
55
|
+
recursive search
|
|
56
|
+
recursively scans
|
|
57
|
+
scans recursively
|
|
58
|
+
searches recursively
|
|
59
|
+
recursively searches
|
|
60
|
+
walks the tree
|
|
61
|
+
walks the directory tree
|
|
62
|
+
recursive traversal
|
|
63
|
+
traverses recursively
|
|
64
|
+
recursively walks
|
|
65
|
+
descends into subdirectories
|
|
66
|
+
all subdirectories
|
|
67
|
+
including subdirectories
|
|
68
|
+
nested directories
|
|
69
|
+
|
|
70
|
+
SHALLOW:
|
|
71
|
+
top-level only
|
|
72
|
+
top level only
|
|
73
|
+
non-recursive
|
|
74
|
+
not recursive
|
|
75
|
+
single directory
|
|
76
|
+
does not recurse
|
|
77
|
+
doesn't recurse
|
|
78
|
+
no recursion
|
|
79
|
+
without recursion
|
|
80
|
+
shallow scan
|
|
81
|
+
flat scan
|
|
82
|
+
does not descend
|
|
83
|
+
doesn't descend
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Negation is asymmetric: a sentence containing no, not, never, without,
|
|
87
|
+
doesn't, don't, or isn't suppresses the RECURSIVE family. SHALLOW phrases
|
|
88
|
+
which express negation themselves still count; other SHALLOW phrases are
|
|
89
|
+
suppressed in negated sentences. This includes the two descent-negation
|
|
90
|
+
phrases at the end of the list. Sentence boundaries are punctuation or
|
|
91
|
+
paragraph boundaries. The bare adverb in the first family counts on its
|
|
92
|
+
own, while the bare noun “recursion” does not. For example:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
walks recursively -> recursive claim
|
|
96
|
+
recursion support -> no claim
|
|
97
|
+
does not scan recursively -> no claim
|
|
98
|
+
this tool is not recursive -> shallow claim
|
|
99
|
+
does not scan top-level only -> no claim
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Rules
|
|
103
|
+
|
|
104
|
+
One finding per claimed family; repeated prose is retained in its claims.
|
|
105
|
+
|
|
106
|
+
| Rule | Severity | Condition |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| documented-recursive-claim-false | HIGH | RECURSIVE claim without production or test evidence |
|
|
109
|
+
| documented-recursive-claim-false-test-only | MEDIUM | RECURSIVE claim with test evidence alone |
|
|
110
|
+
| documented-shallow-claim-false | HIGH | SHALLOW claim with production evidence |
|
|
111
|
+
| documented-shallow-claim-false-test-only | MEDIUM | SHALLOW claim with test evidence alone |
|
|
112
|
+
|
|
113
|
+
A claimed family with no production Python source always gets its base HIGH
|
|
114
|
+
rule with `no-source` evidence. A RECURSIVE claim with production evidence
|
|
115
|
+
is verified. A SHALLOW claim without evidence anywhere is verified. No
|
|
116
|
+
claim means PASS regardless of source evidence.
|
|
117
|
+
|
|
118
|
+
## Named evidence
|
|
119
|
+
|
|
120
|
+
Each call site or definition is one item, deduplicated by file, line, kind.
|
|
121
|
+
|
|
122
|
+
| Kind | Recognized syntax |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| os.walk | Standard library call, including per-file imported aliases |
|
|
125
|
+
| os.path.walk | Legacy standard library call |
|
|
126
|
+
| rglob | Attribute call on any receiver |
|
|
127
|
+
| recursive-glob | glob.glob with literal True for its recursive keyword |
|
|
128
|
+
| glob-pattern | glob.glob or any .glob with a literal first argument containing ** |
|
|
129
|
+
| self-recursive-reader | Function calls its own name and reads directory entries via os.listdir, os.scandir or .iterdir |
|
|
130
|
+
|
|
131
|
+
Production excludes tests/ and fixtures/ directories, test_*.py, *_test.py,
|
|
132
|
+
docs/, and examples/. Test and fixture source is collected separately.
|
|
133
|
+
Symlinked files and directories are skipped. Build outputs, virtual
|
|
134
|
+
environments, Git metadata and bytecode directories are skipped too.
|
|
135
|
+
|
|
136
|
+
## Differentiation
|
|
137
|
+
|
|
138
|
+
| Neighbour | Scope compared with this tool |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| sort-claim-audit | Output order rather than traversal depth |
|
|
141
|
+
| pagination-claim-audit | Result paging rather than local descent |
|
|
142
|
+
| instruction-drift | Instruction-file truth rather than traversal behaviour |
|
|
143
|
+
| streaming-claim-audit | Read chunking rather than tree depth |
|
|
144
|
+
| bandit / ruff | Code issues rather than documented promises |
|
|
145
|
+
| os.walk / pathlib documentation | Primitive semantics rather than docs-versus-code truth |
|
|
146
|
+
|
|
147
|
+
## Honest limitations
|
|
148
|
+
|
|
149
|
+
Exact-phrase matching misses paraphrases and does not resolve which API a
|
|
150
|
+
sentence describes. Evidence is a deliberate over-approximation: dead code
|
|
151
|
+
counts, and presence does not prove the promised operation reaches that
|
|
152
|
+
code. Receiver types are not inferred for attribute matches. Imported names
|
|
153
|
+
are tracked per file, without resolving shadowing, control flow or dynamic
|
|
154
|
+
imports. Nested function definitions are treated separately.
|
|
155
|
+
|
|
156
|
+
A directory-entry read alone (os.listdir, os.scandir, or .iterdir) is not
|
|
157
|
+
evidence. Repeating a read over a fixed list of directories is not evidence.
|
|
158
|
+
glob.glob without the keyword or the double-star pattern is not evidence;
|
|
159
|
+
a plain Path.glob pattern is not evidence either. A function calling itself
|
|
160
|
+
without directory reads is not evidence. A directory reader without a
|
|
161
|
+
self-call or a recognized walker is not evidence. Third-party walkers such
|
|
162
|
+
as pathspec, wcmatch and the scandir package are outside scope. A keyword
|
|
163
|
+
named recursive with literal True on a non-glob call is not evidence.
|
|
164
|
+
Syntax and decoding errors are reported without executing fallback code.
|
|
165
|
+
The tool cannot prove runtime coverage or filesystem permissions.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# recursion-claim-audit
|
|
2
|
+
|
|
3
|
+
Audit documented directory traversal depth against Python source.
|
|
4
|
+
|
|
5
|
+
Version 0.1.0. A stdlib-only, offline static auditor for Python projects.
|
|
6
|
+
It executes no audited code, uses no network, skips symlinks, and never reads
|
|
7
|
+
`.env` contents. Only root `README.md`, Markdown files beneath `docs/`, and
|
|
8
|
+
eligible Python source are opened. Files outside the target are not opened.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Requires Python 3.9 or newer. Install and run:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
python3 -m pip install .
|
|
16
|
+
recursion-claim-audit /path/to/project
|
|
17
|
+
python3 -m recursion_claim_audit /path/to/project --json
|
|
18
|
+
recursion-claim-audit . --verbose --ignore 'vendor/*' --fail-on HIGH
|
|
19
|
+
recursion-claim-audit --version
|
|
20
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
21
|
+
python3 -m build
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The target defaults to the current directory. `--ignore GLOB` is repeatable
|
|
25
|
+
and matches relative paths; matching directories are pruned. `--verbose`
|
|
26
|
+
adds verified claims and source diagnostics to text output. `--json` emits
|
|
27
|
+
an object with `findings`, `verified_claims`, and `errors`. Findings include
|
|
28
|
+
rule, severity, claims (path, line, text, family), and evidence (path, line,
|
|
29
|
+
kind). Parse/read errors appear in diagnostics and do not themselves create
|
|
30
|
+
rules. Exit status is 0 for PASS, 2 for findings meeting the threshold, and
|
|
31
|
+
1 for usage errors. The default threshold is MEDIUM; `--fail-on HIGH` keeps
|
|
32
|
+
MEDIUM findings visible while permitting exit 0.
|
|
33
|
+
|
|
34
|
+
## Claim extraction
|
|
35
|
+
|
|
36
|
+
Claims come only from root README.md and docs Markdown. Fenced blocks are
|
|
37
|
+
excluded; inline code counts as prose. Matching is case-insensitive and uses
|
|
38
|
+
only the exact phrases below, with word boundaries. The families are
|
|
39
|
+
independent; contradictory documentation can produce separate findings.
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
RECURSIVE:
|
|
43
|
+
recursively
|
|
44
|
+
recursive scan
|
|
45
|
+
recursive search
|
|
46
|
+
recursively scans
|
|
47
|
+
scans recursively
|
|
48
|
+
searches recursively
|
|
49
|
+
recursively searches
|
|
50
|
+
walks the tree
|
|
51
|
+
walks the directory tree
|
|
52
|
+
recursive traversal
|
|
53
|
+
traverses recursively
|
|
54
|
+
recursively walks
|
|
55
|
+
descends into subdirectories
|
|
56
|
+
all subdirectories
|
|
57
|
+
including subdirectories
|
|
58
|
+
nested directories
|
|
59
|
+
|
|
60
|
+
SHALLOW:
|
|
61
|
+
top-level only
|
|
62
|
+
top level only
|
|
63
|
+
non-recursive
|
|
64
|
+
not recursive
|
|
65
|
+
single directory
|
|
66
|
+
does not recurse
|
|
67
|
+
doesn't recurse
|
|
68
|
+
no recursion
|
|
69
|
+
without recursion
|
|
70
|
+
shallow scan
|
|
71
|
+
flat scan
|
|
72
|
+
does not descend
|
|
73
|
+
doesn't descend
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Negation is asymmetric: a sentence containing no, not, never, without,
|
|
77
|
+
doesn't, don't, or isn't suppresses the RECURSIVE family. SHALLOW phrases
|
|
78
|
+
which express negation themselves still count; other SHALLOW phrases are
|
|
79
|
+
suppressed in negated sentences. This includes the two descent-negation
|
|
80
|
+
phrases at the end of the list. Sentence boundaries are punctuation or
|
|
81
|
+
paragraph boundaries. The bare adverb in the first family counts on its
|
|
82
|
+
own, while the bare noun “recursion” does not. For example:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
walks recursively -> recursive claim
|
|
86
|
+
recursion support -> no claim
|
|
87
|
+
does not scan recursively -> no claim
|
|
88
|
+
this tool is not recursive -> shallow claim
|
|
89
|
+
does not scan top-level only -> no claim
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Rules
|
|
93
|
+
|
|
94
|
+
One finding per claimed family; repeated prose is retained in its claims.
|
|
95
|
+
|
|
96
|
+
| Rule | Severity | Condition |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| documented-recursive-claim-false | HIGH | RECURSIVE claim without production or test evidence |
|
|
99
|
+
| documented-recursive-claim-false-test-only | MEDIUM | RECURSIVE claim with test evidence alone |
|
|
100
|
+
| documented-shallow-claim-false | HIGH | SHALLOW claim with production evidence |
|
|
101
|
+
| documented-shallow-claim-false-test-only | MEDIUM | SHALLOW claim with test evidence alone |
|
|
102
|
+
|
|
103
|
+
A claimed family with no production Python source always gets its base HIGH
|
|
104
|
+
rule with `no-source` evidence. A RECURSIVE claim with production evidence
|
|
105
|
+
is verified. A SHALLOW claim without evidence anywhere is verified. No
|
|
106
|
+
claim means PASS regardless of source evidence.
|
|
107
|
+
|
|
108
|
+
## Named evidence
|
|
109
|
+
|
|
110
|
+
Each call site or definition is one item, deduplicated by file, line, kind.
|
|
111
|
+
|
|
112
|
+
| Kind | Recognized syntax |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| os.walk | Standard library call, including per-file imported aliases |
|
|
115
|
+
| os.path.walk | Legacy standard library call |
|
|
116
|
+
| rglob | Attribute call on any receiver |
|
|
117
|
+
| recursive-glob | glob.glob with literal True for its recursive keyword |
|
|
118
|
+
| glob-pattern | glob.glob or any .glob with a literal first argument containing ** |
|
|
119
|
+
| self-recursive-reader | Function calls its own name and reads directory entries via os.listdir, os.scandir or .iterdir |
|
|
120
|
+
|
|
121
|
+
Production excludes tests/ and fixtures/ directories, test_*.py, *_test.py,
|
|
122
|
+
docs/, and examples/. Test and fixture source is collected separately.
|
|
123
|
+
Symlinked files and directories are skipped. Build outputs, virtual
|
|
124
|
+
environments, Git metadata and bytecode directories are skipped too.
|
|
125
|
+
|
|
126
|
+
## Differentiation
|
|
127
|
+
|
|
128
|
+
| Neighbour | Scope compared with this tool |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| sort-claim-audit | Output order rather than traversal depth |
|
|
131
|
+
| pagination-claim-audit | Result paging rather than local descent |
|
|
132
|
+
| instruction-drift | Instruction-file truth rather than traversal behaviour |
|
|
133
|
+
| streaming-claim-audit | Read chunking rather than tree depth |
|
|
134
|
+
| bandit / ruff | Code issues rather than documented promises |
|
|
135
|
+
| os.walk / pathlib documentation | Primitive semantics rather than docs-versus-code truth |
|
|
136
|
+
|
|
137
|
+
## Honest limitations
|
|
138
|
+
|
|
139
|
+
Exact-phrase matching misses paraphrases and does not resolve which API a
|
|
140
|
+
sentence describes. Evidence is a deliberate over-approximation: dead code
|
|
141
|
+
counts, and presence does not prove the promised operation reaches that
|
|
142
|
+
code. Receiver types are not inferred for attribute matches. Imported names
|
|
143
|
+
are tracked per file, without resolving shadowing, control flow or dynamic
|
|
144
|
+
imports. Nested function definitions are treated separately.
|
|
145
|
+
|
|
146
|
+
A directory-entry read alone (os.listdir, os.scandir, or .iterdir) is not
|
|
147
|
+
evidence. Repeating a read over a fixed list of directories is not evidence.
|
|
148
|
+
glob.glob without the keyword or the double-star pattern is not evidence;
|
|
149
|
+
a plain Path.glob pattern is not evidence either. A function calling itself
|
|
150
|
+
without directory reads is not evidence. A directory reader without a
|
|
151
|
+
self-call or a recognized walker is not evidence. Third-party walkers such
|
|
152
|
+
as pathspec, wcmatch and the scandir package are outside scope. A keyword
|
|
153
|
+
named recursive with literal True on a non-glob call is not evidence.
|
|
154
|
+
Syntax and decoding errors are reported without executing fallback code.
|
|
155
|
+
The tool cannot prove runtime coverage or filesystem permissions.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "recursion-claim-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Audit documented directory traversal depth against Python source."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
recursion-claim-audit = "recursion_claim_audit.__main__:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .audit import audit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Parser(argparse.ArgumentParser):
|
|
10
|
+
def error(self, message):
|
|
11
|
+
self.print_usage(__import__('sys').stderr)
|
|
12
|
+
self.exit(1, self.prog + ': error: ' + message + '\n')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
parser = Parser(description='Audit documented directory traversal depth against Python source.')
|
|
17
|
+
parser.add_argument('path', nargs='?', default='.')
|
|
18
|
+
parser.add_argument('--json', action='store_true')
|
|
19
|
+
parser.add_argument('--verbose', action='store_true')
|
|
20
|
+
parser.add_argument('--ignore', action='append', default=[], metavar='GLOB')
|
|
21
|
+
parser.add_argument('--fail-on', choices=['MEDIUM', 'HIGH'], default='MEDIUM')
|
|
22
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
if not Path(args.path).is_dir():
|
|
25
|
+
parser.error('path must be an existing directory')
|
|
26
|
+
result = audit(args.path, args.ignore)
|
|
27
|
+
failed = any(f['severity'] == 'HIGH' or args.fail_on == 'MEDIUM' for f in result['findings'])
|
|
28
|
+
if args.json:
|
|
29
|
+
print(json.dumps(result, indent=2))
|
|
30
|
+
else:
|
|
31
|
+
print('FAIL' if failed else 'PASS')
|
|
32
|
+
for finding in result['findings']:
|
|
33
|
+
print(finding['severity'] + ' ' + finding['rule'])
|
|
34
|
+
for claim in finding['claims']:
|
|
35
|
+
print(' {path}:{line}: {text}'.format(**claim))
|
|
36
|
+
for evidence in finding['evidence']:
|
|
37
|
+
print(' ' + json.dumps(evidence, sort_keys=True))
|
|
38
|
+
if args.verbose:
|
|
39
|
+
print(json.dumps(dict(verified_claims=result['verified_claims'], errors=result['errors']), indent=2))
|
|
40
|
+
return 2 if failed else 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == '__main__':
|
|
44
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Offline documentation and directory-descent analysis."""
|
|
2
|
+
import ast
|
|
3
|
+
import fnmatch
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
RECURSIVE = ('recursively', 'recursive scan', 'recursive search', 'recursively scans',
|
|
8
|
+
'scans recursively', 'searches recursively', 'recursively searches',
|
|
9
|
+
'walks the tree', 'walks the directory tree', 'recursive traversal',
|
|
10
|
+
'traverses recursively', 'recursively walks', 'descends into subdirectories',
|
|
11
|
+
'all subdirectories', 'including subdirectories', 'nested directories')
|
|
12
|
+
SHALLOW_NEG = ('not recursive', 'does not recurse', "doesn't recurse", 'no recursion',
|
|
13
|
+
'without recursion', 'does not descend', "doesn't descend")
|
|
14
|
+
SHALLOW = ('top-level only', 'top level only', 'non-recursive', 'single directory',
|
|
15
|
+
'shallow scan', 'flat scan') + SHALLOW_NEG
|
|
16
|
+
NEGATION = re.compile(r"\b(?:no|not|never|without|doesn't|don't|isn't)\b", re.I)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def contains(sentence, phrase):
|
|
20
|
+
return re.search(r'(?<![\w-])' + re.escape(phrase) + r'(?![\w-])', sentence, re.I) is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_claims(text, path='README.md'):
|
|
24
|
+
claims = []
|
|
25
|
+
fence = None
|
|
26
|
+
paragraph = []
|
|
27
|
+
|
|
28
|
+
def flush():
|
|
29
|
+
if not paragraph:
|
|
30
|
+
return
|
|
31
|
+
joined = ' '.join(line for _, line in paragraph)
|
|
32
|
+
offsets = []
|
|
33
|
+
for number, line in paragraph:
|
|
34
|
+
offsets.extend([number] * (len(line) + 1))
|
|
35
|
+
for match in re.finditer(r'.+?(?:[.!?](?=\s|$)|$)', joined):
|
|
36
|
+
sentence = match.group().strip()
|
|
37
|
+
negated = bool(NEGATION.search(sentence))
|
|
38
|
+
for family, phrases in [('recursive', RECURSIVE), ('shallow', SHALLOW)]:
|
|
39
|
+
hits = [p for p in phrases if contains(sentence, p)
|
|
40
|
+
and (not negated or (family == 'shallow' and p in SHALLOW_NEG))]
|
|
41
|
+
if hits:
|
|
42
|
+
start = match.start() + len(match.group()) - len(match.group().lstrip())
|
|
43
|
+
claims.append(dict(family=family, path=path, line=offsets[start],
|
|
44
|
+
text=sentence))
|
|
45
|
+
paragraph.clear()
|
|
46
|
+
|
|
47
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
48
|
+
mark = re.match(r'^\s{0,3}(`{3,}|~{3,})', line)
|
|
49
|
+
if mark:
|
|
50
|
+
token = mark.group(1)
|
|
51
|
+
if fence is None:
|
|
52
|
+
flush()
|
|
53
|
+
fence = token
|
|
54
|
+
elif token[0] == fence[0] and len(token) >= len(fence):
|
|
55
|
+
fence = None
|
|
56
|
+
continue
|
|
57
|
+
if fence:
|
|
58
|
+
continue
|
|
59
|
+
if not line.strip():
|
|
60
|
+
flush()
|
|
61
|
+
else:
|
|
62
|
+
paragraph.append((number, line))
|
|
63
|
+
flush()
|
|
64
|
+
return claims
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dotted(node):
|
|
68
|
+
if isinstance(node, ast.Name):
|
|
69
|
+
return node.id
|
|
70
|
+
if isinstance(node, ast.Attribute):
|
|
71
|
+
return dotted(node.value) + '.' + node.attr
|
|
72
|
+
return ''
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extract_evidence(source, path):
|
|
76
|
+
try:
|
|
77
|
+
tree = ast.parse(source)
|
|
78
|
+
except (SyntaxError, ValueError) as exc:
|
|
79
|
+
return [], str(exc)
|
|
80
|
+
aliases = {}
|
|
81
|
+
for node in ast.walk(tree):
|
|
82
|
+
if isinstance(node, ast.Import):
|
|
83
|
+
for item in node.names:
|
|
84
|
+
aliases[item.asname or item.name] = item.name
|
|
85
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
86
|
+
for item in node.names:
|
|
87
|
+
aliases[item.asname or item.name] = node.module + '.' + item.name
|
|
88
|
+
|
|
89
|
+
def name(call):
|
|
90
|
+
parts = dotted(call.func).split('.')
|
|
91
|
+
return '.'.join([aliases.get(parts[0], parts[0])] + parts[1:])
|
|
92
|
+
|
|
93
|
+
items = set()
|
|
94
|
+
for node in ast.walk(tree):
|
|
95
|
+
if isinstance(node, ast.Call):
|
|
96
|
+
target = name(node)
|
|
97
|
+
attr = node.func.attr if isinstance(node.func, ast.Attribute) else None
|
|
98
|
+
kind = None
|
|
99
|
+
if target in ('os.walk', 'os.path.walk'):
|
|
100
|
+
kind = target
|
|
101
|
+
elif attr == 'rglob':
|
|
102
|
+
kind = 'rglob'
|
|
103
|
+
elif target == 'glob.glob' and any(k.arg == 'recursive' and
|
|
104
|
+
isinstance(k.value, ast.Constant) and k.value.value is True for k in node.keywords):
|
|
105
|
+
kind = 'recursive-glob'
|
|
106
|
+
elif (target == 'glob.glob' or attr == 'glob') and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str) and '**' in node.args[0].value:
|
|
107
|
+
kind = 'glob-pattern'
|
|
108
|
+
if kind:
|
|
109
|
+
items.add((node.lineno, kind))
|
|
110
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
111
|
+
# Nested definitions are separate scopes, not part of this reader.
|
|
112
|
+
def body_nodes(root):
|
|
113
|
+
for child in ast.iter_child_nodes(root):
|
|
114
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
115
|
+
continue
|
|
116
|
+
yield child
|
|
117
|
+
yield from body_nodes(child)
|
|
118
|
+
calls = [n for n in body_nodes(node) if isinstance(n, ast.Call)]
|
|
119
|
+
self_call = any(isinstance(c.func, ast.Name) and c.func.id == node.name for c in calls)
|
|
120
|
+
reader = any(name(c) in ('os.listdir', 'os.scandir') or
|
|
121
|
+
(isinstance(c.func, ast.Attribute) and c.func.attr == 'iterdir') for c in calls)
|
|
122
|
+
if self_call and reader:
|
|
123
|
+
items.add((node.lineno, 'self-recursive-reader'))
|
|
124
|
+
return [dict(path=path, line=line, kind=kind) for line, kind in sorted(items)], None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def audit(root, ignore=()):
|
|
128
|
+
root = Path(root)
|
|
129
|
+
claims, production, testonly, errors = [], [], [], []
|
|
130
|
+
source_count = 0
|
|
131
|
+
# Do not follow directory symlinks, or open non-source files (including .env).
|
|
132
|
+
import os
|
|
133
|
+
for directory, dirs, files in os.walk(root, followlinks=False):
|
|
134
|
+
dirs[:] = sorted(d for d in dirs if not (Path(directory) / d).is_symlink()
|
|
135
|
+
and d not in ('.git', '__pycache__', '.venv', 'venv', 'build', 'dist')
|
|
136
|
+
and not any(fnmatch.fnmatch((Path(directory) / d).relative_to(root).as_posix(), p) for p in ignore))
|
|
137
|
+
for filename in sorted(files):
|
|
138
|
+
file = Path(directory) / filename
|
|
139
|
+
relative = file.relative_to(root).as_posix()
|
|
140
|
+
if file.is_symlink() or any(fnmatch.fnmatch(relative, p) for p in ignore):
|
|
141
|
+
continue
|
|
142
|
+
parts = file.relative_to(root).parts
|
|
143
|
+
is_doc = relative == 'README.md' or (parts[0] == 'docs' and file.suffix == '.md')
|
|
144
|
+
is_test = any(p in ('tests', 'fixtures') for p in parts[:-1]) or filename.startswith('test_') or filename.endswith('_test.py')
|
|
145
|
+
is_source = file.suffix == '.py' and not any(p in ('docs', 'examples') for p in parts[:-1])
|
|
146
|
+
if not (is_doc or is_source):
|
|
147
|
+
continue
|
|
148
|
+
if is_source and not is_test:
|
|
149
|
+
source_count += 1
|
|
150
|
+
try:
|
|
151
|
+
text = file.read_text(encoding='utf-8')
|
|
152
|
+
except (OSError, UnicodeError) as exc:
|
|
153
|
+
errors.append(dict(path=relative, error=str(exc)))
|
|
154
|
+
continue
|
|
155
|
+
if is_doc:
|
|
156
|
+
claims.extend(extract_claims(text, relative))
|
|
157
|
+
if is_source:
|
|
158
|
+
evidence, error = extract_evidence(text, relative)
|
|
159
|
+
(testonly if is_test else production).extend(evidence)
|
|
160
|
+
if error:
|
|
161
|
+
errors.append(dict(path=relative, error=error))
|
|
162
|
+
findings, verified = [], []
|
|
163
|
+
for family in ('recursive', 'shallow'):
|
|
164
|
+
family_claims = [c for c in claims if c['family'] == family]
|
|
165
|
+
if not family_claims:
|
|
166
|
+
continue
|
|
167
|
+
severity, evidence, suffix = None, [], ''
|
|
168
|
+
if not source_count:
|
|
169
|
+
severity, evidence = 'HIGH', [dict(kind='no-source')]
|
|
170
|
+
elif family == 'recursive' and not production:
|
|
171
|
+
severity, evidence = ('MEDIUM', testonly) if testonly else ('HIGH', [])
|
|
172
|
+
suffix = '-test-only' if testonly else ''
|
|
173
|
+
elif family == 'shallow' and (production or testonly):
|
|
174
|
+
severity, evidence = ('HIGH', production) if production else ('MEDIUM', testonly)
|
|
175
|
+
suffix = '' if production else '-test-only'
|
|
176
|
+
if severity:
|
|
177
|
+
findings.append(dict(rule='documented-' + family + '-claim-false' + suffix,
|
|
178
|
+
severity=severity, claims=family_claims, evidence=evidence))
|
|
179
|
+
else:
|
|
180
|
+
verified.append(dict(family=family, claims=family_claims, evidence=production if family == 'recursive' else []))
|
|
181
|
+
return dict(findings=findings, verified_claims=verified, errors=errors)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: recursion-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit documented directory traversal depth against Python source.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# recursion-claim-audit
|
|
12
|
+
|
|
13
|
+
Audit documented directory traversal depth against Python source.
|
|
14
|
+
|
|
15
|
+
Version 0.1.0. A stdlib-only, offline static auditor for Python projects.
|
|
16
|
+
It executes no audited code, uses no network, skips symlinks, and never reads
|
|
17
|
+
`.env` contents. Only root `README.md`, Markdown files beneath `docs/`, and
|
|
18
|
+
eligible Python source are opened. Files outside the target are not opened.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Requires Python 3.9 or newer. Install and run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python3 -m pip install .
|
|
26
|
+
recursion-claim-audit /path/to/project
|
|
27
|
+
python3 -m recursion_claim_audit /path/to/project --json
|
|
28
|
+
recursion-claim-audit . --verbose --ignore 'vendor/*' --fail-on HIGH
|
|
29
|
+
recursion-claim-audit --version
|
|
30
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
31
|
+
python3 -m build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The target defaults to the current directory. `--ignore GLOB` is repeatable
|
|
35
|
+
and matches relative paths; matching directories are pruned. `--verbose`
|
|
36
|
+
adds verified claims and source diagnostics to text output. `--json` emits
|
|
37
|
+
an object with `findings`, `verified_claims`, and `errors`. Findings include
|
|
38
|
+
rule, severity, claims (path, line, text, family), and evidence (path, line,
|
|
39
|
+
kind). Parse/read errors appear in diagnostics and do not themselves create
|
|
40
|
+
rules. Exit status is 0 for PASS, 2 for findings meeting the threshold, and
|
|
41
|
+
1 for usage errors. The default threshold is MEDIUM; `--fail-on HIGH` keeps
|
|
42
|
+
MEDIUM findings visible while permitting exit 0.
|
|
43
|
+
|
|
44
|
+
## Claim extraction
|
|
45
|
+
|
|
46
|
+
Claims come only from root README.md and docs Markdown. Fenced blocks are
|
|
47
|
+
excluded; inline code counts as prose. Matching is case-insensitive and uses
|
|
48
|
+
only the exact phrases below, with word boundaries. The families are
|
|
49
|
+
independent; contradictory documentation can produce separate findings.
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
RECURSIVE:
|
|
53
|
+
recursively
|
|
54
|
+
recursive scan
|
|
55
|
+
recursive search
|
|
56
|
+
recursively scans
|
|
57
|
+
scans recursively
|
|
58
|
+
searches recursively
|
|
59
|
+
recursively searches
|
|
60
|
+
walks the tree
|
|
61
|
+
walks the directory tree
|
|
62
|
+
recursive traversal
|
|
63
|
+
traverses recursively
|
|
64
|
+
recursively walks
|
|
65
|
+
descends into subdirectories
|
|
66
|
+
all subdirectories
|
|
67
|
+
including subdirectories
|
|
68
|
+
nested directories
|
|
69
|
+
|
|
70
|
+
SHALLOW:
|
|
71
|
+
top-level only
|
|
72
|
+
top level only
|
|
73
|
+
non-recursive
|
|
74
|
+
not recursive
|
|
75
|
+
single directory
|
|
76
|
+
does not recurse
|
|
77
|
+
doesn't recurse
|
|
78
|
+
no recursion
|
|
79
|
+
without recursion
|
|
80
|
+
shallow scan
|
|
81
|
+
flat scan
|
|
82
|
+
does not descend
|
|
83
|
+
doesn't descend
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Negation is asymmetric: a sentence containing no, not, never, without,
|
|
87
|
+
doesn't, don't, or isn't suppresses the RECURSIVE family. SHALLOW phrases
|
|
88
|
+
which express negation themselves still count; other SHALLOW phrases are
|
|
89
|
+
suppressed in negated sentences. This includes the two descent-negation
|
|
90
|
+
phrases at the end of the list. Sentence boundaries are punctuation or
|
|
91
|
+
paragraph boundaries. The bare adverb in the first family counts on its
|
|
92
|
+
own, while the bare noun “recursion” does not. For example:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
walks recursively -> recursive claim
|
|
96
|
+
recursion support -> no claim
|
|
97
|
+
does not scan recursively -> no claim
|
|
98
|
+
this tool is not recursive -> shallow claim
|
|
99
|
+
does not scan top-level only -> no claim
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Rules
|
|
103
|
+
|
|
104
|
+
One finding per claimed family; repeated prose is retained in its claims.
|
|
105
|
+
|
|
106
|
+
| Rule | Severity | Condition |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| documented-recursive-claim-false | HIGH | RECURSIVE claim without production or test evidence |
|
|
109
|
+
| documented-recursive-claim-false-test-only | MEDIUM | RECURSIVE claim with test evidence alone |
|
|
110
|
+
| documented-shallow-claim-false | HIGH | SHALLOW claim with production evidence |
|
|
111
|
+
| documented-shallow-claim-false-test-only | MEDIUM | SHALLOW claim with test evidence alone |
|
|
112
|
+
|
|
113
|
+
A claimed family with no production Python source always gets its base HIGH
|
|
114
|
+
rule with `no-source` evidence. A RECURSIVE claim with production evidence
|
|
115
|
+
is verified. A SHALLOW claim without evidence anywhere is verified. No
|
|
116
|
+
claim means PASS regardless of source evidence.
|
|
117
|
+
|
|
118
|
+
## Named evidence
|
|
119
|
+
|
|
120
|
+
Each call site or definition is one item, deduplicated by file, line, kind.
|
|
121
|
+
|
|
122
|
+
| Kind | Recognized syntax |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| os.walk | Standard library call, including per-file imported aliases |
|
|
125
|
+
| os.path.walk | Legacy standard library call |
|
|
126
|
+
| rglob | Attribute call on any receiver |
|
|
127
|
+
| recursive-glob | glob.glob with literal True for its recursive keyword |
|
|
128
|
+
| glob-pattern | glob.glob or any .glob with a literal first argument containing ** |
|
|
129
|
+
| self-recursive-reader | Function calls its own name and reads directory entries via os.listdir, os.scandir or .iterdir |
|
|
130
|
+
|
|
131
|
+
Production excludes tests/ and fixtures/ directories, test_*.py, *_test.py,
|
|
132
|
+
docs/, and examples/. Test and fixture source is collected separately.
|
|
133
|
+
Symlinked files and directories are skipped. Build outputs, virtual
|
|
134
|
+
environments, Git metadata and bytecode directories are skipped too.
|
|
135
|
+
|
|
136
|
+
## Differentiation
|
|
137
|
+
|
|
138
|
+
| Neighbour | Scope compared with this tool |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| sort-claim-audit | Output order rather than traversal depth |
|
|
141
|
+
| pagination-claim-audit | Result paging rather than local descent |
|
|
142
|
+
| instruction-drift | Instruction-file truth rather than traversal behaviour |
|
|
143
|
+
| streaming-claim-audit | Read chunking rather than tree depth |
|
|
144
|
+
| bandit / ruff | Code issues rather than documented promises |
|
|
145
|
+
| os.walk / pathlib documentation | Primitive semantics rather than docs-versus-code truth |
|
|
146
|
+
|
|
147
|
+
## Honest limitations
|
|
148
|
+
|
|
149
|
+
Exact-phrase matching misses paraphrases and does not resolve which API a
|
|
150
|
+
sentence describes. Evidence is a deliberate over-approximation: dead code
|
|
151
|
+
counts, and presence does not prove the promised operation reaches that
|
|
152
|
+
code. Receiver types are not inferred for attribute matches. Imported names
|
|
153
|
+
are tracked per file, without resolving shadowing, control flow or dynamic
|
|
154
|
+
imports. Nested function definitions are treated separately.
|
|
155
|
+
|
|
156
|
+
A directory-entry read alone (os.listdir, os.scandir, or .iterdir) is not
|
|
157
|
+
evidence. Repeating a read over a fixed list of directories is not evidence.
|
|
158
|
+
glob.glob without the keyword or the double-star pattern is not evidence;
|
|
159
|
+
a plain Path.glob pattern is not evidence either. A function calling itself
|
|
160
|
+
without directory reads is not evidence. A directory reader without a
|
|
161
|
+
self-call or a recognized walker is not evidence. Third-party walkers such
|
|
162
|
+
as pathspec, wcmatch and the scandir package are outside scope. A keyword
|
|
163
|
+
named recursive with literal True on a non-glob call is not evidence.
|
|
164
|
+
Syntax and decoding errors are reported without executing fallback code.
|
|
165
|
+
The tool cannot prove runtime coverage or filesystem permissions.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/recursion_claim_audit/__init__.py
|
|
6
|
+
src/recursion_claim_audit/__main__.py
|
|
7
|
+
src/recursion_claim_audit/audit.py
|
|
8
|
+
src/recursion_claim_audit.egg-info/PKG-INFO
|
|
9
|
+
src/recursion_claim_audit.egg-info/SOURCES.txt
|
|
10
|
+
src/recursion_claim_audit.egg-info/dependency_links.txt
|
|
11
|
+
src/recursion_claim_audit.egg-info/entry_points.txt
|
|
12
|
+
src/recursion_claim_audit.egg-info/top_level.txt
|
|
13
|
+
tests/test_audit.py
|
|
14
|
+
tests/fixtures/bad/recursive/README.md
|
|
15
|
+
tests/fixtures/bad/recursive/main.py
|
|
16
|
+
tests/fixtures/bad/shallow/README.md
|
|
17
|
+
tests/fixtures/bad/shallow/main.py
|
|
18
|
+
tests/fixtures/good/recursive/.env
|
|
19
|
+
tests/fixtures/good/recursive/README.md
|
|
20
|
+
tests/fixtures/good/recursive/main.py
|
|
21
|
+
tests/fixtures/good/shallow/README.md
|
|
22
|
+
tests/fixtures/good/shallow/main.py
|
|
23
|
+
tests/fixtures/testonly/README.md
|
|
24
|
+
tests/fixtures/testonly/main.py
|
|
25
|
+
tests/fixtures/testonly/tests/test_work.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recursion_claim_audit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool scans recursively.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool top-level only.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
RECURSION_AUDIT_ENV_MARKER_DO_NOT_READ
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool scans recursively.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool top-level only.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool scans recursively.
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from recursion_claim_audit.audit import audit, extract_claims, extract_evidence, RECURSIVE, SHALLOW
|
|
12
|
+
from recursion_claim_audit.__main__ import main
|
|
13
|
+
|
|
14
|
+
FIXTURES = Path(__file__).parent / 'fixtures'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Claims(unittest.TestCase):
|
|
18
|
+
def test_fenced(self):
|
|
19
|
+
self.assertEqual(extract_claims('```text\nscans recursively\n```'), [])
|
|
20
|
+
|
|
21
|
+
def test_tilde_fenced(self):
|
|
22
|
+
self.assertEqual(extract_claims('~~~\ntop-level only\n~~~'), [])
|
|
23
|
+
|
|
24
|
+
def test_long_fence(self):
|
|
25
|
+
self.assertEqual(extract_claims('````\n```\nrecursively\n````'), [])
|
|
26
|
+
|
|
27
|
+
def test_inline(self):
|
|
28
|
+
self.assertEqual(extract_claims('Uses `recursive scan`.')[0]['family'], 'recursive')
|
|
29
|
+
|
|
30
|
+
def test_bare_noun(self):
|
|
31
|
+
self.assertEqual(extract_claims('recursion support'), [])
|
|
32
|
+
|
|
33
|
+
def test_negated_recursive(self):
|
|
34
|
+
self.assertEqual(extract_claims('does not scan recursively'), [])
|
|
35
|
+
|
|
36
|
+
def test_walks_adverb(self):
|
|
37
|
+
self.assertEqual(extract_claims('walks recursively')[0]['family'], 'recursive')
|
|
38
|
+
|
|
39
|
+
def test_negated_shallow(self):
|
|
40
|
+
self.assertEqual(extract_claims('does not scan top-level only'), [])
|
|
41
|
+
|
|
42
|
+
def test_negated_explicit(self):
|
|
43
|
+
self.assertEqual([c['family'] for c in extract_claims('this tool is not recursive')], ['shallow'])
|
|
44
|
+
|
|
45
|
+
def test_boundaries(self):
|
|
46
|
+
self.assertEqual(extract_claims('non-recursively scanning; recursive scanner'), [])
|
|
47
|
+
|
|
48
|
+
def test_sentence_scope(self):
|
|
49
|
+
self.assertEqual(len(extract_claims('Not supported. Scans recursively.')), 1)
|
|
50
|
+
|
|
51
|
+
def test_inline_dotted_name_negation(self):
|
|
52
|
+
self.assertEqual(extract_claims('Does not use `os.walk` recursively.'), [])
|
|
53
|
+
|
|
54
|
+
def test_multiline_negation(self):
|
|
55
|
+
self.assertEqual(extract_claims('does not scan\nrecursively'), [])
|
|
56
|
+
|
|
57
|
+
def test_multiline_phrase(self):
|
|
58
|
+
self.assertEqual(extract_claims('Uses recursive\nscan.')[0]['line'], 1)
|
|
59
|
+
|
|
60
|
+
def test_line_number(self):
|
|
61
|
+
self.assertEqual(extract_claims('Hello.\n\nScans recursively.')[0]['line'], 3)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Evidence(unittest.TestCase):
|
|
65
|
+
def test_syntax_error(self):
|
|
66
|
+
evidence, error = extract_evidence('def broken(', 'x.py')
|
|
67
|
+
self.assertEqual(evidence, [])
|
|
68
|
+
self.assertTrue(error)
|
|
69
|
+
|
|
70
|
+
def test_nested_function_separate(self):
|
|
71
|
+
source = 'def scan():\n scan()\n def inner():\n os.listdir(".")\n'
|
|
72
|
+
self.assertEqual(extract_evidence(source, 'x.py')[0], [])
|
|
73
|
+
|
|
74
|
+
def test_dedupe(self):
|
|
75
|
+
self.assertEqual(len(extract_evidence('os.walk("."); os.walk(".")', 'x.py')[0]), 1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
POSITIVE = {
|
|
79
|
+
'os_walk': 'import os\nos.walk(".")',
|
|
80
|
+
'import_walk': 'from os import walk\nwalk(".")',
|
|
81
|
+
'alias_walk': 'from os import walk as descend\ndescend(".")',
|
|
82
|
+
'alias_os': 'import os as operating\noperating.walk(".")',
|
|
83
|
+
'legacy': 'os.path.walk(".", callback, None)',
|
|
84
|
+
'rglob': 'Path(".").rglob("*.py")',
|
|
85
|
+
'any_rglob': 'thing.rglob("*")',
|
|
86
|
+
'glob_keyword': 'import glob\nglob.glob("*", recursive=True)',
|
|
87
|
+
'glob_alias': 'from glob import glob as gg\ngg("*", recursive=True)',
|
|
88
|
+
'glob_pattern': 'glob.glob("**/*.py")',
|
|
89
|
+
'path_glob': 'path.glob("**/*.py")',
|
|
90
|
+
'self_scandir': 'def scan(p):\n for x in os.scandir(p):\n scan(x.path)',
|
|
91
|
+
'self_listdir': 'def scan(p):\n os.listdir(p)\n scan(p)',
|
|
92
|
+
'self_iterdir': 'def scan(p):\n p.iterdir()\n scan(p)',
|
|
93
|
+
'self_alias': 'from os import scandir as read\ndef scan(p):\n read(p)\n scan(p)',
|
|
94
|
+
'dead_code': 'def unused():\n os.walk(".")',
|
|
95
|
+
}
|
|
96
|
+
NEGATIVE = {
|
|
97
|
+
'bare_walk': 'walk(".")',
|
|
98
|
+
'flat_listdir': 'os.listdir(".")',
|
|
99
|
+
'flat_scandir': 'os.scandir(".")',
|
|
100
|
+
'flat_iterdir': 'path.iterdir()',
|
|
101
|
+
'plain_glob': 'glob.glob("*.py")',
|
|
102
|
+
'plain_path_glob': 'Path(".").glob("*.py")',
|
|
103
|
+
'variable_recursive': 'glob.glob("*", recursive=setting)',
|
|
104
|
+
'false_recursive': 'glob.glob("*", recursive=False)',
|
|
105
|
+
'integer_recursive': 'glob.glob("*", recursive=1)',
|
|
106
|
+
'non_glob_recursive': 'fetch("*", recursive=True)',
|
|
107
|
+
'factorial': 'def fact(n):\n return n * fact(n-1)',
|
|
108
|
+
'fixed_directories': 'for p in ["a", "b"]:\n os.listdir(p)',
|
|
109
|
+
'pattern_variable': 'glob.glob(pattern)',
|
|
110
|
+
'string_evidence': 'value = "os.walk()"',
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def phrase_test(phrase, family):
|
|
115
|
+
def test(self):
|
|
116
|
+
self.assertEqual([c['family'] for c in extract_claims(phrase.upper())], [family])
|
|
117
|
+
return test
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
for family, phrases in [('recursive', RECURSIVE), ('shallow', SHALLOW)]:
|
|
121
|
+
for i, phrase in enumerate(phrases):
|
|
122
|
+
setattr(Claims, 'test_phrase_' + family + '_' + str(i), phrase_test(phrase, family))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def evidence_test(source, positive):
|
|
126
|
+
def test(self):
|
|
127
|
+
evidence, error = extract_evidence(source, 'main.py')
|
|
128
|
+
self.assertIsNone(error)
|
|
129
|
+
self.assertEqual(bool(evidence), positive)
|
|
130
|
+
return test
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
for positive, cases in [(True, POSITIVE), (False, NEGATIVE)]:
|
|
134
|
+
for name, source in cases.items():
|
|
135
|
+
setattr(Evidence, 'test_' + name, evidence_test(source, positive))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Integration(unittest.TestCase):
|
|
139
|
+
def make_tree(self, claim='scans recursively', source='import os\nos.listdir(".")'):
|
|
140
|
+
temp = tempfile.TemporaryDirectory()
|
|
141
|
+
self.addCleanup(temp.cleanup)
|
|
142
|
+
root = Path(temp.name)
|
|
143
|
+
(root / 'README.md').write_text(claim)
|
|
144
|
+
if source is not None:
|
|
145
|
+
(root / 'main.py').write_text(source)
|
|
146
|
+
return root
|
|
147
|
+
|
|
148
|
+
def cli(self, *args):
|
|
149
|
+
output = io.StringIO()
|
|
150
|
+
with contextlib.redirect_stdout(output):
|
|
151
|
+
code = main(list(map(str, args)))
|
|
152
|
+
return code, output.getvalue()
|
|
153
|
+
|
|
154
|
+
def test_good_recursive(self):
|
|
155
|
+
result = audit(FIXTURES / 'good/recursive')
|
|
156
|
+
self.assertEqual(result['findings'], [])
|
|
157
|
+
self.assertEqual(result['verified_claims'][0]['family'], 'recursive')
|
|
158
|
+
|
|
159
|
+
def test_good_shallow(self):
|
|
160
|
+
self.assertEqual(audit(FIXTURES / 'good/shallow')['verified_claims'][0]['family'], 'shallow')
|
|
161
|
+
|
|
162
|
+
def test_bad_recursive(self):
|
|
163
|
+
findings = audit(FIXTURES / 'bad/recursive')['findings']
|
|
164
|
+
self.assertEqual(len(findings), 1)
|
|
165
|
+
self.assertEqual(findings[0]['rule'], 'documented-recursive-claim-false')
|
|
166
|
+
self.assertEqual(findings[0]['severity'], 'HIGH')
|
|
167
|
+
|
|
168
|
+
def test_bad_shallow(self):
|
|
169
|
+
findings = audit(FIXTURES / 'bad/shallow')['findings']
|
|
170
|
+
self.assertEqual(len(findings), 1)
|
|
171
|
+
self.assertEqual(findings[0]['rule'], 'documented-shallow-claim-false')
|
|
172
|
+
self.assertEqual(len(findings[0]['evidence']), 1)
|
|
173
|
+
|
|
174
|
+
def test_testonly(self):
|
|
175
|
+
code, output = self.cli(FIXTURES / 'testonly', '--json')
|
|
176
|
+
finding = json.loads(output)['findings'][0]
|
|
177
|
+
self.assertEqual(code, 2)
|
|
178
|
+
self.assertEqual(finding['rule'], 'documented-recursive-claim-false-test-only')
|
|
179
|
+
self.assertEqual(finding['severity'], 'MEDIUM')
|
|
180
|
+
|
|
181
|
+
def test_high_threshold(self):
|
|
182
|
+
code, output = self.cli(FIXTURES / 'testonly', '--fail-on', 'HIGH')
|
|
183
|
+
self.assertEqual(code, 0)
|
|
184
|
+
self.assertIn('MEDIUM', output)
|
|
185
|
+
|
|
186
|
+
def test_ignore(self):
|
|
187
|
+
root = self.make_tree(source='os.walk(".")')
|
|
188
|
+
self.assertEqual(audit(root)['findings'], [])
|
|
189
|
+
self.assertEqual(audit(root, ['main.py'])['findings'][0]['evidence'], [{'kind': 'no-source'}])
|
|
190
|
+
|
|
191
|
+
def test_both_no_source(self):
|
|
192
|
+
root = self.make_tree('Scans recursively. Top-level only.', None)
|
|
193
|
+
findings = audit(root)['findings']
|
|
194
|
+
self.assertEqual(len(findings), 2)
|
|
195
|
+
self.assertTrue(all(f['severity'] == 'HIGH' and f['evidence'] == [{'kind': 'no-source'}] for f in findings))
|
|
196
|
+
|
|
197
|
+
def test_both_testonly(self):
|
|
198
|
+
root = self.make_tree('Scans recursively. Top-level only.')
|
|
199
|
+
(root / 'tests').mkdir()
|
|
200
|
+
(root / 'tests/test_x.py').write_text('os.walk(".")')
|
|
201
|
+
self.assertEqual(len(audit(root)['findings']), 2)
|
|
202
|
+
self.assertTrue(all(f['severity'] == 'MEDIUM' for f in audit(root)['findings']))
|
|
203
|
+
|
|
204
|
+
def test_no_claim(self):
|
|
205
|
+
self.assertEqual(audit(self.make_tree('A tool.', 'os.walk(".")'))['findings'], [])
|
|
206
|
+
|
|
207
|
+
def test_shallow_all_evidence(self):
|
|
208
|
+
root = self.make_tree('top-level only', 'os.walk(".")\np.rglob("*")')
|
|
209
|
+
self.assertEqual(len(audit(root)['findings'][0]['evidence']), 2)
|
|
210
|
+
|
|
211
|
+
def test_json_shape(self):
|
|
212
|
+
code, output = self.cli(FIXTURES / 'good/recursive', '--json')
|
|
213
|
+
self.assertEqual(code, 0)
|
|
214
|
+
self.assertEqual(set(json.loads(output)), {'findings', 'verified_claims', 'errors'})
|
|
215
|
+
|
|
216
|
+
def test_env_never_opened(self):
|
|
217
|
+
original = Path.open
|
|
218
|
+
def guarded(path, *args, **kwargs):
|
|
219
|
+
self.assertNotEqual(path.name, '.env')
|
|
220
|
+
return original(path, *args, **kwargs)
|
|
221
|
+
with patch.object(Path, 'open', guarded):
|
|
222
|
+
code, output = self.cli(FIXTURES / 'good/recursive', '--json')
|
|
223
|
+
self.assertEqual(code, 0)
|
|
224
|
+
self.assertNotIn('RECURSION_AUDIT_ENV_MARKER_DO_NOT_READ', output)
|
|
225
|
+
|
|
226
|
+
def test_symlinks(self):
|
|
227
|
+
root = self.make_tree()
|
|
228
|
+
with tempfile.TemporaryDirectory() as outside:
|
|
229
|
+
path = Path(outside) / 'source.py'
|
|
230
|
+
path.write_text('os.walk(".")')
|
|
231
|
+
(root / 'linked.py').symlink_to(path)
|
|
232
|
+
(root / 'linked_dir').symlink_to(outside, target_is_directory=True)
|
|
233
|
+
self.assertEqual(audit(root)['findings'][0]['severity'], 'HIGH')
|
|
234
|
+
self.assertEqual(audit(root)['findings'][0]['evidence'], [])
|
|
235
|
+
|
|
236
|
+
def test_exclusions(self):
|
|
237
|
+
root = self.make_tree('top-level only')
|
|
238
|
+
for directory in ['docs', 'examples']:
|
|
239
|
+
(root / directory).mkdir()
|
|
240
|
+
(root / directory / 'walk.py').write_text('os.walk(".")')
|
|
241
|
+
self.assertEqual(len(audit(root)['verified_claims']), 1)
|
|
242
|
+
|
|
243
|
+
def test_docs_nested(self):
|
|
244
|
+
root = self.make_tree('A tool.')
|
|
245
|
+
(root / 'docs/nested').mkdir(parents=True)
|
|
246
|
+
(root / 'docs/nested/guide.md').write_text('scans recursively')
|
|
247
|
+
self.assertEqual(len(audit(root)['findings']), 1)
|
|
248
|
+
|
|
249
|
+
def test_other_markdown_excluded(self):
|
|
250
|
+
root = self.make_tree('A tool.')
|
|
251
|
+
(root / 'OTHER.md').write_text('scans recursively')
|
|
252
|
+
self.assertEqual(audit(root)['findings'], [])
|
|
253
|
+
|
|
254
|
+
def test_parse_diagnostic(self):
|
|
255
|
+
root = self.make_tree(source='def bad(')
|
|
256
|
+
self.assertEqual(len(audit(root)['errors']), 1)
|
|
257
|
+
|
|
258
|
+
def test_version(self):
|
|
259
|
+
with self.assertRaises(SystemExit) as error:
|
|
260
|
+
code, output = self.cli('--version')
|
|
261
|
+
self.assertEqual(error.exception.code, 0)
|
|
262
|
+
result = subprocess.run([sys.executable, '-m', 'recursion_claim_audit', '--version'], capture_output=True, text=True)
|
|
263
|
+
self.assertEqual(result.stdout.strip(), '0.1.0')
|
|
264
|
+
|
|
265
|
+
def test_usage(self):
|
|
266
|
+
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error:
|
|
267
|
+
main(['--unknown'])
|
|
268
|
+
self.assertEqual(error.exception.code, 1)
|
|
269
|
+
|
|
270
|
+
def test_missing_target(self):
|
|
271
|
+
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error:
|
|
272
|
+
main(['/no/such/audit/tree'])
|
|
273
|
+
self.assertEqual(error.exception.code, 1)
|
|
274
|
+
|
|
275
|
+
def test_self_audit(self):
|
|
276
|
+
result = audit(Path(__file__).resolve().parents[1])
|
|
277
|
+
self.assertEqual(result['findings'], [])
|
|
278
|
+
self.assertEqual(result['verified_claims'], [])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == '__main__':
|
|
282
|
+
unittest.main()
|