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/rules.py ADDED
@@ -0,0 +1,938 @@
1
+ """
2
+ PyCheck Rule Registry — 40+ ML Safety Rules
3
+ Each rule is a self-contained checker with code, severity, and AST visitor logic.
4
+ """
5
+
6
+ import ast
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional, List, Dict, Set
9
+
10
+
11
+ @dataclass
12
+ class Diagnostic:
13
+ line: int
14
+ col: int
15
+ severity: str # "error" | "warning" | "info"
16
+ code: str # "T001", "C001", etc.
17
+ message: str
18
+ help: Optional[str] = None
19
+
20
+ def to_dict(self):
21
+ d = {"line": self.line, "col": self.col, "severity": self.severity,
22
+ "code": self.code, "message": self.message}
23
+ if self.help:
24
+ d["help"] = self.help
25
+ return d
26
+
27
+
28
+ class Rule:
29
+ """Base class for all PyCheck rules."""
30
+ code: str = ""
31
+ name: str = ""
32
+ severity: str = "warning"
33
+ category: str = ""
34
+
35
+ def check_node(self, node: ast.AST, context: 'AnalysisContext') -> List[Diagnostic]:
36
+ return []
37
+
38
+
39
+ class AnalysisContext:
40
+ """Shared context for all rules during analysis."""
41
+ def __init__(self, source_lines: List[str], filepath: str):
42
+ self.source_lines = source_lines
43
+ self.filepath = filepath
44
+ self.tainted_vars: Dict[str, str] = {} # var_name -> taint_reason
45
+ self.fitted_before_split: Set[str] = set()
46
+ self.has_train_test_split = False
47
+ self.split_line: Optional[int] = None
48
+ self.in_loop = False
49
+ self.loop_var: Optional[str] = None
50
+ self.assignments: Dict[str, ast.AST] = {} # var_name -> assigned node
51
+ self.constants: Dict[str, any] = {} # var_name -> literal constant value
52
+
53
+
54
+ # ═══════════════════════════════════════════════════════════════
55
+ # TEMPORAL LEAK RULES (T001–T015)
56
+ # ═══════════════════════════════════════════════════════════════
57
+
58
+ class T001_ShiftNegative(Rule):
59
+ code = "T001"
60
+ name = "future_shift"
61
+ severity = "error"
62
+ category = "TemporalLeak"
63
+
64
+ def check_node(self, node, ctx):
65
+ if not isinstance(node, ast.Call):
66
+ return []
67
+ # Standard: obj.shift(...)
68
+ is_shift = isinstance(node.func, ast.Attribute) and node.func.attr == 'shift'
69
+ # R2-9 fix: getattr(obj, 'shift')(...)
70
+ is_getattr_shift = (isinstance(node.func, ast.Call) and
71
+ isinstance(node.func.func, ast.Name) and
72
+ node.func.func.id == 'getattr' and
73
+ len(node.func.args) >= 2 and
74
+ isinstance(node.func.args[1], ast.Constant) and
75
+ node.func.args[1].value == 'shift')
76
+ if not (is_shift or is_getattr_shift):
77
+ return []
78
+ results = []
79
+ for arg in node.args:
80
+ val = self._get_negative_value(arg, ctx)
81
+ if val is not None and val > 0:
82
+ results.append(Diagnostic(
83
+ node.lineno, node.col_offset, self.severity, self.code,
84
+ f".shift(-{val}) accesses data {val} rows INTO THE FUTURE",
85
+ f"Use .shift({val}) to access past data instead"))
86
+ # Taint the assignment target
87
+ self._taint_parent(node, ctx, f"shift(-{val}) future data")
88
+ for kw in node.keywords:
89
+ if kw.arg == 'periods':
90
+ val = self._get_negative_value(kw.value, ctx)
91
+ if val is not None and val > 0:
92
+ results.append(Diagnostic(
93
+ node.lineno, node.col_offset, self.severity, self.code,
94
+ f".shift(periods=-{val}) accesses data {val} rows INTO THE FUTURE",
95
+ f"Use .shift(periods={val}) to access past data instead"))
96
+ return results
97
+
98
+ def _get_negative_value(self, arg, ctx=None):
99
+ if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
100
+ if isinstance(arg.operand, ast.Constant) and isinstance(arg.operand.value, (int, float)):
101
+ return abs(arg.operand.value)
102
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)) and arg.value < 0:
103
+ return abs(arg.value)
104
+ if isinstance(arg, ast.Name) and ctx is not None and hasattr(ctx, 'constants'):
105
+ val = ctx.constants.get(arg.id)
106
+ if val is not None and isinstance(val, (int, float)) and val < 0:
107
+ return abs(val)
108
+ return None
109
+
110
+ def _taint_parent(self, node, ctx, reason):
111
+ pass # Flow engine handles this
112
+
113
+
114
+ class T002_TrainTestSplit(Rule):
115
+ code = "T002"
116
+ name = "random_split_timeseries"
117
+ severity = "error"
118
+ category = "TemporalLeak"
119
+
120
+ def check_node(self, node, ctx):
121
+ if not isinstance(node, ast.Call):
122
+ return []
123
+ name = self._get_call_name(node)
124
+ if name == 'train_test_split':
125
+ ctx.has_train_test_split = True
126
+ ctx.split_line = node.lineno
127
+ return [Diagnostic(
128
+ node.lineno, node.col_offset, self.severity, self.code,
129
+ "train_test_split() shuffles time-series data, leaking future into training",
130
+ "Use a temporal split: train = data[:split_idx], test = data[split_idx:]")]
131
+ return []
132
+
133
+ def _get_call_name(self, node):
134
+ if isinstance(node.func, ast.Name):
135
+ return node.func.id
136
+ if isinstance(node.func, ast.Attribute):
137
+ return node.func.attr
138
+ return ""
139
+
140
+
141
+ class T003_RollingBeforeSplit(Rule):
142
+ code = "T003"
143
+ name = "rolling_before_split"
144
+ severity = "warning"
145
+ category = "TemporalLeak"
146
+
147
+ def check_node(self, node, ctx):
148
+ if not isinstance(node, ast.Call):
149
+ return []
150
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'rolling':
151
+ return [Diagnostic(
152
+ node.lineno, node.col_offset, self.severity, self.code,
153
+ ".rolling() computed on full dataset may include future data",
154
+ "Compute rolling statistics AFTER splitting into train/test sets")]
155
+ return []
156
+
157
+
158
+ class T004_ExpandingBeforeSplit(Rule):
159
+ code = "T004"
160
+ name = "expanding_before_split"
161
+ severity = "warning"
162
+ category = "TemporalLeak"
163
+
164
+ def check_node(self, node, ctx):
165
+ if not isinstance(node, ast.Call):
166
+ return []
167
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'expanding':
168
+ return [Diagnostic(
169
+ node.lineno, node.col_offset, self.severity, self.code,
170
+ ".expanding() computed on full dataset may include future data",
171
+ "Compute expanding statistics AFTER splitting into train/test sets")]
172
+ return []
173
+
174
+
175
+ class T005_PctChangeNegative(Rule):
176
+ code = "T005"
177
+ name = "future_pct_change"
178
+ severity = "error"
179
+ category = "TemporalLeak"
180
+
181
+ def check_node(self, node, ctx):
182
+ if not isinstance(node, ast.Call):
183
+ return []
184
+ if not (isinstance(node.func, ast.Attribute) and node.func.attr == 'pct_change'):
185
+ return []
186
+ for arg in node.args:
187
+ if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
188
+ if isinstance(arg.operand, ast.Constant):
189
+ return [Diagnostic(
190
+ node.lineno, node.col_offset, self.severity, self.code,
191
+ f"pct_change with negative period accesses future data",
192
+ "Use positive periods to look backwards in time")]
193
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)) and arg.value < 0:
194
+ return [Diagnostic(
195
+ node.lineno, node.col_offset, self.severity, self.code,
196
+ f"pct_change({int(arg.value)}) accesses future data",
197
+ "Use positive periods to look backwards in time")]
198
+ return []
199
+
200
+
201
+ class T006_FutureIndexInLoop(Rule):
202
+ code = "T006"
203
+ name = "future_index_loop"
204
+ severity = "error"
205
+ category = "TemporalLeak"
206
+
207
+ def check_node(self, node, ctx):
208
+ if not isinstance(node, ast.Subscript):
209
+ return []
210
+ # Look for .iloc[i + 1] or [i + 1] patterns inside loops
211
+ if not ctx.in_loop or ctx.loop_var is None:
212
+ return []
213
+ sl = node.slice
214
+ if isinstance(sl, ast.BinOp) and isinstance(sl.op, ast.Add):
215
+ if self._uses_var(sl.left, ctx.loop_var) and isinstance(sl.right, ast.Constant):
216
+ if isinstance(sl.right.value, int) and sl.right.value > 0:
217
+ return [Diagnostic(
218
+ node.lineno, node.col_offset, self.severity, self.code,
219
+ f"Indexing [{ctx.loop_var} + {sl.right.value}] inside loop accesses future data",
220
+ f"Only use [{ctx.loop_var}] or [{ctx.loop_var} - n] to access current/past data")]
221
+ if self._uses_var(sl.right, ctx.loop_var) and isinstance(sl.left, ast.Constant):
222
+ if isinstance(sl.left.value, int) and sl.left.value > 0:
223
+ return [Diagnostic(
224
+ node.lineno, node.col_offset, self.severity, self.code,
225
+ f"Indexing [{sl.left.value} + {ctx.loop_var}] inside loop may access future data",
226
+ f"Verify this does not access future indices")]
227
+ return []
228
+
229
+ def _uses_var(self, node, var_name):
230
+ if isinstance(node, ast.Name) and node.id == var_name:
231
+ return True
232
+ return False
233
+
234
+
235
+ class T007_BackfillLeak(Rule):
236
+ code = "T007"
237
+ name = "backfill_leak"
238
+ severity = "error"
239
+ category = "TemporalLeak"
240
+
241
+ def check_node(self, node, ctx):
242
+ if not isinstance(node, ast.Call):
243
+ return []
244
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ('fillna', 'bfill', 'backfill'):
245
+ if node.func.attr in ('bfill', 'backfill'):
246
+ return [Diagnostic(
247
+ node.lineno, node.col_offset, self.severity, self.code,
248
+ f".{node.func.attr}() fills missing values with FUTURE data",
249
+ "Use .ffill() (forward fill) or .fillna(0) instead")]
250
+ # Check fillna(method='bfill') or fillna(method='backfill')
251
+ for kw in node.keywords:
252
+ if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
253
+ if kw.value.value in ('bfill', 'backfill'):
254
+ return [Diagnostic(
255
+ node.lineno, node.col_offset, self.severity, self.code,
256
+ f".fillna(method='{kw.value.value}') fills missing values with FUTURE data",
257
+ "Use .fillna(method='ffill') or .fillna(0) instead")]
258
+ return []
259
+
260
+
261
+ class T008_NonCausalInterpolate(Rule):
262
+ code = "T008"
263
+ name = "noncausal_interpolate"
264
+ severity = "warning"
265
+ category = "TemporalLeak"
266
+
267
+ def check_node(self, node, ctx):
268
+ if not isinstance(node, ast.Call):
269
+ return []
270
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'interpolate':
271
+ safe_methods = {'pad', 'ffill', 'linear', 'nearest', 'zero', 'slinear'}
272
+ for kw in node.keywords:
273
+ if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
274
+ if kw.value.value in ('cubic', 'spline', 'polynomial', 'quadratic'):
275
+ return [Diagnostic(
276
+ node.lineno, node.col_offset, self.severity, self.code,
277
+ f".interpolate(method='{kw.value.value}') uses future data points for interpolation",
278
+ "Use .interpolate(method='pad') or method='linear' with limit_direction='forward'")]
279
+ if kw.value.value in safe_methods:
280
+ return [] # Explicitly safe — no diagnostic
281
+ # No method keyword specified — default is 'linear' which can be non-causal
282
+ return [Diagnostic(
283
+ node.lineno, node.col_offset, "info", self.code,
284
+ ".interpolate() may use future data depending on method",
285
+ "Ensure method is causal (e.g., method='pad' or method='ffill')")]
286
+ return []
287
+
288
+
289
+ class T009_CenteredRolling(Rule):
290
+ code = "T009"
291
+ name = "centered_rolling"
292
+ severity = "error"
293
+ category = "TemporalLeak"
294
+
295
+ def check_node(self, node, ctx):
296
+ if not isinstance(node, ast.Call):
297
+ return []
298
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'rolling':
299
+ for kw in node.keywords:
300
+ if kw.arg == 'center' and isinstance(kw.value, ast.Constant) and kw.value.value is True:
301
+ return [Diagnostic(
302
+ node.lineno, node.col_offset, self.severity, self.code,
303
+ ".rolling(center=True) uses future data on both sides of the window",
304
+ "Use .rolling(center=False) for causal (backward-looking) windows")]
305
+ return []
306
+
307
+
308
+ class T010_FitBeforeSplit(Rule):
309
+ code = "T010"
310
+ name = "fit_before_split"
311
+ severity = "error"
312
+ category = "TemporalLeak"
313
+
314
+ def _is_train_data(self, node):
315
+ """Recursively check if an AST node represents training data."""
316
+ train_markers = ('_train', 'train_', 'x_train', 'y_train', 'x_tr', 'y_tr', '_trn', 'trn_', 'train')
317
+ if isinstance(node, ast.Name):
318
+ return any(m in node.id.lower() for m in train_markers)
319
+ if isinstance(node, ast.Subscript):
320
+ return self._is_train_data(node.value)
321
+ if isinstance(node, ast.Attribute):
322
+ return self._is_train_data(node.value)
323
+ return False
324
+
325
+ def check_node(self, node, ctx):
326
+ if not isinstance(node, ast.Call):
327
+ return []
328
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ('fit', 'fit_transform'):
329
+ # Only flag on objects that look like ML preprocessors/models
330
+ # Skip: fig.fit(), layout.fit(), etc.
331
+ obj_name = self._get_obj_name(node.func.value)
332
+ safe_objects = {'fig', 'figure', 'ax', 'axes', 'plt', 'layout',
333
+ 'canvas', 'widget', 'app', 'db', 'session', 'socket',
334
+ 'parser', 'formatter', 'logger', 'handler'}
335
+ if obj_name and obj_name.lower() in safe_objects:
336
+ return []
337
+
338
+ # Suppress if all positional arguments are clearly train-only variables
339
+ # (e.g. X_train, y_train, train_df[features], X_tr)
340
+ if node.args and all(self._is_train_data(a) for a in node.args):
341
+ return []
342
+
343
+ if node.func.attr == 'fit':
344
+ return [Diagnostic(
345
+ node.lineno, node.col_offset, "warning", self.code,
346
+ ".fit() may be called on the full dataset including test data",
347
+ "Call .fit() only on training data, use .transform() on test data")]
348
+ else:
349
+ return [Diagnostic(
350
+ node.lineno, node.col_offset, self.severity, self.code,
351
+ ".fit_transform() on full dataset leaks test statistics into training",
352
+ "Use .fit(X_train).transform(X_train), then .transform(X_test) separately")]
353
+ return []
354
+
355
+ def _get_obj_name(self, node):
356
+ if isinstance(node, ast.Name):
357
+ return node.id
358
+ if isinstance(node, ast.Attribute):
359
+ return node.attr
360
+ return None
361
+
362
+
363
+ class T011_ScalerBeforeSplit(Rule):
364
+ code = "T011"
365
+ name = "scaler_before_split"
366
+ severity = "error"
367
+ category = "TemporalLeak"
368
+
369
+ def check_node(self, node, ctx):
370
+ if not isinstance(node, ast.Call):
371
+ return []
372
+ scaler_names = {'StandardScaler', 'MinMaxScaler', 'RobustScaler',
373
+ 'MaxAbsScaler', 'Normalizer', 'QuantileTransformer',
374
+ 'PowerTransformer'}
375
+ if isinstance(node.func, ast.Name) and node.func.id in scaler_names:
376
+ return [Diagnostic(
377
+ node.lineno, node.col_offset, "info", self.code,
378
+ f"{node.func.id}() — ensure this is fit ONLY on training data",
379
+ "Fit the scaler on X_train, then transform both X_train and X_test")]
380
+ return []
381
+
382
+
383
+ class T012_KFoldTimeSeries(Rule):
384
+ code = "T012"
385
+ name = "kfold_timeseries"
386
+ severity = "error"
387
+ category = "TemporalLeak"
388
+
389
+ def check_node(self, node, ctx):
390
+ if not isinstance(node, ast.Call):
391
+ return []
392
+ kfold_names = {'KFold', 'StratifiedKFold', 'RepeatedKFold', 'ShuffleSplit'}
393
+ name = ""
394
+ if isinstance(node.func, ast.Name):
395
+ name = node.func.id
396
+ elif isinstance(node.func, ast.Attribute):
397
+ name = node.func.attr
398
+ if name in kfold_names:
399
+ return [Diagnostic(
400
+ node.lineno, node.col_offset, self.severity, self.code,
401
+ f"{name}() shuffles time-series data across folds, leaking future into training",
402
+ "Use TimeSeriesSplit() for temporal cross-validation")]
403
+ return []
404
+
405
+
406
+ class T013_ResampleBeforeSplit(Rule):
407
+ code = "T013"
408
+ name = "resample_before_split"
409
+ severity = "warning"
410
+ category = "TemporalLeak"
411
+
412
+ def check_node(self, node, ctx):
413
+ if not isinstance(node, ast.Call):
414
+ return []
415
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'resample':
416
+ return [Diagnostic(
417
+ node.lineno, node.col_offset, self.severity, self.code,
418
+ ".resample() on full dataset may leak future aggregate statistics",
419
+ "Resample AFTER splitting into train/test sets")]
420
+ return []
421
+
422
+
423
+ class T014_DiffNegative(Rule):
424
+ code = "T014"
425
+ name = "future_diff"
426
+ severity = "error"
427
+ category = "TemporalLeak"
428
+
429
+ def check_node(self, node, ctx):
430
+ if not isinstance(node, ast.Call):
431
+ return []
432
+ if not (isinstance(node.func, ast.Attribute) and node.func.attr == 'diff'):
433
+ return []
434
+ results = []
435
+ for arg in node.args:
436
+ val = self._get_negative_value(arg, ctx)
437
+ if val is not None and val > 0:
438
+ results.append(Diagnostic(
439
+ node.lineno, node.col_offset, self.severity, self.code,
440
+ f".diff(-{val}) computes difference using future data",
441
+ f"Use .diff({val}) to compute backward differences"))
442
+ for kw in node.keywords:
443
+ if kw.arg == 'periods':
444
+ val = self._get_negative_value(kw.value, ctx)
445
+ if val is not None and val > 0:
446
+ results.append(Diagnostic(
447
+ node.lineno, node.col_offset, self.severity, self.code,
448
+ f".diff(periods=-{val}) computes difference using future data",
449
+ f"Use .diff(periods={val}) to compute backward differences"))
450
+ return results
451
+
452
+ def _get_negative_value(self, arg, ctx=None):
453
+ if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
454
+ if isinstance(arg.operand, ast.Constant) and isinstance(arg.operand.value, (int, float)):
455
+ return abs(arg.operand.value)
456
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)) and arg.value < 0:
457
+ return abs(arg.value)
458
+ if isinstance(arg, ast.Name) and ctx is not None and hasattr(ctx, 'constants'):
459
+ val = ctx.constants.get(arg.id)
460
+ if val is not None and isinstance(val, (int, float)) and val < 0:
461
+ return abs(val)
462
+ return None
463
+
464
+
465
+ class T015_GroupbyShiftLeak(Rule):
466
+ code = "T015"
467
+ name = "groupby_shift_leak"
468
+ severity = "error"
469
+ category = "TemporalLeak"
470
+
471
+ def check_node(self, node, ctx):
472
+ if not isinstance(node, ast.Call):
473
+ return []
474
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ('shift', 'diff', 'pct_change'):
475
+ # Check if this is chained after .transform() or .apply()
476
+ obj = node.func.value
477
+ if isinstance(obj, ast.Call) and isinstance(obj.func, ast.Attribute):
478
+ if obj.func.attr in ('transform', 'apply'):
479
+ for arg in node.args:
480
+ if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
481
+ return [Diagnostic(
482
+ node.lineno, node.col_offset, self.severity, self.code,
483
+ f"Negative {node.func.attr}() inside groupby().transform() leaks future within groups",
484
+ "Use positive periods for backward-looking operations within groups")]
485
+ return []
486
+
487
+
488
+ class T016_MergeAsofDirection(Rule):
489
+ code = "T016"
490
+ name = "merge_asof_future_direction"
491
+ severity = "error"
492
+ category = "TemporalLeak"
493
+
494
+ def check_node(self, node, ctx):
495
+ if not isinstance(node, ast.Call):
496
+ return []
497
+ is_merge_asof = False
498
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'merge_asof':
499
+ is_merge_asof = True
500
+ elif isinstance(node.func, ast.Name) and node.func.id == 'merge_asof':
501
+ is_merge_asof = True
502
+
503
+ if not is_merge_asof:
504
+ return []
505
+
506
+ for kw in node.keywords:
507
+ if kw.arg == 'direction':
508
+ if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
509
+ val = kw.value.value.lower()
510
+ if val in ('nearest', 'forward'):
511
+ return [Diagnostic(
512
+ node.lineno, node.col_offset, self.severity, self.code,
513
+ f"pd.merge_asof() with direction='{kw.value.value}' matches future timestamps (lookahead bias)",
514
+ "Use direction='backward' for point-in-time causal event joins")]
515
+ return []
516
+
517
+
518
+ class T017_TargetLaundering(Rule):
519
+ code = "T017"
520
+ name = "target_laundering_leak"
521
+ severity = "error"
522
+ category = "TemporalLeak"
523
+
524
+ def check_node(self, node, ctx):
525
+ target_col_names = {'target', 'success', 'outcome', 'label', 'labels', 'y',
526
+ 'profit', 'pnl', 'win', 'return', 'returns', 'future_price'}
527
+
528
+ # 1. Detect target encoding / aggregation: df.groupby(...)['target'].transform('mean')
529
+ if isinstance(node, ast.Call):
530
+ if isinstance(node.func, ast.Name) and node.func.id in ('TargetEncoder', 'CatBoostEncoder'):
531
+ return [Diagnostic(
532
+ node.lineno, node.col_offset, self.severity, self.code,
533
+ f"{node.func.id}() calculates target statistics across groups; ensure it is fit ONLY on training data",
534
+ "Fit target encoders strictly on X_train, y_train after temporal splitting")]
535
+
536
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ('transform', 'mean', 'median', 'std', 'sum', 'agg', 'apply'):
537
+ val = node.func.value
538
+ col_name = None
539
+ groupby_call = None
540
+
541
+ if isinstance(val, ast.Subscript):
542
+ if isinstance(val.slice, ast.Constant) and isinstance(val.slice.value, str):
543
+ col_name = val.slice.value.lower()
544
+ groupby_call = val.value
545
+ elif isinstance(val, ast.Call):
546
+ groupby_call = val
547
+
548
+ if col_name and any(t in col_name for t in target_col_names):
549
+ if isinstance(groupby_call, ast.Call) and isinstance(groupby_call.func, ast.Attribute) and groupby_call.func.attr == 'groupby':
550
+ return [Diagnostic(
551
+ node.lineno, node.col_offset, self.severity, self.code,
552
+ f"Target laundering: grouping by target/outcome column '{col_name}' leaks future labels into features",
553
+ "Compute regime/group statistics strictly on training data after splitting")]
554
+
555
+ # 2. Detect assignment creating laundered columns: df['regime_success_rate'] = ...
556
+ if isinstance(node, ast.Assign):
557
+ for target in node.targets:
558
+ if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant) and isinstance(target.slice.value, str):
559
+ col = target.slice.value.lower()
560
+ if any(marker in col for marker in ('success_rate', 'target_enc', 'target_mean', 'regime_success')):
561
+ return [Diagnostic(
562
+ node.lineno, node.col_offset, self.severity, self.code,
563
+ f"Target laundering: column '{target.slice.value}' encodes target outcomes across full dataset",
564
+ "Calculate success rates and target statistics only on the training set")]
565
+ return []
566
+
567
+
568
+ # ═══════════════════════════════════════════════════════════════
569
+ # CAUSAL CONFUSION RULES (C001–C010)
570
+ # ═══════════════════════════════════════════════════════════════
571
+
572
+ class C001_CorrelationCausation(Rule):
573
+ code = "C001"
574
+ name = "correlation_causation"
575
+ severity = "warning"
576
+ category = "CausalConfusion"
577
+
578
+ def check_node(self, node, ctx):
579
+ if not isinstance(node, ast.Call):
580
+ return []
581
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'corr':
582
+ return [Diagnostic(
583
+ node.lineno, node.col_offset, self.severity, self.code,
584
+ ".corr() measures correlation, not causation — do not use for treatment/trading decisions",
585
+ "Use causal inference methods (DoWhy, EconML) or randomized experiments")]
586
+ return []
587
+
588
+
589
+ class C002_TargetInFeatures(Rule):
590
+ code = "C002"
591
+ name = "target_in_features"
592
+ severity = "error"
593
+ category = "CausalConfusion"
594
+
595
+ def check_node(self, node, ctx):
596
+ if not isinstance(node, ast.Subscript):
597
+ return []
598
+ # Detect df[['feature1', 'target']] where target-like names are used as features
599
+ if isinstance(node.slice, ast.List):
600
+ target_names = {'target', 'y', 'label', 'labels', 'outcome', 'y_true',
601
+ 'future_price', 'future_return', 'next_close', 'next_price',
602
+ 'regime_success_rate', 'success_rate'}
603
+ for elt in node.slice.elts:
604
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
605
+ val = elt.value.lower()
606
+ if val in target_names or val.endswith('_success_rate') or val.endswith('_target_enc'):
607
+ return [Diagnostic(
608
+ node.lineno, node.col_offset, self.severity, self.code,
609
+ f"Column '{elt.value}' appears to be a target or target-laundered variable used as a feature",
610
+ "Remove target/outcome and target-laundered columns from feature inputs to avoid data leakage")]
611
+ return []
612
+
613
+
614
+ class C003_PostTreatmentVariable(Rule):
615
+ code = "C003"
616
+ name = "post_treatment_variable"
617
+ severity = "warning"
618
+ category = "CausalConfusion"
619
+
620
+ def check_node(self, node, ctx):
621
+ # Detect variable names that suggest post-treatment data
622
+ if not isinstance(node, ast.Assign):
623
+ return []
624
+ for target in node.targets:
625
+ if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant):
626
+ col_name = str(target.slice.value).lower()
627
+ post_indicators = ('after_', 'post_', 'result_', 'outcome_', 'response_')
628
+ if any(col_name.startswith(p) for p in post_indicators):
629
+ return [Diagnostic(
630
+ node.lineno, node.col_offset, self.severity, self.code,
631
+ f"Column '{target.slice.value}' may be a post-treatment variable",
632
+ "Post-treatment variables should not be used as features in causal models")]
633
+ return []
634
+
635
+
636
+ class C004_SelectionOnOutcome(Rule):
637
+ code = "C004"
638
+ name = "selection_on_outcome"
639
+ severity = "error"
640
+ category = "CausalConfusion"
641
+
642
+ def check_node(self, node, ctx):
643
+ if not isinstance(node, ast.Assign):
644
+ return []
645
+ val = node.value
646
+ # Detect df = df[df['profit'] > 0] patterns (survivorship bias)
647
+ if isinstance(val, ast.Subscript) and isinstance(val.slice, ast.Compare):
648
+ comp = val.slice
649
+ if isinstance(comp.left, ast.Subscript) and isinstance(comp.left.slice, ast.Constant):
650
+ col = str(comp.left.slice.value).lower()
651
+ outcome_cols = {'profit', 'return', 'returns', 'pnl', 'outcome',
652
+ 'survived', 'alive', 'active', 'success'}
653
+ if col in outcome_cols:
654
+ return [Diagnostic(
655
+ node.lineno, node.col_offset, self.severity, self.code,
656
+ f"Filtering by outcome column '{comp.left.slice.value}' before analysis introduces survivorship bias",
657
+ "Include all data points, including failures, in your analysis")]
658
+ return []
659
+
660
+
661
+ class C005_SurvivorshipBias(Rule):
662
+ code = "C005"
663
+ name = "survivorship_bias"
664
+ severity = "warning"
665
+ category = "CausalConfusion"
666
+
667
+ def check_node(self, node, ctx):
668
+ if not isinstance(node, ast.Call):
669
+ return []
670
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'dropna':
671
+ return [Diagnostic(
672
+ node.lineno, node.col_offset, "info", self.code,
673
+ ".dropna() removes rows non-randomly — may introduce survivorship bias",
674
+ "Consider imputation (.fillna()) or analyze the pattern of missing data first")]
675
+ return []
676
+
677
+
678
+ class C006_PHacking(Rule):
679
+ code = "C006"
680
+ name = "p_hacking"
681
+ severity = "warning"
682
+ category = "CausalConfusion"
683
+
684
+ def check_node(self, node, ctx):
685
+ if not isinstance(node, ast.Compare):
686
+ return []
687
+ # Detect patterns like p_value < 0.05
688
+ if isinstance(node.left, ast.Name):
689
+ if 'p_val' in node.left.id.lower() or 'pvalue' in node.left.id.lower():
690
+ return [Diagnostic(
691
+ node.lineno, node.col_offset, self.severity, self.code,
692
+ "Comparing p-values to thresholds — risk of p-hacking with multiple comparisons",
693
+ "Apply Bonferroni correction or use FDR control for multiple hypothesis testing")]
694
+ return []
695
+
696
+
697
+ class C007_CorrWithoutForDecisions(Rule):
698
+ code = "C007"
699
+ name = "corrwith_decisions"
700
+ severity = "warning"
701
+ category = "CausalConfusion"
702
+
703
+ def check_node(self, node, ctx):
704
+ if not isinstance(node, ast.Call):
705
+ return []
706
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'corrwith':
707
+ return [Diagnostic(
708
+ node.lineno, node.col_offset, self.severity, self.code,
709
+ ".corrwith() computes correlation — do not use for causal feature selection",
710
+ "Use mutual information, SHAP values, or causal feature selection instead")]
711
+ return []
712
+
713
+
714
+ # ═══════════════════════════════════════════════════════════════
715
+ # UNCERTAINTY RULES (U001–U008)
716
+ # ═══════════════════════════════════════════════════════════════
717
+
718
+ class U001_PredictNoUncertainty(Rule):
719
+ code = "U001"
720
+ name = "predict_no_uncertainty"
721
+ severity = "warning"
722
+ category = "UncertaintyIgnored"
723
+
724
+ def check_node(self, node, ctx):
725
+ if not isinstance(node, ast.Call):
726
+ return []
727
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'predict':
728
+ return [Diagnostic(
729
+ node.lineno, node.col_offset, self.severity, self.code,
730
+ "model.predict() returns point estimates without confidence scores",
731
+ "Use model.predict_proba() or compute prediction intervals to quantify uncertainty")]
732
+ return []
733
+
734
+
735
+ class U002_HardcodedThreshold(Rule):
736
+ code = "U002"
737
+ name = "hardcoded_threshold"
738
+ severity = "warning"
739
+ category = "UncertaintyIgnored"
740
+
741
+ def check_node(self, node, ctx):
742
+ if not isinstance(node, ast.Compare):
743
+ return []
744
+ # Detect: prediction > 0.5, signal > 0.7, etc.
745
+ if isinstance(node.left, ast.Name):
746
+ pred_names = {'prediction', 'pred', 'signal', 'score', 'probability', 'prob', 'confidence'}
747
+ if node.left.id.lower() in pred_names:
748
+ for comp_val in node.comparators:
749
+ if isinstance(comp_val, ast.Constant) and isinstance(comp_val.value, (int, float)):
750
+ return [Diagnostic(
751
+ node.lineno, node.col_offset, self.severity, self.code,
752
+ f"Hardcoded threshold ({comp_val.value}) on '{node.left.id}' ignores prediction uncertainty",
753
+ "Use calibrated probability thresholds or dynamic thresholds based on confidence intervals")]
754
+ return []
755
+
756
+
757
+ class U003_NoCalibration(Rule):
758
+ code = "U003"
759
+ name = "no_calibration"
760
+ severity = "warning"
761
+ category = "UncertaintyIgnored"
762
+
763
+ def check_node(self, node, ctx):
764
+ if not isinstance(node, ast.Call):
765
+ return []
766
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'predict_proba':
767
+ return [Diagnostic(
768
+ node.lineno, node.col_offset, "info", self.code,
769
+ ".predict_proba() output may not be calibrated — probabilities may not reflect true likelihoods",
770
+ "Use CalibratedClassifierCV or check calibration with calibration_curve()")]
771
+ return []
772
+
773
+
774
+ class U004_NoEnsemble(Rule):
775
+ code = "U004"
776
+ name = "single_model"
777
+ severity = "info"
778
+ category = "UncertaintyIgnored"
779
+
780
+ def check_node(self, node, ctx):
781
+ if not isinstance(node, ast.Call):
782
+ return []
783
+ single_models = {'LinearRegression', 'LogisticRegression', 'DecisionTreeClassifier',
784
+ 'DecisionTreeRegressor', 'SVR', 'SVC', 'KNeighborsClassifier',
785
+ 'SGDClassifier', 'SGDRegressor', 'Perceptron'}
786
+ name = ""
787
+ if isinstance(node.func, ast.Name):
788
+ name = node.func.id
789
+ elif isinstance(node.func, ast.Attribute):
790
+ name = node.func.attr
791
+ if name in single_models:
792
+ return [Diagnostic(
793
+ node.lineno, node.col_offset, self.severity, self.code,
794
+ f"{name}() — single model provides no uncertainty estimate",
795
+ "Consider ensemble methods (RandomForest, GradientBoosting) or Bayesian approaches for uncertainty")]
796
+ return []
797
+
798
+
799
+ class U005_ScoreOnTrainOnly(Rule):
800
+ code = "U005"
801
+ name = "score_train_only"
802
+ severity = "error"
803
+ category = "UncertaintyIgnored"
804
+
805
+ def check_node(self, node, ctx):
806
+ if not isinstance(node, ast.Call):
807
+ return []
808
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'score':
809
+ # Check if the args look like training data
810
+ for arg in node.args:
811
+ if isinstance(arg, ast.Name):
812
+ name_lower = arg.id.lower()
813
+ if 'train' in name_lower and 'test' not in name_lower:
814
+ return [Diagnostic(
815
+ node.lineno, node.col_offset, self.severity, self.code,
816
+ f".score({arg.id}) evaluates on training data — this is overfitting, not validation",
817
+ "Evaluate on held-out test data: model.score(X_test, y_test)")]
818
+ return []
819
+
820
+
821
+ class U006_PredictInLoop(Rule):
822
+ code = "U006"
823
+ name = "predict_production_loop"
824
+ severity = "error"
825
+ category = "UncertaintyIgnored"
826
+
827
+ def check_node(self, node, ctx):
828
+ if not isinstance(node, ast.Call):
829
+ return []
830
+ if not ctx.in_loop:
831
+ return []
832
+ if isinstance(node.func, ast.Attribute) and node.func.attr == 'predict':
833
+ return [Diagnostic(
834
+ node.lineno, node.col_offset, "warning", self.code,
835
+ ".predict() inside a loop without uncertainty check — risky for live trading/production",
836
+ "Add confidence thresholds or uncertainty bounds before acting on predictions")]
837
+ return []
838
+
839
+
840
+ # ═══════════════════════════════════════════════════════════════
841
+ # DATA QUALITY RULES (D001–D007)
842
+ # ═══════════════════════════════════════════════════════════════
843
+
844
+ class D001_DropNaBeforeSplit(Rule):
845
+ code = "D001"
846
+ name = "dropna_before_split"
847
+ severity = "warning"
848
+ category = "DataQuality"
849
+
850
+ def check_node(self, node, ctx):
851
+ # Handled by C005 (survivorship), but with different framing
852
+ return []
853
+
854
+
855
+ class D002_SilentExceptionSwallow(Rule):
856
+ code = "D002"
857
+ name = "silent_exception"
858
+ severity = "warning"
859
+ category = "DataQuality"
860
+
861
+ def check_node(self, node, ctx):
862
+ if not isinstance(node, ast.ExceptHandler):
863
+ return []
864
+ # Check for bare `except: pass`
865
+ if node.type is None and len(node.body) == 1:
866
+ if isinstance(node.body[0], ast.Pass):
867
+ return [Diagnostic(
868
+ node.lineno, node.col_offset, self.severity, self.code,
869
+ "Bare `except: pass` silently swallows errors in data pipeline",
870
+ "Catch specific exceptions and log or handle them properly")]
871
+ return []
872
+
873
+
874
+ class D003_MagicNumbers(Rule):
875
+ code = "D003"
876
+ name = "magic_numbers"
877
+ severity = "info"
878
+ category = "DataQuality"
879
+
880
+ def check_node(self, node, ctx):
881
+ # Detect hardcoded numeric constants in function calls
882
+ if not isinstance(node, ast.Call):
883
+ return []
884
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ('rolling', 'shift', 'head', 'tail', 'nlargest', 'nsmallest'):
885
+ for arg in node.args:
886
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)):
887
+ if arg.value > 100 or (isinstance(arg.value, float) and arg.value != int(arg.value)):
888
+ return [Diagnostic(
889
+ node.lineno, node.col_offset, self.severity, self.code,
890
+ f"Magic number {arg.value} in .{node.func.attr}() — consider using a named constant",
891
+ "Define as a variable: WINDOW_SIZE = {arg.value}")]
892
+ return []
893
+
894
+
895
+ # ═══════════════════════════════════════════════════════════════
896
+ # RULE REGISTRY
897
+ # ═══════════════════════════════════════════════════════════════
898
+
899
+ ALL_RULES: List[Rule] = [
900
+ # Temporal Leak
901
+ T001_ShiftNegative(),
902
+ T002_TrainTestSplit(),
903
+ T003_RollingBeforeSplit(),
904
+ T004_ExpandingBeforeSplit(),
905
+ T005_PctChangeNegative(),
906
+ T006_FutureIndexInLoop(),
907
+ T007_BackfillLeak(),
908
+ T008_NonCausalInterpolate(),
909
+ T009_CenteredRolling(),
910
+ T010_FitBeforeSplit(),
911
+ T011_ScalerBeforeSplit(),
912
+ T012_KFoldTimeSeries(),
913
+ T013_ResampleBeforeSplit(),
914
+ T014_DiffNegative(),
915
+ T015_GroupbyShiftLeak(),
916
+ T016_MergeAsofDirection(),
917
+ T017_TargetLaundering(),
918
+ # Causal Confusion
919
+ C001_CorrelationCausation(),
920
+ C002_TargetInFeatures(),
921
+ C003_PostTreatmentVariable(),
922
+ C004_SelectionOnOutcome(),
923
+ C005_SurvivorshipBias(),
924
+ C006_PHacking(),
925
+ C007_CorrWithoutForDecisions(),
926
+ # Uncertainty
927
+ U001_PredictNoUncertainty(),
928
+ U002_HardcodedThreshold(),
929
+ U003_NoCalibration(),
930
+ U004_NoEnsemble(),
931
+ U005_ScoreOnTrainOnly(),
932
+ U006_PredictInLoop(),
933
+ # Data Quality
934
+ D002_SilentExceptionSwallow(),
935
+ D003_MagicNumbers(),
936
+ ]
937
+
938
+ RULES_BY_CODE = {r.code: r for r in ALL_RULES}