pycheck-neuron 1.0.4__tar.gz → 1.0.6__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.
- {pycheck_neuron-1.0.4/pycheck_neuron.egg-info → pycheck_neuron-1.0.6}/PKG-INFO +12 -9
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/README.md +10 -7
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/__init__.py +10 -10
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/analyzer.py +245 -236
- pycheck_neuron-1.0.6/pycheck/audit.py +307 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/cli.py +223 -214
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/flow.py +624 -551
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/rules.py +1683 -1342
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6/pycheck_neuron.egg-info}/PKG-INFO +12 -9
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pyproject.toml +66 -66
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/tests/test_rules.py +1118 -882
- pycheck_neuron-1.0.4/pycheck/audit.py +0 -153
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/LICENSE +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/__main__.py +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/server.py +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/SOURCES.txt +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/dependency_links.txt +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/entry_points.txt +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/requires.txt +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/top_level.txt +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/setup.cfg +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/tests/test_audit.py +0 -0
- {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/tests/test_cli.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycheck-neuron
|
|
3
|
-
Version: 1.0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.0.6
|
|
4
|
+
Summary: 34-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies.
|
|
5
5
|
Author: NEURON Team
|
|
6
6
|
Author-email: Fayo Ibrahim <fayo@neuron-lab.org>
|
|
7
7
|
License-Expression: MIT
|
|
@@ -38,7 +38,7 @@ Dynamic: license-file
|
|
|
38
38
|
|
|
39
39
|
# PyCheck — ML Safety Analyzer
|
|
40
40
|
|
|
41
|
-
**
|
|
41
|
+
**34 rules. Zero dependencies. Catches the bugs that cost millions.**
|
|
42
42
|
|
|
43
43
|
PyCheck is a static AST analyzer that detects **temporal lookahead bias**, **causal confusion**, and **unguarded uncertainty** in Python ML and trading scripts — before you run a single line of code.
|
|
44
44
|
|
|
@@ -68,7 +68,7 @@ PyCheck catches all three before execution:
|
|
|
68
68
|
=================================================================
|
|
69
69
|
PyCheck — NEURON ML Safety Analyzer
|
|
70
70
|
Scanning: strategy.py
|
|
71
|
-
Rules:
|
|
71
|
+
Rules: 34 active + Data Flow & Provenance Engine
|
|
72
72
|
=================================================================
|
|
73
73
|
|
|
74
74
|
ERROR[T001]: .shift(-1) accesses data 1 rows INTO THE FUTURE
|
|
@@ -100,11 +100,11 @@ PyCheck catches all three before execution:
|
|
|
100
100
|
|
|
101
101
|
## What It Catches
|
|
102
102
|
|
|
103
|
-
### Temporal Leak Rules (
|
|
103
|
+
### Temporal Leak Rules (19 rules — `T001`–`T019`)
|
|
104
104
|
|
|
105
105
|
| Rule | What It Catches | Severity |
|
|
106
106
|
|------|----------------|----------|
|
|
107
|
-
| `T001` | `.shift(-1)` — accessing future data | **Error** |
|
|
107
|
+
| `T001` | `.shift(-1)` — accessing future data (including bound-method aliases like `sh = df.shift; sh(-1)`) | **Error** |
|
|
108
108
|
| `T002` | `train_test_split()` on time-series data without chronological ordering | **Error** |
|
|
109
109
|
| `T003` | `.rolling()` computed before the train/test split | Warning |
|
|
110
110
|
| `T004` | `.expanding()` computed before split | Warning |
|
|
@@ -122,6 +122,7 @@ PyCheck catches all three before execution:
|
|
|
122
122
|
| `T016` | `pd.merge_asof()` with `direction='nearest'` or `'forward'` | **Error** |
|
|
123
123
|
| `T017` | Target laundering & un-split target encoding (`regime_success_rate`) | **Error** |
|
|
124
124
|
| `T018` | In-sample target encoding via `.transform()` or un-split group means | **Error** |
|
|
125
|
+
| `T019` | `np.roll(arr, -k)` — negative shift rotates future data into present position | **Error** |
|
|
125
126
|
|
|
126
127
|
### Causal Confusion Rules (7 rules — `C001`–`C007`)
|
|
127
128
|
|
|
@@ -189,7 +190,7 @@ pycheck script.py # Errors + warnings (default)
|
|
|
189
190
|
pycheck script.py --info # Include info-level diagnostics
|
|
190
191
|
pycheck script.py --quiet # Errors only
|
|
191
192
|
pycheck script.py --json # JSON output — pipe into CI/CD
|
|
192
|
-
pycheck --list # List all
|
|
193
|
+
pycheck --list # List all 34 rules
|
|
193
194
|
pycheck --help
|
|
194
195
|
```
|
|
195
196
|
|
|
@@ -239,8 +240,10 @@ Returns structured output for automated pipelines:
|
|
|
239
240
|
|
|
240
241
|
### Exit Codes
|
|
241
242
|
|
|
242
|
-
- `0` —
|
|
243
|
-
- `1` —
|
|
243
|
+
- `0` — Verified clean / no errors found
|
|
244
|
+
- `1` — Leakage or safety errors detected
|
|
245
|
+
- `2` — CLI usage syntax error
|
|
246
|
+
- `3` — Unverifiable (opaque dynamic code execution like `eval`/`exec` or unscanned local imports in `--audit` mode)
|
|
244
247
|
|
|
245
248
|
Clean integration with `make`, `pre-commit`, GitHub Actions, and GitLab CI.
|
|
246
249
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PyCheck — ML Safety Analyzer
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**34 rules. Zero dependencies. Catches the bugs that cost millions.**
|
|
4
4
|
|
|
5
5
|
PyCheck is a static AST analyzer that detects **temporal lookahead bias**, **causal confusion**, and **unguarded uncertainty** in Python ML and trading scripts — before you run a single line of code.
|
|
6
6
|
|
|
@@ -30,7 +30,7 @@ PyCheck catches all three before execution:
|
|
|
30
30
|
=================================================================
|
|
31
31
|
PyCheck — NEURON ML Safety Analyzer
|
|
32
32
|
Scanning: strategy.py
|
|
33
|
-
Rules:
|
|
33
|
+
Rules: 34 active + Data Flow & Provenance Engine
|
|
34
34
|
=================================================================
|
|
35
35
|
|
|
36
36
|
ERROR[T001]: .shift(-1) accesses data 1 rows INTO THE FUTURE
|
|
@@ -62,11 +62,11 @@ PyCheck catches all three before execution:
|
|
|
62
62
|
|
|
63
63
|
## What It Catches
|
|
64
64
|
|
|
65
|
-
### Temporal Leak Rules (
|
|
65
|
+
### Temporal Leak Rules (19 rules — `T001`–`T019`)
|
|
66
66
|
|
|
67
67
|
| Rule | What It Catches | Severity |
|
|
68
68
|
|------|----------------|----------|
|
|
69
|
-
| `T001` | `.shift(-1)` — accessing future data | **Error** |
|
|
69
|
+
| `T001` | `.shift(-1)` — accessing future data (including bound-method aliases like `sh = df.shift; sh(-1)`) | **Error** |
|
|
70
70
|
| `T002` | `train_test_split()` on time-series data without chronological ordering | **Error** |
|
|
71
71
|
| `T003` | `.rolling()` computed before the train/test split | Warning |
|
|
72
72
|
| `T004` | `.expanding()` computed before split | Warning |
|
|
@@ -84,6 +84,7 @@ PyCheck catches all three before execution:
|
|
|
84
84
|
| `T016` | `pd.merge_asof()` with `direction='nearest'` or `'forward'` | **Error** |
|
|
85
85
|
| `T017` | Target laundering & un-split target encoding (`regime_success_rate`) | **Error** |
|
|
86
86
|
| `T018` | In-sample target encoding via `.transform()` or un-split group means | **Error** |
|
|
87
|
+
| `T019` | `np.roll(arr, -k)` — negative shift rotates future data into present position | **Error** |
|
|
87
88
|
|
|
88
89
|
### Causal Confusion Rules (7 rules — `C001`–`C007`)
|
|
89
90
|
|
|
@@ -151,7 +152,7 @@ pycheck script.py # Errors + warnings (default)
|
|
|
151
152
|
pycheck script.py --info # Include info-level diagnostics
|
|
152
153
|
pycheck script.py --quiet # Errors only
|
|
153
154
|
pycheck script.py --json # JSON output — pipe into CI/CD
|
|
154
|
-
pycheck --list # List all
|
|
155
|
+
pycheck --list # List all 34 rules
|
|
155
156
|
pycheck --help
|
|
156
157
|
```
|
|
157
158
|
|
|
@@ -201,8 +202,10 @@ Returns structured output for automated pipelines:
|
|
|
201
202
|
|
|
202
203
|
### Exit Codes
|
|
203
204
|
|
|
204
|
-
- `0` —
|
|
205
|
-
- `1` —
|
|
205
|
+
- `0` — Verified clean / no errors found
|
|
206
|
+
- `1` — Leakage or safety errors detected
|
|
207
|
+
- `2` — CLI usage syntax error
|
|
208
|
+
- `3` — Unverifiable (opaque dynamic code execution like `eval`/`exec` or unscanned local imports in `--audit` mode)
|
|
206
209
|
|
|
207
210
|
Clean integration with `make`, `pre-commit`, GitHub Actions, and GitLab CI.
|
|
208
211
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""
|
|
2
|
-
PyCheck — NEURON ML Safety Analyzer
|
|
3
|
-
Catches temporal leaks, causal confusion, and uncertainty bugs in Python ML scripts.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
__version__ = "1.0.
|
|
7
|
-
|
|
8
|
-
from pycheck.analyzer import analyze_file, format_output
|
|
9
|
-
from pycheck.rules import ALL_RULES, RULES_BY_CODE
|
|
10
|
-
from pycheck.flow import TaintTracker
|
|
1
|
+
"""
|
|
2
|
+
PyCheck — NEURON ML Safety Analyzer
|
|
3
|
+
Catches temporal leaks, causal confusion, and uncertainty bugs in Python ML scripts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.6"
|
|
7
|
+
|
|
8
|
+
from pycheck.analyzer import analyze_file, analyze_code, format_output
|
|
9
|
+
from pycheck.rules import ALL_RULES, RULES_BY_CODE
|
|
10
|
+
from pycheck.flow import TaintTracker
|
|
@@ -1,236 +1,245 @@
|
|
|
1
|
-
"""
|
|
2
|
-
PyCheck Analyzer — ML Safety Static Analysis Engine
|
|
3
|
-
Uses a rule registry + taint flow analysis to detect temporal leaks,
|
|
4
|
-
causal confusion, and uncertainty bugs in Python ML scripts.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import ast
|
|
8
|
-
import sys
|
|
9
|
-
import json
|
|
10
|
-
from typing import List, Tuple
|
|
11
|
-
|
|
12
|
-
from pycheck.rules import ALL_RULES, RULES_BY_CODE, AnalysisContext, Diagnostic
|
|
13
|
-
from pycheck.flow import TaintTracker
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class PyCheckAnalyzer(ast.NodeVisitor):
|
|
17
|
-
"""
|
|
18
|
-
AST visitor that runs all registered rules against each node.
|
|
19
|
-
Also tracks loop context for loop-aware rules.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
def __init__(self, source_lines: List[str], filepath: str):
|
|
23
|
-
self.ctx = AnalysisContext(source_lines, filepath)
|
|
24
|
-
self.diagnostics: List[Diagnostic] = []
|
|
25
|
-
self.rules = ALL_RULES
|
|
26
|
-
|
|
27
|
-
def _run_rules(self, node: ast.AST):
|
|
28
|
-
"""Run all rules against a single AST node."""
|
|
29
|
-
for rule in self.rules:
|
|
30
|
-
results = rule.check_node(node, self.ctx)
|
|
31
|
-
self.diagnostics.extend(results)
|
|
32
|
-
|
|
33
|
-
def generic_visit(self, node):
|
|
34
|
-
"""Override to run rules on every node."""
|
|
35
|
-
self._run_rules(node)
|
|
36
|
-
super().generic_visit(node)
|
|
37
|
-
|
|
38
|
-
def visit_Assign(self, node):
|
|
39
|
-
"""Track literal constant assignments for constant folding / alias analysis."""
|
|
40
|
-
for target in node.targets:
|
|
41
|
-
if isinstance(target, ast.Name):
|
|
42
|
-
val = None
|
|
43
|
-
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, (int, float)):
|
|
44
|
-
val = node.value.value
|
|
45
|
-
elif isinstance(node.value, ast.UnaryOp) and isinstance(node.value.op, ast.USub):
|
|
46
|
-
if isinstance(node.value.operand, ast.Constant) and isinstance(node.value.operand.value, (int, float)):
|
|
47
|
-
val = -node.value.operand.value
|
|
48
|
-
if val is not None:
|
|
49
|
-
self.ctx.constants[target.id] = val
|
|
50
|
-
self.ctx.assignments[target.id] = node.value
|
|
51
|
-
self._run_rules(node)
|
|
52
|
-
self.generic_visit(node)
|
|
53
|
-
|
|
54
|
-
def visit_For(self, node):
|
|
55
|
-
"""Track loop context for rules that need it."""
|
|
56
|
-
old_in_loop = self.ctx.in_loop
|
|
57
|
-
old_loop_var = self.ctx.loop_var
|
|
58
|
-
|
|
59
|
-
self.ctx.in_loop = True
|
|
60
|
-
if isinstance(node.target, ast.Name):
|
|
61
|
-
self.ctx.loop_var = node.target.id
|
|
62
|
-
|
|
63
|
-
self._run_rules(node)
|
|
64
|
-
# Visit body with loop context
|
|
65
|
-
for child in ast.iter_child_nodes(node):
|
|
66
|
-
self.visit(child)
|
|
67
|
-
|
|
68
|
-
self.ctx.in_loop = old_in_loop
|
|
69
|
-
self.ctx.loop_var = old_loop_var
|
|
70
|
-
|
|
71
|
-
def visit_While(self, node):
|
|
72
|
-
"""Track loop context."""
|
|
73
|
-
old_in_loop = self.ctx.in_loop
|
|
74
|
-
self.ctx.in_loop = True
|
|
75
|
-
|
|
76
|
-
self._run_rules(node)
|
|
77
|
-
for child in ast.iter_child_nodes(node):
|
|
78
|
-
self.visit(child)
|
|
79
|
-
|
|
80
|
-
self.ctx.in_loop = old_in_loop
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def parse_suppressions(source_lines: List[str]) -> dict:
|
|
85
|
-
"""
|
|
86
|
-
Parse inline and line-above suppression comments:
|
|
87
|
-
# pycheck: ignore[T001, C002] reason: ...
|
|
88
|
-
# pycheck: ignore
|
|
89
|
-
# noqa: T001
|
|
90
|
-
"""
|
|
91
|
-
import re
|
|
92
|
-
suppressions = {}
|
|
93
|
-
pattern = re.compile(
|
|
94
|
-
r'#\s*(?:pycheck:\s*ignore(?:\[([a-zA-Z0-9,\s]+)\])?|noqa(?::\s*([a-zA-Z0-9,\s]+))?)',
|
|
95
|
-
re.IGNORECASE
|
|
96
|
-
)
|
|
97
|
-
for lineno, line in enumerate(source_lines, start=1):
|
|
98
|
-
match = pattern.search(line)
|
|
99
|
-
if match:
|
|
100
|
-
rules_str = match.group(1) or match.group(2)
|
|
101
|
-
codes = {c.strip().upper() for c in rules_str.split(',') if c.strip()} if rules_str else {'ALL'}
|
|
102
|
-
suppressions.setdefault(lineno, set()).update(codes)
|
|
103
|
-
# If the comment is on its own standalone line, also apply to the next code line
|
|
104
|
-
if line.strip().startswith('#') and lineno + 1 <= len(source_lines):
|
|
105
|
-
suppressions.setdefault(lineno + 1, set()).update(codes)
|
|
106
|
-
return suppressions
|
|
107
|
-
|
|
108
|
-
def
|
|
109
|
-
"""
|
|
110
|
-
Analyze
|
|
111
|
-
Returns (diagnostics_list, source_lines).
|
|
112
|
-
"""
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
for
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
safe_print(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
safe_print(f"
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
safe_print(f"{line_sep}
|
|
1
|
+
"""
|
|
2
|
+
PyCheck Analyzer — ML Safety Static Analysis Engine
|
|
3
|
+
Uses a rule registry + taint flow analysis to detect temporal leaks,
|
|
4
|
+
causal confusion, and uncertainty bugs in Python ML scripts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
from typing import List, Tuple
|
|
11
|
+
|
|
12
|
+
from pycheck.rules import ALL_RULES, RULES_BY_CODE, AnalysisContext, Diagnostic
|
|
13
|
+
from pycheck.flow import TaintTracker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PyCheckAnalyzer(ast.NodeVisitor):
|
|
17
|
+
"""
|
|
18
|
+
AST visitor that runs all registered rules against each node.
|
|
19
|
+
Also tracks loop context for loop-aware rules.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, source_lines: List[str], filepath: str):
|
|
23
|
+
self.ctx = AnalysisContext(source_lines, filepath)
|
|
24
|
+
self.diagnostics: List[Diagnostic] = []
|
|
25
|
+
self.rules = ALL_RULES
|
|
26
|
+
|
|
27
|
+
def _run_rules(self, node: ast.AST):
|
|
28
|
+
"""Run all rules against a single AST node."""
|
|
29
|
+
for rule in self.rules:
|
|
30
|
+
results = rule.check_node(node, self.ctx)
|
|
31
|
+
self.diagnostics.extend(results)
|
|
32
|
+
|
|
33
|
+
def generic_visit(self, node):
|
|
34
|
+
"""Override to run rules on every node."""
|
|
35
|
+
self._run_rules(node)
|
|
36
|
+
super().generic_visit(node)
|
|
37
|
+
|
|
38
|
+
def visit_Assign(self, node):
|
|
39
|
+
"""Track literal constant assignments for constant folding / alias analysis."""
|
|
40
|
+
for target in node.targets:
|
|
41
|
+
if isinstance(target, ast.Name):
|
|
42
|
+
val = None
|
|
43
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, (int, float)):
|
|
44
|
+
val = node.value.value
|
|
45
|
+
elif isinstance(node.value, ast.UnaryOp) and isinstance(node.value.op, ast.USub):
|
|
46
|
+
if isinstance(node.value.operand, ast.Constant) and isinstance(node.value.operand.value, (int, float)):
|
|
47
|
+
val = -node.value.operand.value
|
|
48
|
+
if val is not None:
|
|
49
|
+
self.ctx.constants[target.id] = val
|
|
50
|
+
self.ctx.assignments[target.id] = node.value
|
|
51
|
+
self._run_rules(node)
|
|
52
|
+
self.generic_visit(node)
|
|
53
|
+
|
|
54
|
+
def visit_For(self, node):
|
|
55
|
+
"""Track loop context for rules that need it."""
|
|
56
|
+
old_in_loop = self.ctx.in_loop
|
|
57
|
+
old_loop_var = self.ctx.loop_var
|
|
58
|
+
|
|
59
|
+
self.ctx.in_loop = True
|
|
60
|
+
if isinstance(node.target, ast.Name):
|
|
61
|
+
self.ctx.loop_var = node.target.id
|
|
62
|
+
|
|
63
|
+
self._run_rules(node)
|
|
64
|
+
# Visit body with loop context
|
|
65
|
+
for child in ast.iter_child_nodes(node):
|
|
66
|
+
self.visit(child)
|
|
67
|
+
|
|
68
|
+
self.ctx.in_loop = old_in_loop
|
|
69
|
+
self.ctx.loop_var = old_loop_var
|
|
70
|
+
|
|
71
|
+
def visit_While(self, node):
|
|
72
|
+
"""Track loop context."""
|
|
73
|
+
old_in_loop = self.ctx.in_loop
|
|
74
|
+
self.ctx.in_loop = True
|
|
75
|
+
|
|
76
|
+
self._run_rules(node)
|
|
77
|
+
for child in ast.iter_child_nodes(node):
|
|
78
|
+
self.visit(child)
|
|
79
|
+
|
|
80
|
+
self.ctx.in_loop = old_in_loop
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_suppressions(source_lines: List[str]) -> dict:
|
|
85
|
+
"""
|
|
86
|
+
Parse inline and line-above suppression comments:
|
|
87
|
+
# pycheck: ignore[T001, C002] reason: ...
|
|
88
|
+
# pycheck: ignore
|
|
89
|
+
# noqa: T001
|
|
90
|
+
"""
|
|
91
|
+
import re
|
|
92
|
+
suppressions = {}
|
|
93
|
+
pattern = re.compile(
|
|
94
|
+
r'#\s*(?:pycheck:\s*ignore(?:\[([a-zA-Z0-9,\s]+)\])?|noqa(?::\s*([a-zA-Z0-9,\s]+))?)',
|
|
95
|
+
re.IGNORECASE
|
|
96
|
+
)
|
|
97
|
+
for lineno, line in enumerate(source_lines, start=1):
|
|
98
|
+
match = pattern.search(line)
|
|
99
|
+
if match:
|
|
100
|
+
rules_str = match.group(1) or match.group(2)
|
|
101
|
+
codes = {c.strip().upper() for c in rules_str.split(',') if c.strip()} if rules_str else {'ALL'}
|
|
102
|
+
suppressions.setdefault(lineno, set()).update(codes)
|
|
103
|
+
# If the comment is on its own standalone line, also apply to the next code line
|
|
104
|
+
if line.strip().startswith('#') and lineno + 1 <= len(source_lines):
|
|
105
|
+
suppressions.setdefault(lineno + 1, set()).update(codes)
|
|
106
|
+
return suppressions
|
|
107
|
+
|
|
108
|
+
def analyze_code(source: str, filepath: str = "<string>") -> Tuple[List[dict], List[str]]:
|
|
109
|
+
"""
|
|
110
|
+
Analyze Python source code for ML safety issues.
|
|
111
|
+
Returns (diagnostics_list, source_lines).
|
|
112
|
+
"""
|
|
113
|
+
source_lines = source.split('\n')
|
|
114
|
+
tree = ast.parse(source, filename=filepath)
|
|
115
|
+
# Link parent references across the AST for sink-gated rule analysis
|
|
116
|
+
for parent in ast.walk(tree):
|
|
117
|
+
for child in ast.iter_child_nodes(parent):
|
|
118
|
+
child.parent = parent
|
|
119
|
+
|
|
120
|
+
# Phase 1: Data flow taint & provenance analysis
|
|
121
|
+
tracker = TaintTracker()
|
|
122
|
+
flow_diagnostics = tracker.analyze(tree, source_lines)
|
|
123
|
+
|
|
124
|
+
# Phase 2: Rule-based AST analysis (with provenance populated in ctx)
|
|
125
|
+
analyzer = PyCheckAnalyzer(source_lines, filepath)
|
|
126
|
+
analyzer.ctx.tree = tree
|
|
127
|
+
analyzer.ctx.tainted_vars = {k: v.reason for k, v in tracker.tainted_vars.items()}
|
|
128
|
+
analyzer.ctx.train_vars = tracker.train_vars
|
|
129
|
+
analyzer.ctx.test_vars = tracker.test_vars
|
|
130
|
+
analyzer.ctx.test_contaminated_vars = tracker.test_contaminated_vars
|
|
131
|
+
analyzer.visit(tree)
|
|
132
|
+
|
|
133
|
+
# Combine and deduplicate
|
|
134
|
+
all_diagnostics = analyzer.diagnostics + flow_diagnostics
|
|
135
|
+
|
|
136
|
+
suppressed_map = parse_suppressions(source_lines)
|
|
137
|
+
|
|
138
|
+
# Deduplicate and filter suppressed diagnostics
|
|
139
|
+
seen = set()
|
|
140
|
+
unique = []
|
|
141
|
+
for d in all_diagnostics:
|
|
142
|
+
key = (d.line, d.code)
|
|
143
|
+
if key not in seen:
|
|
144
|
+
seen.add(key)
|
|
145
|
+
line_suppressed = suppressed_map.get(d.line, set())
|
|
146
|
+
if 'ALL' not in line_suppressed and d.code not in line_suppressed:
|
|
147
|
+
unique.append(d)
|
|
148
|
+
|
|
149
|
+
# Sort by line number
|
|
150
|
+
unique.sort(key=lambda d: (d.line, d.col))
|
|
151
|
+
|
|
152
|
+
return [d.to_dict() for d in unique], source_lines
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def analyze_file(filepath: str) -> Tuple[List[dict], List[str]]:
|
|
156
|
+
"""
|
|
157
|
+
Analyze a Python (.py) or Jupyter notebook (.ipynb) file for ML safety issues.
|
|
158
|
+
Returns (diagnostics_list, source_lines).
|
|
159
|
+
"""
|
|
160
|
+
if filepath.endswith('.ipynb'):
|
|
161
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
162
|
+
nb = json.load(f)
|
|
163
|
+
code_lines = []
|
|
164
|
+
for cell in nb.get('cells', []):
|
|
165
|
+
if cell.get('cell_type') == 'code':
|
|
166
|
+
raw = cell.get('source', [])
|
|
167
|
+
if isinstance(raw, str):
|
|
168
|
+
raw = raw.splitlines(True)
|
|
169
|
+
for line in raw:
|
|
170
|
+
s_line = line.strip()
|
|
171
|
+
if s_line.startswith('%') or s_line.startswith('!'):
|
|
172
|
+
code_lines.append(f"# {line}")
|
|
173
|
+
else:
|
|
174
|
+
code_lines.append(line)
|
|
175
|
+
code_lines.append('\n')
|
|
176
|
+
source = "".join(code_lines)
|
|
177
|
+
else:
|
|
178
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
179
|
+
source = f.read()
|
|
180
|
+
|
|
181
|
+
return analyze_code(source, filepath=filepath)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def format_output(filepath: str, diagnostics: List[dict], source_lines: List[str],
|
|
185
|
+
show_info: bool = False):
|
|
186
|
+
"""Format diagnostics as human-readable output."""
|
|
187
|
+
|
|
188
|
+
# Filter by severity
|
|
189
|
+
if not show_info:
|
|
190
|
+
diagnostics = [d for d in diagnostics if d['severity'] != 'info']
|
|
191
|
+
|
|
192
|
+
errors = [d for d in diagnostics if d['severity'] == 'error']
|
|
193
|
+
warnings = [d for d in diagnostics if d['severity'] == 'warning']
|
|
194
|
+
infos = [d for d in diagnostics if d['severity'] == 'info']
|
|
195
|
+
|
|
196
|
+
def safe_print(text):
|
|
197
|
+
try:
|
|
198
|
+
print(text)
|
|
199
|
+
except UnicodeEncodeError:
|
|
200
|
+
print(text.encode('ascii', errors='replace').decode('ascii'))
|
|
201
|
+
|
|
202
|
+
header_sep = "=" * 65
|
|
203
|
+
line_sep = "-" * 65
|
|
204
|
+
|
|
205
|
+
safe_print(f"\n{header_sep}")
|
|
206
|
+
safe_print(f" PyCheck — NEURON ML Safety Analyzer")
|
|
207
|
+
safe_print(f" Scanning: {filepath}")
|
|
208
|
+
safe_print(f" Rules: {len(ALL_RULES)} active")
|
|
209
|
+
safe_print(f"{header_sep}\n")
|
|
210
|
+
|
|
211
|
+
if not diagnostics:
|
|
212
|
+
safe_print(" [OK] No issues found.\n")
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
safe_print(f" {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)\n")
|
|
216
|
+
|
|
217
|
+
for d in diagnostics:
|
|
218
|
+
line_num = d['line']
|
|
219
|
+
|
|
220
|
+
# Color-like severity prefix
|
|
221
|
+
severity_label = d['severity'].upper()
|
|
222
|
+
safe_print(f" {severity_label}[{d['code']}]: {d['message']}")
|
|
223
|
+
safe_print(f" --> {filepath}:{line_num}:{d['col']}")
|
|
224
|
+
|
|
225
|
+
if 0 < line_num <= len(source_lines):
|
|
226
|
+
line_text = source_lines[line_num - 1]
|
|
227
|
+
safe_print(f" {line_num:>3} | {line_text}")
|
|
228
|
+
pointer_start = d['col']
|
|
229
|
+
safe_print(f" {' ' * pointer_start}^^^^^^^^^^^")
|
|
230
|
+
|
|
231
|
+
if 'help' in d:
|
|
232
|
+
safe_print(f" help: {d['help']}")
|
|
233
|
+
|
|
234
|
+
safe_print("")
|
|
235
|
+
|
|
236
|
+
safe_print(f"{line_sep}")
|
|
237
|
+
safe_print(f" Summary: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)")
|
|
238
|
+
if errors:
|
|
239
|
+
safe_print(f" These {len(errors)} error(s) would be COMPILE-TIME ERRORS in NEURON")
|
|
240
|
+
elif warnings:
|
|
241
|
+
safe_print(f" These {len(warnings)} temporal issue(s) are guarded at compile-time in NEURON")
|
|
242
|
+
safe_print(f" Python detected: 0 of these issues at runtime.")
|
|
243
|
+
safe_print(f" --> NEURON Zero-Leakage Language: https://github.com/neuronlabs-ai/neuron-lang")
|
|
244
|
+
safe_print(f" --> PyCheck Documentation & CI/CD: https://github.com/neuronlabs-ai/pycheck")
|
|
245
|
+
safe_print(f"{line_sep}\n")
|