Skip to content
Open
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

.venv/

*.pyc

codra.egg-info/

result.json
23 changes: 23 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug codra (-m)",
"type": "python",
"request": "launch",
"cwd": "${workspaceFolder}",
"env": {
"PYTHONPATH": "${workspaceFolder}"
},
"module": "codra.cli",
"args": [
"${workspaceFolder}/codra",
"--threshold-csa", "5",
"--threshold-id", "2",
"--threshold-bps", "0.7"
],
"console": "integratedTerminal",
"justMyCode": false
}
]
}
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,40 @@
# Codra

## Requisiti

- Python 3.10+
- Node.js 18+ (solo per il viewer)

## Installazione (core Python)

Assicurati che il tuo `python3.10` sia disponibile nel PATH.

```bash
python3.10 -m venv .venv
source .venv/bin/activate
pip install -e .
```

## Esecuzione CLI

Analizza un percorso e stampa il report JSON su stdout:

```bash
python3 -m codra.cli /percorso/progetto
```

Esempio con soglie:

```bash
python3 -m codra.cli /percorso/progetto --threshold-csa 10 --threshold-id 2 --threshold-bps 0.7
```

## Viewer React (opzionale)

```bash
cd viewer
npm install
npm run dev
```

Carica il file JSON generato dalla CLI tramite il file input dell'interfaccia.
25 changes: 25 additions & 0 deletions codra/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from .bps_analyzer import BpsAnalyzer
from .bps_result import BpsResult
from .csa_analyzer import CsaAnalyzer
from .csa_result import CsaResult
from .directory_scanner import DirectoryScanner
from .indirection_analyzer import IndirectionAnalyzer
from .indirection_result import IndirectionResult
from .report import Report
from .report_builder import ReportBuilder
from .report_serializer import ReportSerializer
from .unit_definition import UnitDefinition

__all__ = [
"BpsAnalyzer",
"BpsResult",
"CsaAnalyzer",
"CsaResult",
"DirectoryScanner",
"IndirectionAnalyzer",
"IndirectionResult",
"Report",
"ReportBuilder",
"ReportSerializer",
"UnitDefinition",
]
47 changes: 47 additions & 0 deletions codra/alias_collector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import ast
from dataclasses import dataclass, field


@dataclass
class AliasCollector(ast.NodeVisitor):
aliases: dict[str, str] = field(default_factory=dict)
depth: int = 0

def collect(self, tree: ast.AST) -> dict[str, str]:
self.visit(tree)
return dict(self.aliases)

def visit_Module(self, node: ast.Module) -> None:
self.depth += 1
for statement in node.body:
self.visit(statement)
self.depth -= 1

def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
return None

def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
return None

def visit_ClassDef(self, node: ast.ClassDef) -> None:
return None

def visit_Assign(self, node: ast.Assign) -> None:
if self.depth != 1:
return
if not isinstance(node.value, ast.Name):
return
for target in node.targets:
if isinstance(target, ast.Name):
self.aliases[target.id] = node.value.id

def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
if self.depth != 1:
return
if not isinstance(node.target, ast.Name):
return
if not isinstance(node.value, ast.Name):
return
self.aliases[node.target.id] = node.value.id
43 changes: 43 additions & 0 deletions codra/bps_analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

import ast
from dataclasses import dataclass

from .bps_result import BpsResult
from .condition_metrics_collector import ConditionMetricsCollector
from .function_symbol_collector import FunctionSymbolCollector
from .if_collector import IfCollector
from .unit_node_collector import UnitNodeCollector


@dataclass
class BpsAnalyzer:
def analyze_file(self, file_path: str) -> list[BpsResult]:
with open(file_path, "r", encoding="utf-8") as handle:
source = handle.read()
tree = ast.parse(source, filename=file_path)
unit_collector = UnitNodeCollector(file_path=file_path)
unit_collector.visit(tree)
results: list[BpsResult] = []
for unit_node in unit_collector.units:
usage = FunctionSymbolCollector().collect(unit_node.node)
local_names = usage.locals
if_nodes = IfCollector().collect(unit_node.node)
if not if_nodes:
bps = 1.0
else:
scores: list[float] = []
for if_node in if_nodes:
metrics = ConditionMetricsCollector(local_names=local_names).collect(
if_node.test
)
penalty = (
metrics.bool_ops
+ metrics.compare_ops
+ 2 * metrics.calls
+ 2 * metrics.external_refs
)
scores.append(1.0 / (1.0 + penalty))
bps = sum(scores) / len(scores)
results.append(BpsResult(unit=unit_node.definition, bps=bps))
return results
11 changes: 11 additions & 0 deletions codra/bps_result.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations

from dataclasses import dataclass

from .unit_definition import UnitDefinition


@dataclass(frozen=True)
class BpsResult:
unit: UnitDefinition
bps: float
28 changes: 28 additions & 0 deletions codra/call_collector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

import ast
from dataclasses import dataclass, field


@dataclass
class CallCollector(ast.NodeVisitor):
calls: list[str] = field(default_factory=list)

def collect(self, node: ast.AST) -> list[str]:
for statement in node.body:
self.visit(statement)
return list(self.calls)

