fork-meter 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fork_meter-1.0.0/CHANGELOG.md +11 -0
- fork_meter-1.0.0/LICENSE +21 -0
- fork_meter-1.0.0/PKG-INFO +153 -0
- fork_meter-1.0.0/README.md +107 -0
- fork_meter-1.0.0/fork_meter/__init__.py +26 -0
- fork_meter-1.0.0/fork_meter/__main__.py +121 -0
- fork_meter-1.0.0/fork_meter/analyzer.py +349 -0
- fork_meter-1.0.0/fork_meter/logging.ini +29 -0
- fork_meter-1.0.0/fork_meter/models.py +37 -0
- fork_meter-1.0.0/fork_meter/parser.py +79 -0
- fork_meter-1.0.0/fork_meter/reporter/__init__.py +6 -0
- fork_meter-1.0.0/fork_meter/reporter/html_reporter.py +182 -0
- fork_meter-1.0.0/fork_meter/reporter/json_reporter.py +60 -0
- fork_meter-1.0.0/fork_meter/scanner.py +122 -0
- fork_meter-1.0.0/pyproject.toml +44 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 - 2026-08-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Initial release of fork-meter.
|
|
8
|
+
- Command-line interface via `fork-meter` script.
|
|
9
|
+
- Cyclomatic complexity measurement by counting decision points and branching paths in source code.
|
|
10
|
+
- Logging via `logenrich` with configurable log directory (`FORK_METER_CONFIG_DIR`).
|
|
11
|
+
- Configuration directory bootstrapping via `env-dir-bootstrap`.
|
fork_meter-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ron Webb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fork-meter
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A command-line tool that measures cyclomatic complexity by counting decision points and branching paths in source code.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Ron Webb
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Python: >=3.14
|
|
28
|
+
Classifier: License :: Other/Proprietary License
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
31
|
+
Requires-Dist: braincraft (>=1.2.0,<2.0.0)
|
|
32
|
+
Requires-Dist: click (>=8.4.2,<9.0.0)
|
|
33
|
+
Requires-Dist: env-dir-bootstrap (>=1.0.0,<2.0.0)
|
|
34
|
+
Requires-Dist: jinja2 (>=3.1.0,<4.0.0)
|
|
35
|
+
Requires-Dist: logenrich (>=1.0.1,<2.0.0)
|
|
36
|
+
Requires-Dist: rich (>=15.0.0,<16.0.0)
|
|
37
|
+
Requires-Dist: tree-sitter (>=0.26.0,<0.27.0)
|
|
38
|
+
Requires-Dist: tree-sitter-go (>=0.25.0,<0.26.0)
|
|
39
|
+
Requires-Dist: tree-sitter-gosu (>=0.2.0,<0.3.0)
|
|
40
|
+
Requires-Dist: tree-sitter-java (>=0.23.5,<0.24.0)
|
|
41
|
+
Requires-Dist: tree-sitter-javascript (>=0.25.0,<0.26.0)
|
|
42
|
+
Requires-Dist: tree-sitter-python (>=0.25.0,<0.26.0)
|
|
43
|
+
Requires-Dist: tree-sitter-typescript (>=0.23.2,<0.24.0)
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# fork-meter
|
|
47
|
+
|
|
48
|
+
[](LICENSE)
|
|
49
|
+
[](CHANGELOG.md)
|
|
50
|
+
|
|
51
|
+
A command-line tool that measures cyclomatic complexity by counting decision points and branching paths in source code.
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python `>=3.14`
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install fork-meter
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Supported Languages
|
|
64
|
+
|
|
65
|
+
| Language | Grammar |
|
|
66
|
+
|----------|---------|
|
|
67
|
+
| Python | tree-sitter-python |
|
|
68
|
+
| JavaScript | tree-sitter-javascript |
|
|
69
|
+
| TypeScript | tree-sitter-typescript |
|
|
70
|
+
| Java | tree-sitter-java |
|
|
71
|
+
| Go | tree-sitter-go |
|
|
72
|
+
| Gosu | tree-sitter-gosu |
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
fork-meter [OPTIONS] PATHS...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`PATHS` may be files or directories; directories are scanned recursively.
|
|
81
|
+
|
|
82
|
+
### Options
|
|
83
|
+
|
|
84
|
+
| Option | Default | Description |
|
|
85
|
+
|--------|---------|-------------|
|
|
86
|
+
| `--max INT` | `10` | Report functions with complexity strictly above this value |
|
|
87
|
+
| `--output NAME` | `fork-meter-output` | Base name for output files (no extension) |
|
|
88
|
+
| `--output-dir DIR` | `<cwd>/reports` | Directory for output files |
|
|
89
|
+
| `--format [json\|html\|both]` | `both` | Output format |
|
|
90
|
+
| `--exclude PATTERN` | — | Glob pattern to exclude from scanning (repeatable) |
|
|
91
|
+
| `-V, --version` | | Show version and exit |
|
|
92
|
+
| `-h, --help` | | Show help and exit |
|
|
93
|
+
|
|
94
|
+
### Example
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Scan a project, flag anything above complexity 5, write HTML only
|
|
98
|
+
fork-meter src/ --max 5 --format html --exclude "**/*_test.py"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Output:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
fork-meter done in 0.08s
|
|
105
|
+
✓ reports/fork-meter-output.html
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Complexity Scale
|
|
109
|
+
|
|
110
|
+
| Range | Colour | Risk |
|
|
111
|
+
|-------|--------|------|
|
|
112
|
+
| 1 – 5 | 🟢 Green | Low |
|
|
113
|
+
| 6 – 10 | 🟡 Yellow | Moderate |
|
|
114
|
+
| 11 – 15 | 🟠 Orange | High |
|
|
115
|
+
| 16 + | 🔴 Red | Critical |
|
|
116
|
+
|
|
117
|
+
## Output Formats
|
|
118
|
+
|
|
119
|
+
- **HTML** — self-contained interactive report with a sortable complexity table.
|
|
120
|
+
- **JSON** — machine-readable report suitable for CI integration.
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
`fork-meter` bootstraps its log/config directory automatically. Override the location with:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
export FORK_METER_CONFIG_DIR=/path/to/dir
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
### Setup
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
poetry install
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Format and Lint
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
poetry run black fork_meter; poetry run pylint fork_meter
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Run Tests with Coverage
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
poetry run pytest --cov=fork_meter tests --cov-report html
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
|
153
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# fork-meter
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](CHANGELOG.md)
|
|
5
|
+
|
|
6
|
+
A command-line tool that measures cyclomatic complexity by counting decision points and branching paths in source code.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Python `>=3.14`
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install fork-meter
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Supported Languages
|
|
19
|
+
|
|
20
|
+
| Language | Grammar |
|
|
21
|
+
|----------|---------|
|
|
22
|
+
| Python | tree-sitter-python |
|
|
23
|
+
| JavaScript | tree-sitter-javascript |
|
|
24
|
+
| TypeScript | tree-sitter-typescript |
|
|
25
|
+
| Java | tree-sitter-java |
|
|
26
|
+
| Go | tree-sitter-go |
|
|
27
|
+
| Gosu | tree-sitter-gosu |
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
fork-meter [OPTIONS] PATHS...
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`PATHS` may be files or directories; directories are scanned recursively.
|
|
36
|
+
|
|
37
|
+
### Options
|
|
38
|
+
|
|
39
|
+
| Option | Default | Description |
|
|
40
|
+
|--------|---------|-------------|
|
|
41
|
+
| `--max INT` | `10` | Report functions with complexity strictly above this value |
|
|
42
|
+
| `--output NAME` | `fork-meter-output` | Base name for output files (no extension) |
|
|
43
|
+
| `--output-dir DIR` | `<cwd>/reports` | Directory for output files |
|
|
44
|
+
| `--format [json\|html\|both]` | `both` | Output format |
|
|
45
|
+
| `--exclude PATTERN` | — | Glob pattern to exclude from scanning (repeatable) |
|
|
46
|
+
| `-V, --version` | | Show version and exit |
|
|
47
|
+
| `-h, --help` | | Show help and exit |
|
|
48
|
+
|
|
49
|
+
### Example
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Scan a project, flag anything above complexity 5, write HTML only
|
|
53
|
+
fork-meter src/ --max 5 --format html --exclude "**/*_test.py"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Output:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
fork-meter done in 0.08s
|
|
60
|
+
✓ reports/fork-meter-output.html
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Complexity Scale
|
|
64
|
+
|
|
65
|
+
| Range | Colour | Risk |
|
|
66
|
+
|-------|--------|------|
|
|
67
|
+
| 1 – 5 | 🟢 Green | Low |
|
|
68
|
+
| 6 – 10 | 🟡 Yellow | Moderate |
|
|
69
|
+
| 11 – 15 | 🟠 Orange | High |
|
|
70
|
+
| 16 + | 🔴 Red | Critical |
|
|
71
|
+
|
|
72
|
+
## Output Formats
|
|
73
|
+
|
|
74
|
+
- **HTML** — self-contained interactive report with a sortable complexity table.
|
|
75
|
+
- **JSON** — machine-readable report suitable for CI integration.
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
`fork-meter` bootstraps its log/config directory automatically. Override the location with:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
export FORK_METER_CONFIG_DIR=/path/to/dir
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
### Setup
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
poetry install
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Format and Lint
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
poetry run black fork_meter; poetry run pylint fork_meter
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Run Tests with Coverage
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
poetry run pytest --cov=fork_meter tests --cov-report html
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fork_meter package.
|
|
3
|
+
|
|
4
|
+
A command-line tool that measures cyclomatic complexity by counting
|
|
5
|
+
decision points and branching paths in source code.
|
|
6
|
+
|
|
7
|
+
:author: Ron Webb
|
|
8
|
+
:since: 1.0.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from env_dir_bootstrap import EnvDirBootstrap
|
|
12
|
+
from logenrich import setup_logger
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
16
|
+
_bootstrapper = EnvDirBootstrap(
|
|
17
|
+
env_var="FORK_METER_CONFIG_DIR",
|
|
18
|
+
resources=["logging.ini"],
|
|
19
|
+
package="fork_meter",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_bootstrapper.setup()
|
|
23
|
+
|
|
24
|
+
CONF_DIR = str(_bootstrapper.get_dir())
|
|
25
|
+
|
|
26
|
+
setup_logger("fork_meter", conf_dir=CONF_DIR)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point for the fork-meter command-line tool.
|
|
3
|
+
|
|
4
|
+
:author: Ron Webb
|
|
5
|
+
:since: 1.0.0
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .analyzer import analyze
|
|
17
|
+
from .reporter import html_reporter, json_reporter
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
_console = Console()
|
|
21
|
+
|
|
22
|
+
_CC_STYLE = {
|
|
23
|
+
(1, 5): "green",
|
|
24
|
+
(6, 10): "yellow",
|
|
25
|
+
(11, 15): "orange3",
|
|
26
|
+
(16, 9999): "red",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_elapsed(elapsed: float, written: list[Path]) -> None:
|
|
31
|
+
"""Print a single-line elapsed status with report paths."""
|
|
32
|
+
_console.print(
|
|
33
|
+
f"[bold cyan]fork-meter[/bold cyan] done in [bold]{elapsed:.2f}s[/bold]"
|
|
34
|
+
)
|
|
35
|
+
for path in written:
|
|
36
|
+
_console.print(f" [green]\u2713[/green] {path}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
40
|
+
@click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True))
|
|
41
|
+
@click.option(
|
|
42
|
+
"--max",
|
|
43
|
+
"max_threshold",
|
|
44
|
+
default=10,
|
|
45
|
+
show_default=True,
|
|
46
|
+
metavar="INT",
|
|
47
|
+
help="Report functions with complexity strictly above this value.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--output",
|
|
51
|
+
"output_name",
|
|
52
|
+
default="fork-meter-output",
|
|
53
|
+
show_default=True,
|
|
54
|
+
metavar="NAME",
|
|
55
|
+
help="Base name for output files (no extension).",
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--output-dir",
|
|
59
|
+
default=None,
|
|
60
|
+
type=click.Path(),
|
|
61
|
+
metavar="DIR",
|
|
62
|
+
help="Directory for output files. Defaults to <cwd>/reports.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--format",
|
|
66
|
+
"output_format",
|
|
67
|
+
default="both",
|
|
68
|
+
type=click.Choice(["json", "html", "both"], case_sensitive=False),
|
|
69
|
+
show_default=True,
|
|
70
|
+
help="Output format.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--exclude",
|
|
74
|
+
multiple=True,
|
|
75
|
+
metavar="PATTERN",
|
|
76
|
+
help="Glob pattern to exclude from scanning (repeatable).",
|
|
77
|
+
)
|
|
78
|
+
@click.version_option(version=__version__, prog_name="fork-meter")
|
|
79
|
+
def main( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
80
|
+
paths: tuple[str, ...],
|
|
81
|
+
max_threshold: int,
|
|
82
|
+
output_name: str,
|
|
83
|
+
output_dir: str | None,
|
|
84
|
+
output_format: str,
|
|
85
|
+
exclude: tuple[str, ...],
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Measure cyclomatic complexity of source code.
|
|
88
|
+
|
|
89
|
+
PATH arguments may be files or directories; directories are scanned recursively.
|
|
90
|
+
Only functions with complexity strictly above --max are included in the report.
|
|
91
|
+
"""
|
|
92
|
+
_logger.info("fork-meter started")
|
|
93
|
+
|
|
94
|
+
resolved_paths = tuple(Path(p) for p in paths)
|
|
95
|
+
out_dir = Path(output_dir) if output_dir else Path.cwd() / "reports"
|
|
96
|
+
|
|
97
|
+
start = time.monotonic()
|
|
98
|
+
result = analyze(
|
|
99
|
+
resolved_paths, exclude_patterns=exclude, max_threshold=max_threshold
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
written: list[Path] = []
|
|
103
|
+
if output_format in ("json", "both"):
|
|
104
|
+
written.append(
|
|
105
|
+
json_reporter.write(
|
|
106
|
+
result,
|
|
107
|
+
[str(p) for p in resolved_paths],
|
|
108
|
+
max_threshold,
|
|
109
|
+
out_dir / f"{output_name}.json",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
if output_format in ("html", "both"):
|
|
113
|
+
written.append(
|
|
114
|
+
html_reporter.write(result, max_threshold, out_dir / f"{output_name}.html")
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
_print_elapsed(time.monotonic() - start, written)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main() # pylint: disable=no-value-for-parameter
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cyclomatic complexity analyzer using tree-sitter AST traversal.
|
|
3
|
+
|
|
4
|
+
:author: Ron Webb
|
|
5
|
+
:since: 1.0.0
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from bisect import bisect_left
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from tree_sitter import Language, Node, Query, QueryCursor
|
|
14
|
+
|
|
15
|
+
from . import parser as _parser
|
|
16
|
+
from . import scanner as _scanner
|
|
17
|
+
from .models import ComplexityResult
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Per-language S-expression queries selecting named functions, methods, and constructors.
|
|
22
|
+
_FRAGMENT_QUERIES: dict[str, str] = {
|
|
23
|
+
"Python": "(function_definition) @fragment",
|
|
24
|
+
"JavaScript": "[(function_declaration) (method_definition)] @fragment",
|
|
25
|
+
"TypeScript": "[(function_declaration) (method_definition)] @fragment",
|
|
26
|
+
"Java": "[(method_declaration) (constructor_declaration)] @fragment",
|
|
27
|
+
"Go": "[(function_declaration) (method_declaration)] @fragment",
|
|
28
|
+
"Gosu": "[(function_declaration) (constructor_declaration)] @fragment",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Node types that represent a branching decision point per language.
|
|
32
|
+
_DECISION_NODES: dict[str, frozenset[str]] = {
|
|
33
|
+
"Python": frozenset(
|
|
34
|
+
{
|
|
35
|
+
"if_statement",
|
|
36
|
+
"elif_clause",
|
|
37
|
+
"for_statement",
|
|
38
|
+
"while_statement",
|
|
39
|
+
"except_clause",
|
|
40
|
+
"conditional_expression",
|
|
41
|
+
"case_clause",
|
|
42
|
+
}
|
|
43
|
+
),
|
|
44
|
+
"JavaScript": frozenset(
|
|
45
|
+
{
|
|
46
|
+
"if_statement",
|
|
47
|
+
"for_statement",
|
|
48
|
+
"for_in_statement",
|
|
49
|
+
"for_of_statement",
|
|
50
|
+
"while_statement",
|
|
51
|
+
"do_statement",
|
|
52
|
+
"catch_clause",
|
|
53
|
+
"switch_case",
|
|
54
|
+
"ternary_expression",
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
"TypeScript": frozenset(
|
|
58
|
+
{
|
|
59
|
+
"if_statement",
|
|
60
|
+
"for_statement",
|
|
61
|
+
"for_in_statement",
|
|
62
|
+
"for_of_statement",
|
|
63
|
+
"while_statement",
|
|
64
|
+
"do_statement",
|
|
65
|
+
"catch_clause",
|
|
66
|
+
"switch_case",
|
|
67
|
+
"ternary_expression",
|
|
68
|
+
}
|
|
69
|
+
),
|
|
70
|
+
"Java": frozenset(
|
|
71
|
+
{
|
|
72
|
+
"if_statement",
|
|
73
|
+
"for_statement",
|
|
74
|
+
"enhanced_for_statement",
|
|
75
|
+
"while_statement",
|
|
76
|
+
"do_statement",
|
|
77
|
+
"catch_clause",
|
|
78
|
+
"switch_label",
|
|
79
|
+
"ternary_expression",
|
|
80
|
+
}
|
|
81
|
+
),
|
|
82
|
+
"Go": frozenset({"if_statement", "for_statement", "case_clause", "comm_clause"}),
|
|
83
|
+
"Gosu": frozenset(
|
|
84
|
+
{
|
|
85
|
+
"if_statement",
|
|
86
|
+
"for_statement",
|
|
87
|
+
"while_statement",
|
|
88
|
+
"do_while_statement",
|
|
89
|
+
"catch_clause",
|
|
90
|
+
"switch_label",
|
|
91
|
+
"ternary_expression",
|
|
92
|
+
}
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Node types whose subtrees are pruned during decision-point counting (nested code units).
|
|
97
|
+
_NESTED_STOP_TYPES: dict[str, frozenset[str]] = {
|
|
98
|
+
"Python": frozenset({"function_definition", "class_definition"}),
|
|
99
|
+
"JavaScript": frozenset(
|
|
100
|
+
{
|
|
101
|
+
"function_declaration",
|
|
102
|
+
"function_expression",
|
|
103
|
+
"arrow_function",
|
|
104
|
+
"method_definition",
|
|
105
|
+
"class_declaration",
|
|
106
|
+
}
|
|
107
|
+
),
|
|
108
|
+
"TypeScript": frozenset(
|
|
109
|
+
{
|
|
110
|
+
"function_declaration",
|
|
111
|
+
"function_expression",
|
|
112
|
+
"arrow_function",
|
|
113
|
+
"method_definition",
|
|
114
|
+
"class_declaration",
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
"Java": frozenset(
|
|
118
|
+
{
|
|
119
|
+
"method_declaration",
|
|
120
|
+
"constructor_declaration",
|
|
121
|
+
"class_declaration",
|
|
122
|
+
"lambda_expression",
|
|
123
|
+
}
|
|
124
|
+
),
|
|
125
|
+
"Go": frozenset({"function_declaration", "method_declaration", "func_literal"}),
|
|
126
|
+
"Gosu": frozenset(
|
|
127
|
+
{"function_declaration", "constructor_declaration", "class_declaration"}
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# AST node types that represent a class body, used when walking up the parent chain.
|
|
132
|
+
_CLASS_NODE_TYPES: frozenset[str] = frozenset(
|
|
133
|
+
{
|
|
134
|
+
"class_definition", # Python
|
|
135
|
+
"class_declaration", # JavaScript, TypeScript, Java, Gosu
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
_FRAGMENT_TYPE_MAP: dict[str, str] = {
|
|
140
|
+
"function_definition": "function",
|
|
141
|
+
"function_declaration": "function",
|
|
142
|
+
"method_definition": "method",
|
|
143
|
+
"method_declaration": "method",
|
|
144
|
+
"constructor_declaration": "constructor",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class AnalysisResult:
|
|
150
|
+
"""Aggregated output of a complexity analysis run.
|
|
151
|
+
|
|
152
|
+
:param files_scanned: Number of source files processed.
|
|
153
|
+
:param functions_analyzed: Total named code blocks measured before threshold filtering.
|
|
154
|
+
:param results: Complexity results with ``complexity > max_threshold``.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
files_scanned: int
|
|
158
|
+
functions_analyzed: int
|
|
159
|
+
results: list[ComplexityResult] = field(default_factory=list)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_name(node: Node) -> str | None:
|
|
163
|
+
"""Return the declared name of *node*, or ``None`` for anonymous code blocks."""
|
|
164
|
+
name_node = node.child_by_field_name("name")
|
|
165
|
+
if name_node is None:
|
|
166
|
+
return None
|
|
167
|
+
return name_node.text.decode("utf-8", errors="replace")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _get_parent_class_go(node: Node) -> str | None:
|
|
171
|
+
"""Return the receiver type name for a Go ``method_declaration``."""
|
|
172
|
+
receiver = node.child_by_field_name("receiver")
|
|
173
|
+
if receiver is None:
|
|
174
|
+
return None
|
|
175
|
+
for child in receiver.children:
|
|
176
|
+
if child.type == "parameter_declaration":
|
|
177
|
+
type_node = child.child_by_field_name("type")
|
|
178
|
+
if type_node is None:
|
|
179
|
+
continue
|
|
180
|
+
if type_node.type == "pointer_type":
|
|
181
|
+
# *Foo — get the named inner type (skip the '*' token)
|
|
182
|
+
for inner in reversed(type_node.children):
|
|
183
|
+
if inner.is_named:
|
|
184
|
+
return inner.text.decode("utf-8", errors="replace")
|
|
185
|
+
return type_node.text.decode("utf-8", errors="replace")
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _get_parent_class(node: Node, language: str) -> str | None:
|
|
190
|
+
"""Return the enclosing class name, or ``None`` for top-level code blocks."""
|
|
191
|
+
if language == "Go" and node.type == "method_declaration":
|
|
192
|
+
return _get_parent_class_go(node)
|
|
193
|
+
current = node.parent
|
|
194
|
+
while current is not None:
|
|
195
|
+
if current.type in _CLASS_NODE_TYPES:
|
|
196
|
+
name_node = current.child_by_field_name("name")
|
|
197
|
+
if name_node is not None:
|
|
198
|
+
return name_node.text.decode("utf-8", errors="replace")
|
|
199
|
+
current = current.parent
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _resolve_fragment_type(node: Node, language: str, parent_class: str | None) -> str:
|
|
204
|
+
"""Return the fragment type string for *node*."""
|
|
205
|
+
raw = _FRAGMENT_TYPE_MAP.get(node.type, "function")
|
|
206
|
+
# Python uses function_definition for both functions and methods.
|
|
207
|
+
if (
|
|
208
|
+
language == "Python"
|
|
209
|
+
and node.type == "function_definition"
|
|
210
|
+
and parent_class is not None
|
|
211
|
+
):
|
|
212
|
+
return "method"
|
|
213
|
+
return raw
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _count_decisions(
|
|
217
|
+
node: Node, decision_nodes: frozenset[str], stop_types: frozenset[str]
|
|
218
|
+
) -> int:
|
|
219
|
+
"""Recursively count decision-point nodes under *node*, pruning at *stop_types*.
|
|
220
|
+
|
|
221
|
+
:param node: AST node to walk (typically the function/method root).
|
|
222
|
+
:param decision_nodes: Node type names that each add one to the complexity count.
|
|
223
|
+
:param stop_types: Node types whose entire subtrees are excluded (nested code units).
|
|
224
|
+
:returns: Total decision points found within *node*'s subtree.
|
|
225
|
+
"""
|
|
226
|
+
count = 0
|
|
227
|
+
for child in node.children:
|
|
228
|
+
if child.type in stop_types:
|
|
229
|
+
continue
|
|
230
|
+
if child.type in decision_nodes:
|
|
231
|
+
count += 1
|
|
232
|
+
count += _count_decisions(child, decision_nodes, stop_types)
|
|
233
|
+
return count
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _compile_query(lang: Language, pattern: str, language_name: str) -> Query | None:
|
|
237
|
+
"""Return a compiled tree-sitter Query, or ``None`` on failure."""
|
|
238
|
+
try:
|
|
239
|
+
return Query(lang, pattern)
|
|
240
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
241
|
+
_logger.warning("Failed to compile query for %s: %s", language_name, exc)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _capture_fragments(tree, language: str) -> list[Node]:
|
|
246
|
+
"""Run the fragment query for *language* against *tree* and return matched nodes."""
|
|
247
|
+
lang_obj = _parser.get_language(language)
|
|
248
|
+
if lang_obj is None:
|
|
249
|
+
return []
|
|
250
|
+
query_str = _FRAGMENT_QUERIES.get(language)
|
|
251
|
+
if query_str is None:
|
|
252
|
+
return []
|
|
253
|
+
query = _compile_query(lang_obj, query_str, language)
|
|
254
|
+
if query is None:
|
|
255
|
+
return []
|
|
256
|
+
cursor = QueryCursor(query)
|
|
257
|
+
captures = cursor.captures(tree.root_node)
|
|
258
|
+
nodes: list[Node] = list(captures.get("fragment", []))
|
|
259
|
+
del cursor, captures
|
|
260
|
+
return nodes
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _build_result(
|
|
264
|
+
node: Node,
|
|
265
|
+
file_path: str,
|
|
266
|
+
language: str,
|
|
267
|
+
newline_offsets: list[int],
|
|
268
|
+
) -> ComplexityResult | None:
|
|
269
|
+
"""Return a :class:`~fork_meter.models.ComplexityResult` for *node*, or ``None`` if anonymous."""
|
|
270
|
+
name = _get_name(node)
|
|
271
|
+
if name is None:
|
|
272
|
+
return None
|
|
273
|
+
parent_class = _get_parent_class(node, language)
|
|
274
|
+
fragment_type = _resolve_fragment_type(node, language, parent_class)
|
|
275
|
+
decision_nodes = _DECISION_NODES.get(language, frozenset())
|
|
276
|
+
stop_types = _NESTED_STOP_TYPES.get(language, frozenset())
|
|
277
|
+
complexity = 1 + _count_decisions(node, decision_nodes, stop_types)
|
|
278
|
+
return ComplexityResult(
|
|
279
|
+
file_path=file_path,
|
|
280
|
+
language=language,
|
|
281
|
+
fragment_type=fragment_type,
|
|
282
|
+
name=name,
|
|
283
|
+
parent_class=parent_class,
|
|
284
|
+
start_line=bisect_left(newline_offsets, node.start_byte) + 1,
|
|
285
|
+
end_line=bisect_left(newline_offsets, node.end_byte) + 1,
|
|
286
|
+
complexity=complexity,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def analyze_file(file_path: Path, language: str) -> list[ComplexityResult]:
|
|
291
|
+
"""Parse *file_path* and return a complexity result for each named code block.
|
|
292
|
+
|
|
293
|
+
:param file_path: Path to a source file.
|
|
294
|
+
:param language: Language name matching :data:`fork_meter.scanner.EXTENSION_TO_LANGUAGE`.
|
|
295
|
+
:returns: Unsorted list of :class:`~fork_meter.models.ComplexityResult` objects.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
source_bytes = file_path.read_bytes()
|
|
299
|
+
except OSError as exc:
|
|
300
|
+
_logger.warning("Cannot read %s: %s", file_path, exc)
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
tree = _parser.parse(source_bytes, language)
|
|
304
|
+
if tree is None:
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
# Keep `tree` alive for the duration of node traversal; nodes reference its C memory.
|
|
308
|
+
fragment_nodes = _capture_fragments(tree, language)
|
|
309
|
+
newline_offsets = [i for i, b in enumerate(source_bytes) if b == ord(b"\n")]
|
|
310
|
+
|
|
311
|
+
results: list[ComplexityResult] = []
|
|
312
|
+
for node in fragment_nodes:
|
|
313
|
+
built = _build_result(node, str(file_path), language, newline_offsets)
|
|
314
|
+
if built is not None:
|
|
315
|
+
results.append(built)
|
|
316
|
+
|
|
317
|
+
_logger.debug("Analyzed %d code blocks in %s", len(results), file_path.name)
|
|
318
|
+
return results
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def analyze(
|
|
322
|
+
paths: tuple[Path, ...],
|
|
323
|
+
exclude_patterns: tuple[str, ...] = (),
|
|
324
|
+
max_threshold: int = 10,
|
|
325
|
+
) -> AnalysisResult:
|
|
326
|
+
"""Scan *paths*, compute cyclomatic complexity, and filter results by threshold.
|
|
327
|
+
|
|
328
|
+
:param paths: File or directory paths to scan.
|
|
329
|
+
:param exclude_patterns: Glob patterns to exclude from scanning.
|
|
330
|
+
:param max_threshold: Only include results with complexity strictly above this value.
|
|
331
|
+
:returns: :class:`AnalysisResult` with summary counts and filtered results.
|
|
332
|
+
"""
|
|
333
|
+
files = _scanner.scan(paths, exclude_patterns)
|
|
334
|
+
all_results: list[ComplexityResult] = []
|
|
335
|
+
for file_path, language in files:
|
|
336
|
+
all_results.extend(analyze_file(file_path, language))
|
|
337
|
+
|
|
338
|
+
filtered = [r for r in all_results if r.complexity > max_threshold]
|
|
339
|
+
_logger.info(
|
|
340
|
+
"Files: %d | Functions: %d | Above threshold: %d",
|
|
341
|
+
len(files),
|
|
342
|
+
len(all_results),
|
|
343
|
+
len(filtered),
|
|
344
|
+
)
|
|
345
|
+
return AnalysisResult(
|
|
346
|
+
files_scanned=len(files),
|
|
347
|
+
functions_analyzed=len(all_results),
|
|
348
|
+
results=filtered,
|
|
349
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[loggers]
|
|
2
|
+
keys=root
|
|
3
|
+
|
|
4
|
+
[handlers]
|
|
5
|
+
keys=consoleHandler,fileHandler
|
|
6
|
+
|
|
7
|
+
[formatters]
|
|
8
|
+
keys=logFormatter,consoleFormatter
|
|
9
|
+
|
|
10
|
+
[logger_root]
|
|
11
|
+
level=INFO
|
|
12
|
+
handlers=consoleHandler,fileHandler
|
|
13
|
+
|
|
14
|
+
[handler_consoleHandler]
|
|
15
|
+
level=ERROR
|
|
16
|
+
class=StreamHandler
|
|
17
|
+
formatter=consoleFormatter
|
|
18
|
+
args=(sys.stderr,)
|
|
19
|
+
|
|
20
|
+
[handler_fileHandler]
|
|
21
|
+
class=FileHandler
|
|
22
|
+
formatter=logFormatter
|
|
23
|
+
args=('fork_meter.log', 'a')
|
|
24
|
+
|
|
25
|
+
[formatter_logFormatter]
|
|
26
|
+
format=%(asctime)s [%(levelname)s] %(name)s - %(message)s
|
|
27
|
+
|
|
28
|
+
[formatter_consoleFormatter]
|
|
29
|
+
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for fork-meter cyclomatic complexity analysis.
|
|
3
|
+
|
|
4
|
+
:author: Ron Webb
|
|
5
|
+
:since: 1.0.0
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ComplexityResult: # pylint: disable=too-many-instance-attributes
|
|
13
|
+
"""Cyclomatic complexity measurement for a single named code block.
|
|
14
|
+
|
|
15
|
+
:param file_path: Absolute path to the source file.
|
|
16
|
+
:param language: Language name (e.g. ``"Python"``).
|
|
17
|
+
:param fragment_type: Code block kind: ``"function"``, ``"method"``, or ``"constructor"``.
|
|
18
|
+
:param name: Declared name of the code block.
|
|
19
|
+
:param parent_class: Enclosing class name, or ``None`` for top-level functions.
|
|
20
|
+
:param start_line: 1-based line where the code block starts.
|
|
21
|
+
:param end_line: 1-based line where the code block ends.
|
|
22
|
+
:param complexity: Cyclomatic complexity (≥ 1).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
file_path: str
|
|
26
|
+
language: str
|
|
27
|
+
fragment_type: str
|
|
28
|
+
name: str
|
|
29
|
+
parent_class: str | None
|
|
30
|
+
start_line: int
|
|
31
|
+
end_line: int
|
|
32
|
+
complexity: int
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def line_count(self) -> int:
|
|
36
|
+
"""Return the number of source lines spanned by this code block."""
|
|
37
|
+
return self.end_line - self.start_line + 1
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tree-sitter parser wrapper providing one cached Language per grammar.
|
|
3
|
+
|
|
4
|
+
:author: Ron Webb
|
|
5
|
+
:since: 1.0.0
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
from tree_sitter import Language, Parser, Tree
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_language( # pylint: disable=too-many-return-statements
|
|
17
|
+
language_name: str,
|
|
18
|
+
) -> Language | None:
|
|
19
|
+
"""Instantiate the tree-sitter Language for *language_name*."""
|
|
20
|
+
match language_name:
|
|
21
|
+
case "Python":
|
|
22
|
+
import tree_sitter_python as m # pylint: disable=import-outside-toplevel
|
|
23
|
+
|
|
24
|
+
return Language(m.language())
|
|
25
|
+
case "JavaScript":
|
|
26
|
+
import tree_sitter_javascript as m # pylint: disable=import-outside-toplevel
|
|
27
|
+
|
|
28
|
+
return Language(m.language())
|
|
29
|
+
case "TypeScript":
|
|
30
|
+
import tree_sitter_typescript as m # pylint: disable=import-outside-toplevel
|
|
31
|
+
|
|
32
|
+
return Language(m.language_typescript())
|
|
33
|
+
case "Java":
|
|
34
|
+
import tree_sitter_java as m # pylint: disable=import-outside-toplevel
|
|
35
|
+
|
|
36
|
+
return Language(m.language())
|
|
37
|
+
case "Gosu":
|
|
38
|
+
import tree_sitter_gosu as m # pylint: disable=import-outside-toplevel
|
|
39
|
+
|
|
40
|
+
return Language(m.language())
|
|
41
|
+
case "Go":
|
|
42
|
+
import tree_sitter_go as m # pylint: disable=import-outside-toplevel
|
|
43
|
+
|
|
44
|
+
return Language(m.language())
|
|
45
|
+
case _:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@lru_cache(maxsize=None)
|
|
50
|
+
def get_language(language_name: str) -> Language | None:
|
|
51
|
+
"""Return a cached Language for *language_name*, or ``None`` if unavailable."""
|
|
52
|
+
try:
|
|
53
|
+
lang = _build_language(language_name)
|
|
54
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
55
|
+
_logger.warning("Failed to load grammar for %s: %s", language_name, exc)
|
|
56
|
+
return None
|
|
57
|
+
if lang is None:
|
|
58
|
+
_logger.warning("No tree-sitter grammar for language: %s", language_name)
|
|
59
|
+
return lang
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse(source_bytes: bytes, language_name: str) -> Tree | None:
|
|
63
|
+
"""Parse *source_bytes* with the grammar for *language_name*.
|
|
64
|
+
|
|
65
|
+
Creates a fresh :class:`~tree_sitter.Parser` per call to avoid internal-state
|
|
66
|
+
corruption across files in tree-sitter 0.26+.
|
|
67
|
+
|
|
68
|
+
:param source_bytes: UTF-8-encoded source code.
|
|
69
|
+
:param language_name: Language name as used by :data:`fork_meter.scanner.EXTENSION_TO_LANGUAGE`.
|
|
70
|
+
:returns: Parsed :class:`tree_sitter.Tree`, or ``None`` if the grammar is unavailable.
|
|
71
|
+
"""
|
|
72
|
+
lang = get_language(language_name)
|
|
73
|
+
if lang is None:
|
|
74
|
+
_logger.warning("Skipping parse — no grammar for %s", language_name)
|
|
75
|
+
return None
|
|
76
|
+
tree = Parser(lang).parse(source_bytes)
|
|
77
|
+
if tree.root_node.has_error:
|
|
78
|
+
_logger.debug("Parse errors detected in %s source.", language_name)
|
|
79
|
+
return tree
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTML reporter for fork-meter complexity analysis.
|
|
3
|
+
|
|
4
|
+
Produces a self-contained, interactive HTML file with a sortable complexity table.
|
|
5
|
+
|
|
6
|
+
:author: Ron Webb
|
|
7
|
+
:since: 1.0.0
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from jinja2 import Environment
|
|
15
|
+
|
|
16
|
+
from .. import __version__
|
|
17
|
+
from ..analyzer import AnalysisResult
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_TEMPLATE = """\
|
|
22
|
+
<!DOCTYPE html>
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="UTF-8" />
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
27
|
+
<title>fork-meter — Complexity Report</title>
|
|
28
|
+
<style>
|
|
29
|
+
:root {
|
|
30
|
+
--bg: #e8eaed; --surface: #f0f2f5; --overlay: #dde0e6;
|
|
31
|
+
--text: #2c2f3a; --subtext: #5a6070; --accent: #5b21b6;
|
|
32
|
+
--border: #c4c8d0;
|
|
33
|
+
--green: #16a34a; --yellow: #d97706; --orange: #ea580c; --red: #dc2626;
|
|
34
|
+
}
|
|
35
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
36
|
+
body {
|
|
37
|
+
background: var(--bg); color: var(--text);
|
|
38
|
+
font-family: 'Segoe UI', system-ui, sans-serif; padding: 2rem;
|
|
39
|
+
}
|
|
40
|
+
h1 { color: var(--accent); font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
41
|
+
.meta { color: var(--subtext); font-size: 0.8rem; margin-bottom: 1.75rem; }
|
|
42
|
+
.summary { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.75rem; }
|
|
43
|
+
.card {
|
|
44
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
45
|
+
border-radius: 8px; padding: 1rem 1.5rem; min-width: 140px;
|
|
46
|
+
}
|
|
47
|
+
.card .label {
|
|
48
|
+
color: var(--subtext); font-size: 0.7rem;
|
|
49
|
+
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.35rem;
|
|
50
|
+
}
|
|
51
|
+
.card .value { font-size: 2rem; font-weight: 700; color: var(--accent); }
|
|
52
|
+
.empty { color: var(--green); margin-top: 2rem; }
|
|
53
|
+
table { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
|
54
|
+
thead { background: var(--overlay); }
|
|
55
|
+
th {
|
|
56
|
+
padding: 0.75rem 1rem; text-align: left; color: var(--subtext);
|
|
57
|
+
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em;
|
|
58
|
+
cursor: pointer; user-select: none; white-space: nowrap;
|
|
59
|
+
}
|
|
60
|
+
th:hover { color: var(--text); }
|
|
61
|
+
th.sort-asc::after { content: ' ↑'; color: var(--accent); }
|
|
62
|
+
th.sort-desc::after { content: ' ↓'; color: var(--accent); }
|
|
63
|
+
td { padding: 0.6rem 1rem; border-top: 1px solid var(--border); font-size: 0.875rem; }
|
|
64
|
+
tr:hover td { background: var(--overlay); }
|
|
65
|
+
.file { color: var(--subtext); font-size: 0.8rem; word-break: break-all; }
|
|
66
|
+
.name { font-weight: 600; }
|
|
67
|
+
.tag {
|
|
68
|
+
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px;
|
|
69
|
+
font-size: 0.7rem; background: var(--overlay); color: var(--subtext);
|
|
70
|
+
}
|
|
71
|
+
.cc { font-weight: 700; font-size: 1rem; }
|
|
72
|
+
.cc-low { color: var(--green); }
|
|
73
|
+
.cc-med { color: var(--yellow); }
|
|
74
|
+
.cc-high { color: var(--orange); }
|
|
75
|
+
.cc-crit { color: var(--red); }
|
|
76
|
+
</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<h1>fork-meter</h1>
|
|
80
|
+
<p class="meta">
|
|
81
|
+
Generated {{ generated_at }} ·
|
|
82
|
+
fork-meter v{{ version }} ·
|
|
83
|
+
Threshold: complexity > {{ max_threshold }}
|
|
84
|
+
</p>
|
|
85
|
+
<div class="summary">
|
|
86
|
+
<div class="card">
|
|
87
|
+
<div class="label">Files Scanned</div>
|
|
88
|
+
<div class="value">{{ files_scanned }}</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="card">
|
|
91
|
+
<div class="label">Functions Analyzed</div>
|
|
92
|
+
<div class="value">{{ functions_analyzed }}</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="card">
|
|
95
|
+
<div class="label">Above Threshold</div>
|
|
96
|
+
<div class="value">{{ results | length }}</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
{% if results %}
|
|
100
|
+
<table id="tbl">
|
|
101
|
+
<thead>
|
|
102
|
+
<tr>
|
|
103
|
+
<th data-col="0">File</th>
|
|
104
|
+
<th data-col="1">Class</th>
|
|
105
|
+
<th data-col="2">Code Block</th>
|
|
106
|
+
<th data-col="3">Type</th>
|
|
107
|
+
<th data-col="4">Lines</th>
|
|
108
|
+
<th data-col="5">Complexity</th>
|
|
109
|
+
</tr>
|
|
110
|
+
</thead>
|
|
111
|
+
<tbody>
|
|
112
|
+
{% for r in results %}
|
|
113
|
+
<tr>
|
|
114
|
+
<td class="file">{{ r.file_path }}</td>
|
|
115
|
+
<td>{{ r.parent_class if r.parent_class else '\u2014' }}</td>
|
|
116
|
+
<td class="name">{{ r.name }}</td>
|
|
117
|
+
<td><span class="tag">{{ r.fragment_type }}</span></td>
|
|
118
|
+
<td>{{ r.line_count }}</td>
|
|
119
|
+
<td class="cc {% if r.complexity <= 5 %}cc-low
|
|
120
|
+
{%- elif r.complexity <= 10 %}cc-med
|
|
121
|
+
{%- elif r.complexity <= 15 %}cc-high
|
|
122
|
+
{%- else %}cc-crit{% endif %}">{{ r.complexity }}</td>
|
|
123
|
+
</tr>
|
|
124
|
+
{% endfor %}
|
|
125
|
+
</tbody>
|
|
126
|
+
</table>
|
|
127
|
+
{% else %}
|
|
128
|
+
<p class="empty">✓ No functions exceed the complexity threshold of {{ max_threshold }}.</p>
|
|
129
|
+
{% endif %}
|
|
130
|
+
<script>
|
|
131
|
+
(function () {
|
|
132
|
+
var tbl = document.getElementById('tbl');
|
|
133
|
+
if (!tbl) { return; }
|
|
134
|
+
var sc = 5, sd = -1;
|
|
135
|
+
function sortBy(col) {
|
|
136
|
+
if (sc === col) { sd *= -1; } else { sc = col; sd = (col >= 4) ? -1 : 1; }
|
|
137
|
+
var tb = tbl.querySelector('tbody');
|
|
138
|
+
var rows = Array.prototype.slice.call(tb.rows);
|
|
139
|
+
rows.sort(function (a, b) {
|
|
140
|
+
var av = a.cells[col].textContent.trim();
|
|
141
|
+
var bv = b.cells[col].textContent.trim();
|
|
142
|
+
if (col >= 4) { return (parseInt(av, 10) - parseInt(bv, 10)) * sd; }
|
|
143
|
+
return av.localeCompare(bv) * sd;
|
|
144
|
+
});
|
|
145
|
+
rows.forEach(function (r) { tb.appendChild(r); });
|
|
146
|
+
tbl.querySelectorAll('th').forEach(function (th, i) {
|
|
147
|
+
th.classList.remove('sort-asc', 'sort-desc');
|
|
148
|
+
if (i === sc) { th.classList.add(sd === 1 ? 'sort-asc' : 'sort-desc'); }
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
tbl.querySelectorAll('th').forEach(function (th) {
|
|
152
|
+
th.addEventListener('click', function () { sortBy(parseInt(th.dataset.col, 10)); });
|
|
153
|
+
});
|
|
154
|
+
sortBy(5);
|
|
155
|
+
}());
|
|
156
|
+
</script>
|
|
157
|
+
</body>
|
|
158
|
+
</html>"""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def write(result: AnalysisResult, max_threshold: int, output_path: Path) -> Path:
|
|
162
|
+
"""Render the complexity report as a self-contained HTML file.
|
|
163
|
+
|
|
164
|
+
:param result: Aggregated analysis output.
|
|
165
|
+
:param max_threshold: Complexity threshold used for filtering.
|
|
166
|
+
:param output_path: Destination ``.html`` file path.
|
|
167
|
+
:returns: Resolved path of the written file.
|
|
168
|
+
"""
|
|
169
|
+
env = Environment(autoescape=True)
|
|
170
|
+
template = env.from_string(_TEMPLATE)
|
|
171
|
+
html = template.render(
|
|
172
|
+
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
173
|
+
version=__version__,
|
|
174
|
+
max_threshold=max_threshold,
|
|
175
|
+
files_scanned=result.files_scanned,
|
|
176
|
+
functions_analyzed=result.functions_analyzed,
|
|
177
|
+
results=result.results,
|
|
178
|
+
)
|
|
179
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
output_path.write_text(html, encoding="utf-8")
|
|
181
|
+
_logger.info("HTML report written to %s", output_path)
|
|
182
|
+
return output_path.resolve()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON reporter for fork-meter complexity analysis.
|
|
3
|
+
|
|
4
|
+
:author: Ron Webb
|
|
5
|
+
:since: 1.0.0
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .. import __version__
|
|
14
|
+
from ..analyzer import AnalysisResult
|
|
15
|
+
|
|
16
|
+
_logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write(
|
|
20
|
+
result: AnalysisResult,
|
|
21
|
+
scan_paths: list[str],
|
|
22
|
+
max_threshold: int,
|
|
23
|
+
output_path: Path,
|
|
24
|
+
) -> Path:
|
|
25
|
+
"""Write *result* to a JSON file at *output_path*.
|
|
26
|
+
|
|
27
|
+
:param result: Aggregated analysis output.
|
|
28
|
+
:param scan_paths: Original paths supplied by the user.
|
|
29
|
+
:param max_threshold: Complexity threshold used for filtering.
|
|
30
|
+
:param output_path: Destination ``.json`` file path.
|
|
31
|
+
:returns: Resolved path of the written file.
|
|
32
|
+
"""
|
|
33
|
+
payload = {
|
|
34
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
35
|
+
"version": __version__,
|
|
36
|
+
"scan_paths": scan_paths,
|
|
37
|
+
"max_threshold": max_threshold,
|
|
38
|
+
"summary": {
|
|
39
|
+
"files_scanned": result.files_scanned,
|
|
40
|
+
"functions_analyzed": result.functions_analyzed,
|
|
41
|
+
"above_threshold": len(result.results),
|
|
42
|
+
},
|
|
43
|
+
"results": [
|
|
44
|
+
{
|
|
45
|
+
"file_path": r.file_path,
|
|
46
|
+
"language": r.language,
|
|
47
|
+
"fragment_type": r.fragment_type,
|
|
48
|
+
"name": r.name,
|
|
49
|
+
"parent_class": r.parent_class,
|
|
50
|
+
"start_line": r.start_line,
|
|
51
|
+
"end_line": r.end_line,
|
|
52
|
+
"complexity": r.complexity,
|
|
53
|
+
}
|
|
54
|
+
for r in result.results
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
59
|
+
_logger.info("JSON report written to %s", output_path)
|
|
60
|
+
return output_path.resolve()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File-system scanner that discovers source files for complexity analysis.
|
|
3
|
+
|
|
4
|
+
:author: Ron Webb
|
|
5
|
+
:since: 1.0.0
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from braincraft import IgnoreFile
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
EXTENSION_TO_LANGUAGE: dict[str, str] = {
|
|
17
|
+
".py": "Python",
|
|
18
|
+
".js": "JavaScript",
|
|
19
|
+
".mjs": "JavaScript",
|
|
20
|
+
".cjs": "JavaScript",
|
|
21
|
+
".ts": "TypeScript",
|
|
22
|
+
".tsx": "TypeScript",
|
|
23
|
+
".java": "Java",
|
|
24
|
+
".go": "Go",
|
|
25
|
+
".gs": "Gosu",
|
|
26
|
+
".gsx": "Gosu",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
".git",
|
|
32
|
+
".svn",
|
|
33
|
+
".hg",
|
|
34
|
+
".venv",
|
|
35
|
+
"venv",
|
|
36
|
+
"env",
|
|
37
|
+
".env",
|
|
38
|
+
"__pycache__",
|
|
39
|
+
"node_modules",
|
|
40
|
+
"build",
|
|
41
|
+
"dist",
|
|
42
|
+
"target",
|
|
43
|
+
"out",
|
|
44
|
+
".tox",
|
|
45
|
+
".pytest_cache",
|
|
46
|
+
"htmlcov",
|
|
47
|
+
".mypy_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def scan(
|
|
54
|
+
paths: tuple[Path, ...],
|
|
55
|
+
exclude_patterns: tuple[str, ...] = (),
|
|
56
|
+
ignore_file: IgnoreFile | None = None,
|
|
57
|
+
) -> list[tuple[Path, str]]:
|
|
58
|
+
"""Walk *paths* and return ``(path, language)`` pairs for supported source files.
|
|
59
|
+
|
|
60
|
+
:param paths: File or directory paths to scan.
|
|
61
|
+
:param exclude_patterns: fnmatch-style glob patterns whose matching paths are skipped.
|
|
62
|
+
:param ignore_file: Optional gitignore-style filter; matched paths are skipped.
|
|
63
|
+
:returns: List of ``(absolute_path, language_name)`` tuples.
|
|
64
|
+
"""
|
|
65
|
+
results: list[tuple[Path, str]] = []
|
|
66
|
+
seen: set[Path] = set()
|
|
67
|
+
|
|
68
|
+
for path in paths:
|
|
69
|
+
path = path.resolve()
|
|
70
|
+
_logger.debug("Scanning: %s", path)
|
|
71
|
+
if path.is_file():
|
|
72
|
+
if path in seen or _matches_any(path, exclude_patterns):
|
|
73
|
+
continue
|
|
74
|
+
seen.add(path)
|
|
75
|
+
if ignore_file is not None and ignore_file.is_ignored(path):
|
|
76
|
+
_logger.debug("Ignored (ignore file): %s", path)
|
|
77
|
+
continue
|
|
78
|
+
if language := EXTENSION_TO_LANGUAGE.get(path.suffix.lower()):
|
|
79
|
+
results.append((path, language))
|
|
80
|
+
elif path.is_dir():
|
|
81
|
+
for entry in _walk(path, exclude_patterns, ignore_file):
|
|
82
|
+
if entry not in seen:
|
|
83
|
+
seen.add(entry)
|
|
84
|
+
if language := EXTENSION_TO_LANGUAGE.get(entry.suffix.lower()):
|
|
85
|
+
results.append((entry, language))
|
|
86
|
+
|
|
87
|
+
_logger.debug("Found %d source files across %d path(s)", len(results), len(paths))
|
|
88
|
+
return results
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _walk(
|
|
92
|
+
root: Path,
|
|
93
|
+
exclude_patterns: tuple[str, ...],
|
|
94
|
+
ignore_file: IgnoreFile | None,
|
|
95
|
+
):
|
|
96
|
+
"""Yield source file paths, skipping excluded directories and glob-matched files."""
|
|
97
|
+
try:
|
|
98
|
+
for child in sorted(root.iterdir()):
|
|
99
|
+
if child.is_dir():
|
|
100
|
+
if child.name in _DEFAULT_EXCLUDE_DIRS:
|
|
101
|
+
continue
|
|
102
|
+
if _matches_any(child, exclude_patterns):
|
|
103
|
+
continue
|
|
104
|
+
if ignore_file is not None and ignore_file.is_ignored(child):
|
|
105
|
+
_logger.debug("Ignored (ignore file): %s", child)
|
|
106
|
+
continue
|
|
107
|
+
yield from _walk(child, exclude_patterns, ignore_file)
|
|
108
|
+
elif child.is_file():
|
|
109
|
+
if _matches_any(child, exclude_patterns):
|
|
110
|
+
continue
|
|
111
|
+
if ignore_file is not None and ignore_file.is_ignored(child):
|
|
112
|
+
_logger.debug("Ignored (ignore file): %s", child)
|
|
113
|
+
continue
|
|
114
|
+
yield child
|
|
115
|
+
except PermissionError as exc:
|
|
116
|
+
_logger.warning("Permission denied reading %s: %s", root, exc)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _matches_any(path: Path, patterns: tuple[str, ...]) -> bool:
|
|
120
|
+
"""Return ``True`` if *path* matches any fnmatch-style pattern in *patterns*."""
|
|
121
|
+
path_str = str(path)
|
|
122
|
+
return any(fnmatch.fnmatch(path_str, p) for p in patterns)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fork-meter"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A command-line tool that measures cyclomatic complexity by counting decision points and branching paths in source code."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {file = "LICENSE"}
|
|
7
|
+
requires-python = ">=3.14"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"logenrich (>=1.0.1,<2.0.0)",
|
|
10
|
+
"env-dir-bootstrap (>=1.0.0,<2.0.0)",
|
|
11
|
+
"tree-sitter (>=0.26.0,<0.27.0)",
|
|
12
|
+
"tree-sitter-python (>=0.25.0,<0.26.0)",
|
|
13
|
+
"tree-sitter-javascript (>=0.25.0,<0.26.0)",
|
|
14
|
+
"tree-sitter-typescript (>=0.23.2,<0.24.0)",
|
|
15
|
+
"tree-sitter-java (>=0.23.5,<0.24.0)",
|
|
16
|
+
"tree-sitter-go (>=0.25.0,<0.26.0)",
|
|
17
|
+
"tree-sitter-gosu (>=0.2.0,<0.3.0)",
|
|
18
|
+
"click (>=8.4.2,<9.0.0)",
|
|
19
|
+
"rich (>=15.0.0,<16.0.0)",
|
|
20
|
+
"jinja2 (>=3.1.0,<4.0.0)",
|
|
21
|
+
"braincraft (>=1.2.0,<2.0.0)"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
fork-meter = "fork_meter.__main__:main"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"black (>=26.5.1,<27.0.0)",
|
|
30
|
+
"pylint (>=4.0.5,<5.0.0)",
|
|
31
|
+
"pytest (>=9.0.3,<10.0.0)",
|
|
32
|
+
"pytest-cov (>=7.1.0,<8.0.0)"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.poetry]
|
|
36
|
+
packages = [{include = "fork_meter"}]
|
|
37
|
+
include = ["fork_meter/logging.ini", "CHANGELOG.md"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
addopts = "--cov=fork_meter tests --cov-report html --cov-fail-under=90"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["poetry-core"]
|
|
44
|
+
build-backend = "poetry.core.masonry.api"
|