code-prune 1.1.0__tar.gz → 1.2.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.
- {code_prune-1.1.0 → code_prune-1.2.0}/CHANGELOG.md +13 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/PKG-INFO +5 -2
- {code_prune-1.1.0 → code_prune-1.2.0}/README.md +4 -1
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/__init__.py +1 -1
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/cli.py +73 -13
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/scanner.py +21 -5
- code_prune-1.2.0/code_prune/searcher.py +145 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/store.py +57 -14
- {code_prune-1.1.0 → code_prune-1.2.0}/pyproject.toml +1 -1
- {code_prune-1.1.0 → code_prune-1.2.0}/LICENSE +0 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/.codepruneignore +0 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/config.ini +0 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/config.py +0 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/logging.ini +0 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/parser.py +0 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/reporter.py +0 -0
- {code_prune-1.1.0 → code_prune-1.2.0}/code_prune/symbol_ignore +0 -0
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0 - 2026-11-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `--search-path`: files and/or directories (separated by `;`) searched
|
|
8
|
+
textually for each `unused-symbol` candidate. Symbols found there are no
|
|
9
|
+
longer reported. Classes and functions/methods are both matched with a
|
|
10
|
+
whole-word regex; a method additionally requires its enclosing class's name
|
|
11
|
+
to be found in the same file. The symbol's own file is ignored and
|
|
12
|
+
`.codepruneignore` rules apply.
|
|
13
|
+
- `--search-ext`: extensions of the files searched in `--search-path`
|
|
14
|
+
(default: every text file). Requires `--search-path`.
|
|
15
|
+
|
|
3
16
|
## 1.1.0 - 2026-11-05
|
|
4
17
|
|
|
5
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-prune
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Find unreachable, unused, and obsolete code before it becomes technical debt.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -56,7 +56,7 @@ Description-Content-Type: text/markdown
|
|
|
56
56
|
|
|
57
57
|
# code-prune
|
|
58
58
|
|
|
59
|
-
[](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [](https://github.com/rcw3bb/code-prune/blob/main/CHANGELOG.md) [](https://www.python.org/) [](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,
|
|
@@ -120,11 +120,14 @@ Common options:
|
|
|
120
120
|
| `--workers` | Number of parallel worker threads used to scan files. |
|
|
121
121
|
| `--db` | Path to the SQLite scratch database used during a scan run. |
|
|
122
122
|
| `--target-list` | Treat `path` as a file listing target paths, one per line. |
|
|
123
|
+
| `--search-path` | Files and/or directories (separated by `;`) searched textually for each `unused-symbol` candidate; a symbol found there is not reported. Classes and functions/methods are both matched by a whole-word regex; a method additionally requires its enclosing class's name to appear in the same file. The symbol's own file is skipped and `.codepruneignore` rules apply. |
|
|
124
|
+
| `--search-ext` | Comma-separated extensions of the files searched in `--search-path` (e.g. `xml,json`). Omit to search every text file. Requires `--search-path`. |
|
|
123
125
|
|
|
124
126
|
Example:
|
|
125
127
|
|
|
126
128
|
```powershell
|
|
127
129
|
code-prune src --categories unreachable,unused-import,todo-marker --format all
|
|
130
|
+
code-prune src --categories unused-symbol --search-path "templates;../other-repo" --search-ext xml,json,py
|
|
128
131
|
```
|
|
129
132
|
|
|
130
133
|
### Reports
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# code-prune
|
|
2
2
|
|
|
3
|
-
[](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [](https://github.com/rcw3bb/code-prune/blob/main/LICENSE) [](https://github.com/rcw3bb/code-prune/blob/main/CHANGELOG.md) [](https://www.python.org/) [](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,
|
|
@@ -64,11 +64,14 @@ Common options:
|
|
|
64
64
|
| `--workers` | Number of parallel worker threads used to scan files. |
|
|
65
65
|
| `--db` | Path to the SQLite scratch database used during a scan run. |
|
|
66
66
|
| `--target-list` | Treat `path` as a file listing target paths, one per line. |
|
|
67
|
+
| `--search-path` | Files and/or directories (separated by `;`) searched textually for each `unused-symbol` candidate; a symbol found there is not reported. Classes and functions/methods are both matched by a whole-word regex; a method additionally requires its enclosing class's name to appear in the same file. The symbol's own file is skipped and `.codepruneignore` rules apply. |
|
|
68
|
+
| `--search-ext` | Comma-separated extensions of the files searched in `--search-path` (e.g. `xml,json`). Omit to search every text file. Requires `--search-path`. |
|
|
67
69
|
|
|
68
70
|
Example:
|
|
69
71
|
|
|
70
72
|
```powershell
|
|
71
73
|
code-prune src --categories unreachable,unused-import,todo-marker --format all
|
|
74
|
+
code-prune src --categories unused-symbol --search-path "templates;../other-repo" --search-ext xml,json,py
|
|
72
75
|
```
|
|
73
76
|
|
|
74
77
|
### Reports
|
|
@@ -44,6 +44,7 @@ from .scanner import (
|
|
|
44
44
|
load_name_ignore_patterns,
|
|
45
45
|
scan_file,
|
|
46
46
|
)
|
|
47
|
+
from .searcher import SymbolSearcher
|
|
47
48
|
from .store import SessionStore
|
|
48
49
|
|
|
49
50
|
_VALID_FORMATS = ("json", "html", "all")
|
|
@@ -288,11 +289,14 @@ def discover_files(
|
|
|
288
289
|
path: Path,
|
|
289
290
|
extensions: list[str],
|
|
290
291
|
ignore: IgnoreFile | _CombinedIgnore | None = None,
|
|
292
|
+
restrict_to_supported: bool = True,
|
|
291
293
|
) -> list[Path]:
|
|
292
|
-
"""Discover
|
|
294
|
+
"""Discover source files under *path*.
|
|
293
295
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
+
By default only files with an extension in :data:`SUPPORTED_EXTENSIONS`
|
|
297
|
+
are returned and *extensions* narrows that set further. With
|
|
298
|
+
``restrict_to_supported=False`` (used for search paths) *extensions* is
|
|
299
|
+
taken as-is and an empty list accepts every file. Both files and
|
|
296
300
|
directories are accepted. When *ignore* is given, matching files are
|
|
297
301
|
skipped and matching directories are pruned from the walk entirely.
|
|
298
302
|
|
|
@@ -307,11 +311,16 @@ def discover_files(
|
|
|
307
311
|
else:
|
|
308
312
|
return []
|
|
309
313
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
314
|
+
if restrict_to_supported:
|
|
315
|
+
allowed = (
|
|
316
|
+
SUPPORTED_EXTENSIONS.intersection(extensions)
|
|
317
|
+
if extensions
|
|
318
|
+
else SUPPORTED_EXTENSIONS
|
|
319
|
+
)
|
|
320
|
+
elif not extensions:
|
|
321
|
+
return candidates
|
|
322
|
+
else:
|
|
323
|
+
allowed = frozenset(extensions)
|
|
315
324
|
return [f for f in candidates if f.suffix.lower() in allowed]
|
|
316
325
|
|
|
317
326
|
|
|
@@ -432,6 +441,25 @@ def _run_concurrent_scan(
|
|
|
432
441
|
"path list. Blank lines and lines starting with '#' are skipped."
|
|
433
442
|
),
|
|
434
443
|
)
|
|
444
|
+
@click.option(
|
|
445
|
+
"--search-path",
|
|
446
|
+
"search_path",
|
|
447
|
+
default="",
|
|
448
|
+
help=(
|
|
449
|
+
"Semicolon-separated files and/or directories searched textually for "
|
|
450
|
+
"each unused-symbol candidate. A symbol found there is not reported. "
|
|
451
|
+
"Classes are matched by name; functions/methods by a whole-word regex."
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
@click.option(
|
|
455
|
+
"--search-ext",
|
|
456
|
+
"search_ext",
|
|
457
|
+
default="",
|
|
458
|
+
help=(
|
|
459
|
+
"Comma-separated extensions of the files to search in --search-path "
|
|
460
|
+
"(e.g. xml,json,py). Omit to search every text file."
|
|
461
|
+
),
|
|
462
|
+
)
|
|
435
463
|
def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
|
436
464
|
path: str,
|
|
437
465
|
ext: str,
|
|
@@ -442,6 +470,8 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
|
|
|
442
470
|
db_path: Path,
|
|
443
471
|
categories: str,
|
|
444
472
|
target_list: bool,
|
|
473
|
+
search_path: str,
|
|
474
|
+
search_ext: str,
|
|
445
475
|
) -> None:
|
|
446
476
|
"""Scan source files for unreachable, unused, and obsolete code.
|
|
447
477
|
|
|
@@ -458,6 +488,10 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
|
|
|
458
488
|
f"[yellow]Ignoring unsupported extension(s): {', '.join(unsupported)}[/yellow]"
|
|
459
489
|
)
|
|
460
490
|
selected_categories = _parse_categories(categories)
|
|
491
|
+
search_paths = _parse_paths(search_path)
|
|
492
|
+
search_extensions = _parse_extensions(search_ext)
|
|
493
|
+
if search_extensions and not search_paths:
|
|
494
|
+
raise click.UsageError("--search-ext requires --search-path.")
|
|
461
495
|
paths = _parse_paths(path)
|
|
462
496
|
if target_list:
|
|
463
497
|
if len(paths) != 1 or not paths[0].is_file():
|
|
@@ -474,7 +508,10 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
|
|
|
474
508
|
|
|
475
509
|
_console.print(f"[bold]Scanning[/bold] {len(files)} file(s)\u2026")
|
|
476
510
|
|
|
477
|
-
|
|
511
|
+
searcher = _build_searcher(search_paths, search_extensions)
|
|
512
|
+
findings, interrupted = _scan_project(
|
|
513
|
+
files, db_path, workers, selected_categories, searcher
|
|
514
|
+
)
|
|
478
515
|
|
|
479
516
|
if interrupted:
|
|
480
517
|
raise SystemExit(1)
|
|
@@ -498,7 +535,9 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
|
|
|
498
535
|
)
|
|
499
536
|
|
|
500
537
|
|
|
501
|
-
def _discover_all_files(
|
|
538
|
+
def _discover_all_files(
|
|
539
|
+
paths: list[Path], extensions: list[str], restrict_to_supported: bool = True
|
|
540
|
+
) -> list[Path]:
|
|
502
541
|
"""Discover the de-duplicated set of files to scan across *paths*.
|
|
503
542
|
|
|
504
543
|
Author: Ron Webb
|
|
@@ -509,15 +548,36 @@ def _discover_all_files(paths: list[Path], extensions: list[str]) -> list[Path]:
|
|
|
509
548
|
with _console.status("[bold cyan]Discovering files\u2026", spinner="dots"):
|
|
510
549
|
for target_path in paths:
|
|
511
550
|
ignore = _build_combined_ignore(target_path)
|
|
512
|
-
for found_file in discover_files(
|
|
551
|
+
for found_file in discover_files(
|
|
552
|
+
target_path, extensions, ignore, restrict_to_supported
|
|
553
|
+
):
|
|
513
554
|
if found_file not in seen:
|
|
514
555
|
seen.add(found_file)
|
|
515
556
|
files.append(found_file)
|
|
516
557
|
return files
|
|
517
558
|
|
|
518
559
|
|
|
560
|
+
def _build_searcher(
|
|
561
|
+
search_paths: list[Path], search_extensions: list[str]
|
|
562
|
+
) -> SymbolSearcher | None:
|
|
563
|
+
"""Build a :class:`SymbolSearcher` over *search_paths*, or ``None`` when unset.
|
|
564
|
+
|
|
565
|
+
Author: Ron Webb
|
|
566
|
+
Since: 1.2.0
|
|
567
|
+
"""
|
|
568
|
+
if not search_paths:
|
|
569
|
+
return None
|
|
570
|
+
search_files = _discover_all_files(search_paths, search_extensions, False)
|
|
571
|
+
_logger.info("Searching %s file(s) for unused-symbol references", len(search_files))
|
|
572
|
+
return SymbolSearcher(search_files)
|
|
573
|
+
|
|
574
|
+
|
|
519
575
|
def _scan_project(
|
|
520
|
-
files: list[Path],
|
|
576
|
+
files: list[Path],
|
|
577
|
+
db_path: Path,
|
|
578
|
+
workers: int,
|
|
579
|
+
categories: set[str],
|
|
580
|
+
searcher: SymbolSearcher | None = None,
|
|
521
581
|
) -> tuple[list[dict[str, object]], bool]:
|
|
522
582
|
"""Run the full scan pipeline and return ``(findings, interrupted)``.
|
|
523
583
|
|
|
@@ -534,7 +594,7 @@ def _scan_project(
|
|
|
534
594
|
if CATEGORY_UNUSED_SYMBOL in categories:
|
|
535
595
|
name_ignore_patterns = load_name_ignore_patterns(Path(SYMBOL_IGNORE_PATH))
|
|
536
596
|
findings.extend(
|
|
537
|
-
store.compute_unused_symbols(session_id, name_ignore_patterns)
|
|
597
|
+
store.compute_unused_symbols(session_id, name_ignore_patterns, searcher)
|
|
538
598
|
)
|
|
539
599
|
except KeyboardInterrupt:
|
|
540
600
|
interrupted = True
|
|
@@ -32,6 +32,8 @@ CATEGORY_UNREACHABLE = "unreachable"
|
|
|
32
32
|
CATEGORY_UNUSED_IMPORT = "unused-import"
|
|
33
33
|
CATEGORY_UNUSED_LOCAL = "unused-local"
|
|
34
34
|
CATEGORY_UNUSED_SYMBOL = "unused-symbol"
|
|
35
|
+
SYMBOL_KIND_CLASS = "class"
|
|
36
|
+
SYMBOL_KIND_FUNCTION = "function"
|
|
35
37
|
CATEGORY_OBSOLETE = "obsolete"
|
|
36
38
|
CATEGORY_TODO_MARKER = "todo-marker"
|
|
37
39
|
CATEGORY_COMMENTED_OUT = "commented-out"
|
|
@@ -242,6 +244,10 @@ class Finding:
|
|
|
242
244
|
class Definition:
|
|
243
245
|
"""A function/class/method definition discovered in one file.
|
|
244
246
|
|
|
247
|
+
``enclosing_class`` is the name of the class a method is directly
|
|
248
|
+
declared in (``None`` for classes and free functions); it lets search-path
|
|
249
|
+
matching require both the class and method names to appear together.
|
|
250
|
+
|
|
245
251
|
Author: Ron Webb
|
|
246
252
|
Since: 1.0.0
|
|
247
253
|
"""
|
|
@@ -251,6 +257,7 @@ class Definition:
|
|
|
251
257
|
file_path: Path
|
|
252
258
|
line: int
|
|
253
259
|
column: int
|
|
260
|
+
enclosing_class: str | None = None
|
|
254
261
|
|
|
255
262
|
|
|
256
263
|
@dataclass
|
|
@@ -513,7 +520,7 @@ def _collect_bindings(
|
|
|
513
520
|
function_types = FUNCTION_NODE_TYPES.get(language, frozenset())
|
|
514
521
|
class_types = CLASS_NODE_TYPES.get(language, frozenset())
|
|
515
522
|
|
|
516
|
-
def walk(node: Node, inside_function: bool) -> None:
|
|
523
|
+
def walk(node: Node, inside_function: bool, enclosing_class: str | None) -> None:
|
|
517
524
|
if node.type in import_types:
|
|
518
525
|
for name_node in _import_bound_name_nodes(node, language):
|
|
519
526
|
bindings.import_bindings.append(
|
|
@@ -528,25 +535,34 @@ def _collect_bindings(
|
|
|
528
535
|
(name_node, _decode(source_bytes, name_node))
|
|
529
536
|
)
|
|
530
537
|
bindings.excluded_positions.add(name_node.start_byte)
|
|
538
|
+
child_enclosing_class = enclosing_class
|
|
531
539
|
if node.type in function_types or node.type in class_types:
|
|
532
540
|
name_node = node.child_by_field_name("name")
|
|
541
|
+
is_class = node.type in class_types
|
|
542
|
+
kind = SYMBOL_KIND_CLASS if is_class else SYMBOL_KIND_FUNCTION
|
|
533
543
|
if name_node is not None:
|
|
534
|
-
|
|
544
|
+
decoded_name = _decode(source_bytes, name_node)
|
|
535
545
|
bindings.definitions.append(
|
|
536
546
|
Definition(
|
|
537
|
-
name=
|
|
547
|
+
name=decoded_name,
|
|
538
548
|
kind=kind,
|
|
539
549
|
file_path=file_path,
|
|
540
550
|
line=name_node.start_point[0] + 1,
|
|
541
551
|
column=name_node.start_point[1],
|
|
552
|
+
enclosing_class=None if is_class else enclosing_class,
|
|
542
553
|
)
|
|
543
554
|
)
|
|
544
555
|
bindings.excluded_positions.add(name_node.start_byte)
|
|
556
|
+
# A method's body is local scope: nested definitions are not
|
|
557
|
+
# themselves direct members of the enclosing class.
|
|
558
|
+
child_enclosing_class = (
|
|
559
|
+
decoded_name if is_class and name_node is not None else None
|
|
560
|
+
)
|
|
545
561
|
inside_function = inside_function or node.type in function_types
|
|
546
562
|
for child in node.children:
|
|
547
|
-
walk(child, inside_function)
|
|
563
|
+
walk(child, inside_function, child_enclosing_class)
|
|
548
564
|
|
|
549
|
-
walk(tree.root_node, False)
|
|
565
|
+
walk(tree.root_node, False, None)
|
|
550
566
|
return bindings
|
|
551
567
|
|
|
552
568
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Textual search of extra paths for symbols reported as unused.
|
|
2
|
+
|
|
3
|
+
Symbols that are only referenced from files outside the scanned project
|
|
4
|
+
(templates, configuration, other code bases) would otherwise be reported as
|
|
5
|
+
unused. :class:`SymbolSearcher` checks such files purely textually, without
|
|
6
|
+
parsing them. Both class and function/method names are matched with the same
|
|
7
|
+
whole-word regular expression; a method defined inside a class is only
|
|
8
|
+
considered referenced when its own name *and* its enclosing class's name are
|
|
9
|
+
both found in the same search file.
|
|
10
|
+
|
|
11
|
+
Author: Ron Webb
|
|
12
|
+
Since: 1.2.0
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .scanner import SYMBOL_KIND_FUNCTION
|
|
21
|
+
|
|
22
|
+
#: ``(name, kind, defining_file, enclosing_class)``. ``enclosing_class`` is
|
|
23
|
+
#: ``None`` for classes and free functions.
|
|
24
|
+
Candidate = tuple[str, str, str, str | None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_pattern(name: str) -> re.Pattern[str]:
|
|
28
|
+
"""Return a whole-word regex for *name* that also treats ``$`` as a word character.
|
|
29
|
+
|
|
30
|
+
Author: Ron Webb
|
|
31
|
+
Since: 1.2.0
|
|
32
|
+
"""
|
|
33
|
+
return re.compile(rf"(?<![\w$]){re.escape(name)}(?![\w$])")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_text(file_path: Path) -> str | None:
|
|
37
|
+
"""Return the text of *file_path*, or ``None`` for unreadable/binary files.
|
|
38
|
+
|
|
39
|
+
Author: Ron Webb
|
|
40
|
+
Since: 1.2.0
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
data = file_path.read_bytes()
|
|
44
|
+
except OSError:
|
|
45
|
+
return None
|
|
46
|
+
if b"\x00" in data:
|
|
47
|
+
return None
|
|
48
|
+
return data.decode("utf-8", errors="replace")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SymbolSearcher:
|
|
52
|
+
"""Find which symbols are textually referenced in a set of search files.
|
|
53
|
+
|
|
54
|
+
Author: Ron Webb
|
|
55
|
+
Since: 1.2.0
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, search_files: list[Path]) -> None:
|
|
59
|
+
"""Store the files to search.
|
|
60
|
+
|
|
61
|
+
Author: Ron Webb
|
|
62
|
+
Since: 1.2.0
|
|
63
|
+
"""
|
|
64
|
+
self._search_files = search_files
|
|
65
|
+
|
|
66
|
+
def find_referenced(self, candidates: list[Candidate]) -> set[Candidate]:
|
|
67
|
+
"""Return the subset of *candidates* found in the search files.
|
|
68
|
+
|
|
69
|
+
The candidate's own defining file is never counted as a reference.
|
|
70
|
+
Every search file is read once and the search stops as soon as every
|
|
71
|
+
candidate has been resolved.
|
|
72
|
+
|
|
73
|
+
Author: Ron Webb
|
|
74
|
+
Since: 1.2.0
|
|
75
|
+
"""
|
|
76
|
+
pending = set(candidates)
|
|
77
|
+
found: set[Candidate] = set()
|
|
78
|
+
patterns: dict[str, re.Pattern[str]] = {}
|
|
79
|
+
for search_file in self._search_files:
|
|
80
|
+
if not pending:
|
|
81
|
+
break
|
|
82
|
+
text = _read_text(search_file)
|
|
83
|
+
if text is None:
|
|
84
|
+
continue
|
|
85
|
+
resolved = _resolve(search_file)
|
|
86
|
+
hits = self._matches_in_text(text, resolved, pending, patterns)
|
|
87
|
+
found.update(hits)
|
|
88
|
+
pending.difference_update(hits)
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _matches_in_text(
|
|
93
|
+
text: str,
|
|
94
|
+
resolved_file: str,
|
|
95
|
+
pending: set[Candidate],
|
|
96
|
+
patterns: dict[str, re.Pattern[str]],
|
|
97
|
+
) -> set[Candidate]:
|
|
98
|
+
"""Return the *pending* candidates referenced in *text*.
|
|
99
|
+
|
|
100
|
+
A plain class or free function matches once its own name is found. A
|
|
101
|
+
method (``enclosing_class`` set) additionally requires its enclosing
|
|
102
|
+
class's name to be found in the same *text*.
|
|
103
|
+
|
|
104
|
+
Author: Ron Webb
|
|
105
|
+
Since: 1.2.0
|
|
106
|
+
"""
|
|
107
|
+
hits: set[Candidate] = set()
|
|
108
|
+
for candidate in pending:
|
|
109
|
+
name, kind, defining_file, enclosing_class = candidate
|
|
110
|
+
if _resolve(Path(defining_file)) == resolved_file:
|
|
111
|
+
continue
|
|
112
|
+
if not _name_matches(text, name, patterns):
|
|
113
|
+
continue
|
|
114
|
+
if (
|
|
115
|
+
kind == SYMBOL_KIND_FUNCTION
|
|
116
|
+
and enclosing_class
|
|
117
|
+
and not _name_matches(text, enclosing_class, patterns)
|
|
118
|
+
):
|
|
119
|
+
continue
|
|
120
|
+
hits.add(candidate)
|
|
121
|
+
return hits
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _name_matches(text: str, name: str, patterns: dict[str, re.Pattern[str]]) -> bool:
|
|
125
|
+
"""Return whether *name* occurs as a whole word in *text*.
|
|
126
|
+
|
|
127
|
+
Author: Ron Webb
|
|
128
|
+
Since: 1.2.0
|
|
129
|
+
"""
|
|
130
|
+
if name not in text:
|
|
131
|
+
return False
|
|
132
|
+
pattern = patterns.setdefault(name, _build_pattern(name))
|
|
133
|
+
return pattern.search(text) is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resolve(path: Path) -> str:
|
|
137
|
+
"""Return a normalised string form of *path* for own-file comparison.
|
|
138
|
+
|
|
139
|
+
Author: Ron Webb
|
|
140
|
+
Since: 1.2.0
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
return str(path.resolve())
|
|
144
|
+
except OSError:
|
|
145
|
+
return str(path.absolute())
|
|
@@ -21,6 +21,7 @@ import threading
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
23
|
from .scanner import CATEGORY_UNUSED_SYMBOL, Definition, Finding
|
|
24
|
+
from .searcher import SymbolSearcher
|
|
24
25
|
|
|
25
26
|
_CREATE_FINDINGS_TABLE = (
|
|
26
27
|
"CREATE TABLE IF NOT EXISTS findings ("
|
|
@@ -35,14 +36,16 @@ _CREATE_FINDINGS_TABLE = (
|
|
|
35
36
|
)
|
|
36
37
|
_CREATE_DEFINITIONS_TABLE = (
|
|
37
38
|
"CREATE TABLE IF NOT EXISTS definitions ("
|
|
38
|
-
" session_id
|
|
39
|
-
" file_path
|
|
40
|
-
" line
|
|
41
|
-
" col
|
|
42
|
-
" name
|
|
43
|
-
" kind
|
|
39
|
+
" session_id TEXT NOT NULL,"
|
|
40
|
+
" file_path TEXT NOT NULL,"
|
|
41
|
+
" line INTEGER NOT NULL,"
|
|
42
|
+
" col INTEGER NOT NULL,"
|
|
43
|
+
" name TEXT NOT NULL,"
|
|
44
|
+
" kind TEXT NOT NULL,"
|
|
45
|
+
" enclosing_class TEXT"
|
|
44
46
|
")"
|
|
45
47
|
)
|
|
48
|
+
_ADD_ENCLOSING_CLASS_COLUMN = "ALTER TABLE definitions ADD COLUMN enclosing_class TEXT"
|
|
46
49
|
_CREATE_IDENTIFIERS_TABLE = (
|
|
47
50
|
"CREATE TABLE IF NOT EXISTS identifiers ("
|
|
48
51
|
" session_id TEXT NOT NULL,"
|
|
@@ -65,8 +68,9 @@ _INSERT_FINDING = (
|
|
|
65
68
|
" VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
66
69
|
)
|
|
67
70
|
_INSERT_DEFINITION = (
|
|
68
|
-
"INSERT INTO definitions
|
|
69
|
-
"
|
|
71
|
+
"INSERT INTO definitions"
|
|
72
|
+
" (session_id, file_path, line, col, name, kind, enclosing_class)"
|
|
73
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
70
74
|
)
|
|
71
75
|
_INSERT_IDENTIFIER = "INSERT INTO identifiers (session_id, name) VALUES (?, ?)"
|
|
72
76
|
|
|
@@ -75,7 +79,7 @@ _SELECT_FINDINGS = (
|
|
|
75
79
|
" WHERE session_id = ? ORDER BY file_path ASC, line ASC, col ASC"
|
|
76
80
|
)
|
|
77
81
|
_SELECT_UNUSED_DEFINITIONS = (
|
|
78
|
-
"SELECT d.file_path, d.line, d.col, d.name, d.kind"
|
|
82
|
+
"SELECT d.file_path, d.line, d.col, d.name, d.kind, d.enclosing_class"
|
|
79
83
|
" FROM definitions d"
|
|
80
84
|
" WHERE d.session_id = ?"
|
|
81
85
|
" AND NOT EXISTS ("
|
|
@@ -121,11 +125,27 @@ class SessionStore:
|
|
|
121
125
|
self._conn.execute(_CREATE_FINDINGS_TABLE)
|
|
122
126
|
self._conn.execute(_CREATE_DEFINITIONS_TABLE)
|
|
123
127
|
self._conn.execute(_CREATE_IDENTIFIERS_TABLE)
|
|
128
|
+
self._migrate_definitions_table()
|
|
124
129
|
self._conn.execute(_CREATE_FINDINGS_INDEX)
|
|
125
130
|
self._conn.execute(_CREATE_DEFINITIONS_INDEX)
|
|
126
131
|
self._conn.execute(_CREATE_IDENTIFIERS_INDEX)
|
|
127
132
|
self._conn.commit()
|
|
128
133
|
|
|
134
|
+
def _migrate_definitions_table(self) -> None:
|
|
135
|
+
"""Add the ``enclosing_class`` column to a pre-1.2.0 ``definitions`` table.
|
|
136
|
+
|
|
137
|
+
The scratch database file is reused across runs, so a table created
|
|
138
|
+
by an older version may be missing this column.
|
|
139
|
+
|
|
140
|
+
Author: Ron Webb
|
|
141
|
+
Since: 1.2.0
|
|
142
|
+
"""
|
|
143
|
+
columns = {
|
|
144
|
+
row[1] for row in self._conn.execute("PRAGMA table_info(definitions)")
|
|
145
|
+
}
|
|
146
|
+
if "enclosing_class" not in columns:
|
|
147
|
+
self._conn.execute(_ADD_ENCLOSING_CLASS_COLUMN)
|
|
148
|
+
|
|
129
149
|
def insert_findings(self, session_id: str, findings: list[Finding]) -> None:
|
|
130
150
|
"""Persist *findings* for *session_id* in the database.
|
|
131
151
|
|
|
@@ -157,7 +177,15 @@ class SessionStore:
|
|
|
157
177
|
Since: 1.0.0
|
|
158
178
|
"""
|
|
159
179
|
rows = [
|
|
160
|
-
(
|
|
180
|
+
(
|
|
181
|
+
session_id,
|
|
182
|
+
str(d.file_path),
|
|
183
|
+
d.line,
|
|
184
|
+
d.column,
|
|
185
|
+
d.name,
|
|
186
|
+
d.kind,
|
|
187
|
+
d.enclosing_class,
|
|
188
|
+
)
|
|
161
189
|
for d in definitions
|
|
162
190
|
]
|
|
163
191
|
with self._lock:
|
|
@@ -196,14 +224,18 @@ class SessionStore:
|
|
|
196
224
|
]
|
|
197
225
|
|
|
198
226
|
def compute_unused_symbols(
|
|
199
|
-
self,
|
|
227
|
+
self,
|
|
228
|
+
session_id: str,
|
|
229
|
+
ignore_patterns: list[re.Pattern[str]],
|
|
230
|
+
searcher: SymbolSearcher | None = None,
|
|
200
231
|
) -> list[dict[str, object]]:
|
|
201
232
|
"""Return a finding for every definition never referenced elsewhere.
|
|
202
233
|
|
|
203
234
|
A definition is "unused" when its name never appears in the
|
|
204
235
|
project-wide ``identifiers`` table collected for *session_id*. Names
|
|
205
236
|
matching any of *ignore_patterns* (e.g. dunder methods, ``main``,
|
|
206
|
-
test hooks) are suppressed.
|
|
237
|
+
test hooks) are suppressed. When *searcher* is given, definitions that
|
|
238
|
+
it finds textually referenced in the search paths are suppressed too.
|
|
207
239
|
|
|
208
240
|
Author: Ron Webb
|
|
209
241
|
Since: 1.0.0
|
|
@@ -212,9 +244,20 @@ class SessionStore:
|
|
|
212
244
|
rows = self._conn.execute(
|
|
213
245
|
_SELECT_UNUSED_DEFINITIONS, (session_id,)
|
|
214
246
|
).fetchall()
|
|
247
|
+
rows = [
|
|
248
|
+
row for row in rows if not any(p.search(row[3]) for p in ignore_patterns)
|
|
249
|
+
]
|
|
250
|
+
referenced: set[tuple[str, str, str, str | None]] = set()
|
|
251
|
+
if searcher is not None:
|
|
252
|
+
referenced = searcher.find_referenced(
|
|
253
|
+
[
|
|
254
|
+
(name, kind, file_path, enclosing_class)
|
|
255
|
+
for file_path, _, _, name, kind, enclosing_class in rows
|
|
256
|
+
]
|
|
257
|
+
)
|
|
215
258
|
results: list[dict[str, object]] = []
|
|
216
|
-
for file_path, line, col, name, kind in rows:
|
|
217
|
-
if
|
|
259
|
+
for file_path, line, col, name, kind, enclosing_class in rows:
|
|
260
|
+
if (name, kind, file_path, enclosing_class) in referenced:
|
|
218
261
|
continue
|
|
219
262
|
results.append(
|
|
220
263
|
{
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|