typedframes 0.6.0__tar.gz → 0.7.0__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 (37) hide show
  1. {typedframes-0.6.0 → typedframes-0.7.0}/PKG-INFO +2 -2
  2. {typedframes-0.6.0 → typedframes-0.7.0}/README.md +1 -1
  3. {typedframes-0.6.0 → typedframes-0.7.0}/pyproject.toml +1 -1
  4. {typedframes-0.6.0 → typedframes-0.7.0}/rust/Cargo.lock +1 -1
  5. {typedframes-0.6.0 → typedframes-0.7.0}/rust/Cargo.toml +1 -1
  6. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/constants.rs +3 -0
  7. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/errors.rs +44 -0
  8. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/index.rs +315 -1
  9. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/linter.rs +1284 -129
  10. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/notebook.rs +77 -1
  11. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/pyapi.rs +4 -0
  12. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/__init__.py +1 -1
  13. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/cli.py +175 -23
  14. {typedframes-0.6.0 → typedframes-0.7.0}/rust/.cargo/config.toml +0 -0
  15. {typedframes-0.6.0 → typedframes-0.7.0}/rust/README.md +0 -0
  16. {typedframes-0.6.0 → typedframes-0.7.0}/rust/benches/parser_bench.rs +0 -0
  17. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/ast_extract.rs +0 -0
  18. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/config.rs +0 -0
  19. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/contract.rs +0 -0
  20. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/lib.rs +0 -0
  21. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/main.rs +0 -0
  22. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/sql.rs +0 -0
  23. {typedframes-0.6.0 → typedframes-0.7.0}/rust/src/typo.rs +0 -0
  24. {typedframes-0.6.0 → typedframes-0.7.0}/rust/tests/integration_test.rs +0 -0
  25. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/_rust_checker.pyi +0 -0
  26. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/base_schema.py +0 -0
  27. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/column.py +0 -0
  28. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/column_group.py +0 -0
  29. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/column_group_error.py +0 -0
  30. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/column_set.py +0 -0
  31. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/missing_dependency_error.py +0 -0
  32. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/mypy.py +0 -0
  33. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/pandas.py +0 -0
  34. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/pandera.py +0 -0
  35. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/polars.py +0 -0
  36. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/py.typed +0 -0
  37. {typedframes-0.6.0 → typedframes-0.7.0}/src/typedframes/schema_algebra.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typedframes
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -556,7 +556,7 @@ The action installs the PyPI wheel into a throwaway virtualenv and runs the chec
556
556
  | `version` | `latest` | PyPI version to install, e.g. `"0.5.0"` |
557
557
  | `strict` | `true` | Fail the step on errors — `typedframes check` exits 0 without it |
558
558
  | `coverage-fail-under` | *(unset)* | Minimum DataFrame schema coverage, e.g. `"90"` |
559
- | `coverage-detail` | `summary` | Or `term-missing` for the per-file breakdown |
559
+ | `coverage-detail` | `summary` | Or `term-missing` for the per-file breakdown, `explain` to diagnose a lower-than-expected total |
560
560
  | `no-warnings` | `false` | Suppress warning-level diagnostics |
561
561
  | `args` | *(empty)* | Escape hatch for `--no-index`, `--lenient-ingest`, `--no-info` |
562
562
 
@@ -522,7 +522,7 @@ The action installs the PyPI wheel into a throwaway virtualenv and runs the chec
522
522
  | `version` | `latest` | PyPI version to install, e.g. `"0.5.0"` |
523
523
  | `strict` | `true` | Fail the step on errors — `typedframes check` exits 0 without it |
524
524
  | `coverage-fail-under` | *(unset)* | Minimum DataFrame schema coverage, e.g. `"90"` |
525
- | `coverage-detail` | `summary` | Or `term-missing` for the per-file breakdown |
525
+ | `coverage-detail` | `summary` | Or `term-missing` for the per-file breakdown, `explain` to diagnose a lower-than-expected total |
526
526
  | `no-warnings` | `false` | Suppress warning-level diagnostics |
527
527
  | `args` | *(empty)* | Escape hatch for `--no-index`, `--lenient-ingest`, `--no-info` |
