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.
@@ -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`.
@@ -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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
49
+ [![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](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,6 @@
1
+ """
2
+ Report writers for fork-meter complexity analysis.
3
+
4
+ :author: Ron Webb
5
+ :since: 1.0.0
6
+ """
@@ -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 }}&nbsp;&middot;&nbsp;
82
+ fork-meter v{{ version }}&nbsp;&middot;&nbsp;
83
+ Threshold: complexity &gt; {{ 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">&#10003; 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"