diff --git a/pyrefly/lib/commands/coverage/collect.rs b/pyrefly/lib/commands/coverage/collect.rs index d958b2f83b..485d61587e 100644 --- a/pyrefly/lib/commands/coverage/collect.rs +++ b/pyrefly/lib/commands/coverage/collect.rs @@ -152,6 +152,11 @@ fn has_function_ancestor(parent: &NestingContext) -> bool { } } +/// True if the class's top-level binding was removed by a module-scope `del`. +fn is_deleted_class(bindings: &Bindings, cls: &ClassBinding) -> bool { + cls.parent.is_toplevel() && bindings.module_deletes().contains(&cls.def.name.id) +} + /// Build a class's fully-qualified name: module prefix, nesting context, then class name. Returns /// e.g. `"pkg.mod.Outer.Inner"` for a nested class or `"pkg.mod.MyClass"` for a top-level one. fn class_fqn( @@ -620,7 +625,7 @@ fn parse_instance_attrs( BindingClass::ClassDef(cls) => cls, BindingClass::FunctionalClassDef(..) => continue, }; - if has_function_ancestor(&cls_binding.parent) { + if has_function_ancestor(&cls_binding.parent) || is_deleted_class(bindings, cls_binding) { continue; } @@ -724,8 +729,8 @@ fn parse_functions( let func_name = if let Some(class_key) = fun.class_key { match bindings.get(class_key) { BindingClass::ClassDef(cls) => { - // Skip methods of classes nested inside functions - if has_function_ancestor(&cls.parent) { + // Skip methods of function-nested and `del`eted classes + if has_function_ancestor(&cls.parent) || is_deleted_class(bindings, cls) { continue; } // Skip private class methods (single-underscore prefix). @@ -1144,7 +1149,6 @@ fn parse_classes( tco_classes: &SmallSet>, ) -> Vec { let mut classes = Vec::new(); - let deleted = bindings.module_deletes(); // group method definitions by class let mut methods_by_class: HashMap, Vec>> = @@ -1179,8 +1183,7 @@ fn parse_classes( if has_function_ancestor(parent) { continue; } - // Skip top-level classes `del`eted at module scope. - if parent.is_toplevel() && deleted.contains(&name.id) { + if is_deleted_class(bindings, cls_binding) { continue; } let class_type = match answers.get_idx(class_idx) { @@ -2569,6 +2572,13 @@ mod tests { compare_snapshot("del_module_level.expected.json", &report); } + /// Methods and instance attrs of a `del`eted class must not appear either (issue #4021). + #[test] + fn test_report_del_class() { + let report = build_module_report_for_test("del_class.py"); + compare_snapshot("del_class.expected.json", &report); + } + /// --module name override: symbol counts (n_functions vs n_methods) must /// be correct even when the output module name differs from the derived name. #[test] diff --git a/pyrefly/lib/test/coverage/test_files/del_class.expected.json b/pyrefly/lib/test/coverage/test_files/del_class.expected.json new file mode 100644 index 0000000000..ed1dc8ec2f --- /dev/null +++ b/pyrefly/lib/test/coverage/test_files/del_class.expected.json @@ -0,0 +1,76 @@ +{ + "name": "test", + "path": "test.py", + "names": [ + "test.Kept.attr", + "test.Kept.method", + "test.Kept.__init__", + "test.Kept" + ], + "line_count": 29, + "symbol_reports": [ + { + "kind": "attr", + "name": "test.Kept.attr", + "n_typable": 1, + "n_typed": 1, + "n_any": 0, + "n_untyped": 0, + "location": { + "line": 28, + "column": 14 + } + }, + { + "kind": "function", + "name": "test.Kept.method", + "n_typable": 2, + "n_typed": 2, + "n_any": 0, + "n_untyped": 0, + "location": { + "line": 24, + "column": 5 + } + }, + { + "kind": "function", + "name": "test.Kept.__init__", + "n_typable": 0, + "n_typed": 0, + "n_any": 0, + "n_untyped": 0, + "location": { + "line": 27, + "column": 5 + } + }, + { + "kind": "class", + "name": "test.Kept", + "n_typable": 0, + "n_typed": 0, + "n_any": 0, + "n_untyped": 0, + "location": { + "line": 23, + "column": 1 + } + } + ], + "type_ignores": [], + "n_typable": 3, + "n_typed": 3, + "n_any": 0, + "n_untyped": 0, + "coverage": 100.0, + "strict_coverage": 100.0, + "n_functions": 0, + "n_methods": 2, + "n_function_params": 0, + "n_method_params": 1, + "n_classes": 1, + "n_attrs": 1, + "n_properties": 0, + "n_type_ignores": 0 +} diff --git a/pyrefly/lib/test/coverage/test_files/del_class.py b/pyrefly/lib/test/coverage/test_files/del_class.py new file mode 100644 index 0000000000..7d6381c3c4 --- /dev/null +++ b/pyrefly/lib/test/coverage/test_files/del_class.py @@ -0,0 +1,28 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# gh-4021: methods and instance attrs of a module-scope `del`eted class must +# not appear in the report as dangling symbols. + + +class Gone: + def method(self, x): + pass + + def __init__(self): + self.attr = 1 + + alias = method + + +del Gone + + +class Kept: + def method(self, x: int) -> int: + return x + + def __init__(self) -> None: + self.attr: int = 1