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}