Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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] = {}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions tests/fixtures/lang_samples/javascript/route.js
Original file line number Diff line number Diff line change
@@ -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();
}
118 changes: 118 additions & 0 deletions tests/unit/health/test_file_nloc.py
Original file line number Diff line number Diff line change
@@ -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
Loading