diff --git a/packages/core/src/repowise/core/analysis/health/complexity/walker.py b/packages/core/src/repowise/core/analysis/health/complexity/walker.py index ab1be9e2..eaa19ebb 100644 --- a/packages/core/src/repowise/core/analysis/health/complexity/walker.py +++ b/packages/core/src/repowise/core/analysis/health/complexity/walker.py @@ -118,6 +118,7 @@ class FileComplexity: functions: list[FunctionComplexity] classes: list[ClassComplexity] + file_nloc: int = 0 # Leaf node types that carry a declared name at the bottom of a C/C++ @@ -166,6 +167,41 @@ def _count_nloc(node: Node, source: bytes) -> int: return sum(1 for line in snippet.splitlines() if line.strip()) +def _count_file_nloc(source: bytes) -> int: + """Count non-blank lines in *source* bytes (plain fallback, no tree).""" + try: + text = source.decode("utf-8", errors="replace") + except Exception: + return 0 + return sum(1 for line in text.splitlines() if line.strip()) + + +def _count_file_nloc_tree(root_node: Node, source: bytes) -> int: + """Count lines that have at least one non-comment token. + + Lines where all content is inside comment nodes are excluded; lines + with real code plus a trailing comment still count. + """ + try: + lines = source.decode("utf-8", errors="replace").splitlines() + except Exception: + return 0 + code_lines: set[int] = set() + stack = [root_node] + while stack: + node = stack.pop() + if "comment" in node.type: + continue + if not node.children and node.start_byte < node.end_byte: + for line in range(node.start_point[0], node.end_point[0] + 1): + if line < len(lines) and lines[line].strip(): + code_lines.add(line) + else: + for child in node.children: + stack.append(child) + return len(code_lines) + + def _is_boolean_operator(node: Node, lmap: LanguageNodeMap) -> bool: """True if this node represents a logical ``&&`` / ``||`` operator.""" if node.type in lmap.boolean_operator_kinds: @@ -829,7 +865,7 @@ def walk_file( """ lmap = get_language_map(language) if lmap is None: - return FileComplexity(functions=[], classes=[]) + return FileComplexity(functions=[], classes=[], file_nloc=_count_file_nloc(source)) try: from tree_sitter import Parser @@ -840,18 +876,18 @@ def walk_file( from repowise.core.ingestion.parser import _get_language except Exception as exc: log.debug("complexity_walker_import_failed", error=str(exc)) - return FileComplexity(functions=[], classes=[]) + return FileComplexity(functions=[], classes=[], file_nloc=_count_file_nloc(source)) grammar = _get_language(language) if grammar is None: - return FileComplexity(functions=[], classes=[]) + return FileComplexity(functions=[], classes=[], file_nloc=_count_file_nloc(source)) try: parser = Parser(grammar) tree = parser.parse(source) except Exception as exc: log.debug("complexity_walker_parse_failed", path=abs_path, error=str(exc)) - return FileComplexity(functions=[], classes=[]) + return FileComplexity(functions=[], classes=[], file_nloc=_count_file_nloc(source)) functions: list[FunctionComplexity] = [] fc_by_node_id: dict[int, FunctionComplexity] = {} @@ -875,7 +911,11 @@ def walk_file( fc_by_node_id[fn_node.id] = fc classes = _collect_classes(tree.root_node, lmap, source, fc_by_node_id) - return FileComplexity(functions=functions, classes=classes) + return FileComplexity( + functions=functions, + classes=classes, + file_nloc=_count_file_nloc_tree(tree.root_node, source), + ) def walk_file_complexity( diff --git a/packages/core/src/repowise/core/analysis/health/engine.py b/packages/core/src/repowise/core/analysis/health/engine.py index 24ab7d59..0734806f 100644 --- a/packages/core/src/repowise/core/analysis/health/engine.py +++ b/packages/core/src/repowise/core/analysis/health/engine.py @@ -577,7 +577,7 @@ def _evaluate_file( fn_metrics: dict[str, FunctionComplexity] = {fc.name: fc for fc in fc_list} max_ccn = max((fc.ccn for fc in fc_list), default=1) max_nesting = max((fc.max_nesting for fc in fc_list), default=0) - nloc = sum(fc.nloc for fc in fc_list) + nloc = fcx.file_nloc dependents_count = 0 if self.graph is not None and file_path in self.graph: diff --git a/tests/fixtures/lang_samples/javascript/route.js b/tests/fixtures/lang_samples/javascript/route.js new file mode 100644 index 00000000..22dba70f --- /dev/null +++ b/tests/fixtures/lang_samples/javascript/route.js @@ -0,0 +1,32 @@ +const express = require("express"); +const router = express.Router(); + +// POST /x with middleware chain +router.post("/x", + logRequest, + validateRequest, + async (req, res) => { + const id = req.body.id; + if (!id) { + return res.status(400).json({ error: "missing id" }); + } + const result = await doWork(id); + if (result.ok) { + res.json({ data: result.data }); + } else { + res.status(500).json({ error: result.message }); + } + } +); + +function logRequest(req, res, next) { + console.log(req.method, req.path); + next(); +} + +function validateRequest(req, res, next) { + if (!req.body) { + return res.status(400).json({ error: "no body" }); + } + next(); +} diff --git a/tests/unit/health/test_file_nloc.py b/tests/unit/health/test_file_nloc.py new file mode 100644 index 00000000..9b0827d4 --- /dev/null +++ b/tests/unit/health/test_file_nloc.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from repowise.core.analysis.health.complexity import walk_file +from repowise.core.analysis.health.complexity.walker import _count_file_nloc +from repowise.core.analysis.health.duplication import DuplicationReport +from repowise.core.analysis.health.engine import HealthAnalyzer +from repowise.core.analysis.health.models import HealthFileMetricData + +FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "lang_samples" + + +def _non_blank(source: bytes) -> int: + return sum(1 for line in source.decode("utf-8", errors="replace").splitlines() if line.strip()) + + +def test_js_file_nloc_excludes_comment_only(): + source = b"const x = 1;\n// comment\nconst y = 2;\n" + fcx = walk_file("/tmp/t.js", "javascript", source) + if fcx.file_nloc == 0: + pytest.skip("javascript tree-sitter pack missing") + assert fcx.file_nloc == 2 + + +def test_python_file_nloc_excludes_comment_only(): + source = b"x = 1\n# comment\ny = 2\n" + fcx = walk_file("/tmp/t.py", "python", source) + if fcx.file_nloc == 0: + pytest.skip("python tree-sitter pack missing") + assert fcx.file_nloc == 2 + + +def test_trailing_comment_line_still_counts(): + source = b"x = 1 # trailing\ny = 2\n" + fcx = walk_file("/tmp/t.py", "python", source) + if fcx.file_nloc == 0: + pytest.skip("python tree-sitter pack missing") + assert fcx.file_nloc == 2 + + +def test_multiline_string_blank_line_does_not_count(): + source = b'x = """a\n\n b"""\ny = 2\n' + fcx = walk_file("/tmp/t.py", "python", source) + if fcx.file_nloc == 0: + pytest.skip("python tree-sitter pack missing") + assert fcx.file_nloc == 3 + + +def test_js_route_file_nloc_excludes_one_comment_line(): + p = FIXTURES / "javascript" / "route.js" + if not p.exists(): + pytest.skip(f"fixture missing: {p}") + source = p.read_bytes() + fcx = walk_file(str(p), "javascript", source) + if fcx.file_nloc == 0: + pytest.skip("javascript tree-sitter pack missing") + assert fcx.file_nloc == _non_blank(source) - 1 + + +def test_js_route_function_sum_less_than_file_nloc(): + p = FIXTURES / "javascript" / "route.js" + if not p.exists(): + pytest.skip(f"fixture missing: {p}") + source = p.read_bytes() + fcx = walk_file(str(p), "javascript", source) + if not fcx.functions: + pytest.skip("javascript tree-sitter pack missing or no functions detected") + assert sum(fn.nloc for fn in fcx.functions) < fcx.file_nloc + + +def test_health_metric_nloc_uses_file_nloc(): + p = FIXTURES / "javascript" / "route.js" + if not p.exists(): + pytest.skip(f"fixture missing: {p}") + source = p.read_bytes() + fcx = walk_file(str(p), "javascript", source) + if fcx.file_nloc == 0: + pytest.skip("javascript tree-sitter pack missing") + + pf = SimpleNamespace( + file_info=SimpleNamespace(path="src/route.js", language="javascript", abs_path=str(p)), + symbols=[], + ) + metric, _ = HealthAnalyzer(graph=None)._evaluate_file( + pf, + fcx, + all_paths={"src/route.js"}, + disabled=[], + dup_report=DuplicationReport(), + ) + assert isinstance(metric, HealthFileMetricData) + assert metric.nloc == fcx.file_nloc + + +def test_unsupported_language_fallback_nloc(): + source = b"foo bar baz\n\nqux quux\n# comment\n" + fcx = walk_file("/tmp/x.klingon", "klingon", source) + assert fcx.functions == [] + assert fcx.classes == [] + assert fcx.file_nloc == _non_blank(source) + assert fcx.file_nloc > 0 + + +def test_count_file_nloc_empty_source(): + assert _count_file_nloc(b"") == 0 + + +def test_count_file_nloc_blank_only(): + assert _count_file_nloc(b"\n \n\t\n") == 0 + + +def test_count_file_nloc_mixed(): + source = b"a = 1\n\n \nb = 2\n# comment\n" + assert _count_file_nloc(source) == 3