codeecho 1.0.1__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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-08-15
4
+
5
+ ### Added
6
+
7
+ - Gosu (`.gs`, `.gsx`) is now parsed with a dedicated [tree-sitter-gosu](https://github.com/rcw3bb/tree-sitter-gosu)
8
+ grammar instead of the Java fallback, enabling accurate fragment extraction using Gosu-native node types
9
+ (`compilation_unit`, `function_declaration`, `constructor_declaration`, `class_declaration`).
10
+
3
11
  ## 1.0.1 - 2026-07-10
4
12
 
5
13
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeecho
3
- Version: 1.0.1
3
+ Version: 1.1.0
4
4
  Summary: A developer tool that scans your codebase to detect and highlight ECHOES of duplicated or near-duplicated code so you can refactor toward cleaner, more maintainable designs.
5
5
  License: MIT License
6
6
 
@@ -37,18 +37,19 @@ Requires-Dist: logenrich (>=1.0.1,<2.0.0)
37
37
  Requires-Dist: rich (>=15.0.0,<16.0.0)
38
38
  Requires-Dist: tree-sitter (>=0.26.0,<0.27.0)
39
39
  Requires-Dist: tree-sitter-go (>=0.25.0,<0.26.0)
40
+ Requires-Dist: tree-sitter-gosu (>=0.2.0,<0.3.0)
40
41
  Requires-Dist: tree-sitter-java (>=0.23.5,<0.24.0)
41
42
  Requires-Dist: tree-sitter-javascript (>=0.25.0,<0.26.0)
42
43
  Requires-Dist: tree-sitter-python (>=0.25.0,<0.26.0)
43
44
  Requires-Dist: tree-sitter-typescript (>=0.23.2,<0.24.0)
44
45
  Description-Content-Type: text/markdown
45
46
 
46
- # codeecho 1.0.1
47
+ # codeecho 1.1.0
47
48
 
48
49
  > A developer tool that scans your codebase to detect and highlight **echoes** of duplicated or near-duplicated code, so you can refactor toward cleaner, more maintainable designs.
49
50
 
50
51
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
51
- [![Version](https://img.shields.io/badge/version-1.0.1-blue.svg)](CHANGELOG.md)
52
+ [![Version](https://img.shields.io/badge/version-1.1.0-blue.svg)](CHANGELOG.md)
52
53
 
53
54
  ## Prerequisites
54
55
 
@@ -76,14 +77,14 @@ python -m codeecho <path> [options]
76
77
 
77
78
  ### Supported languages
78
79
 
79
- | Language | Extensions |
80
- |----------|-----------|
81
- | Python | `.py` |
82
- | JavaScript | `.js`, `.mjs`, `.cjs` |
83
- | TypeScript | `.ts`, `.tsx` |
84
- | Java | `.java` |
85
- | Go | `.go` |
86
- | Gosu | `.gs`, `.gsx` |
80
+ | Language | Extensions | Grammar |
81
+ |----------|-----------|--------|
82
+ | Python | `.py` | [tree-sitter-python](https://github.com/tree-sitter/tree-sitter-python) |
83
+ | JavaScript | `.js`, `.mjs`, `.cjs` | [tree-sitter-javascript](https://github.com/tree-sitter/tree-sitter-javascript) |
84
+ | TypeScript | `.ts`, `.tsx` | [tree-sitter-typescript](https://github.com/tree-sitter/tree-sitter-typescript) |
85
+ | Java | `.java` | [tree-sitter-java](https://github.com/tree-sitter/tree-sitter-java) |
86
+ | Go | `.go` | [tree-sitter-go](https://github.com/tree-sitter/tree-sitter-go) |
87
+ | Gosu | `.gs`, `.gsx` | [tree-sitter-gosu](https://github.com/rcw3bb/tree-sitter-gosu) |
87
88
 
88
89
  ### Arguments
89
90
 
@@ -1,9 +1,9 @@
1
- # codeecho 1.0.1
1
+ # codeecho 1.1.0
2
2
 
3
3
  > A developer tool that scans your codebase to detect and highlight **echoes** of duplicated or near-duplicated code, so you can refactor toward cleaner, more maintainable designs.
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
- [![Version](https://img.shields.io/badge/version-1.0.1-blue.svg)](CHANGELOG.md)
6
+ [![Version](https://img.shields.io/badge/version-1.1.0-blue.svg)](CHANGELOG.md)
7
7
 
8
8
  ## Prerequisites
9
9
 
@@ -31,14 +31,14 @@ python -m codeecho <path> [options]
31
31
 
32
32
  ### Supported languages
33
33
 
34
- | Language | Extensions |
35
- |----------|-----------|
36
- | Python | `.py` |
37
- | JavaScript | `.js`, `.mjs`, `.cjs` |
38
- | TypeScript | `.ts`, `.tsx` |
39
- | Java | `.java` |
40
- | Go | `.go` |
41
- | Gosu | `.gs`, `.gsx` |
34
+ | Language | Extensions | Grammar |
35
+ |----------|-----------|--------|
36
+ | Python | `.py` | [tree-sitter-python](https://github.com/tree-sitter/tree-sitter-python) |
37
+ | JavaScript | `.js`, `.mjs`, `.cjs` | [tree-sitter-javascript](https://github.com/tree-sitter/tree-sitter-javascript) |
38
+ | TypeScript | `.ts`, `.tsx` | [tree-sitter-typescript](https://github.com/tree-sitter/tree-sitter-typescript) |
39
+ | Java | `.java` | [tree-sitter-java](https://github.com/tree-sitter/tree-sitter-java) |
40
+ | Go | `.go` | [tree-sitter-go](https://github.com/tree-sitter/tree-sitter-go) |
41
+ | Gosu | `.gs`, `.gsx` | [tree-sitter-gosu](https://github.com/rcw3bb/tree-sitter-gosu) |
42
42
 
43
43
  ### Arguments
44
44
 
@@ -7,7 +7,7 @@ maintainable designs.
7
7
  from env_dir_bootstrap import EnvDirBootstrap
8
8
  from logenrich import setup_logger
9
9
 
10
- __version__ = "1.0.1"
10
+ __version__ = "1.1.0"
11
11
 
12
12
  _bootstrapper = EnvDirBootstrap(
13
13
  env_var="CODEECHO_CONFIG_DIR",
@@ -6,6 +6,8 @@ Invoked via::
6
6
  poetry run python -m codeecho [OPTIONS] PATH
7
7
  """
8
8
 
9
+ import json
10
+ import os
9
11
  import uuid
10
12
  from pathlib import Path
11
13
 
@@ -57,7 +59,9 @@ def _parse_types(types_str: str) -> set[int]:
57
59
  version=__version__, prog_name="codeecho", message="%(prog)s v%(version)s"
58
60
  )
59
61
  @click.argument(
60
- "path", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path)
62
+ "paths",
63
+ nargs=-1,
64
+ type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
61
65
  )
62
66
  @click.option(
63
67
  "--types",
@@ -117,7 +121,7 @@ def _parse_types(types_str: str) -> set[int]:
117
121
  help="Glob pattern(s) to exclude from scanning (repeatable).",
118
122
  )
119
123
  def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,invalid-name
120
- path: Path,
124
+ paths: tuple[Path, ...],
121
125
  types: str,
122
126
  threshold: float,
123
127
  output: str,
@@ -127,10 +131,10 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
127
131
  min_tokens: int,
128
132
  exclude: tuple[str, ...],
129
133
  ) -> None:
130
- """Scan PATH for duplicate and near-duplicate code.
134
+ """Scan one or more PATH(s) for duplicate and near-duplicate code.
131
135
 
132
- Generates JSON and/or HTML reports, then removes the intermediate session data
133
- from the embedded database.
136
+ Each PATH may be a file or a directory. Generates JSON and/or HTML reports,
137
+ then removes the intermediate session data from the embedded database.
134
138
  """
135
139
  _console.print(
136
140
  Panel(
@@ -139,6 +143,9 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
139
143
  )
140
144
  )
141
145
 
146
+ if not paths:
147
+ raise click.UsageError("At least one PATH is required.")
148
+
142
149
  detect_types = _parse_types(types)
143
150
  if output_dir is None:
144
151
  output_dir = Path.cwd() / "reports"
@@ -152,14 +159,27 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
152
159
 
153
160
  output_dir.mkdir(parents=True, exist_ok=True)
154
161
 
162
+ resolved = [p.resolve() for p in paths]
163
+
155
164
  db_path = Path(get_db_path(str(db_dir) if db_dir else None))
156
165
  with SessionDB(db_path=db_path) as session_db:
157
- session_db.create_session(session_id, str(path.resolve()), config)
166
+ session_db.create_session(
167
+ session_id, json.dumps([str(p) for p in resolved]), config
168
+ )
158
169
 
159
170
  # ── Phase 1: File discovery ─────────────────────────────────────────
160
- _console.print(f"[dim]Scanning:[/dim] [bold]{path.resolve()}[/bold]")
161
- _ignore_file = IgnoreFile(Path(CONF_DIR) / ".ignore", base_dir=path.resolve())
162
- files = scanner.scan(path, exclude_patterns=exclude, ignore_file=_ignore_file)
171
+ for p in resolved:
172
+ _console.print(f"[dim]Scanning:[/dim] [bold]{p}[/bold]")
173
+ try:
174
+ base_dir = Path(os.path.commonpath(resolved))
175
+ if not base_dir.is_dir():
176
+ base_dir = base_dir.parent
177
+ except ValueError:
178
+ base_dir = Path.cwd()
179
+ _ignore_file = IgnoreFile(Path(CONF_DIR) / ".ignore", base_dir=base_dir)
180
+ files = scanner.scan(
181
+ tuple(resolved), exclude_patterns=exclude, ignore_file=_ignore_file
182
+ )
163
183
  total_fragments = 0
164
184
 
165
185
  if not files:
@@ -177,7 +197,8 @@ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,to
177
197
 
178
198
  result = ScanResult(
179
199
  session_id=session_id,
180
- scan_path=str(path.resolve()),
200
+ version=__version__,
201
+ scan_path=[str(p) for p in resolved],
181
202
  files_scanned=len(files),
182
203
  fragments_extracted=total_fragments,
183
204
  type1_groups=cnt1,
@@ -43,9 +43,12 @@ _QUERIES: dict[tuple[str, str], str] = {
43
43
  ("Java", "function"): "[(method_declaration) (constructor_declaration)] @fragment",
44
44
  ("Java", "class"): "(class_declaration) @fragment",
45
45
  ("Java", "file"): "(program) @fragment",
46
- ("Gosu", "function"): "[(method_declaration) (constructor_declaration)] @fragment",
46
+ (
47
+ "Gosu",
48
+ "function",
49
+ ): "[(function_declaration) (constructor_declaration)] @fragment",
47
50
  ("Gosu", "class"): "(class_declaration) @fragment",
48
- ("Gosu", "file"): "(program) @fragment",
51
+ ("Gosu", "file"): "(compilation_unit) @fragment",
49
52
  ("Go", "function"): "[(function_declaration) (method_declaration)] @fragment",
50
53
  ("Go", "class"): "(type_declaration) @fragment",
51
54
  ("Go", "file"): "(source_file) @fragment",
@@ -1,5 +1,5 @@
1
1
  [loggers]
2
- keys=root
2
+ keys=root,tree_sitter_gosu
3
3
 
4
4
  [handlers]
5
5
  keys=consoleHandler,fileHandler
@@ -11,6 +11,12 @@ keys=logFormatter,consoleFormatter
11
11
  level=INFO
12
12
  handlers=consoleHandler,fileHandler
13
13
 
14
+ [logger_tree_sitter_gosu]
15
+ level=CRITICAL
16
+ handlers=
17
+ qualname=tree_sitter_gosu
18
+ propagate=0
19
+
14
20
  [handler_consoleHandler]
15
21
  class=StreamHandler
16
22
  formatter=consoleFormatter
@@ -40,11 +40,12 @@ class CloneGroup:
40
40
 
41
41
 
42
42
  @dataclass
43
- class ScanResult:
43
+ class ScanResult: # pylint: disable=too-many-instance-attributes
44
44
  """Summary statistics for a completed duplicate detection scan."""
45
45
 
46
46
  session_id: str
47
- scan_path: str
47
+ version: str
48
+ scan_path: list[str]
48
49
  files_scanned: int
49
50
  fragments_extracted: int
50
51
  type1_groups: int
@@ -1,9 +1,6 @@
1
1
  """
2
2
  Tree-sitter parser wrapper providing one cached :class:`tree_sitter.Parser` per language.
3
3
 
4
- Gosu (``.gs``) files are parsed using the Java grammar because Gosu is syntactically
5
- Java-like (methods, classes, blocks).
6
-
7
4
  :author: Ron Webb
8
5
  :since: 1.0.0
9
6
  """
@@ -16,9 +13,9 @@ from tree_sitter import Language, Parser, Tree
16
13
  _logger = logging.getLogger("codeecho.parser")
17
14
 
18
15
 
19
- def _build_language(
16
+ def _build_language( # pylint: disable=too-many-return-statements
20
17
  language_name: str,
21
- ) -> Language | None: # pylint: disable=too-many-return-statements
18
+ ) -> Language | None:
22
19
  """Instantiate and return the tree-sitter :class:`Language` for *language_name*."""
23
20
  match language_name:
24
21
  case "Python":
@@ -33,9 +30,13 @@ def _build_language(
33
30
  import tree_sitter_typescript as m # pylint: disable=import-outside-toplevel
34
31
 
35
32
  return Language(m.language_typescript())
36
- case "Java" | "Gosu":
33
+ case "Java":
37
34
  import tree_sitter_java as m # pylint: disable=import-outside-toplevel
38
35
 
36
+ return Language(m.language())
37
+ case "Gosu":
38
+ import tree_sitter_gosu as m # pylint: disable=import-outside-toplevel
39
+
39
40
  return Language(m.language())
40
41
  case "Go":
41
42
  import tree_sitter_go as m # pylint: disable=import-outside-toplevel
@@ -79,7 +79,7 @@ _TEMPLATE: str = """<!DOCTYPE html>
79
79
  </style>
80
80
  </head>
81
81
  <body>
82
- <h1>CodeEcho</h1>
82
+ <h1>CodeEcho <span style="color:var(--muted);font-size:1rem;font-weight:400">v{{ result.version }}</span></h1>
83
83
  <div class="subtitle">Clone Detection Report &mdash; {{ generated_at }}</div>
84
84
 
85
85
  <div class="toolbar">
@@ -149,7 +149,7 @@ _TEMPLATE: str = """<!DOCTYPE html>
149
149
  </details>
150
150
  {% endfor %}
151
151
 
152
- <footer>Scan path: {{ result.scan_path }} &bull; Session: {{ result.session_id }}</footer>
152
+ <footer>Scan path{{ 's' if result.scan_path | length > 1 else '' }}: {{ result.scan_path | join(', ') }} &bull; Session: {{ result.session_id }}</footer>
153
153
 
154
154
  <script>
155
155
  function toggleAll(open) {
@@ -62,6 +62,7 @@ def write(
62
62
 
63
63
  payload = {
64
64
  "generated_at": datetime.now(timezone.utc).isoformat(),
65
+ "version": result.version,
65
66
  "session_id": result.session_id,
66
67
  "scan_path": result.scan_path,
67
68
  "summary": {
@@ -51,27 +51,44 @@ _DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset(
51
51
 
52
52
 
53
53
  def scan(
54
- root: Path,
54
+ paths: tuple[Path, ...],
55
55
  exclude_patterns: tuple[str, ...] = (),
56
56
  ignore_file: IgnoreFile | None = None,
57
57
  ) -> list[tuple[Path, str]]:
58
- """Walk *root* recursively and return ``(path, language)`` pairs for supported files.
58
+ """Walk *paths* and return ``(path, language)`` pairs for supported source files.
59
+
60
+ Each entry in *paths* may be a file or a directory. Directories are walked
61
+ recursively; files are checked for a supported extension and included directly.
62
+ Duplicate resolved paths across multiple entries are silently deduplicated.
59
63
 
60
- :param root: Root directory to scan.
64
+ :param paths: One or more file or directory paths to scan.
61
65
  :param exclude_patterns: Glob patterns (fnmatch-style) whose matching paths are skipped.
62
66
  :param ignore_file: Optional gitignore-style :class:`~braincraft.IgnoreFile`; matched paths are skipped.
63
67
  :returns: List of ``(absolute_path, language_name)`` tuples.
64
68
  """
65
69
  results: list[tuple[Path, str]] = []
66
- root = root.resolve()
67
- _logger.debug("Scanning root: %s", root)
70
+ seen: set[Path] = set()
68
71
 
69
- for entry in _walk(root, exclude_patterns, ignore_file):
70
- suffix = entry.suffix.lower()
71
- if language := EXTENSION_TO_LANGUAGE.get(suffix):
72
- results.append((entry, language))
72
+ for path in paths:
73
+ path = path.resolve()
74
+ _logger.debug("Scanning: %s", path)
75
+ if path.is_file():
76
+ if path in seen or _matches_any(path, exclude_patterns):
77
+ continue
78
+ seen.add(path)
79
+ if ignore_file is not None and ignore_file.is_ignored(path):
80
+ _logger.debug("Ignored (ignore file): %s", path)
81
+ continue
82
+ if language := EXTENSION_TO_LANGUAGE.get(path.suffix.lower()):
83
+ results.append((path, language))
84
+ elif path.is_dir():
85
+ for entry in _walk(path, exclude_patterns, ignore_file):
86
+ if entry not in seen:
87
+ seen.add(entry)
88
+ if language := EXTENSION_TO_LANGUAGE.get(entry.suffix.lower()):
89
+ results.append((entry, language))
73
90
 
74
- _logger.debug("Found %d source files under %s", len(results), root)
91
+ _logger.debug("Found %d source files across %d path(s)", len(results), len(paths))
75
92
  return results
76
93
 
77
94
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codeecho"
3
- version = "1.0.1"
3
+ version = "1.1.0"
4
4
  description = "A developer tool that scans your codebase to detect and highlight ECHOES of duplicated or near-duplicated code so you can refactor toward cleaner, more maintainable designs."
5
5
  authors = [
6
6
  {name = "Ron Webb"}
@@ -20,7 +20,8 @@ dependencies = [
20
20
  "click (>=8.4.2,<9.0.0)",
21
21
  "rich (>=15.0.0,<16.0.0)",
22
22
  "jinja2 (>=3.1)",
23
- "braincraft (>=1.2.0,<2.0.0)"
23
+ "braincraft (>=1.2.0,<2.0.0)",
24
+ "tree-sitter-gosu (>=0.2.0,<0.3.0)"
24
25
  ]
25
26
 
26
27
 
File without changes
File without changes
File without changes
File without changes