def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
return None

def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
return None

def visit_ClassDef(self, node: ast.ClassDef) -> None:
return None

def visit_Call(self, node: ast.Call) -> None:
if isinstance(node.func, ast.Name):
self.calls.append(node.func.id)
self.generic_visit(node)
75 changes: 75 additions & 0 deletions codra/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from __future__ import annotations

import argparse
import sys
from dataclasses import dataclass

from .bps_analyzer import BpsAnalyzer
from .csa_analyzer import CsaAnalyzer
from .file_path_collector import FilePathCollector
from .indirection_analyzer import IndirectionAnalyzer
from .report_builder import ReportBuilder
from .report_serializer import ReportSerializer
from .threshold_config import ThresholdConfig


@dataclass(frozen=True)
class CliArgs:
path: str
threshold_csa: int | None
threshold_id: int | None
threshold_bps: float | None


@dataclass(frozen=True)
class CliDependencies:
builder: ReportBuilder
serializer: ReportSerializer


def parse_args(argv: list[str]) -> CliArgs:
parser = argparse.ArgumentParser()
parser.add_argument("path")
parser.add_argument("--threshold-csa", type=int, default=None)
parser.add_argument("--threshold-id", type=int, default=None)
parser.add_argument("--threshold-bps", type=float, default=None)
o = parser.parse_args(argv)
return CliArgs(
path=o.path,
threshold_csa=o.threshold_csa,
threshold_id=o.threshold_id,
threshold_bps=o.threshold_bps,
)


def build_thresholds(args: CliArgs) -> ThresholdConfig:
return ThresholdConfig(
csa=args.threshold_csa,
indirection=args.threshold_id,
bps=args.threshold_bps,
)


def build_dependencies() -> CliDependencies:
builder = ReportBuilder(
csa_analyzer=CsaAnalyzer(),
indirection_analyzer=IndirectionAnalyzer(),
bps_analyzer=BpsAnalyzer(),
path_collector=FilePathCollector(),
)
return CliDependencies(builder=builder, serializer=ReportSerializer())


def run_cli(args: CliArgs, deps: CliDependencies) -> int:
report = deps.builder.build(args.path)
sys.stdout.write(deps.serializer.to_json(report))
sys.stdout.write("\n")
return 0 if deps.builder.check_thresholds(report, build_thresholds(args)) else 1


def main() -> None:
raise SystemExit(run_cli(parse_args(sys.argv[1:]), build_dependencies()))


if __name__ == "__main__":
main()
11 changes: 11 additions & 0 deletions codra/condition_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ConditionMetrics:
bool_ops: int
compare_ops: int
calls: int
external_refs: int
40 changes: 40 additions & 0 deletions codra/condition_metrics_collector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

import ast
from dataclasses import dataclass

from .condition_metrics import ConditionMetrics


@dataclass
class ConditionMetricsCollector(ast.NodeVisitor):
local_names: set[str]
bool_ops: int = 0
compare_ops: int = 0
calls: int = 0
external_refs: int = 0

def collect(self, node: ast.AST) -> ConditionMetrics:
self.visit(node)
return ConditionMetrics(
bool_ops=self.bool_ops,
compare_ops=self.compare_ops,
calls=self.calls,
external_refs=self.external_refs,
)

def visit_BoolOp(self, node: ast.BoolOp) -> None:
self.bool_ops += max(0, len(node.values) - 1)
self.generic_visit(node)

def visit_Compare(self, node: ast.Compare) -> None:
self.compare_ops += len(node.ops)
self.generic_visit(node)

def visit_Call(self, node: ast.Call) -> None:
self.calls += 1
self.generic_visit(node)

def visit_Name(self, node: ast.Name) -> None:
if isinstance(node.ctx, ast.Load) and node.id not in self.local_names:
self.external_refs += 1
37 changes: 37 additions & 0 deletions codra/csa_analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

import ast
from dataclasses import dataclass

from .csa_result import CsaResult
from .function_symbol_collector import FunctionSymbolCollector
from .module_symbol_collector import ModuleSymbolCollector
from .unit_node_collector import UnitNodeCollector


@dataclass
class CsaAnalyzer:
def analyze_file(self, file_path: str) -> list[CsaResult]:
with open(file_path, "r", encoding="utf-8") as handle:
source = handle.read()
tree = ast.parse(source, filename=file_path)
module_symbols = ModuleSymbolCollector().collect(tree)
unit_collector = UnitNodeCollector(file_path=file_path)
unit_collector.visit(tree)
results: list[CsaResult] = []
for unit_node in unit_collector.units:
usage = FunctionSymbolCollector().collect(unit_node.node)
used_names = usage.used_names
local_names = usage.locals
global_symbols = used_names.intersection(module_symbols) - local_names
free_symbols = used_names - local_names - module_symbols
external_symbols = global_symbols.union(free_symbols)
results.append(
CsaResult(
unit=unit_node.definition,
csa_main=len(external_symbols),
external_symbols=sorted(external_symbols),
self_fields_read=sorted(usage.self_fields_read),
)
)
return results
Loading