528
528
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "typedframes"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Static analysis for pandas and polars DataFrames. Catch column errors at lint-time, not runtime."
9
9
  keywords = ["pandas", "polars", "type-checking", "static-analysis", "dataframe", "linter", "mypy-plugin"]
10
10
  readme = "README.md"
@@ -1347,7 +1347,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
1347
1347
 
1348
1348
  [[package]]
1349
1349
  name = "typedframes_checker"
1350
- version = "0.6.0"
1350
+ version = "0.7.0"
1351
1351
  dependencies = [
1352
1352
  "anyhow",
1353
1353
  "criterion",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "typedframes_checker"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  edition = "2021"
5
5
  description = "A high-performance Rust-based checker for typedframes DataFrame column schemas"
6
6
  license = "MIT"
@@ -182,6 +182,9 @@ pub(crate) const LOAD_FUNCTIONS: &[&str] = &[
182
182
  "read_database",
183
183
  "read_database_uri",
184
184
  "read_gbq",
185
+ // Not an I/O read, but shares the same usecols=/columns=/dtype=/schema= recognition
186
+ // and bare-load-without-a-hint fallback as every function above it.
187
+ "DataFrame",
185
188
  ];
186
189
 
187
190
  pub(crate) const LOAD_MODULES: &[&str] = &["pd", "pandas", "pl", "polars"];
@@ -63,6 +63,15 @@ pub struct FileStats {
63
63
  pub dataframes_total: usize,
64
64
  pub dataframes_typed: usize,
65
65
  pub untyped_sites: Vec<UntypedSite>,
66
+ pub typed_sites: Vec<TypedSite>,
67
+ /// Every `<load module>.<load function>(...)`-shaped call found anywhere in the
68
+ /// file by an unconditional, position-independent scan (see
69
+ /// `linter::LoadCallSiteCollector`) -- populated regardless of whether the site
70
+ /// was actually recognized as an origin by the targeted (`Assign`/`AnnAssign`-only)
71
+ /// counting logic. Powers `--coverage-detail=explain`: cross-referencing this list
72
+ /// against `typed_sites`/`untyped_sites` by line is what tells a reader whether a
73
+ /// given call was counted at all, not just whether it was typed.
74
+ pub all_dataframe_calls: Vec<DataFrameCallSite>,
66
75
  }
67
76
 
68
77
  /// One DataFrame origin the linter recognized but could not resolve columns for.
@@ -85,6 +94,41 @@ pub struct UntypedSite {
85
94
  pub var: String,
86
95
  }
87
96
 
97
+ /// A recognized DataFrame origin that DID resolve to a concrete column set --
98
+ /// the `dataframes_typed` counterpart of [`UntypedSite`]. Line and column are
99
+ /// 1-indexed, matching [`LintError`].
100
+ #[derive(Debug, Serialize)]
101
+ pub struct TypedSite {
102
+ pub line: usize,
103
+ pub col: usize,
104
+ /// The assigned variable name where one was available, else a generic stand-in.
105
+ pub var: String,
106
+ /// A human-readable rendering of the resolved schema, from
107
+ /// `Linter::schema_display`: the schema's class name and column set for a named
108
+ /// `BaseSchema`, or `inferred column set {...}` (plus an origin, where known)
109
+ /// for one inferred from `usecols=`/a dict literal/etc.
110
+ pub schema: String,
111
+ }
112
+
113
+ /// One `<load module>.<load function>(...)`-shaped call site found anywhere in a
114
+ /// file by [`crate::linter::LoadCallSiteCollector`]'s unconditional scan -- see
115
+ /// [`FileStats::all_dataframe_calls`]. Line and column are 1-indexed and point at
116
+ /// the call expression itself (e.g. the `pd` in `pd.DataFrame(...)`), NOT at an
117
+ /// enclosing assignment statement the way [`UntypedSite`]/[`TypedSite`] do -- the two
118
+ /// coordinate systems are cross-referenced by LINE only (see
119
+ /// `--coverage-detail=explain` in the CLI), not by column.
120
+ #[derive(Debug, Serialize)]
121
+ pub struct DataFrameCallSite {
122
+ pub line: usize,
123
+ pub col: usize,
124
+ /// E.g. `"pd.DataFrame"`, `"pl.read_csv"`.
125
+ pub label: String,
126
+ /// Human-readable description of the syntactic position this call was found in
127
+ /// (direct assignment, return value, call argument, collection element, ...) --
128
+ /// see `linter::CallContext::describe`.
129
+ pub context: String,
130
+ }
131
+
88
132
  /// The JSON payload returned by the `check_file` entry point: the diagnostics plus
89
133
  /// coverage stats.
90
134
  #[derive(Debug, Serialize)]
@@ -9,7 +9,7 @@ use crate::ast_extract;
9
9
  use crate::config::{find_project_root_opt, load_linter_config, LinterConfig};
10
10
  use crate::constants::DEFAULT_EXCLUDED_DIRS;
11
11
  use crate::errors::{LintError, CODE_UNKNOWN_COLUMN, CODE_UNTRACKED_DATAFRAME};
12
- use crate::linter::{Linter, ParamGovernedTemplate};
12
+ use crate::linter::{collect_self_attr_origins, Linter, ParamGovernedTemplate, SelfAttrOrigin};
13
13
  use ruff_python_ast::{self as ast, Expr, Stmt};
14
14
  use ruff_python_parser::parse_module;
15
15
  use ruff_source_file::{LineIndex, SourceCode};
@@ -139,6 +139,33 @@ pub(crate) struct ProjectIndex {
139
139
  // warning and it would be wrong to silently report nothing).
140
140
  #[serde(default)]
141
141
  pub(crate) resolved_governed: std::collections::HashSet<(String, String)>,
142
+ // Defining file -> bare function/method names confirmed called from a direct
143
+ // `x = name(...)` call site SOMEWHERE in the project (any file, including the
144
+ // defining file itself -- redundant with what same-file confirmation already
145
+ // finds there, but harmless: `Linter::functions_called` is a plain HashSet, and
146
+ // inserting an already-present name is a no-op).
147
+ //
148
+ // This is what lets `Linter::pending_passthrough_reversals` (see its doc comment
149
+ // in linter.rs) confirm a pass-through function's count across a file boundary --
150
+ // `loaders.py` defines `load_orders` with a bare `return df`; without this, only
151
+ // `pipeline.py`'s own call site would ever confirm it (via cross-file
152
+ // `self.functions` resolution), while `loaders.py`'s OWN per-file check has no way
153
+ // to know anyone calls it, so it would keep counting its internal origin too --
154
+ // the exact cross-file double-count this field closes. Seeded into each file's
155
+ // `Linter::functions_called` by `load_cross_file_symbols`, BEFORE that file's own
156
+ // statement walk runs, so the existing end-of-walk resolution loop
157
+ // (`check_parsed_module`) picks up a cross-file confirmation with no change to the
158
+ // reversal mechanism itself.
159
+ //
160
+ // Scoped to bare-name calls only (`resolve_delegate_target`'s own resolution
161
+ // capability, the same one `resolve_param_governed_call_sites` already relies on)
162
+ // -- a cross-file `self.<attr>.<method>()` call site is NOT covered (would need
163
+ // `current_self_attrs`-equivalent tracking reconstructed for every file in this
164
+ // lightweight re-scan, not just the `Linter`-based `index_file` pass), so that
165
+ // shape's cross-file confirmation remains a known, narrower follow-up gap; its
166
+ // SAME-FILE confirmation is unaffected and already works.
167
+ #[serde(default)]
168
+ pub(crate) called_functions: HashMap<String, std::collections::HashSet<String>>,
142
169
  }
143
170
 
144
171
  // Union schema name -> column list across every file's `schemas` map. First
@@ -638,6 +665,7 @@ pub(crate) fn finalise_index(
638
665
  resolve_param_schema_requires(&mut files, &all_schemas);
639
666
  resolve_transitive_requires(project_root, &mut files);
640
667
  let governed = resolve_param_governed_call_sites(project_root, &files);
668
+ let called_functions = resolve_called_functions(project_root, &files);
641
669
  ProjectIndex {
642
670
  version: 1,
643
671
  files,
@@ -645,6 +673,7 @@ pub(crate) fn finalise_index(
645
673
  all_schema_locations,
646
674
  call_site_errors: governed.call_site_errors,
647
675
  resolved_governed: governed.resolved_governed,
676
+ called_functions,
648
677
  }
649
678
  }
650
679
 
@@ -878,6 +907,291 @@ pub(crate) fn resolve_delegate_target(
878
907
  None
879
908
  }
880
909
 
910
+ // Project-wide pass: for every direct `x = name(...)` call site anywhere in the
911
+ // project, resolve the callee to its defining (file, func) via `resolve_delegate_target`
912
+ // and record it unconditionally -- see `ProjectIndex.called_functions`'s doc comment
913
+ // for what this is for. Much simpler than `resolve_param_governed_call_sites`/
914
+ // `check_governed_call_site`: no template or literal-value resolution, every call site
915
+ // that resolves at all is recorded.
916
+ pub(crate) fn resolve_called_functions(
917
+ project_root: &Path,
918
+ files: &HashMap<String, IndexEntry>,
919
+ ) -> HashMap<String, std::collections::HashSet<String>> {
920
+ let mut called: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
921
+ let has_any_functions = files
922
+ .values()
923
+ .any(|entry| !entry.functions.is_empty() || !entry.class_methods.is_empty());
924
+ if !has_any_functions {
925
+ return called;
926
+ }
927
+
928
+ for file_path in files.keys() {
929
+ let Ok(source) = fs::read_to_string(file_path) else {
930
+ continue;
931
+ };
932
+ let Ok(parsed) = parse_module(&source) else {
933
+ continue;
934
+ };
935
+ let module = parsed.into_syntax();
936
+ scan_stmts_for_called_functions(
937
+ &module.body,
938
+ file_path,
939
+ project_root,
940
+ files,
941
+ None,
942
+ &mut called,
943
+ );
944
+ }
945
+ called
946
+ }
947
+
948
+ // Resolve a `self.<attr>` origin (see `collect_self_attr_origins`) to the file that
949
+ // defines its class: same-file first (a class defined in `from_file` itself), then
950
+ // `imports`/`module_aliases` -- mirroring how a real `Linter`'s `self.class_methods`
951
+ // ends up covering both cases (local class visits plus `import_name`'s cross-file
952
+ // pull), just resolved fresh here since this lightweight re-scan has no `Linter`
953
+ // instance whose state already merged them (see `index_file`, which never calls
954
+ // `load_cross_file_symbols` before its own walk).
955
+ fn resolve_self_attr_class_file(
956
+ from_file: &str,
957
+ origin: &SelfAttrOrigin,
958
+ project_root: &Path,
959
+ files: &HashMap<String, IndexEntry>,
960
+ ) -> Option<String> {
961
+ let from_entry = files.get(from_file)?;
962
+ if from_entry.class_methods.contains_key(&origin.class_name) {
963
+ return Some(from_file.to_string());
964
+ }
965
+ let module_name = if origin.is_attribute_call {
966
+ from_entry.module_aliases.get(&origin.resolve_name)
967
+ } else {
968
+ from_entry.imports.get(&origin.resolve_name)
969
+ }?;
970
+ let target_file = resolve_module_file(module_name, project_root, files)?;
971
+ if files
972
+ .get(&target_file)?
973
+ .class_methods
974
+ .contains_key(&origin.class_name)
975
+ {
976
+ Some(target_file)
977
+ } else {
978
+ None
979
+ }
980
+ }
981
+
982
+ // Traversal shape mirrors `scan_stmts_for_governed_calls`'s If/For/While/With
983
+ // recursion, EXTENDED to also descend into function and class bodies (see the
984
+ // FunctionDef/ClassDef arms below for why: unlike the narrower Feast-specific
985
+ // scanner, this one's whole purpose depends on finding real call sites, which
986
+ // overwhelmingly live inside a function or method body, not at module level).
987
+ //
988
+ // `self_attrs` is `Some` while inside a class body (recomputed fresh at each
989
+ // `Stmt::ClassDef` from that class's own `__init__`, matching a real `Linter`'s
990
+ // per-class `current_self_attrs` scoping -- a nested class does NOT inherit an
991
+ // enclosing class's attrs) and `None` everywhere else (module level, a plain
992
+ // function). It's what lets a `self.<attr>.<method>(...)` call site resolve, the
993
+ // shape `resolve_delegate_target` alone can't cover (it only resolves bare names via
994
+ // imports/module_aliases, with no notion of instance attributes).
995
+ pub(crate) fn scan_stmts_for_called_functions(
996
+ stmts: &[Stmt],
997
+ file_path: &str,
998
+ project_root: &Path,
999
+ files: &HashMap<String, IndexEntry>,
1000
+ self_attrs: Option<&HashMap<String, SelfAttrOrigin>>,
1001
+ called: &mut HashMap<String, std::collections::HashSet<String>>,
1002
+ ) {
1003
+ for stmt in stmts {
1004
+ let call = match stmt {
1005
+ Stmt::Expr(expr_stmt) => match &*expr_stmt.value {
1006
+ Expr::Call(c) => Some(c),
1007
+ _ => None,
1008
+ },
1009
+ Stmt::Assign(assign) => match &*assign.value {
1010
+ Expr::Call(c) => Some(c),
1011
+ _ => None,
1012
+ },
1013
+ _ => None,
1014
+ };
1015
+ if let Some(call) = call {
1016
+ let callee_name = match &*call.func {
1017
+ Expr::Name(n) => Some(n.id.as_str()),
1018
+ Expr::Attribute(attr) => Some(attr.attr.as_str()),
1019
+ _ => None,
1020
+ };
1021
+ if let Some(callee_name) = callee_name {
1022
+ if let Some((target_file, target_func)) =
1023
+ resolve_delegate_target(file_path, callee_name, project_root, files)
1024
+ {
1025
+ called.entry(target_file).or_default().insert(target_func);
1026
+ }
1027
+ }
1028
+ // self.<attr>.<method>(...) -- a shape resolve_delegate_target can't
1029
+ // cover (bare-name imports/module_aliases only, no notion of instance
1030
+ // attributes). Resolved via `self_attrs`, this class's own `__init__`
1031
+ // origins (see the ClassDef arm below).
1032
+ if let (Expr::Attribute(attr), Some(attrs)) = (&*call.func, self_attrs) {
1033
+ if let Expr::Attribute(recv_attr) = &*attr.value {
1034
+ if let Expr::Name(recv_base) = &*recv_attr.value {
1035
+ if recv_base.id.as_str() == "self" {
1036
+ if let Some(origin) = attrs.get(recv_attr.attr.as_str()) {
1037
+ if let Some(target_file) = resolve_self_attr_class_file(
1038
+ file_path,
1039
+ origin,
1040
+ project_root,
1041
+ files,
1042
+ ) {
1043
+ let method_known = files
1044
+ .get(&target_file)
1045
+ .and_then(|e| e.class_methods.get(&origin.class_name))
1046
+ .is_some_and(|methods| {
1047
+ methods.contains_key(attr.attr.as_str())
1048
+ });
1049
+ if method_known {
1050
+ called
1051
+ .entry(target_file)
1052
+ .or_default()
1053
+ .insert(attr.attr.to_string());
1054
+ }
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+ match stmt {
1063
+ Stmt::If(if_stmt) => {
1064
+ scan_stmts_for_called_functions(
1065
+ &if_stmt.body,
1066
+ file_path,
1067
+ project_root,
1068
+ files,
1069
+ self_attrs,
1070
+ called,
1071
+ );
1072
+ for clause in &if_stmt.elif_else_clauses {
1073
+ scan_stmts_for_called_functions(
1074
+ &clause.body,
1075
+ file_path,
1076
+ project_root,
1077
+ files,
1078
+ self_attrs,
1079
+ called,
1080
+ );
1081
+ }
1082
+ }
1083
+ Stmt::For(for_stmt) => {
1084
+ scan_stmts_for_called_functions(
1085
+ &for_stmt.body,
1086
+ file_path,
1087
+ project_root,
1088
+ files,
1089
+ self_attrs,
1090
+ called,
1091
+ );
1092
+ }
1093
+ Stmt::While(while_stmt) => {
1094
+ scan_stmts_for_called_functions(
1095
+ &while_stmt.body,
1096
+ file_path,
1097
+ project_root,
1098
+ files,
1099
+ self_attrs,
1100
+ called,
1101
+ );
1102
+ }
1103
+ Stmt::With(with_stmt) => {
1104
+ scan_stmts_for_called_functions(
1105
+ &with_stmt.body,
1106
+ file_path,
1107
+ project_root,
1108
+ files,
1109
+ self_attrs,
1110
+ called,
1111
+ );
1112
+ }
1113
+ Stmt::Try(try_stmt) => {
1114
+ scan_stmts_for_called_functions(
1115
+ &try_stmt.body,
1116
+ file_path,
1117
+ project_root,
1118
+ files,
1119
+ self_attrs,
1120
+ called,
1121
+ );
1122
+ for handler in &try_stmt.handlers {
1123
+ let ast::ExceptHandler::ExceptHandler(h) = handler;
1124
+ scan_stmts_for_called_functions(
1125
+ &h.body,
1126
+ file_path,
1127
+ project_root,
1128
+ files,
1129
+ self_attrs,
1130
+ called,
1131
+ );
1132
+ }
1133
+ scan_stmts_for_called_functions(
1134
+ &try_stmt.orelse,
1135
+ file_path,
1136
+ project_root,
1137
+ files,
1138
+ self_attrs,
1139
+ called,
1140
+ );
1141
+ scan_stmts_for_called_functions(
1142
+ &try_stmt.finalbody,
1143
+ file_path,
1144
+ project_root,
1145
+ files,
1146
+ self_attrs,
1147
+ called,
1148
+ );
1149
+ }
1150
+ // Function and class bodies -- deliberately recursed into, UNLIKE
1151
+ // `scan_stmts_for_governed_calls`'s narrower traversal: almost every real
1152
+ // call site lives inside a function or method body, not at module level,
1153
+ // so skipping these (matching the Feast-specific scanner's scope) would
1154
+ // make cross-file confirmation find close to nothing in practice.
1155
+ Stmt::FunctionDef(func_def) => {
1156
+ // `self_attrs` carries through unchanged -- a nested function defined
1157
+ // inside a method still sees the enclosing class's `self.<attr>`
1158
+ // origins, matching a real Linter's `current_self_attrs` staying in
1159
+ // effect for a class's whole body, nested defs included.
1160
+ scan_stmts_for_called_functions(
1161
+ &func_def.body,
1162
+ file_path,
1163
+ project_root,
1164
+ files,
1165
+ self_attrs,
1166
+ called,
1167
+ );
1168
+ }
1169
+ Stmt::ClassDef(class_def) => {
1170
+ // Fresh per class, from THIS class's own __init__ only -- a nested
1171
+ // class does NOT inherit an enclosing class's attrs, matching
1172
+ // `collect_self_attr_origins`'s same-class scoping in the real Linter.
1173
+ let mut class_self_attrs = HashMap::new();
1174
+ for body_stmt in &class_def.body {
1175
+ if let Stmt::FunctionDef(func_def) = body_stmt {
1176
+ if func_def.name.as_str() == "__init__" {
1177
+ collect_self_attr_origins(&func_def.body, &mut class_self_attrs);
1178
+ }
1179
+ }
1180
+ }
1181
+ scan_stmts_for_called_functions(
1182
+ &class_def.body,
1183
+ file_path,
1184
+ project_root,
1185
+ files,
1186
+ Some(&class_self_attrs),
1187
+ called,
1188
+ );
1189
+ }
1190
+ _ => {}
1191
+ }
1192
+ }
1193
+ }
1194
+
881
1195
  // Line/column for a source offset, without needing a full `Linter` instance — used by
882
1196
  // resolve_param_governed_call_sites, which parses each file fresh outside any Linter's
883
1197
  // own `check_file_internal` pass.