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.
@@ -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.1.0
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
- [![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/)
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.2.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,
@@ -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
- [![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/)
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.2.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,
@@ -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
@@ -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.1.0"
13
+ __version__ = "1.2.0"
14
14
 
15
15
  _bootstrapper = EnvDirBootstrap(
16
16
  env_var="CODE_PRUNE_CONFIG_DIR",
@@ -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 supported source files under *path*.
294
+ """Discover source files under *path*.
293
295
 
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
+ 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
- allowed = (
311
- SUPPORTED_EXTENSIONS.intersection(extensions)
312
- if extensions
313
- else SUPPORTED_EXTENSIONS
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
- findings, interrupted = _scan_project(files, db_path, workers, selected_categories)
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(paths: list[Path], extensions: list[str]) -> list[Path]:
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(target_path, extensions, ignore):
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], db_path: Path, workers: int, categories: set[str]
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
- kind = "class" if node.type in class_types else "function"
544
+ decoded_name = _decode(source_bytes, name_node)
535
545
  bindings.definitions.append(
536
546
  Definition(
537
- name=_decode(source_bytes, name_node),
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 TEXT NOT NULL,"
39
- " file_path TEXT NOT NULL,"
40
- " line INTEGER NOT NULL,"
41
- " col INTEGER NOT NULL,"
42
- " name TEXT NOT NULL,"
43
- " kind TEXT NOT NULL"
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 (session_id, file_path, line, col, name, kind)"
69
- " VALUES (?, ?, ?, ?, ?, ?)"
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
- (session_id, str(d.file_path), d.line, d.column, d.name, d.kind)
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, session_id: str, ignore_patterns: list[re.Pattern[str]]
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 any(p.search(name) for p in ignore_patterns):
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
  {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "code-prune"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Find unreachable, unused, and obsolete code before it becomes technical debt."
5
5
  authors = [
6
6
  {name = "Ron Webb"}
File without changes