recursion-claim-audit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,9 @@
1
+ recursion_claim_audit/__init__.py,sha256=tyalzAT4_vApfhWD0BRLYxa7CWomUC8DfTN73emd4Zs,94
2
+ recursion_claim_audit/__main__.py,sha256=E65AtSdNvcXg4uRaULQU7zG_xoO1LBLTf4bIOxCXAic,1798
3
+ recursion_claim_audit/audit.py,sha256=_fjdlKAuCm5xGgc_fwFbaPTm1HV3RfQiKv4_SnkH4Xk,8482
4
+ recursion_claim_audit-0.1.0.dist-info/licenses/LICENSE,sha256=npbfPSaCrk2DCGzXcDpFkAFgZ1Ghc4aeo9Kl0GJTjyk,1091
5
+ recursion_claim_audit-0.1.0.dist-info/METADATA,sha256=_VZA66FPQz-WU9DAJzGHn5GvQS1cTS9f4CY6HkumDW0,6662
6
+ recursion_claim_audit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ recursion_claim_audit-0.1.0.dist-info/entry_points.txt,sha256=43nMkT4UzORGzAPc6yfmUtd3UOOoDpxscgvb8PfZYgw,78
8
+ recursion_claim_audit-0.1.0.dist-info/top_level.txt,sha256=74_YOD4h_M5wQU0iUGgNTh0QtDPgT18k2hi1igRjeTo,22
9
+ recursion_claim_audit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ recursion-claim-audit = recursion_claim_audit.__main__:main
@@ -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 @@
1
+ recursion_claim_audit