code-prune 1.0.0__tar.gz → 1.1.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.
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-11-05
4
+
5
+ ### Added
6
+
7
+ - `todo-marker` category: `TODO`/`FIXME`/`XXX` comments are reported separately
8
+ from `obsolete` since they are not necessarily obsolete.
9
+ - `--categories` now accepts `unused-import`, `unused-local` and `unused-symbol`
10
+ explicitly; `unused` remains as shorthand for all three.
11
+ - `category-counts` in the JSON report, and per-category counts in the HTML
12
+ report category filter dropdown.
13
+ - A `.codepruneignore` in the target path extends the configured ignore
14
+ patterns; it is not affected by `config.ini`.
15
+
16
+ ### Changed
17
+
18
+ - The HTML report's category filter is now a dropdown of the categories present
19
+ in the report (with counts).
20
+ - Only supported source files are read; all other files are skipped.
21
+ - `obsolete` now covers `@deprecated`-style markers and `deprecated` comments
22
+ only (TODO/FIXME/XXX moved to `todo-marker`).
23
+ - README: installation uses `pip install code-prune`; Poetry instructions moved
24
+ to a Development section.
25
+
26
+ ### Fixed
27
+
28
+ - Comments following a terminating statement are no longer reported as
29
+ unreachable code.
30
+ - Documentation comments (Javadoc/JSDoc `/** ... */`) are no longer reported as
31
+ commented-out code.
32
+
3
33
  ## 1.0.0 - 2026-11-01
4
34
 
5
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-prune
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Find unreachable, unused, and obsolete code before it becomes technical debt.
5
5
  License: MIT License
6
6
 
@@ -27,7 +27,7 @@ License-File: LICENSE
27
27
  Keywords: cli,static-analysis,dead-code,unused-code,tree-sitter,code-quality
28
28
  Author: Ron Webb
29
29
  Requires-Python: >=3.14
30
- Classifier: Development Status :: 4 - Beta
30
+ Classifier: Development Status :: 5 - Production/Stable
31
31
  Classifier: Environment :: Console
32
32
  Classifier: Intended Audience :: Developers
33
33
  Classifier: Operating System :: OS Independent
@@ -56,7 +56,7 @@ Description-Content-Type: text/markdown
56
56
 
57
57
  # code-prune
58
58
 
