From fc507f99574b3fe8ff4f9e70dbbe08d26a2138d5 Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Fri, 1 May 2026 15:38:51 +1000 Subject: [PATCH 1/3] Add line_number field to FunctionInfo and ClassInfo - Add line_number field to FunctionInfo and ClassInfo models - Capture lineno from AST nodes during extraction - Store line_number property in Neo4j for Function/Method/Class nodes - Update param_complexity query to use line_number instead of null - All 289 unit tests passing --- src/mapper/ast_parser/extractor.py | 2 + src/mapper/ast_parser/models.py | 2 + src/mapper/graph_loader/loader.py | 4 + src/mapper/quality/rules/param_complexity.py | 2 +- tests/unit/ast_parser/test_extractor.py | 53 ++++++++++++ tests/unit/graph_loader/test_loader.py | 87 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/mapper/ast_parser/extractor.py b/src/mapper/ast_parser/extractor.py index 3ea12d9..6139095 100644 --- a/src/mapper/ast_parser/extractor.py +++ b/src/mapper/ast_parser/extractor.py @@ -152,6 +152,7 @@ def _extract_class(self, node: ast.ClassDef) -> models.ClassInfo: return models.ClassInfo( name=node.name, is_public=self._is_public(node.name), + line_number=node.lineno, docstring=ast.get_docstring(node), bases=bases, decorators=decorators, @@ -189,6 +190,7 @@ def _extract_function(self, node: ast.FunctionDef) -> models.FunctionInfo: return models.FunctionInfo( name=node.name, is_public=self._is_public(node.name), + line_number=node.lineno, docstring=ast.get_docstring(node), parameters=parameters, return_type=return_type, diff --git a/src/mapper/ast_parser/models.py b/src/mapper/ast_parser/models.py index 05f5a1e..9827177 100644 --- a/src/mapper/ast_parser/models.py +++ b/src/mapper/ast_parser/models.py @@ -111,6 +111,7 @@ class FunctionInfo: name: str is_public: bool + line_number: int | None = None docstring: str | None = None parameters: list[ParameterInfo] = attrs.field(factory=list) return_type: str | None = None @@ -124,6 +125,7 @@ class ClassInfo: name: str is_public: bool + line_number: int | None = None docstring: str | None = None bases: list[str] = attrs.field(factory=list) decorators: list[DecoratorInfo] = attrs.field(factory=list) diff --git a/src/mapper/graph_loader/loader.py b/src/mapper/graph_loader/loader.py index 8f3eb5b..2fe1770 100644 --- a/src/mapper/graph_loader/loader.py +++ b/src/mapper/graph_loader/loader.py @@ -262,6 +262,8 @@ def _create_class_node(self, class_info: ast_parser.models.ClassInfo, fqn: str) "package": self.package_name, "is_public": class_info.is_public, } + if class_info.line_number is not None: + properties["line_number"] = class_info.line_number if class_info.docstring: properties["docstring"] = class_info.docstring if class_info.bases: @@ -296,6 +298,8 @@ def _create_function_node( "package": self.package_name, "is_public": func_info.is_public, } + if func_info.line_number is not None: + properties["line_number"] = func_info.line_number if func_info.docstring: properties["docstring"] = func_info.docstring if func_info.return_type: diff --git a/src/mapper/quality/rules/param_complexity.py b/src/mapper/quality/rules/param_complexity.py index faeca93..c062f35 100644 --- a/src/mapper/quality/rules/param_complexity.py +++ b/src/mapper/quality/rules/param_complexity.py @@ -52,7 +52,7 @@ def run( RETURN m.path as file_path, collect({ function: f.name, - line: null, + line: f.line_number, param_count: param_count }) as violations ORDER BY file_path diff --git a/tests/unit/ast_parser/test_extractor.py b/tests/unit/ast_parser/test_extractor.py index 2e2c213..ccc0150 100644 --- a/tests/unit/ast_parser/test_extractor.py +++ b/tests/unit/ast_parser/test_extractor.py @@ -291,3 +291,56 @@ class _PrivateClass: init_method = next(m for m in public_class.methods if m.name == "__init__") assert init_method.is_public is True + + def test_line_number_extraction(self): + """Test that line numbers are captured for functions and classes.""" + code = textwrap.dedent(''' + """Module docstring.""" + + def first_function(): + """First function on line 4.""" + pass + + def second_function(): + """Second function on line 7.""" + pass + + class FirstClass: + """First class on line 11.""" + pass + + class SecondClass: + """Second class on line 15.""" + + def method_one(self): + """Method on line 18.""" + pass + + def method_two(self): + """Method on line 22.""" + pass + ''') + + extractor = ast_parser.ASTExtractor(code, "module.py") + result = extractor.extract() + + # Check function line numbers + first_func = next(f for f in result.functions if f.name == "first_function") + assert first_func.line_number == 4 + + second_func = next(f for f in result.functions if f.name == "second_function") + assert second_func.line_number == 8 + + # Check class line numbers + first_class = next(c for c in result.classes if c.name == "FirstClass") + assert first_class.line_number == 12 + + second_class = next(c for c in result.classes if c.name == "SecondClass") + assert second_class.line_number == 16 + + # Check method line numbers + method_one = next(m for m in second_class.methods if m.name == "method_one") + assert method_one.line_number == 19 + + method_two = next(m for m in second_class.methods if m.name == "method_two") + assert method_two.line_number == 23 diff --git a/tests/unit/graph_loader/test_loader.py b/tests/unit/graph_loader/test_loader.py index 6d2b25c..9ec12cf 100644 --- a/tests/unit/graph_loader/test_loader.py +++ b/tests/unit/graph_loader/test_loader.py @@ -608,6 +608,93 @@ def test_load_with_external_base_class(self): assert mock_connection.create_node.call_count >= 2 # module + class # Relationship creation depends on whether base class exists in graph + def test_load_with_line_numbers(self): + """Test that line numbers are stored in Neo4j nodes.""" + mock_connection = Mock() + loader = graph_loader.GraphLoader(mock_connection, package_name="test-pkg") + + module_info = ast_parser.models.ModuleInfo(path="test.py", name="test") + + # Function with line number + func_info = ast_parser.models.FunctionInfo( + name="my_function", + is_public=True, + line_number=42, + docstring="Function on line 42", + ) + + # Class with line number + class_info = ast_parser.models.ClassInfo( + name="MyClass", + is_public=True, + line_number=100, + docstring="Class on line 100", + methods=[ + ast_parser.models.FunctionInfo( + name="my_method", + is_public=True, + line_number=105, + docstring="Method on line 105", + ) + ], + ) + + extraction = ast_parser.models.ExtractionResult( + module=module_info, + functions=[func_info], + classes=[class_info], + ) + + loader.load_extraction(extraction) + + # Module + Class + Method + Function = 4 nodes (classes processed before functions) + assert mock_connection.create_node.call_count == 4 + + # Verify class node has line_number (call 1 - first after module) + class_call = mock_connection.create_node.call_args_list[1] + class_props = class_call[0][1] + assert class_props["name"] == "MyClass" + assert class_props["line_number"] == 100 + + # Verify method node has line_number (call 2 - inside class) + method_call = mock_connection.create_node.call_args_list[2] + method_props = method_call[0][1] + assert method_props["name"] == "my_method" + assert method_props["line_number"] == 105 + + # Verify function node has line_number (call 3 - functions processed after classes) + func_call = mock_connection.create_node.call_args_list[3] + func_props = func_call[0][1] + assert func_props["name"] == "my_function" + assert func_props["line_number"] == 42 + + def test_load_without_line_numbers(self): + """Test that missing line numbers (None) are handled gracefully.""" + mock_connection = Mock() + loader = graph_loader.GraphLoader(mock_connection, package_name="test-pkg") + + module_info = ast_parser.models.ModuleInfo(path="test.py", name="test") + + # Function without line number (backwards compatibility) + func_info = ast_parser.models.FunctionInfo( + name="my_function", + is_public=True, + line_number=None, + ) + + extraction = ast_parser.models.ExtractionResult( + module=module_info, + functions=[func_info], + ) + + loader.load_extraction(extraction) + + # Verify function node was created without line_number property + func_call = mock_connection.create_node.call_args_list[1] + func_props = func_call[0][1] + assert func_props["name"] == "my_function" + assert "line_number" not in func_props # Should not include None values + class TestGraphLoaderBatch: """Tests for batch loading operations.""" From 336b6879e2d97e2774f35bc065e05ce2792a1fa1 Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Fri, 1 May 2026 15:39:03 +1000 Subject: [PATCH 2/3] Update CHANGELOG for v0.8.4 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdbedf..0da388c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.4] - 2026-05-01 + +### Added +- **Line number storage** - Functions, methods, and classes now store their source code line numbers + - Added `line_number` field to `FunctionInfo` and `ClassInfo` models + - AST extractor captures `lineno` attribute from AST nodes + - Graph loader stores `line_number` property in Neo4j for Function, Method, and Class nodes + - Parameter complexity quality rule now returns line numbers in violation output + - Enables "navigate to source" functionality for quality rule violations + - **User Outcome**: Users can navigate directly to violations in their code + - All 289 unit tests passing + ## [0.8.3] - 2026-04-14 ### Changed From 7124f77be336a01c6c7b8e4624d2674a55937721 Mon Sep 17 00:00:00 2001 From: Youcef Kadri Date: Fri, 1 May 2026 15:39:07 +1000 Subject: [PATCH 3/3] =?UTF-8?q?Bump=20version:=200.8.3=20=E2=86=92=200.8.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 4 ++-- README.md | 2 +- pyproject.toml | 4 ++-- src/mapper/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c07ded7..d699a66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -422,5 +422,5 @@ Review these documents to understand patterns and best practices: --- -**Last Updated**: 2026-04-14 -**Current Version**: 0.8.3 +**Last Updated**: 2026-05-01 +**Current Version**: 0.8.4 diff --git a/README.md b/README.md index 0164d05..b837a20 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Mapper (Application Mapper) -![Version](https://img.shields.io/badge/version-0.8.3-blue.svg) +![Version](https://img.shields.io/badge/version-0.8.4-blue.svg) ![Tests](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ydkadri/9501806ed5eac873dd324bc606c6dd79/raw/mapper-tests.json&cacheSeconds=300) ![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ydkadri/9501806ed5eac873dd324bc606c6dd79/raw/mapper-coverage.json&cacheSeconds=300) ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg) diff --git a/pyproject.toml b/pyproject.toml index 7b6854e..47512bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mapper" -version = "0.8.3" +version = "0.8.4" description = "Mapper (Application Mapper) - AST-based Python code analyzer with Neo4j graph storage" readme = "README.md" requires-python = ">=3.10" @@ -93,7 +93,7 @@ addopts = [ testpaths = ["tests"] [tool.bumpversion] -current_version = "0.8.3" +current_version = "0.8.4" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" diff --git a/src/mapper/__init__.py b/src/mapper/__init__.py index 4184f32..7fac3f3 100644 --- a/src/mapper/__init__.py +++ b/src/mapper/__init__.py @@ -3,7 +3,7 @@ # Public modules for programmatic access from mapper import analyser, graph, graph_loader -__version__ = "0.8.3" +__version__ = "0.8.4" __all__ = [ # Version