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.
Files changed (23) hide show
  1. {pycheck_neuron-1.0.4/pycheck_neuron.egg-info → pycheck_neuron-1.0.6}/PKG-INFO +12 -9
  2. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/README.md +10 -7
  3. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/__init__.py +10 -10
  4. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/analyzer.py +245 -236
  5. pycheck_neuron-1.0.6/pycheck/audit.py +307 -0
  6. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/cli.py +223 -214
  7. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/flow.py +624 -551
  8. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/rules.py +1683 -1342
  9. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6/pycheck_neuron.egg-info}/PKG-INFO +12 -9
  10. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pyproject.toml +66 -66
  11. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/tests/test_rules.py +1118 -882
  12. pycheck_neuron-1.0.4/pycheck/audit.py +0 -153
  13. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/LICENSE +0 -0
  14. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/__main__.py +0 -0
  15. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck/server.py +0 -0
  16. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/SOURCES.txt +0 -0
  17. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/dependency_links.txt +0 -0
  18. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/entry_points.txt +0 -0
  19. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/requires.txt +0 -0
  20. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/pycheck_neuron.egg-info/top_level.txt +0 -0
  21. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/setup.cfg +0 -0
  22. {pycheck_neuron-1.0.4 → pycheck_neuron-1.0.6}/tests/test_audit.py +0 -0
  23. {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
4
- Summary: 33-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies.
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
- **33 rules. Zero dependencies. Catches the bugs that cost millions.**
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: 33 active + Data Flow & Provenance Engine
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 (18 rules — `T001`–`T018`)
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 33 rules
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` — No errors found
243
- - `1` — One or more errors found
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
- **33 rules. Zero dependencies. Catches the bugs that cost millions.**
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 active + Data Flow & Provenance Engine
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 (18 rules — `T001`–`T018`)
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 33 rules
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` — No errors found
205
- - `1` — One or more errors found
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.4"
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 analyze_file(filepath: str) -> Tuple[List[dict], List[str]]:
109
- """
110
- Analyze a Python (.py) or Jupyter notebook (.ipynb) file for ML safety issues.
111
- Returns (diagnostics_list, source_lines).
112
- """
113
- if filepath.endswith('.ipynb'):
114
- with open(filepath, 'r', encoding='utf-8') as f:
115
- nb = json.load(f)
116
- code_lines = []
117
- for cell in nb.get('cells', []):
118
- if cell.get('cell_type') == 'code':
119
- raw = cell.get('source', [])
120
- if isinstance(raw, str):
121
- raw = raw.splitlines(True)
122
- for line in raw:
123
- s_line = line.strip()
124
- if s_line.startswith('%') or s_line.startswith('!'):
125
- code_lines.append(f"# {line}")
126
- else:
127
- code_lines.append(line)
128
- code_lines.append('\n')
129
- source = "".join(code_lines)
130
- else:
131
- with open(filepath, 'r', encoding='utf-8') as f:
132
- source = f.read()
133
-
134
- source_lines = source.split('\n')
135
- tree = ast.parse(source, filename=filepath)
136
- # Link parent references across the AST for sink-gated rule analysis
137
- for parent in ast.walk(tree):
138
- for child in ast.iter_child_nodes(parent):
139
- child.parent = parent
140
-
141
- # Phase 1: Data flow taint & provenance analysis
142
- tracker = TaintTracker()
143
- flow_diagnostics = tracker.analyze(tree, source_lines)
144
-
145
- # Phase 2: Rule-based AST analysis (with provenance populated in ctx)
146
- analyzer = PyCheckAnalyzer(source_lines, filepath)
147
- analyzer.ctx.tainted_vars = {k: v.reason for k, v in tracker.tainted_vars.items()}
148
- analyzer.ctx.train_vars = tracker.train_vars
149
- analyzer.ctx.test_vars = tracker.test_vars
150
- analyzer.ctx.test_contaminated_vars = tracker.test_contaminated_vars
151
- analyzer.visit(tree)
152
-
153
- # Combine and deduplicate
154
- all_diagnostics = analyzer.diagnostics + flow_diagnostics
155
-
156
- suppressed_map = parse_suppressions(source_lines)
157
-
158
- # Deduplicate and filter suppressed diagnostics
159
- seen = set()
160
- unique = []
161
- for d in all_diagnostics:
162
- key = (d.line, d.code)
163
- if key not in seen:
164
- seen.add(key)
165
- line_suppressed = suppressed_map.get(d.line, set())
166
- if 'ALL' not in line_suppressed and d.code not in line_suppressed:
167
- unique.append(d)
168
-
169
- # Sort by line number
170
- unique.sort(key=lambda d: (d.line, d.col))
171
-
172
- return [d.to_dict() for d in unique], source_lines
173
-
174
-
175
- def format_output(filepath: str, diagnostics: List[dict], source_lines: List[str],
176
- show_info: bool = False):
177
- """Format diagnostics as human-readable output."""
178
-
179
- # Filter by severity
180
- if not show_info:
181
- diagnostics = [d for d in diagnostics if d['severity'] != 'info']
182
-
183
- errors = [d for d in diagnostics if d['severity'] == 'error']
184
- warnings = [d for d in diagnostics if d['severity'] == 'warning']
185
- infos = [d for d in diagnostics if d['severity'] == 'info']
186
-
187
- def safe_print(text):
188
- try:
189
- print(text)
190
- except UnicodeEncodeError:
191
- print(text.encode('ascii', errors='replace').decode('ascii'))
192
-
193
- header_sep = "=" * 65
194
- line_sep = "-" * 65
195
-
196
- safe_print(f"\n{header_sep}")
197
- safe_print(f" PyCheck — NEURON ML Safety Analyzer")
198
- safe_print(f" Scanning: {filepath}")
199
- safe_print(f" Rules: {len(ALL_RULES)} active")
200
- safe_print(f"{header_sep}\n")
201
-
202
- if not diagnostics:
203
- safe_print(" [OK] No issues found.\n")
204
- return
205
-
206
- safe_print(f" {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)\n")
207
-
208
- for d in diagnostics:
209
- line_num = d['line']
210
-
211
- # Color-like severity prefix
212
- severity_label = d['severity'].upper()
213
- safe_print(f" {severity_label}[{d['code']}]: {d['message']}")
214
- safe_print(f" --> {filepath}:{line_num}:{d['col']}")
215
-
216
- if 0 < line_num <= len(source_lines):
217
- line_text = source_lines[line_num - 1]
218
- safe_print(f" {line_num:>3} | {line_text}")
219
- pointer_start = d['col']
220
- safe_print(f" {' ' * pointer_start}^^^^^^^^^^^")
221
-
222
- if 'help' in d:
223
- safe_print(f" help: {d['help']}")
224
-
225
- safe_print("")
226
-
227
- safe_print(f"{line_sep}")
228
- safe_print(f" Summary: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)")
229
- if errors:
230
- safe_print(f" These {len(errors)} error(s) would be COMPILE-TIME ERRORS in NEURON")
231
- elif warnings:
232
- safe_print(f" These {len(warnings)} temporal issue(s) are guarded at compile-time in NEURON")
233
- safe_print(f" Python detected: 0 of these issues at runtime.")
234
- safe_print(f" --> NEURON Zero-Leakage Language: https://github.com/neuronlabs-ai/neuron-lang")
235
- safe_print(f" --> PyCheck Documentation & CI/CD: https://github.com/neuronlabs-ai/pycheck")
236
- safe_print(f"{line_sep}\n")
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")