pycheck-neuron 1.0.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.
pycheck/__init__.py ADDED
@@ -0,0 +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__ = "0.2.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
pycheck/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Enable `python -m pycheck script.py` execution."""
2
+ from pycheck.cli import main
3
+ main()
pycheck/analyzer.py ADDED
@@ -0,0 +1,198 @@
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
+ def analyze_file(filepath: str) -> Tuple[List[dict], List[str]]:
84
+ """
85
+ Analyze a Python (.py) or Jupyter notebook (.ipynb) file for ML safety issues.
86
+ Returns (diagnostics_list, source_lines).
87
+ """
88
+ if filepath.endswith('.ipynb'):
89
+ with open(filepath, 'r', encoding='utf-8') as f:
90
+ nb = json.load(f)
91
+ code_lines = []
92
+ for cell in nb.get('cells', []):
93
+ if cell.get('cell_type') == 'code':
94
+ raw = cell.get('source', [])
95
+ if isinstance(raw, str):
96
+ raw = raw.splitlines(True)
97
+ for line in raw:
98
+ s_line = line.strip()
99
+ if s_line.startswith('%') or s_line.startswith('!'):
100
+ code_lines.append(f"# {line}")
101
+ else:
102
+ code_lines.append(line)
103
+ code_lines.append('\n')
104
+ source = "".join(code_lines)
105
+ else:
106
+ with open(filepath, 'r', encoding='utf-8') as f:
107
+ source = f.read()
108
+
109
+ source_lines = source.split('\n')
110
+ tree = ast.parse(source, filename=filepath)
111
+
112
+ # Phase 1: Rule-based AST analysis
113
+ analyzer = PyCheckAnalyzer(source_lines, filepath)
114
+ analyzer.visit(tree)
115
+
116
+ # Phase 2: Data flow taint analysis
117
+ tracker = TaintTracker()
118
+ flow_diagnostics = tracker.analyze(tree, source_lines)
119
+
120
+ # Combine and deduplicate
121
+ all_diagnostics = analyzer.diagnostics + flow_diagnostics
122
+
123
+ # Deduplicate by (line, code)
124
+ seen = set()
125
+ unique = []
126
+ for d in all_diagnostics:
127
+ key = (d.line, d.code)
128
+ if key not in seen:
129
+ seen.add(key)
130
+ unique.append(d)
131
+
132
+ # Sort by line number
133
+ unique.sort(key=lambda d: (d.line, d.col))
134
+
135
+ return [d.to_dict() for d in unique], source_lines
136
+
137
+
138
+ def format_output(filepath: str, diagnostics: List[dict], source_lines: List[str],
139
+ show_info: bool = False):
140
+ """Format diagnostics as human-readable output."""
141
+
142
+ # Filter by severity
143
+ if not show_info:
144
+ diagnostics = [d for d in diagnostics if d['severity'] != 'info']
145
+
146
+ errors = [d for d in diagnostics if d['severity'] == 'error']
147
+ warnings = [d for d in diagnostics if d['severity'] == 'warning']
148
+ infos = [d for d in diagnostics if d['severity'] == 'info']
149
+
150
+ def safe_print(text):
151
+ try:
152
+ print(text)
153
+ except UnicodeEncodeError:
154
+ print(text.encode('ascii', errors='replace').decode('ascii'))
155
+
156
+ header_sep = "=" * 65
157
+ line_sep = "-" * 65
158
+
159
+ safe_print(f"\n{header_sep}")
160
+ safe_print(f" PyCheck — NEURON ML Safety Analyzer")
161
+ safe_print(f" Scanning: {filepath}")
162
+ safe_print(f" Rules: {len(ALL_RULES)} active")
163
+ safe_print(f"{header_sep}\n")
164
+
165
+ if not diagnostics:
166
+ safe_print(" [OK] No issues found.\n")
167
+ return
168
+
169
+ safe_print(f" {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)\n")
170
+
171
+ for d in diagnostics:
172
+ line_num = d['line']
173
+
174
+ # Color-like severity prefix
175
+ severity_label = d['severity'].upper()
176
+ safe_print(f" {severity_label}[{d['code']}]: {d['message']}")
177
+ safe_print(f" --> {filepath}:{line_num}:{d['col']}")
178
+
179
+ if 0 < line_num <= len(source_lines):
180
+ line_text = source_lines[line_num - 1]
181
+ safe_print(f" {line_num:>3} | {line_text}")
182
+ pointer_start = d['col']
183
+ safe_print(f" {' ' * pointer_start}^^^^^^^^^^^")
184
+
185
+ if 'help' in d:
186
+ safe_print(f" help: {d['help']}")
187
+
188
+ safe_print("")
189
+
190
+ safe_print(f"{line_sep}")
191
+ safe_print(f" Summary: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)")
192
+ if errors:
193
+ safe_print(f" These {len(errors)} error(s) would be COMPILE-TIME ERRORS in NEURON")
194
+ elif warnings:
195
+ safe_print(f" These {len(warnings)} temporal issue(s) are guarded at compile-time in NEURON")
196
+ safe_print(f" Explore the zero-leakage language: https://github.com/neuronlabs-ai/neuron-lang")
197
+ safe_print(f" Python detected: 0 of these issues at runtime")
198
+ safe_print(f"{line_sep}\n")
pycheck/cli.py ADDED
@@ -0,0 +1,191 @@
1
+ import sys
2
+ import json
3
+ import os
4
+ import hashlib
5
+ from pycheck.analyzer import analyze_file, format_output
6
+ from pycheck.rules import ALL_RULES
7
+
8
+
9
+ def main():
10
+ args = sys.argv[1:]
11
+
12
+ if not args or '--help' in args or '-h' in args:
13
+ print("PyCheck — NEURON ML Safety Analyzer")
14
+ print(f" {len(ALL_RULES)} rules for temporal leaks, causal confusion, and uncertainty bugs\n")
15
+ print("Usage: pycheck <path> [options]\n")
16
+ print("Options:")
17
+ print(" --json Output diagnostics as JSON")
18
+ print(" --gitlab Output diagnostics in GitLab Code Quality JSON format")
19
+ print(" -o, --output <file> Write JSON / GitLab report directly to specified file")
20
+ print(" --info Include info-level diagnostics")
21
+ print(" --quiet Only show errors (no warnings)")
22
+ print(" --list List all available rules")
23
+ print(" -h, --help Show this help message")
24
+ sys.exit(0)
25
+
26
+ if '--list' in args:
27
+ print(f"\nPyCheck Rules ({len(ALL_RULES)} total):\n")
28
+ current_cat = ""
29
+ for rule in ALL_RULES:
30
+ if rule.category != current_cat:
31
+ current_cat = rule.category
32
+ print(f"\n [{current_cat}]")
33
+ print(f" {rule.code} {rule.severity:<8} {rule.name}")
34
+ print()
35
+ sys.exit(0)
36
+
37
+ show_info = '--info' in args
38
+ is_quiet = '--quiet' in args
39
+ is_json = '--json' in args
40
+ is_gitlab = '--gitlab' in args
41
+
42
+ output_file = None
43
+ if '--output' in args:
44
+ idx = args.index('--output')
45
+ if idx + 1 < len(args):
46
+ output_file = args[idx + 1]
47
+ elif '-o' in args:
48
+ idx = args.index('-o')
49
+ if idx + 1 < len(args):
50
+ output_file = args[idx + 1]
51
+
52
+ # Get target path (first non-flag argument not consumed by an option)
53
+ filepath = None
54
+ skip_next = False
55
+ for a in args:
56
+ if skip_next:
57
+ skip_next = False
58
+ continue
59
+ if a in ('--output', '-o'):
60
+ skip_next = True
61
+ continue
62
+ if not a.startswith('-'):
63
+ filepath = a
64
+ break
65
+
66
+ if not filepath:
67
+ filepath = "."
68
+
69
+ if not os.path.exists(filepath):
70
+ print(f"Error: Path not found: {filepath}")
71
+ sys.exit(1)
72
+
73
+ # Collect target files
74
+ files_to_scan = []
75
+ if os.path.isdir(filepath):
76
+ ignored_dirs = {'.git', '__pycache__', '.pytest_cache', '.venv', 'venv', 'env', 'node_modules', 'dist', 'build', '.egg-info'}
77
+ for root, dirs, files in os.walk(filepath):
78
+ dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith('.')]
79
+ for f in files:
80
+ if f.endswith('.py') or f.endswith('.ipynb'):
81
+ files_to_scan.append(os.path.join(root, f))
82
+ else:
83
+ files_to_scan = [filepath]
84
+
85
+ if not files_to_scan:
86
+ if not is_json and not is_gitlab:
87
+ print(f"No Python (.py) or notebook (.ipynb) files found in {filepath}")
88
+ sys.exit(0)
89
+
90
+ total_errors = 0
91
+ total_warnings = 0
92
+ files_with_issues = 0
93
+ all_json_results = {}
94
+ gitlab_issues = []
95
+
96
+ for f_path in files_to_scan:
97
+ try:
98
+ diagnostics, source_lines = analyze_file(f_path)
99
+ except SyntaxError as e:
100
+ if not is_json and (not is_gitlab or output_file):
101
+ print(f"Syntax error in {f_path}: {e}")
102
+ total_errors += 1
103
+ continue
104
+ except Exception as e:
105
+ if not is_json and (not is_gitlab or output_file):
106
+ print(f"Error analyzing {f_path}: {e}")
107
+ continue
108
+
109
+ if is_quiet:
110
+ diagnostics = [d for d in diagnostics if d['severity'] == 'error']
111
+ elif not show_info:
112
+ diagnostics = [d for d in diagnostics if d['severity'] != 'info']
113
+
114
+ err_count = sum(1 for d in diagnostics if d['severity'] == 'error')
115
+ warn_count = sum(1 for d in diagnostics if d['severity'] == 'warning')
116
+ total_errors += err_count
117
+ total_warnings += warn_count
118
+
119
+ if diagnostics:
120
+ files_with_issues += 1
121
+ if is_json:
122
+ all_json_results[f_path] = diagnostics
123
+ elif is_gitlab:
124
+ rel_path = os.path.relpath(f_path).replace("\\", "/")
125
+ for d in diagnostics:
126
+ sev_map = {
127
+ 'error': 'critical',
128
+ 'warning': 'major',
129
+ 'info': 'info'
130
+ }
131
+ gl_sev = sev_map.get(d.get('severity', 'warning'), 'major')
132
+ fp = hashlib.md5(f"{rel_path}:{d.get('line', 1)}:{d.get('code', 'P000')}".encode('utf-8')).hexdigest()
133
+ gitlab_issues.append({
134
+ "description": f"[{d.get('code', 'PYCHECK')}] {d.get('message', '')}. Guarded at compile-time in NEURON: https://github.com/neuronlabs-ai/neuron-lang",
135
+ "check_name": d.get('code', 'pycheck'),
136
+ "fingerprint": fp,
137
+ "severity": gl_sev,
138
+ "location": {
139
+ "path": rel_path,
140
+ "lines": {
141
+ "begin": d.get('line', 1)
142
+ }
143
+ }
144
+ })
145
+ if output_file:
146
+ format_output(f_path, diagnostics, source_lines, show_info=show_info)
147
+ else:
148
+ format_output(f_path, diagnostics, source_lines, show_info=show_info)
149
+
150
+ if is_gitlab:
151
+ gl_content = json.dumps(gitlab_issues, indent=2)
152
+ if output_file:
153
+ with open(output_file, 'w', encoding='utf-8') as f:
154
+ f.write(gl_content)
155
+ else:
156
+ print(gl_content)
157
+ elif is_json:
158
+ json_content = json.dumps(all_json_results, indent=2)
159
+ if output_file:
160
+ with open(output_file, 'w', encoding='utf-8') as f:
161
+ f.write(json_content)
162
+ else:
163
+ print(json_content)
164
+
165
+ # Print summary if human output or if output was redirected to a file
166
+ if (not is_json and not is_gitlab) or output_file:
167
+ if len(files_to_scan) > 1 or output_file:
168
+ clean_files = len(files_to_scan) - files_with_issues
169
+ print("=================================================================")
170
+ print(" PyCheck Directory Summary")
171
+ print(f" Scanned: {len(files_to_scan)} files | Clean: {clean_files} | Flagged: {files_with_issues}")
172
+ print(f" Total: {total_errors} error(s), {total_warnings} warning(s)")
173
+ if output_file:
174
+ report_type = "GitLab Code Quality" if is_gitlab else "JSON"
175
+ print(f" {report_type} Report written to: {output_file}")
176
+ if files_with_issues > 0:
177
+ print("-----------------------------------------------------------------")
178
+ if total_errors > 0:
179
+ print(f" These {total_errors} error(s) would be COMPILE-TIME ERRORS in NEURON")
180
+ elif total_warnings > 0:
181
+ print(f" These {total_warnings} temporal issue(s) are guarded at compile-time in NEURON")
182
+ print(" Explore the zero-leakage language: https://github.com/neuronlabs-ai/neuron-lang")
183
+ print(" Python detected: 0 of these issues at runtime")
184
+ print("=================================================================\n")
185
+
186
+ if total_errors > 0:
187
+ sys.exit(1)
188
+
189
+
190
+ if __name__ == '__main__':
191
+ main()
pycheck/flow.py ADDED
@@ -0,0 +1,265 @@
1
+ """
2
+ PyCheck Data Flow Engine — Taint Propagation Analysis
3
+ Tracks how data flows through assignments, function returns, and method chains
4
+ to detect when future-tainted data reaches training/prediction sinks.
5
+ """
6
+
7
+ import ast
8
+ from typing import Dict, Set, List, Optional, Tuple
9
+ from pycheck.rules import Diagnostic
10
+
11
+
12
+ class TaintSource:
13
+ """Represents a source of tainted (future-leaked) data."""
14
+ def __init__(self, var_name: str, reason: str, line: int, col: int):
15
+ self.var_name = var_name
16
+ self.reason = reason
17
+ self.line = line
18
+ self.col = col
19
+
20
+
21
+ class TaintTracker:
22
+ """
23
+ Tracks taint propagation through Python AST.
24
+
25
+ Taint sources: shift(-n), bfill(), future column access, etc.
26
+ Taint sinks: .fit(), .predict(), model training calls.
27
+
28
+ When tainted data flows into a sink, a diagnostic is emitted.
29
+ """
30
+
31
+ def __init__(self):
32
+ self.tainted_vars: Dict[str, TaintSource] = {}
33
+ self.diagnostics: List[Diagnostic] = []
34
+ self.constants: Dict[str, any] = {}
35
+
36
+ def analyze(self, tree: ast.AST, source_lines: List[str]) -> List[Diagnostic]:
37
+ """Run taint analysis on a full AST."""
38
+ self.tainted_vars.clear()
39
+ self.diagnostics.clear()
40
+ self.constants.clear()
41
+
42
+ # Two-pass analysis:
43
+ # Pass 1: Identify all taint sources (assignments from leaky operations)
44
+ # Pass 2: Track propagation and detect sinks
45
+ self._collect_taints(tree)
46
+ self._check_sinks(tree)
47
+
48
+ return self.diagnostics
49
+
50
+ def _extract_constant(self, expr):
51
+ if isinstance(expr, ast.Constant) and isinstance(expr.value, (int, float)):
52
+ return expr.value
53
+ if isinstance(expr, ast.UnaryOp) and isinstance(expr.op, ast.USub):
54
+ if isinstance(expr.operand, ast.Constant) and isinstance(expr.operand.value, (int, float)):
55
+ return -expr.operand.value
56
+ return None
57
+
58
+ def _is_negative_period(self, expr) -> bool:
59
+ if isinstance(expr, ast.UnaryOp) and isinstance(expr.op, ast.USub):
60
+ return True
61
+ if isinstance(expr, ast.Constant) and isinstance(expr.value, (int, float)) and expr.value < 0:
62
+ return True
63
+ if isinstance(expr, ast.Name):
64
+ val = self.constants.get(expr.id)
65
+ if val is not None and isinstance(val, (int, float)) and val < 0:
66
+ return True
67
+ return False
68
+
69
+ def _collect_taints(self, tree: ast.AST):
70
+ """Pass 1: Walk the AST and mark variables that receive tainted data."""
71
+ for node in ast.walk(tree):
72
+ if isinstance(node, ast.Assign):
73
+ for target in node.targets:
74
+ if isinstance(target, ast.Name):
75
+ val = self._extract_constant(node.value)
76
+ if val is not None:
77
+ self.constants[target.id] = val
78
+
79
+ for node in ast.walk(tree):
80
+ if isinstance(node, ast.Assign):
81
+ self._check_assignment_taint(node)
82
+ elif isinstance(node, ast.AugAssign):
83
+ self._check_augassign_taint(node)
84
+
85
+ def _check_assignment_taint(self, node: ast.Assign):
86
+ """Check if an assignment creates or propagates a taint."""
87
+ # Get the taint from the RHS
88
+ taint_reason = self._expr_taint(node.value)
89
+ if taint_reason is None:
90
+ return
91
+
92
+ # Mark all LHS targets as tainted
93
+ for target in node.targets:
94
+ var_name = self._get_var_name(target)
95
+ if var_name:
96
+ self.tainted_vars[var_name] = TaintSource(
97
+ var_name, taint_reason, node.lineno, node.col_offset)
98
+
99
+ def _check_augassign_taint(self, node: ast.AugAssign):
100
+ """Check if an augmented assignment (+=, etc.) propagates taint."""
101
+ taint_reason = self._expr_taint(node.value)
102
+ if taint_reason:
103
+ var_name = self._get_var_name(node.target)
104
+ if var_name:
105
+ self.tainted_vars[var_name] = TaintSource(
106
+ var_name, taint_reason, node.lineno, node.col_offset)
107
+
108
+ def _expr_taint(self, node: ast.AST) -> Optional[str]:
109
+ """
110
+ Determine if an expression produces tainted data.
111
+ Returns the taint reason string, or None if clean.
112
+ """
113
+ if isinstance(node, ast.Call):
114
+ return self._call_taint(node)
115
+
116
+ if isinstance(node, ast.Name):
117
+ # Propagation: if RHS is a tainted variable, the assignment is tainted
118
+ if node.id in self.tainted_vars:
119
+ return f"derived from tainted variable '{node.id}' ({self.tainted_vars[node.id].reason})"
120
+
121
+ if isinstance(node, ast.BinOp):
122
+ # Binary operations propagate taint from either operand
123
+ left_taint = self._expr_taint(node.left)
124
+ right_taint = self._expr_taint(node.right)
125
+ return left_taint or right_taint
126
+
127
+ if isinstance(node, ast.Subscript):
128
+ # Subscript propagates taint from the value being indexed
129
+ val_taint = self._expr_taint(node.value)
130
+ if val_taint:
131
+ return val_taint
132
+ # Check if indexing a tainted column name
133
+ if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
134
+ col_lower = node.slice.value.lower()
135
+ # Only flag column names that unambiguously refer to future data.
136
+ # 'target' and 'label' are intentionally excluded — they are standard
137
+ # ML label column names and produce false positives when accessed on a
138
+ # correctly split train/test dataset.
139
+ future_cols = {'future_price', 'future_return', 'next_close', 'next_price',
140
+ 'next_day', 'tomorrow', 'price_change_next_day', 'future_avg_price'}
141
+ if any(f in col_lower for f in future_cols):
142
+ return f"accessing future-named column '{node.slice.value}'"
143
+
144
+ if isinstance(node, ast.Attribute):
145
+ return self._expr_taint(node.value)
146
+
147
+ return None
148
+
149
+ def _call_taint(self, node: ast.Call) -> Optional[str]:
150
+ """Check if a function call produces tainted output."""
151
+ if isinstance(node.func, ast.Attribute):
152
+ method = node.func.attr
153
+
154
+ # Direct taint sources
155
+ if method == 'shift':
156
+ for arg in node.args:
157
+ if self._is_negative_period(arg):
158
+ return "shift() with negative period (future access)"
159
+ for kw in node.keywords:
160
+ if kw.arg == 'periods' and self._is_negative_period(kw.value):
161
+ return "shift() with negative period (future access)"
162
+
163
+ if method in ('bfill', 'backfill'):
164
+ return f".{method}() backfills from future values"
165
+
166
+ if method == 'fillna':
167
+ for kw in node.keywords:
168
+ if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
169
+ if kw.value.value in ('bfill', 'backfill'):
170
+ return f".fillna(method='{kw.value.value}') backfills from future"
171
+
172
+ if method == 'diff':
173
+ for arg in node.args:
174
+ if self._is_negative_period(arg):
175
+ return ".diff() with negative period (future access)"
176
+ for kw in node.keywords:
177
+ if kw.arg == 'periods' and self._is_negative_period(kw.value):
178
+ return ".diff() with negative period (future access)"
179
+
180
+ # Taint propagation through method chains
181
+ obj_taint = self._expr_taint(node.func.value)
182
+ if obj_taint:
183
+ # Most method calls on tainted data produce tainted output
184
+ non_taint_methods = {'shape', 'dtype', 'dtypes', 'columns', 'index',
185
+ 'describe', 'info', 'head', 'tail', 'len', '__len__'}
186
+ if method not in non_taint_methods:
187
+ return obj_taint
188
+
189
+ if isinstance(node.func, ast.Name):
190
+ # Check if calling a function with tainted arguments
191
+ for arg in node.args:
192
+ arg_taint = self._expr_taint(arg)
193
+ if arg_taint:
194
+ return f"function receives tainted input: {arg_taint}"
195
+
196
+ # R2-9 fix: Detect getattr(obj, 'shift')(-1) and similar patterns
197
+ # Pattern: call(getattr(obj, 'shift'), -1) => obj.shift(-1)
198
+ if isinstance(node.func, ast.Call):
199
+ inner_call = node.func
200
+ if isinstance(inner_call.func, ast.Name) and inner_call.func.id == 'getattr':
201
+ if len(inner_call.args) >= 2 and isinstance(inner_call.args[1], ast.Constant):
202
+ attr_name = inner_call.args[1].value
203
+ if attr_name == 'shift':
204
+ for arg in node.args:
205
+ if self._is_negative_period(arg):
206
+ return "getattr(..., 'shift')() with negative period (future access)"
207
+ for kw in node.keywords:
208
+ if kw.arg == 'periods' and self._is_negative_period(kw.value):
209
+ return "getattr(..., 'shift')() with negative period (future access)"
210
+ elif attr_name == 'diff':
211
+ for arg in node.args:
212
+ if self._is_negative_period(arg):
213
+ return "getattr(..., 'diff')() with negative period (future access)"
214
+ for kw in node.keywords:
215
+ if kw.arg == 'periods' and self._is_negative_period(kw.value):
216
+ return "getattr(..., 'diff')() with negative period (future access)"
217
+ elif attr_name in ('bfill', 'backfill'):
218
+ return f"getattr(..., '{attr_name}')() backfills from future values"
219
+
220
+ return None
221
+
222
+ def _check_sinks(self, tree: ast.AST):
223
+ """Pass 2: Check if tainted data flows into sensitive sinks."""
224
+ for node in ast.walk(tree):
225
+ if not isinstance(node, ast.Call):
226
+ continue
227
+
228
+ if isinstance(node.func, ast.Attribute):
229
+ method = node.func.attr
230
+ sinks = {'fit', 'fit_transform', 'fit_predict', 'train',
231
+ 'partial_fit', 'predict', 'score'}
232
+
233
+ if method in sinks:
234
+ # Check if any positional argument is tainted
235
+ for arg in node.args:
236
+ arg_taint = self._expr_taint(arg)
237
+ if arg_taint:
238
+ self.diagnostics.append(Diagnostic(
239
+ node.lineno, node.col_offset, "error", "F001",
240
+ f"Tainted data flows into .{method}(): {arg_taint}",
241
+ "Ensure training/prediction data is free of future information"))
242
+ # R2-8 fix: Check keyword arguments for taint as well
243
+ for kw in node.keywords:
244
+ kw_taint = self._expr_taint(kw.value)
245
+ if kw_taint:
246
+ kw_name = kw.arg if kw.arg else "**kwargs"
247
+ self.diagnostics.append(Diagnostic(
248
+ node.lineno, node.col_offset, "error", "F001",
249
+ f"Tainted data flows into .{method}() via keyword '{kw_name}': {kw_taint}",
250
+ "Ensure training/prediction data is free of future information"))
251
+
252
+ def _get_var_name(self, node: ast.AST) -> Optional[str]:
253
+ """Extract variable name from an assignment target."""
254
+ if isinstance(node, ast.Name):
255
+ return node.id
256
+ if isinstance(node, ast.Subscript):
257
+ # df['col_name'] — return as df.col_name
258
+ base = self._get_var_name(node.value)
259
+ if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
260
+ return f"{base}.{node.slice.value}" if base else node.slice.value
261
+ return base
262
+ if isinstance(node, ast.Attribute):
263
+ base = self._get_var_name(node.value)
264
+ return f"{base}.{node.attr}" if base else node.attr
265
+ return None