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.
Files changed (27) hide show
  1. recursion_claim_audit-0.1.0/LICENSE +21 -0
  2. recursion_claim_audit-0.1.0/MANIFEST.in +2 -0
  3. recursion_claim_audit-0.1.0/PKG-INFO +165 -0
  4. recursion_claim_audit-0.1.0/README.md +155 -0
  5. recursion_claim_audit-0.1.0/pyproject.toml +18 -0
  6. recursion_claim_audit-0.1.0/setup.cfg +4 -0
  7. recursion_claim_audit-0.1.0/src/recursion_claim_audit/__init__.py +2 -0
  8. recursion_claim_audit-0.1.0/src/recursion_claim_audit/__main__.py +44 -0
  9. recursion_claim_audit-0.1.0/src/recursion_claim_audit/audit.py +181 -0
  10. recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/PKG-INFO +165 -0
  11. recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/SOURCES.txt +25 -0
  12. recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/dependency_links.txt +1 -0
  13. recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/entry_points.txt +2 -0
  14. recursion_claim_audit-0.1.0/src/recursion_claim_audit.egg-info/top_level.txt +1 -0
  15. recursion_claim_audit-0.1.0/tests/fixtures/bad/recursive/README.md +1 -0
  16. recursion_claim_audit-0.1.0/tests/fixtures/bad/recursive/main.py +2 -0
  17. recursion_claim_audit-0.1.0/tests/fixtures/bad/shallow/README.md +1 -0
  18. recursion_claim_audit-0.1.0/tests/fixtures/bad/shallow/main.py +2 -0
  19. recursion_claim_audit-0.1.0/tests/fixtures/good/recursive/.env +1 -0
  20. recursion_claim_audit-0.1.0/tests/fixtures/good/recursive/README.md +1 -0
  21. recursion_claim_audit-0.1.0/tests/fixtures/good/recursive/main.py +2 -0
  22. recursion_claim_audit-0.1.0/tests/fixtures/good/shallow/README.md +1 -0
  23. recursion_claim_audit-0.1.0/tests/fixtures/good/shallow/main.py +2 -0
  24. recursion_claim_audit-0.1.0/tests/fixtures/testonly/README.md +1 -0
  25. recursion_claim_audit-0.1.0/tests/fixtures/testonly/main.py +2 -0
  26. recursion_claim_audit-0.1.0/tests/fixtures/testonly/tests/test_work.py +4 -0
  27. 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,2 @@
1
+ include LICENSE README.md
2
+ recursive-include tests *.py *.md .env
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Audit documented directory traversal depth against Python source."""
2
+ __version__ = '0.1.0'
@@ -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,2 @@
1
+ [console_scripts]
2
+ recursion-claim-audit = recursion_claim_audit.__main__:main
@@ -0,0 +1 @@
1
+ This tool scans recursively.
@@ -0,0 +1,2 @@
1
+ import os
2
+ entries = os.listdir(".")
@@ -0,0 +1 @@
1
+ This tool top-level only.
@@ -0,0 +1,2 @@
1
+ import os
2
+ for entry in os.walk("."): pass
@@ -0,0 +1 @@
1
+ RECURSION_AUDIT_ENV_MARKER_DO_NOT_READ
@@ -0,0 +1 @@
1
+ This tool scans recursively.
@@ -0,0 +1,2 @@
1
+ import os
2
+ for entry in os.walk("."): pass
@@ -0,0 +1 @@
1
+ This tool top-level only.
@@ -0,0 +1,2 @@
1
+ import os
2
+ entries = os.listdir(".")
@@ -0,0 +1 @@
1
+ This tool scans recursively.
@@ -0,0 +1,2 @@
1
+ import os
2
+ entries = os.listdir(".")
@@ -0,0 +1,4 @@
1
+ import os
2
+
3
+ def test_walk():
4
+ return list(os.walk("."))
@@ -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()