Skip to main content

fxrank_lang_python/
lib.rs

1//! Python (libcst-based) frontend for FxRank's effect-cost profiler.
2
3pub mod coverage;
4pub mod detect;
5pub mod functions;
6pub mod imports;
7pub mod module_map;
8pub mod source;
9
10use fxrank_core::CorpusProfile;
11use fxrank_core::frontend::{Frontend, FrontendOutput, Language, SourceFile};
12use fxrank_core::model::Diagnostic;
13use libcst_native::parse_module;
14
15/// Python corpus hygiene.
16///
17/// Virtual-environment and build-artifact directories are pruned so that
18/// `fxrank scan .` on a Python project doesn't descend into installed packages.
19/// `pyvenv.cfg` marks a venv root for content-based pruning (`prune_marker_files`).
20pub const CORPUS_PROFILE: CorpusProfile = CorpusProfile {
21    prune_dirs: &[
22        ".venv",
23        "venv",
24        ".tox",
25        ".nox",
26        "__pycache__",
27        ".eggs",
28        "build",
29        "dist",
30        ".mypy_cache",
31        ".pytest_cache",
32        ".ruff_cache",
33        "site-packages",
34    ],
35    exclude_file_globs: &["*_pb2.py", "*_pb2_grpc.py"],
36    test_file_globs: &["test_*.py", "*_test.py", "conftest.py", "tests"],
37    prune_marker_files: &["pyvenv.cfg"],
38};
39
40/// The Python language frontend.
41///
42/// When `include_tests` is `false` (the default), two skip mechanisms apply:
43///
44/// - **Path-based**: entire files whose base name matches `test_*.py` / `*_test.py` /
45///   `conftest.py`, or whose path contains a `tests/` directory segment, are skipped.
46/// - **Source-based**: within an otherwise-scanned file, units that are a `test_*`-named
47///   function, a method of a `Test*`-named class, or a method of a `unittest.TestCase`
48///   subclass are skipped.
49///
50/// Skipped units are counted in `FrontendOutput::skipped_tests`.
51/// `--include-tests` (`include_tests: true`) disables both skip mechanisms.
52pub struct PythonFrontend {
53    pub include_tests: bool,
54}
55
56impl Frontend for PythonFrontend {
57    fn language(&self) -> Language {
58        Language::Python
59    }
60
61    fn corpus_profile(&self) -> CorpusProfile {
62        CORPUS_PROFILE
63    }
64
65    fn analyze(&self, files: &[SourceFile]) -> FrontendOutput {
66        // Build PyModuleMap once over ALL files so every per-file build_record call
67        // can compute canonical_path without rebuilding the package tree each time.
68        let module_map = module_map::PyModuleMap::build(files);
69
70        let mut output = FrontendOutput::default();
71        for file in files {
72            let src = source::strip_bom(&file.text);
73            match parse_module(src, None) {
74                Err(e) => {
75                    output.diagnostics.push(Diagnostic {
76                        path: file.path.clone(),
77                        parsed: false,
78                        error: format!("{e}"),
79                    });
80                }
81                Ok(module) => {
82                    // Single borrowed pass: collect + analyze while `module`/`src`
83                    // are both alive. `analyze_unit` emits owned `Hotspot`s.
84                    let imports = imports::Imports::build(&module);
85                    let module_bindings = crate::imports::module_bindings(&module);
86                    // Build SpanIndex once per file; pass it into both `collect`
87                    // (for lambda-anchor line/col) and `analyze_unit` (for effect
88                    // line resolution) — no duplicate O(n) line-start indexing.
89                    let span = source::SpanIndex::new(src);
90
91                    // Tokenize once to obtain lambda anchors. `parse_module`
92                    // succeeded above, so tokenization is a strict subset and
93                    // must also succeed — `None` is a theoretically-impossible
94                    // state. We propagate failure rather than swallowing it with
95                    // `unwrap_or_default()`: an empty anchor vec would make the
96                    // count guard see `0 == 0` and silently drop every lambda.
97                    let anchors = match source::lambda_anchors(src) {
98                        Some(a) => a,
99                        None => {
100                            output.diagnostics.push(Diagnostic {
101                                path: file.path.clone(),
102                                parsed: true,
103                                error: "lambda anchoring unavailable: tokenizer failed on \
104                                        a file that parsed successfully; hotspots for this \
105                                        file are omitted to avoid mis-anchored output"
106                                    .into(),
107                            });
108                            continue;
109                        }
110                    };
111
112                    // Pass the pre-computed anchors into collect so tokenization
113                    // runs exactly once per file (no second tokenizer pass for the
114                    // mismatch guard).
115                    let (units, lambda_node_count) =
116                        functions::collect(&module, src, &span, &anchors);
117
118                    // Runtime lambda-anchor mismatch guard (node count, not emitted count).
119                    // `collect` guards the bijection with a `debug_assert_eq!`
120                    // (loud in tests/debug builds). In a release build that assert
121                    // is stripped, so we add a non-panicking runtime check here.
122                    //
123                    // CRITICAL: we compare the Lambda-NODE count (`lambda_node_count`,
124                    // which `collect` increments on every Lambda node visited, even when
125                    // `anchors.get(idx)` returns `None` and no unit is emitted) against
126                    // `anchors.len()`. Comparing emitted-unit count instead would miss
127                    // the N>M case: if there are more Lambda nodes (N) than anchors (M),
128                    // the first M emit normally, the remaining N−M are silently skipped,
129                    // and emitted(M)==anchors(M) → guard passes → silent drop. Using the
130                    // node count detects the mismatch in both directions (N<M and N>M).
131                    {
132                        let anchor_count = anchors.len();
133                        if lambda_node_count != anchor_count {
134                            output.diagnostics.push(Diagnostic {
135                                path: file.path.clone(),
136                                parsed: true,
137                                error: format!(
138                                    "lambda-anchor mismatch: CST walk found {lambda_node_count} Lambda node(s) \
139                                     but tokenizer found {anchor_count} lambda keyword(s); \
140                                     hotspots for this file are omitted to avoid mis-anchored output"
141                                ),
142                            });
143                            continue;
144                        }
145                    }
146
147                    if !self.include_tests && is_test_file(&file.path) {
148                        // Path-based skip: the entire file is test code.
149                        output.skipped_tests += units.len();
150                    } else {
151                        for unit in &units {
152                            if !self.include_tests && unit.is_test_unit {
153                                // Source-based skip: individual test unit within a
154                                // non-test-named file.
155                                output.skipped_tests += 1;
156                            } else {
157                                output.functions.push(detect::analyze_unit(
158                                    unit,
159                                    &file.path,
160                                    &imports,
161                                    &module_bindings,
162                                    &span,
163                                ));
164                                output.records.push(detect::build_record(
165                                    unit,
166                                    &file.path,
167                                    &imports,
168                                    &module_bindings,
169                                    &span,
170                                    &module_map,
171                                ));
172                            }
173                        }
174
175                        // Module-init unit: score the module's top-level executable
176                        // statements as a synthetic `<module>` unit. Emitted only
177                        // when the module has ≥1 effect (import-time IO, effectful
178                        // top-level call, etc.). A pure module (imports + function/
179                        // class definitions only) produces no `<module>` entry.
180                        //
181                        // Root-ness is CLI-level, not a frontend heuristic: the
182                        // frontend always emits `record.is_root = false`; the CLI sets
183                        // `root` for units whose file was an explicit FILE arg (the
184                        // agent's observation focus). So the `<module>` unit is a root
185                        // iff its file is explicit, like any other unit — NOT
186                        // automatically. (Guideline: *Roots — the agent's observation
187                        // focus*.)
188                        if let Some(init_unit) = functions::module_init_unit(&module) {
189                            let h = detect::analyze_unit(
190                                &init_unit,
191                                &file.path,
192                                &imports,
193                                &module_bindings,
194                                &span,
195                            );
196                            if !h.effects.is_empty() {
197                                let rec = detect::build_record(
198                                    &init_unit,
199                                    &file.path,
200                                    &imports,
201                                    &module_bindings,
202                                    &span,
203                                    &module_map,
204                                );
205                                output.records.push(rec);
206                                output.functions.push(h);
207                            }
208                        }
209                    }
210                }
211            }
212        }
213        output
214    }
215}
216
217/// Return `true` if `path` identifies a test file by Python convention.
218///
219/// Delegates to a `CorpusMatcher` built from `CORPUS_PROFILE.test_file_globs`:
220/// - `test_*.py` / `*_test.py` match by base-name glob (pytest conventions), OR
221/// - `conftest.py` matches exactly (pytest fixtures / configuration), OR
222/// - `tests` as a bare literal matches any path segment (e.g. `src/tests/foo.py`).
223pub fn is_test_file(path: &str) -> bool {
224    use std::sync::OnceLock;
225    static M: OnceLock<fxrank_core::CorpusMatcher> = OnceLock::new();
226    M.get_or_init(|| fxrank_core::CorpusMatcher::test_matcher(CORPUS_PROFILE.test_file_globs))
227        .matches_test_file(path)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    /// Build a `FrontendOutput` from the given fixture file paths.
235    ///
236    /// The path used for `is_test_file` detection is the same as the read path.
237    fn analyze_files(paths: &[&str], include_tests: bool) -> FrontendOutput {
238        let files: Vec<SourceFile> = paths
239            .iter()
240            .map(|p| {
241                let text =
242                    std::fs::read_to_string(p).unwrap_or_else(|e| panic!("cannot read {p}: {e}"));
243                SourceFile {
244                    path: p.to_string(),
245                    text,
246                }
247            })
248            .collect();
249        PythonFrontend { include_tests }.analyze(&files)
250    }
251
252    /// Build a `FrontendOutput` from a fixture file, but report it under a
253    /// different `logical_path` for `is_test_file` detection.
254    ///
255    /// This lets source-based skip tests use a fixture that lives under
256    /// `tests/fixtures/` (which would otherwise trigger the `tests/` path rule)
257    /// while controlling the path that the skip logic sees.
258    fn analyze_fixture_as(
259        fixture: &str,
260        logical_path: &str,
261        include_tests: bool,
262    ) -> FrontendOutput {
263        let text = std::fs::read_to_string(fixture)
264            .unwrap_or_else(|e| panic!("cannot read {fixture}: {e}"));
265        PythonFrontend { include_tests }.analyze(&[SourceFile {
266            path: logical_path.to_string(),
267            text,
268        }])
269    }
270
271    #[test]
272    fn skips_test_code_by_default_and_counts() {
273        // File named test_sample.py → path-based file skip.
274        let out = analyze_files(
275            &["tests/fixtures/test_sample.py"],
276            /*include_tests=*/ false,
277        );
278        assert_eq!(out.functions.len(), 0);
279        assert!(out.skipped_tests >= 1);
280
281        // With --include-tests, all units are scored.
282        let inc = analyze_files(
283            &["tests/fixtures/test_sample.py"],
284            /*include_tests=*/ true,
285        );
286        assert!(inc.functions.len() >= 3);
287    }
288
289    #[test]
290    fn source_based_skip_independent_of_path_skip() {
291        // The fixture is read from tests/fixtures/ but we report it under
292        // "src/mixed_tests.py" so the path-based skip rule doesn't apply
293        // (no test_*/conftest base name, no `tests/` segment in the logical path).
294        // Source-based rules must skip test_something, TestWidget.test_render,
295        // TestWidget.helper (ALL methods of a Test* class are skipped, even
296        // non-test_*-named ones), and MyCase.test_case (unittest.TestCase
297        // subclass method).
298        let out = analyze_fixture_as(
299            "tests/fixtures/mixed_tests.py",
300            "src/mixed_tests.py",
301            /*include_tests=*/ false,
302        );
303        let symbols: Vec<&str> = out.functions.iter().map(|h| h.symbol.as_str()).collect();
304
305        // normal_function is always kept.
306        assert!(
307            symbols.contains(&"normal_function"),
308            "normal_function must not be skipped; got: {symbols:?}"
309        );
310        // test_* function is skipped.
311        assert!(
312            !symbols.contains(&"test_something"),
313            "test_something must be skipped; got: {symbols:?}"
314        );
315        // TestWidget.test_render is skipped (method of Test* class).
316        assert!(
317            !symbols.contains(&"test_render"),
318            "test_render must be skipped; got: {symbols:?}"
319        );
320        // TestWidget.helper is skipped too: ALL methods of a Test* class are
321        // skipped, including non-test_*-named ones (spec: "Test* class → its
322        // methods skipped"). Keeping helper would violate the spec.
323        assert!(
324            !symbols.contains(&"helper"),
325            "helper must be skipped (method of Test* class TestWidget); got: {symbols:?}"
326        );
327        // MyCase.test_case is skipped (unittest.TestCase subclass method).
328        assert!(
329            !symbols.contains(&"test_case"),
330            "test_case must be skipped; got: {symbols:?}"
331        );
332        // Some units must have been skipped.
333        assert!(
334            out.skipped_tests >= 1,
335            "expected skipped_tests >= 1; got: {}",
336            out.skipped_tests
337        );
338
339        // With --include-tests, all units including test ones are returned.
340        let inc = analyze_fixture_as(
341            "tests/fixtures/mixed_tests.py",
342            "src/mixed_tests.py",
343            /*include_tests=*/ true,
344        );
345        let inc_symbols: Vec<&str> = inc.functions.iter().map(|h| h.symbol.as_str()).collect();
346        assert!(
347            inc_symbols.contains(&"test_something"),
348            "with include_tests, test_something must be scored; got: {inc_symbols:?}"
349        );
350    }
351
352    #[test]
353    fn corpus_profile_method_returns_const() {
354        use fxrank_core::frontend::Frontend;
355        let p = PythonFrontend {
356            include_tests: false,
357        }
358        .corpus_profile();
359        assert_eq!(p.prune_dirs, CORPUS_PROFILE.prune_dirs);
360        assert_eq!(p.test_file_globs, CORPUS_PROFILE.test_file_globs);
361    }
362
363    #[test]
364    fn is_test_file_characterization() {
365        for p in [
366            "test_views.py",
367            "views_test.py",
368            "conftest.py",
369            "pkg/tests/helpers.py",
370            "tests/x.py",
371        ] {
372            assert!(is_test_file(p), "expected test file: {p}");
373        }
374        for p in [
375            "views.py",
376            "pkg/mytests/foo.py",
377            "tests.py",
378            "contest.py",
379            "test_views.txt",
380        ] {
381            assert!(!is_test_file(p), "expected NON-test file: {p}");
382        }
383    }
384
385    /// End-to-end false-resolve guard (spec 025-3e §8).
386    ///
387    /// A file imports `subprocess.run` as the local name `run` AND defines a
388    /// local `def run(): ...` at module level. When `caller()` calls `run()`,
389    /// the frontend must NOT resolve the call to the local `def run` — the import
390    /// table wins: `run` is `subprocess.run` (stdlib, not in the scan batch), so
391    /// `resolved_target` is `None` and `qualified` is `true`. `resolve_ref_precise`
392    /// must therefore return `Edge::Opaque` (ThirdParty), never `Edge::Resolved`.
393    ///
394    /// This is the headline false-resolve the 025-3e adoption closes for Python:
395    /// the same-named local cannot hijack a stdlib-qualified import in the adopted
396    /// CanonicalIndex path.
397    #[test]
398    fn false_resolve_killed() {
399        use fxrank_core::record::CallSiteRef;
400        use fxrank_core::resolve::{CanonicalIndex, resolve_ref_precise};
401
402        // The collision source: `from subprocess import run` + `def run(): ...` + `caller`.
403        // The same-named local is the essential ingredient: without it the test cannot
404        // catch an erroneous Resolved-to-local edge.
405        let src = "\
406from subprocess import run
407
408def run():
409    pass
410
411def caller():
412    run(['ls'])
413";
414        let file_path = "app.py";
415        // Single-file batch (no __init__.py) — subprocess is NOT in-batch.
416        let files = vec![SourceFile {
417            path: file_path.to_string(),
418            text: src.to_string(),
419        }];
420        let out = PythonFrontend {
421            include_tests: false,
422        }
423        .analyze(&files);
424        assert!(
425            out.diagnostics.is_empty(),
426            "unexpected parse error: {:?}",
427            out.diagnostics
428        );
429
430        // Build the CanonicalIndex — must be adopted (local `def run` has a canonical_path).
431        let idx = CanonicalIndex::from_records(&out.records);
432        assert!(
433            idx.adopted(),
434            "index must be adopted: the local `def run` must carry a canonical_path"
435        );
436
437        // Find the caller's record and locate the `run` ref.
438        let caller_rec = out
439            .records
440            .iter()
441            .find(|r| r.symbol == "caller")
442            .expect("caller record not found");
443        let run_ref: &CallSiteRef = caller_rec
444            .refs
445            .iter()
446            .find(|r| r.base == "run")
447            .expect("expected a ref with base 'run' in caller");
448
449        // The ref must be qualified (run is imported from subprocess).
450        assert!(
451            run_ref.qualified,
452            "run ref must be qualified=true (imported from subprocess)"
453        );
454        // resolved_target must be None (subprocess is not in-batch → out-of-corpus).
455        assert_eq!(
456            run_ref.resolved_target, None,
457            "subprocess.run is not in-batch → resolved_target must be None"
458        );
459
460        // The headline assertion: resolve_ref_precise must yield Opaque, NEVER Resolved.
461        let edge = resolve_ref_precise(run_ref, &idx, file_path);
462        let is_opaque = matches!(edge, Some(fxrank_core::graph::Edge::Opaque(_)));
463        assert!(
464            is_opaque,
465            "subprocess.run must resolve to Edge::Opaque (stdlib), \
466             not Edge::Resolved to the local `def run`; got: {edge:?}",
467            edge = if matches!(edge, Some(fxrank_core::graph::Edge::Resolved(_))) {
468                "Edge::Resolved (FALSE RESOLVE — BUG)"
469            } else if edge.is_none() {
470                "None"
471            } else {
472                "Edge::Opaque (correct)"
473            }
474        );
475    }
476}