59
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [![Version](https://img.shields.io/badge/Version-1.0.0-green.svg)](https://github.com/rcw3bb/code-prune/blob/main/CHANGELOG.md) [![Python](https://img.shields.io/badge/Python-3.14%2B-blue)](https://www.python.org/) [![PyPI](https://img.shields.io/badge/PyPI-code-prune-orange)](https://pypi.org/project/code-prune/)
59
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [![Version](https://img.shields.io/badge/Version-1.1.0-green.svg)](https://github.com/rcw3bb/code-prune/blob/main/CHANGELOG.md) [![Python](https://img.shields.io/badge/Python-3.14%2B-blue)](https://www.python.org/) [![PyPI](https://img.shields.io/badge/PyPI-code--prune-orange)](https://pypi.org/project/code-prune/)
60
60
 
61
61
 
62
62
  A CLI tool that scans a codebase for unreachable, unused, and obsolete code,
@@ -65,20 +65,26 @@ helping you find dead code before it becomes technical debt.
65
65
  ## Features
66
66
 
67
67
  `code-prune` parses source files with [tree-sitter](https://tree-sitter.github.io/tree-sitter/)
68
- and reports five categories of findings across **Python, JavaScript,
68
+ and reports six categories of findings across **Python, JavaScript,
69
69
  TypeScript, Java, and Gosu**:
70
70
 
71
71
  - **Unreachable code** — statements after `return`/`raise`/`throw`/`break`/
72
72
  `continue`, and `if` branches whose condition is a constant falsy literal
73
- (`False`/`false`/`0`).
73
+ (`False`/`false`/`0`). Comments are never reported as unreachable.
74
74
  - **Unused imports & local variables** — per-file, syntactic detection of
75
75
  bindings that are never referenced again in the same file.
76
76
  - **Unused functions/classes/methods** — cross-file detection of definitions
77
77
  that are never referenced anywhere in the scanned project.
78
78
  - **Obsolete markers** — `@deprecated`/`@Deprecated`-style decorators and
79
- annotations, plus `TODO`/`FIXME`/`XXX` comment tags.
79
+ annotations, plus comments mentioning `deprecated`.
80
+ - **TODO markers** — `TODO`/`FIXME`/`XXX` comment tags (not necessarily
81
+ obsolete, so reported separately).
80
82
  - **Commented-out code** — comments whose content heuristically resembles
81
- source code.
83
+ source code. Documentation comments (Javadoc/JSDoc `/** ... */`) and Python
84
+ docstrings are not treated as commented-out code.
85
+
86
+ Only supported files (`.py`, `.pyi`, `.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`,
87
+ `.java`, `.gs`, `.gsx`) are read; every other file is skipped.
82
88
 
83
89
  > Detection is heuristic and syntactic rather than a full scope- or
84
90
  > call-graph-aware analysis — treat findings as a starting point for review,
@@ -87,26 +93,27 @@ TypeScript, Java, and Gosu**:
87
93
  ## Requirements
88
94
 
89
95
  - Python `>=3.14`
90
- - Poetry `2.2`
91
96
 
92
97
  ## Installation
93
98
 
94
99
  ```powershell
95
- poetry install
100
+ pip install code-prune
96
101
  ```
97
102
 
98
103
  ## Usage
99
104
 
100
105
  ```powershell
101
- poetry run code-prune <path> [options]
106
+ code-prune <path> [options]
102
107
  ```
103
108
 
109
+ `<path>` may be a file, a directory, or several of them separated by `;`.
110
+
104
111
  Common options:
105
112
 
106
113
  | Option | Description |
107
114
  |-------------------|------------------------------------------------------------------------------|
108
- | `--ext` | Comma-separated extensions to include (e.g. `py,js,ts,java,gs,gsx`). |
109
- | `--categories` | Comma-separated categories to report: `unreachable,unused,obsolete,commented-out` (default: all). |
115
+ | `--ext` | Comma-separated extensions to include (e.g. `py,js,ts,java,gs,gsx`). Omit to scan every supported file. |
116
+ | `--categories` | Comma-separated categories to report: `unreachable`, `unused-import`, `unused-local`, `unused-symbol`, `obsolete`, `todo-marker`, `commented-out`. `unused` is shorthand for all three `unused-*` categories (default: all). |
110
117
  | `--format` | Output format: `json`, `html`, or `all` (default: `json`). |
111
118
  | `--output` | Base output filename without extension (default: `code-prune-output`). |
112
119
  | `--output-dir` | Directory for the report file(s) (default: `reports`). |
@@ -117,9 +124,16 @@ Common options:
117
124
  Example:
118
125
 
119
126
  ```powershell
120
- poetry run code-prune src --categories unreachable,unused --format all
127
+ code-prune src --categories unreachable,unused-import,todo-marker --format all
121
128
  ```
122
129
 
130
+ ### Reports
131
+
132
+ Both report formats include the number of findings per category: the JSON
133
+ report has a `category-counts` object, and the HTML report shows the counts in
134
+ a category dropdown filter listing the categories present in the report. The
135
+ HTML summary line shows only the total number of findings.
136
+
123
137
  ## Configuration
124
138
 
125
139
  Logging is bootstrapped via `env-dir-bootstrap`. Set the `CODE_PRUNE_CONFIG_DIR`
@@ -130,13 +144,17 @@ resolve `logging.ini`, `config.ini`, `.codepruneignore`, and `symbol_ignore`.
130
144
 
131
145
  `.codepruneignore` holds gitignore-style patterns for files/directories to
132
146
  exclude from scanning (mirroring `.gitignore` syntax). Override the filename
133
- via `config.ini`:
147
+ used in the configuration directory via `config.ini`:
134
148
 
135
149
  ```ini
136
150
  [override]
137
151
  ignore-file = .codepruneignore
138
152
  ```
139
153
 
154
+ In addition, a `.codepruneignore` placed in the target path (the scanned
155
+ directory, or the directory containing a scanned file) extends those patterns.
156
+ It always uses that fixed name and is not affected by `config.ini`.
157
+
140
158
  ### Ignoring symbol names
141
159
 
142
160
  `symbol_ignore` holds one regex pattern per line, matched against symbol
@@ -146,6 +164,20 @@ intentionally-unreferenced symbols.
146
164
 
147
165
  ## Development
148
166
 
167
+ Development uses [Poetry](https://python-poetry.org/) `2.2`.
168
+
169
+ Install the project and its dev dependencies:
170
+
171
+ ```powershell
172
+ poetry install
173
+ ```
174
+
175
+ Run the CLI from the working tree:
176
+
177
+ ```powershell
178
+ poetry run code-prune <path> [options]
179
+ ```
180
+
149
181
  Run tests with coverage and generate an HTML report:
150
182
 
151
183
  ```powershell
@@ -1,6 +1,6 @@
1
1
  # code-prune
2
2
 
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [![Version](https://img.shields.io/badge/Version-1.0.0-green.svg)](https://github.com/rcw3bb/code-prune/blob/main/CHANGELOG.md) [![Python](https://img.shields.io/badge/Python-3.14%2B-blue)](https://www.python.org/) [![PyPI](https://img.shields.io/badge/PyPI-code-prune-orange)](https://pypi.org/project/code-prune/)
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [![Version](https://img.shields.io/badge/Version-1.1.0-green.svg)](https://github.com/rcw3bb/code-prune/blob/main/CHANGELOG.md) [![Python](https://img.shields.io/badge/Python-3.14%2B-blue)](https://www.python.org/) [![PyPI](https://img.shields.io/badge/PyPI-code--prune-orange)](https://pypi.org/project/code-prune/)
4
4
 
5
5
 
6
6
  A CLI tool that scans a codebase for unreachable, unused, and obsolete code,
@@ -9,20 +9,26 @@ helping you find dead code before it becomes technical debt.
9
9
  ## Features
10
10
 
11
11
  `code-prune` parses source files with [tree-sitter](https://tree-sitter.github.io/tree-sitter/)
12
- and reports five categories of findings across **Python, JavaScript,
12
+ and reports six categories of findings across **Python, JavaScript,
13
13
  TypeScript, Java, and Gosu**:
14
14
 
15
15
  - **Unreachable code** — statements after `return`/`raise`/`throw`/`break`/
16
16
  `continue`, and `if` branches whose condition is a constant falsy literal
17
- (`False`/`false`/`0`).
17
+ (`False`/`false`/`0`). Comments are never reported as unreachable.
18
18
  - **Unused imports & local variables** — per-file, syntactic detection of
19
19
  bindings that are never referenced again in the same file.
20
20
  - **Unused functions/classes/methods** — cross-file detection of definitions
21
21
  that are never referenced anywhere in the scanned project.
22
22
  - **Obsolete markers** — `@deprecated`/`@Deprecated`-style decorators and
23
- annotations, plus `TODO`/`FIXME`/`XXX` comment tags.
23
+ annotations, plus comments mentioning `deprecated`.
24
+ - **TODO markers** — `TODO`/`FIXME`/`XXX` comment tags (not necessarily
25
+ obsolete, so reported separately).
24
26
  - **Commented-out code** — comments whose content heuristically resembles
25
- source code.
27
+ source code. Documentation comments (Javadoc/JSDoc `/** ... */`) and Python
28
+ docstrings are not treated as commented-out code.
29
+
30
+ Only supported files (`.py`, `.pyi`, `.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`,
31
+ `.java`, `.gs`, `.gsx`) are read; every other file is skipped.
26
32
 
27
33
  > Detection is heuristic and syntactic rather than a full scope- or
28
34
  > call-graph-aware analysis — treat findings as a starting point for review,
@@ -31,26 +37,27 @@ TypeScript, Java, and Gosu**:
31
37
  ## Requirements
32
38
 
33
39
  - Python `>=3.14`
34
- - Poetry `2.2`
35
40
 
36
41
  ## Installation
37
42
 
38
43
  ```powershell
39
- poetry install
44
+ pip install code-prune
40
45
  ```
41
46
 
42
47
  ## Usage
43
48
 
44
49
  ```powershell
45
- poetry run code-prune <path> [options]
50
+ code-prune <path> [options]
46
51
  ```
47
52
 
53
+ `<path>` may be a file, a directory, or several of them separated by `;`.
54
+
48
55
  Common options:
49
56
 
50
57
  | Option | Description |
51
58
  |-------------------|------------------------------------------------------------------------------|
52
- | `--ext` | Comma-separated extensions to include (e.g. `py,js,ts,java,gs,gsx`). |
53
- | `--categories` | Comma-separated categories to report: `unreachable,unused,obsolete,commented-out` (default: all). |
59
+ | `--ext` | Comma-separated extensions to include (e.g. `py,js,ts,java,gs,gsx`). Omit to scan every supported file. |
60
+ | `--categories` | Comma-separated categories to report: `unreachable`, `unused-import`, `unused-local`, `unused-symbol`, `obsolete`, `todo-marker`, `commented-out`. `unused` is shorthand for all three `unused-*` categories (default: all). |
54
61
  | `--format` | Output format: `json`, `html`, or `all` (default: `json`). |
55
62
  | `--output` | Base output filename without extension (default: `code-prune-output`). |
56
63
  | `--output-dir` | Directory for the report file(s) (default: `reports`). |
@@ -61,9 +68,16 @@ Common options:
61
68
  Example:
62
69
 
63
70
  ```powershell
64
- poetry run code-prune src --categories unreachable,unused --format all
71
+ code-prune src --categories unreachable,unused-import,todo-marker --format all
65
72
  ```
66
73
 
74
+ ### Reports
75
+
76
+ Both report formats include the number of findings per category: the JSON
77
+ report has a `category-counts` object, and the HTML report shows the counts in
78
+ a category dropdown filter listing the categories present in the report. The
79
+ HTML summary line shows only the total number of findings.
80
+
67
81
  ## Configuration
68
82
 
69
83
  Logging is bootstrapped via `env-dir-bootstrap`. Set the `CODE_PRUNE_CONFIG_DIR`
@@ -74,13 +88,17 @@ resolve `logging.ini`, `config.ini`, `.codepruneignore`, and `symbol_ignore`.
74
88
 
75
89
  `.codepruneignore` holds gitignore-style patterns for files/directories to
76
90
  exclude from scanning (mirroring `.gitignore` syntax). Override the filename
77
- via `config.ini`:
91
+ used in the configuration directory via `config.ini`:
78
92
 
79
93
  ```ini
80
94
  [override]
81
95
  ignore-file = .codepruneignore
82
96
  ```
83
97
 
98
+ In addition, a `.codepruneignore` placed in the target path (the scanned
99
+ directory, or the directory containing a scanned file) extends those patterns.
100
+ It always uses that fixed name and is not affected by `config.ini`.
101
+
84
102
  ### Ignoring symbol names
85
103
 
86
104
  `symbol_ignore` holds one regex pattern per line, matched against symbol
@@ -90,6 +108,20 @@ intentionally-unreferenced symbols.
90
108
 
91
109
  ## Development
92
110
 
111
+ Development uses [Poetry](https://python-poetry.org/) `2.2`.
112
+
113
+ Install the project and its dev dependencies:
114
+
115
+ ```powershell
116
+ poetry install
117
+ ```
118
+
119
+ Run the CLI from the working tree:
120
+
121
+ ```powershell
122
+ poetry run code-prune <path> [options]
123
+ ```
124
+
93
125
  Run tests with coverage and generate an HTML report:
94
126
 
95
127
  ```powershell
@@ -10,7 +10,7 @@ Since: 1.0.0
10
10
  from env_dir_bootstrap import EnvDirBootstrap
11
11
 
12
12
  __app_name__ = "code-prune"
13
- __version__ = "1.0.0"
13
+ __version__ = "1.1.0"
14
14
 
15
15
  _bootstrapper = EnvDirBootstrap(
16
16
  env_var="CODE_PRUNE_CONFIG_DIR",
@@ -35,10 +35,19 @@ from . import PATH_IGNORE_PATH
35
35
  from . import SYMBOL_IGNORE_PATH
36
36
  from .config import Config
37
37
  from .reporter import write_outputs
38
- from .scanner import CLI_CATEGORIES, load_name_ignore_patterns, scan_file
38
+ from .scanner import (
39
+ CATEGORY_UNUSED_ALIAS,
40
+ CATEGORY_UNUSED_SYMBOL,
41
+ CLI_CATEGORIES,
42
+ SUPPORTED_EXTENSIONS,
43
+ UNUSED_CATEGORIES,
44
+ load_name_ignore_patterns,
45
+ scan_file,
46
+ )
39
47
  from .store import SessionStore
40
48
 
41
49
  _VALID_FORMATS = ("json", "html", "all")
50
+ _TARGET_IGNORE_FILENAME = ".codepruneignore"
42
51
 
43
52
 
44
53
  def _configure_stream_encoding(stream: object) -> None:
@@ -117,7 +126,10 @@ def _read_target_list(list_file: Path) -> list[Path]:
117
126
  def _parse_categories(raw: str) -> set[str]:
118
127
  """Parse a comma-separated category string into a validated set.
119
128
 
120
- Raises :class:`click.UsageError` when an unknown category is given.
129
+ The ``unused`` alias is expanded into ``unused-import``, ``unused-local``
130
+ and ``unused-symbol``, so the returned set only holds concrete finding
131
+ categories. Raises :class:`click.UsageError` when an unknown category is
132
+ given.
121
133
 
122
134
  Author: Ron Webb
123
135
  Since: 1.0.0
@@ -129,6 +141,9 @@ def _parse_categories(raw: str) -> set[str]:
129
141
  f"Unknown categories: {', '.join(sorted(unknown))}. "
130
142
  f"Valid categories: {', '.join(CLI_CATEGORIES)}."
131
143
  )
144
+ if CATEGORY_UNUSED_ALIAS in requested:
145
+ requested.discard(CATEGORY_UNUSED_ALIAS)
146
+ requested.update(UNUSED_CATEGORIES)
132
147
  return requested
133
148
 
134
149
 
@@ -145,7 +160,7 @@ def _scan_and_store(task: tuple[Path, SessionStore, str, set[str]]) -> None:
145
160
  result = scan_file(file_path)
146
161
  findings = [f for f in result.findings if _category_enabled(f.category, categories)]
147
162
  store.insert_findings(session_id, findings)
148
- if "unused" in categories:
163
+ if CATEGORY_UNUSED_SYMBOL in categories:
149
164
  store.insert_definitions(session_id, result.definitions)
150
165
  store.insert_identifiers(session_id, result.usages)
151
166
 
@@ -156,15 +171,7 @@ def _category_enabled(category: str, categories: set[str]) -> bool:
156
171
  Author: Ron Webb
157
172
  Since: 1.0.0
158
173
  """
159
- if category == "unreachable":
160
- return "unreachable" in categories
161
- if category in ("unused-import", "unused-local", "unused-symbol"):
162
- return "unused" in categories
163
- if category == "obsolete":
164
- return "obsolete" in categories
165
- if category == "commented-out":
166
- return "commented-out" in categories
167
- return False
174
+ return category in categories
168
175
 
169
176
 
170
177
  def _load_ignore_file(ignore_path: Path, base_dir: Path) -> IgnoreFile | None:
@@ -207,8 +214,61 @@ def _build_ignore(base_dir: Path) -> IgnoreFile | None:
207
214
  return _load_ignore_file(Path(PATH_IGNORE_PATH), base_dir)
208
215
 
209
216
 
217
+ class _CombinedIgnore:
218
+ """Path matcher that ignores a path when any wrapped matcher does.
219
+
220
+ Author: Ron Webb
221
+ Since: 1.1.0
222
+ """
223
+
224
+ def __init__(self, matchers: list[IgnoreFile]) -> None:
225
+ """Store the matchers to consult.
226
+
227
+ Author: Ron Webb
228
+ Since: 1.1.0
229
+ """
230
+ self._matchers = matchers
231
+
232
+ def is_ignored(self, path: Path) -> bool:
233
+ """Return whether any wrapped matcher ignores *path*.
234
+
235
+ Author: Ron Webb
236
+ Since: 1.1.0
237
+ """
238
+ return any(matcher.is_ignored(path) for matcher in self._matchers)
239
+
240
+
241
+ def _build_target_ignore(target: Path) -> IgnoreFile | None:
242
+ """Build a matcher from the ``.codepruneignore`` located at *target*.
243
+
244
+ The file is looked up in *target* itself (or its parent for a file target)
245
+ under a fixed name, so it is not affected by ``config.ini``. Returns
246
+ ``None`` when there is no such file.
247
+
248
+ Author: Ron Webb
249
+ Since: 1.1.0
250
+ """
251
+ base_dir = target if target.is_dir() else target.parent
252
+ ignore_path = base_dir / _TARGET_IGNORE_FILENAME
253
+ if not ignore_path.is_file():
254
+ return None
255
+ return _load_ignore_file(ignore_path, base_dir)
256
+
257
+
258
+ def _build_combined_ignore(target: Path) -> _CombinedIgnore | None:
259
+ """Combine the configured ignore file with the target's ``.codepruneignore``.
260
+
261
+ The target-level file extends, rather than replaces, the configured one.
262
+
263
+ Author: Ron Webb
264
+ Since: 1.1.0
265
+ """
266
+ matchers = [m for m in (_build_ignore(target), _build_target_ignore(target)) if m]
267
+ return _CombinedIgnore(matchers) if matchers else None
268
+
269
+
210
270
  def _walk_unignored(
211
- root: Path, ignore: IgnoreFile | None
271
+ root: Path, ignore: IgnoreFile | _CombinedIgnore | None
212
272
  ) -> Generator[Path, None, None]:
213
273
  """Recursively yield files under *root*, pruning directories matched by *ignore*.
214
274
 
@@ -225,14 +285,16 @@ def _walk_unignored(
225
285
 
226
286
 
227
287
  def discover_files(
228
- path: Path, extensions: list[str], ignore: IgnoreFile | None = None
288
+ path: Path,
289
+ extensions: list[str],
290
+ ignore: IgnoreFile | _CombinedIgnore | None = None,
229
291
  ) -> list[Path]:
230
- """Discover files under *path* that match the given extensions.
292
+ """Discover supported source files under *path*.
231
293
 
232
- When *extensions* is empty every file is included.
233
- Both files and directories are accepted; for a plain file the extension
234
- filter still applies. When *ignore* is given, matching files are skipped
235
- and matching directories are pruned from the walk entirely.
294
+ Only files with an extension in :data:`SUPPORTED_EXTENSIONS` are returned;
295
+ when *extensions* is given it narrows that set further. Both files and
296
+ directories are accepted. When *ignore* is given, matching files are
297
+ skipped and matching directories are pruned from the walk entirely.
236
298
 
237
299
  Author: Ron Webb
238
300
  Since: 1.0.0
@@ -245,9 +307,12 @@ def discover_files(
245
307
  else:
246
308
  return []
247
309
 
248
- if not extensions:
249
- return candidates
250
- return [f for f in candidates if f.suffix.lower() in extensions]
310
+ allowed = (
311
+ SUPPORTED_EXTENSIONS.intersection(extensions)
312
+ if extensions
313
+ else SUPPORTED_EXTENSIONS
314
+ )
315
+ return [f for f in candidates if f.suffix.lower() in allowed]
251
316
 
252
317
 
253
318
  def _run_concurrent_scan(
@@ -297,7 +362,7 @@ def _run_concurrent_scan(
297
362
  default="",
298
363
  help=(
299
364
  "Comma-separated file extensions to include "
300
- "(e.g. py,java,js,ts,gs,gsx). Omit to scan all files."
365
+ "(e.g. py,java,js,ts,gs,gsx). Omit to scan every supported file."
301
366
  ),
302
367
  )
303
368
  @click.option(
@@ -352,8 +417,8 @@ def _run_concurrent_scan(
352
417
  help=(
353
418
  "Comma-separated categories to report: "
354
419
  f"{', '.join(CLI_CATEGORIES)} (default: all). "
355
- "'unused' covers unused imports, local variables, and "
356
- "cross-file-unused functions/classes/methods."
420
+ "'unused' is shorthand for unused-import, unused-local and "
421
+ "unused-symbol (cross-file unused functions/classes/methods)."
357
422
  ),
358
423
  )
359
424
  @click.option(
@@ -387,6 +452,11 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
387
452
  _logger.info(_header)
388
453
  _console.print(f"[bold]{_header}[/bold]")
389
454
  extensions = _parse_extensions(ext) if ext else []
455
+ unsupported = sorted(set(extensions) - SUPPORTED_EXTENSIONS)
456
+ if unsupported:
457
+ _console.print(
458
+ f"[yellow]Ignoring unsupported extension(s): {', '.join(unsupported)}[/yellow]"
459
+ )
390
460
  selected_categories = _parse_categories(categories)
391
461
  paths = _parse_paths(path)
392
462
  if target_list:
@@ -438,7 +508,7 @@ def _discover_all_files(paths: list[Path], extensions: list[str]) -> list[Path]:
438
508
  files: list[Path] = []
439
509
  with _console.status("[bold cyan]Discovering files\u2026", spinner="dots"):
440
510
  for target_path in paths:
441
- ignore = _build_ignore(target_path)
511
+ ignore = _build_combined_ignore(target_path)
442
512
  for found_file in discover_files(target_path, extensions, ignore):
443
513
  if found_file not in seen:
444
514
  seen.add(found_file)
@@ -461,7 +531,7 @@ def _scan_project(
461
531
  try:
462
532
  _run_concurrent_scan(files, store, session_id, workers, categories)
463
533
  findings = store.read_findings(session_id)
464
- if "unused" in categories:
534
+ if CATEGORY_UNUSED_SYMBOL in categories:
465
535
  name_ignore_patterns = load_name_ignore_patterns(Path(SYMBOL_IGNORE_PATH))
466
536
  findings.extend(
467
537
  store.compute_unused_symbols(session_id, name_ignore_patterns)
@@ -59,7 +59,7 @@ _CSS = """
59
59
  th.sortable.desc .sort-icon::after { content: '\\25bc'; }
60
60
  th.sortable:not(.asc):not(.desc) .sort-icon::after { content: '\\21c5'; opacity: 0.6; }
61
61
  .filter-row th { background: #1a3a5c; padding: 0.3em 1em 0.5em; }
62
- .filter-row input {
62
+ .filter-row input, .filter-row select {
63
63
  width: 100%;
64
64
  padding: 0.3em 0.5em;
65
65
  border: 1px solid #3d6b9e;
@@ -70,7 +70,7 @@ _CSS = """
70
70
  outline: none;
71
71
  }
72
72
  .filter-row input::placeholder { color: #aac4e8; }
73
- .filter-row input:focus { border-color: #7eb3e8; background: #255085; }
73
+ .filter-row input:focus, .filter-row select:focus { border-color: #7eb3e8; background: #255085; }
74
74
  td {
75
75
  padding: 0.6em 1em;
76
76
  border-bottom: 1px solid #e8ecf0;
@@ -93,6 +93,23 @@ _CSS = """
93
93
  """
94
94
 
95
95
 
96
+ def _category_counts(
97
+ findings: list[dict[str, object]], categories: list[str]
98
+ ) -> dict[str, int]:
99
+ """Return the number of findings per category, sorted by category name.
100
+
101
+ Every selected category is present, with ``0`` when it has no findings.
102
+
103
+ Author: Ron Webb
104
+ Since: 1.1.0
105
+ """
106
+ counts = dict.fromkeys(categories, 0)
107
+ for finding in findings:
108
+ category = str(finding["category"])
109
+ counts[category] = counts.get(category, 0) + 1
110
+ return dict(sorted(counts.items()))
111
+
112
+
96
113
  def _write_json(
97
114
  findings: list[dict[str, object]],
98
115
  path: Path,
@@ -111,12 +128,31 @@ def _write_json(
111
128
  "run-date": run_date,
112
129
  "paths-scanned": paths_scanned,
113
130
  "categories": categories,
131
+ "category-counts": _category_counts(findings, categories),
114
132
  "findings": findings,
115
133
  }
116
134
  path.write_text(json.dumps(report, indent=2), encoding="utf-8")
117
135
 
118
136
 
119
- def _build_thead_html() -> str:
137
+ def _build_category_select(counts: dict[str, int]) -> str:
138
+ """Return the category ``<select>`` listing categories that have findings.
139
+
140
+ Author: Ron Webb
141
+ Since: 1.1.0
142
+ """
143
+ options = ['<option value="">All categories</option>']
144
+ for category, count in counts.items():
145
+ if count:
146
+ escaped = _html.escape(category, quote=True)
147
+ options.append(f'<option value="{escaped}">{escaped} ({count})</option>')
148
+ return (
149
+ '<select id="filter-category" onchange="codePruneFilter()">'
150
+ + "".join(options)
151
+ + "</select>"
152
+ )
153
+
154
+
155
+ def _build_thead_html(counts: dict[str, int]) -> str:
120
156
  """Return the HTML ``<thead>`` element with sortable headers and a filter row.
121
157
 
122
158
  Author: Ron Webb
@@ -133,8 +169,7 @@ def _build_thead_html() -> str:
133
169
  "</tr>\n"
134
170
  ' <tr class="filter-row">'
135
171
  "<th></th>"
136
- '<th><input type="text" id="filter-category" placeholder="Filter category\u2026"'
137
- ' oninput="codePruneFilter()"></th>'
172
+ f"<th>{_build_category_select(counts)}</th>"
138
173
  '<th><input type="text" id="filter-location" placeholder="Filter location\u2026"'
139
174
  ' oninput="codePruneFilter()"></th>'
140
175
  '<th><input type="text" id="filter-name" placeholder="Filter name\u2026"'
@@ -145,6 +180,15 @@ def _build_thead_html() -> str:
145
180
  )
146
181
 
147
182
 
183
+ def _build_summary(total: int) -> str:
184
+ """Return the summary line with the total number of findings.
185
+
186
+ Author: Ron Webb
187
+ Since: 1.1.0
188
+ """
189
+ return f"Found {total} finding(s)"
190
+
191
+
148
192
  def _build_html_scaffold( # pylint: disable=too-many-arguments,too-many-positional-arguments
149
193
  run_date: str,
150
194
  total: int,
@@ -152,13 +196,14 @@ def _build_html_scaffold( # pylint: disable=too-many-arguments,too-many-positio
152
196
  script: str,
153
197
  paths_scanned: list[str],
154
198
  categories: list[str],
199
+ counts: dict[str, int],
155
200
  ) -> str:
156
201
  """Wrap the table body and script into a complete HTML document.
157
202
 
158
203
  Author: Ron Webb
159
204
  Since: 1.0.0
160
205
  """
161
- thead = _build_thead_html()
206
+ thead = _build_thead_html(counts)
162
207
  paths_html = "<br>".join(_html.escape(p, quote=False) for p in paths_scanned)
163
208
  categories_html = (
164
209
  ", ".join(_html.escape(c, quote=False) for c in categories) or "(none)"
@@ -181,7 +226,7 @@ def _build_html_scaffold( # pylint: disable=too-many-arguments,too-many-positio
181
226
  f" <p>Paths scanned: {paths_html}</p>\n"
182
227
  f" <p>Categories: {categories_html}</p>\n"
183
228
  " </header>\n"
184
- f' <p class="summary">Found {total} finding(s)</p>\n'
229
+ f' <p class="summary">{_build_summary(total)}</p>\n'
185
230
  ' <div class="table-wrap">\n'
186
231
  " <table>\n"
187
232
  f"{thead}"
@@ -236,12 +281,12 @@ _SCRIPT = (
236
281
  " }\n"
237
282
  "\n"
238
283
  " function applyFilter() {\n"
239
- " var catFilter = document.getElementById('filter-category').value.toLowerCase();\n"
284
+ " var catFilter = document.getElementById('filter-category').value;\n"
240
285
  " var locFilter = document.getElementById('filter-location').value.toLowerCase();\n"
241
286
  " var nameFilter = document.getElementById('filter-name').value.toLowerCase();\n"
242
287
  " var tbody = document.querySelector('tbody');\n"
243
288
  " tbody.querySelectorAll('tr').forEach(function (row) {\n"
244
- " var catMatch = !catFilter || row.dataset.category.toLowerCase().indexOf(catFilter) !== -1;\n"
289
+ " var catMatch = !catFilter || row.dataset.category === catFilter;\n"
245
290
  " var locMatch = !locFilter || row.dataset.location.toLowerCase().indexOf(locFilter) !== -1;\n"
246
291
  " var nameMatch = !nameFilter || row.dataset.name.toLowerCase().indexOf(nameFilter) !== -1;\n"
247
292
  " row.style.display = (catMatch && locMatch && nameMatch) ? '' : 'none';\n"
@@ -309,7 +354,13 @@ def _build_html(
309
354
  )
310
355
  rows_html = "\n".join(rows)
311
356
  return _build_html_scaffold(
312
- run_date, len(findings), rows_html, _SCRIPT, paths_scanned, categories
357
+ run_date,
358
+ len(findings),
359
+ rows_html,
360
+ _SCRIPT,
361
+ paths_scanned,
362
+ categories,
363
+ _category_counts(findings, categories),
313
364
  )
314
365
 
315
366
 
@@ -33,12 +33,38 @@ CATEGORY_UNUSED_IMPORT = "unused-import"
33
33
  CATEGORY_UNUSED_LOCAL = "unused-local"
34
34
  CATEGORY_UNUSED_SYMBOL = "unused-symbol"
35
35
  CATEGORY_OBSOLETE = "obsolete"
36
+ CATEGORY_TODO_MARKER = "todo-marker"
36
37
  CATEGORY_COMMENTED_OUT = "commented-out"
37
38
 
38
- #: Categories a user may toggle via ``--categories``. ``unused`` expands to
39
- #: both :data:`CATEGORY_UNUSED_IMPORT`/:data:`CATEGORY_UNUSED_LOCAL` (per-file)
40
- #: and :data:`CATEGORY_UNUSED_SYMBOL` (cross-file).
41
- CLI_CATEGORIES = ("unreachable", "unused", "obsolete", "commented-out")
39
+ #: Blanket ``--categories`` value that expands to every ``unused-*`` category.
40
+ CATEGORY_UNUSED_ALIAS = "unused"
41
+
42
+ #: Concrete ``unused-*`` finding categories covered by :data:`CATEGORY_UNUSED_ALIAS`.
43
+ UNUSED_CATEGORIES = (
44
+ CATEGORY_UNUSED_IMPORT,
45
+ CATEGORY_UNUSED_LOCAL,
46
+ CATEGORY_UNUSED_SYMBOL,
47
+ )
48
+
49
+ #: Concrete finding categories (what appears in reports).
50
+ FINDING_CATEGORIES = (
51
+ CATEGORY_UNREACHABLE,
52
+ *UNUSED_CATEGORIES,
53
+ CATEGORY_OBSOLETE,
54
+ CATEGORY_TODO_MARKER,
55
+ CATEGORY_COMMENTED_OUT,
56
+ )
57
+
58
+ #: Categories a user may toggle via ``--categories``. ``unused`` is an alias
59
+ #: expanding to :data:`UNUSED_CATEGORIES`.
60
+ CLI_CATEGORIES = (
61
+ CATEGORY_UNREACHABLE,
62
+ CATEGORY_UNUSED_ALIAS,
63
+ *UNUSED_CATEGORIES,
64
+ CATEGORY_OBSOLETE,
65
+ CATEGORY_TODO_MARKER,
66
+ CATEGORY_COMMENTED_OUT,
67
+ )
42
68
 
43
69
  # --------------------------------------------------------------------------
44
70
  # Per-language grammar node-type tables (verified empirically against each
@@ -58,6 +84,9 @@ EXTENSION_TO_LANGUAGE: dict[str, str] = {
58
84
  ".gsx": "Gosu",
59
85
  }
60
86
 
87
+ #: File extensions the scanner can parse.
88
+ SUPPORTED_EXTENSIONS = frozenset(EXTENSION_TO_LANGUAGE)
89
+
61
90
  #: Function/method definition node types, per language.
62
91
  FUNCTION_NODE_TYPES: dict[str, frozenset[str]] = {
63
92
  "Python": frozenset({"function_definition"}),
@@ -178,8 +207,11 @@ USAGE_NODE_TYPES: dict[str, frozenset[str]] = {
178
207
  #: optionally stripping surrounding parentheses added by some grammars).
179
208
  _FALSY_CONDITION_RE = re.compile(r"^\(*\s*(False|false|0)\s*\)*$")
180
209
 
181
- #: Regex matching obsolete markers inside comment text.
182
- _OBSOLETE_COMMENT_RE = re.compile(r"\b(TODO|FIXME|XXX|deprecated)\b", re.IGNORECASE)
210
+ #: Regex matching TODO-style markers inside comment text.
211
+ _TODO_COMMENT_RE = re.compile(r"\b(TODO|FIXME|XXX)\b", re.IGNORECASE)
212
+
213
+ #: Regex matching deprecation markers inside comment text.
214
+ _DEPRECATED_COMMENT_RE = re.compile(r"\b(deprecated)\b", re.IGNORECASE)
183
215
 
184
216
  #: Regex fragments that suggest a comment line contains commented-out code.
185
217
  _CODE_LIKE_RE = re.compile(
@@ -296,7 +328,12 @@ def detect_unreachable(
296
328
  for node in _iter_nodes(tree.root_node):
297
329
  if node.type in block_types:
298
330
  findings.extend(
299
- _unreachable_after_terminator(node, terminating_types, file_path)
331
+ _unreachable_after_terminator(
332
+ node,
333
+ terminating_types,
334
+ COMMENT_NODE_TYPES.get(language, frozenset()),
335
+ file_path,
336
+ )
300
337
  )
301
338
  elif node.type == "if_statement":
302
339
  findings.extend(_unreachable_dead_branch(node, source_bytes, file_path))
@@ -305,15 +342,20 @@ def detect_unreachable(
305
342
 
306
343
 
307
344
  def _unreachable_after_terminator(
308
- block: Node, terminating_types: frozenset[str], file_path: Path
345
+ block: Node,
346
+ terminating_types: frozenset[str],
347
+ comment_types: frozenset[str],
348
+ file_path: Path,
309
349
  ) -> list[Finding]:
310
350
  """Flag statements following a terminator within *block*.
311
351
 
352
+ Comment nodes are not statements and are never flagged.
353
+
312
354
  Author: Ron Webb
313
355
  Since: 1.0.0
314
356
  """
315
357
  findings: list[Finding] = []
316
- statements = block.named_children
358
+ statements = [n for n in block.named_children if n.type not in comment_types]
317
359
  terminator_index: int | None = None
318
360
  for index, statement in enumerate(statements):
319
361
  if terminator_index is not None:
@@ -600,10 +642,34 @@ def _unused_binding_findings(
600
642
  # --------------------------------------------------------------------------
601
643
 
602
644
 
645
+ def _marker_finding(
646
+ category: str, node: Node, file_path: Path, name: str, message: str
647
+ ) -> Finding:
648
+ """Build a :class:`Finding` located at the start of *node*.
649
+
650
+ Author: Ron Webb
651
+ Since: 1.1.0
652
+ """
653
+ row, col = node.start_point
654
+ return Finding(
655
+ category=category,
656
+ file_path=file_path,
657
+ line=row + 1,
658
+ column=col,
659
+ name=name,
660
+ message=message,
661
+ )
662
+
663
+
603
664
  def detect_obsolete_markers(
604
665
  tree: Tree, source_bytes: bytes, language: str, file_path: Path
605
666
  ) -> list[Finding]:
606
- """Detect deprecation decorators/annotations and TODO/FIXME-style comments.
667
+ """Detect obsolete code markers.
668
+
669
+ Deprecation decorators/annotations and ``deprecated`` comments are reported
670
+ as :data:`CATEGORY_OBSOLETE`; ``TODO``/``FIXME``/``XXX`` comments, which are
671
+ not necessarily obsolete, are reported separately as
672
+ :data:`CATEGORY_TODO_MARKER`.
607
673
 
608
674
  Author: Ron Webb
609
675
  Since: 1.0.0
@@ -614,32 +680,38 @@ def detect_obsolete_markers(
614
680
 
615
681
  for node in _iter_nodes(tree.root_node):
616
682
  if decorator_types and node.type in decorator_types:
617
- text = _decode(source_bytes, node)
618
- if re.search(r"deprecated", text, re.IGNORECASE):
619
- row, col = node.start_point
683
+ if re.search(r"deprecated", _decode(source_bytes, node), re.IGNORECASE):
620
684
  findings.append(
621
- Finding(
622
- category=CATEGORY_OBSOLETE,
623
- file_path=file_path,
624
- line=row + 1,
625
- column=col,
626
- name=node.type,
627
- message="Deprecated marker found.",
685
+ _marker_finding(
686
+ CATEGORY_OBSOLETE,
687
+ node,
688
+ file_path,
689
+ node.type,
690
+ "Deprecated marker found.",
628
691
  )
629
692
  )
630
693
  elif node.type in comment_types:
631
694
  text = _decode(source_bytes, node)
632
- match = _OBSOLETE_COMMENT_RE.search(text)
633
- if match:
634
- row, col = node.start_point
695
+ todo = _TODO_COMMENT_RE.search(text)
696
+ if todo:
635
697
  findings.append(
636
- Finding(
637
- category=CATEGORY_OBSOLETE,
638
- file_path=file_path,
639
- line=row + 1,
640
- column=col,
641
- name=match.group(1).upper(),
642
- message=f"Obsolete marker '{match.group(1)}' found in comment.",
698
+ _marker_finding(
699
+ CATEGORY_TODO_MARKER,
700
+ node,
701
+ file_path,
702
+ todo.group(1).upper(),
703
+ f"Marker '{todo.group(1)}' found in comment.",
704
+ )
705
+ )
706
+ deprecated = _DEPRECATED_COMMENT_RE.search(text)
707
+ if deprecated:
708
+ findings.append(
709
+ _marker_finding(
710
+ CATEGORY_OBSOLETE,
711
+ node,
712
+ file_path,
713
+ deprecated.group(1).upper(),
714
+ f"Obsolete marker '{deprecated.group(1)}' found in comment.",
643
715
  )
644
716
  )
645
717
  return findings
@@ -681,6 +753,16 @@ def _looks_like_code(text: str) -> bool:
681
753
  return stripped.endswith(("{", "}")) and len(stripped) > 1
682
754
 
683
755
 
756
+ def _is_doc_comment(text: str) -> bool:
757
+ """Return ``True`` for ``/** ... */`` documentation comments (Javadoc/JSDoc).
758
+
759
+ Author: Ron Webb
760
+ Since: 1.1.0
761
+ """
762
+ stripped = text.lstrip()
763
+ return stripped.startswith("/**") and not stripped.startswith("/**/")
764
+
765
+
684
766
  def detect_commented_out_code(
685
767
  tree: Tree, source_bytes: bytes, language: str, file_path: Path
686
768
  ) -> list[Finding]:
@@ -696,7 +778,11 @@ def detect_commented_out_code(
696
778
  comment_types = COMMENT_NODE_TYPES.get(language, frozenset())
697
779
  if not comment_types:
698
780
  return []
699
- comments = [n for n in _iter_nodes(tree.root_node) if n.type in comment_types]
781
+ comments = [
782
+ n
783
+ for n in _iter_nodes(tree.root_node)
784
+ if n.type in comment_types and not _is_doc_comment(_decode(source_bytes, n))
785
+ ]
700
786
  comments.sort(key=lambda n: n.start_point)
701
787
 
702
788
  findings: list[Finding] = []
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "code-prune"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "Find unreachable, unused, and obsolete code before it becomes technical debt."
5
5
  authors = [
6
6
  {name = "Ron Webb"}
@@ -10,7 +10,7 @@ license = {file = "LICENSE"}
10
10
  requires-python = ">=3.14"
11
11
  keywords = ["cli", "static-analysis", "dead-code", "unused-code", "tree-sitter", "code-quality"]
12
12
  classifiers = [
13
- "Development Status :: 4 - Beta",
13
+ "Development Status :: 5 - Production/Stable",
14
14
  "Environment :: Console",
15
15
  "Intended Audience :: Developers",
16
16
  "Operating System :: OS Independent",
File without changes