codeecho 1.0.0__py3-none-any.whl

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.
codeecho/.ignore ADDED
@@ -0,0 +1,41 @@
1
+ # .ignore — codeecho scan exclusions
2
+ #
3
+ # This file uses gitignore-style pattern matching. Patterns are matched
4
+ # relative to the scan root directory (the PATH argument passed to codeecho).
5
+ #
6
+ # Edit this file to exclude directories or files from clone detection.
7
+ # Uncomment or add entries below to activate them.
8
+ #
9
+ # --- Directory exclusion examples ---
10
+ #
11
+ # build/
12
+ # dist/
13
+ # target/
14
+ # out/
15
+ # generated/
16
+ #
17
+ # --- File extension exclusion examples ---
18
+ #
19
+ # *.min.js
20
+ # *.bundle.js
21
+ # *.generated.py
22
+ # *.pb.go
23
+ #
24
+ # --- Specific file exclusion (anchored to scan root) ---
25
+ #
26
+ # /config/generated_config.py
27
+ # /src/proto/generated.ts
28
+ #
29
+ # --- Name-based exclusion anywhere in the tree ---
30
+ #
31
+ # *_generated.java
32
+ # *Test.java
33
+ #
34
+ # --- Double-star (match across directory boundaries) ---
35
+ #
36
+ # **/fixtures/
37
+ # **/*.test.ts
38
+ #
39
+ # --- Negation (un-exclude even if a parent pattern matches) ---
40
+ #
41
+ # !important_dir/
codeecho/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ codeecho - A developer tool that scans your codebase to detect and highlight ECHOES
3
+ of duplicated or near-duplicated code so you can refactor toward cleaner, more
4
+ maintainable designs.
5
+ """
6
+
7
+ from env_dir_bootstrap import EnvDirBootstrap
8
+ from logenrich import setup_logger
9
+
10
+ __version__ = "1.0.0"
11
+
12
+ _bootstrapper = EnvDirBootstrap(
13
+ env_var="CODEECHO_CONFIG_DIR",
14
+ resources=["logging.ini", ".ignore"],
15
+ package="codeecho",
16
+ )
17
+
18
+ _bootstrapper.setup()
19
+
20
+ CONF_DIR = str(_bootstrapper.get_dir())
21
+
22
+ setup_logger("codeecho", conf_dir=CONF_DIR)
codeecho/__main__.py ADDED
@@ -0,0 +1,289 @@
1
+ """
2
+ CLI entry point for codeecho.
3
+
4
+ Invoked via::
5
+
6
+ poetry run python -m codeecho [OPTIONS] PATH
7
+ """
8
+
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ import click
13
+ from braincraft import IgnoreFile
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.progress import (
17
+ BarColumn,
18
+ MofNCompleteColumn,
19
+ Progress,
20
+ SpinnerColumn,
21
+ TextColumn,
22
+ )
23
+ from rich.table import Table
24
+
25
+ from codeecho import __version__, CONF_DIR
26
+ from codeecho import extractor, fingerprint, parser as ts_parser, scanner
27
+ from codeecho.db import SessionDB, get_db_path
28
+ from codeecho.detector import detect
29
+ from codeecho.models import ScanResult
30
+ from codeecho.reporter import html_reporter, json_reporter
31
+
32
+ _console = Console()
33
+
34
+ _FORMAT_CHOICES = click.Choice(["json", "html", "both"])
35
+
36
+
37
+ def _parse_types(types_str: str) -> set[int]:
38
+ """Parse ``--types`` value into a set of integers."""
39
+ if types_str.strip().lower() == "all":
40
+ return {1, 2, 3}
41
+ result: set[int] = set()
42
+ for token in types_str.split(","):
43
+ token = token.strip()
44
+ if token.isdigit() and token in {"1", "2", "3"}:
45
+ result.add(int(token))
46
+ if not result:
47
+ raise click.BadParameter(
48
+ f"Invalid types value: {types_str!r}. Use 'all' or e.g. '1,2,3'."
49
+ )
50
+ return result
51
+
52
+
53
+ @click.command(
54
+ name="codeecho", context_settings={"help_option_names": ["-h", "--help"]}
55
+ )
56
+ @click.version_option(
57
+ version=__version__, prog_name="codeecho", message="%(prog)s v%(version)s"
58
+ )
59
+ @click.argument(
60
+ "path", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path)
61
+ )
62
+ @click.option(
63
+ "--types",
64
+ default="all",
65
+ show_default=True,
66
+ metavar="TYPES",
67
+ help="Clone types to detect: comma-separated (e.g. '1,2') or 'all'.",
68
+ )
69
+ @click.option(
70
+ "--threshold",
71
+ default=0.8,
72
+ show_default=True,
73
+ type=click.FloatRange(0.0, 1.0),
74
+ help="Jaccard similarity threshold for Type-3 (near-duplicate) detection.",
75
+ )
76
+ @click.option(
77
+ "--output",
78
+ default="codeecho-output",
79
+ show_default=True,
80
+ metavar="NAME",
81
+ help="Base name (without extension) for output file(s).",
82
+ )
83
+ @click.option(
84
+ "--output-dir",
85
+ default=None,
86
+ show_default=False,
87
+ type=click.Path(file_okay=False, path_type=Path),
88
+ help="Directory where output file(s) will be written. [default: <cwd>/reports]",
89
+ )
90
+ @click.option(
91
+ "--db-dir",
92
+ "db_dir",
93
+ default=None,
94
+ show_default=False,
95
+ type=click.Path(file_okay=False, path_type=Path),
96
+ help="Directory for the SQLite scratch database. [default: ~/.codeecho]",
97
+ )
98
+ @click.option(
99
+ "--format",
100
+ "fmt",
101
+ default="both",
102
+ show_default=True,
103
+ type=_FORMAT_CHOICES,
104
+ help="Output format.",
105
+ )
106
+ @click.option(
107
+ "--min-tokens",
108
+ default=10,
109
+ show_default=True,
110
+ type=click.IntRange(1),
111
+ help="Minimum token count for a fragment to be considered.",
112
+ )
113
+ @click.option(
114
+ "--exclude",
115
+ multiple=True,
116
+ metavar="PATTERN",
117
+ help="Glob pattern(s) to exclude from scanning (repeatable).",
118
+ )
119
+ def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,invalid-name
120
+ path: Path,
121
+ types: str,
122
+ threshold: float,
123
+ output: str,
124
+ output_dir: Path | None,
125
+ db_dir: Path | None,
126
+ fmt: str,
127
+ min_tokens: int,
128
+ exclude: tuple[str, ...],
129
+ ) -> None:
130
+ """Scan PATH for duplicate and near-duplicate code.
131
+
132
+ Generates JSON and/or HTML reports, then removes the intermediate session data
133
+ from the embedded database.
134
+ """
135
+ _console.print(
136
+ Panel(
137
+ f"[bold cyan]codeecho[/bold cyan] [dim]v{__version__}[/dim] — Code Duplicate Scanner",
138
+ border_style="dim",
139
+ )
140
+ )
141
+
142
+ detect_types = _parse_types(types)
143
+ if output_dir is None:
144
+ output_dir = Path.cwd() / "reports"
145
+ session_id = str(uuid.uuid4())
146
+ config = {
147
+ "types": types,
148
+ "threshold": threshold,
149
+ "min_tokens": min_tokens,
150
+ "exclude": list(exclude),
151
+ }
152
+
153
+ output_dir.mkdir(parents=True, exist_ok=True)
154
+
155
+ db_path = Path(get_db_path(str(db_dir) if db_dir else None))
156
+ with SessionDB(db_path=db_path) as session_db:
157
+ session_db.create_session(session_id, str(path.resolve()), config)
158
+
159
+ # ── 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)
163
+ total_fragments = 0
164
+
165
+ if not files:
166
+ _console.print("[yellow]No supported source files found.[/yellow]")
167
+ session_db.delete_session(session_id)
168
+ return
169
+
170
+ # ── Phase 2: Parse → Extract → Hash ────────────────────────────────
171
+ total_fragments = _process_files(files, session_db, session_id, min_tokens)
172
+
173
+ # ── Phase 3: Clone detection ────────────────────────────────────────
174
+ cnt1, cnt2, cnt3 = _detect_clones(
175
+ session_db, session_id, detect_types, threshold
176
+ )
177
+
178
+ result = ScanResult(
179
+ session_id=session_id,
180
+ scan_path=str(path.resolve()),
181
+ files_scanned=len(files),
182
+ fragments_extracted=total_fragments,
183
+ type1_groups=cnt1,
184
+ type2_groups=cnt2,
185
+ type3_groups=cnt3,
186
+ )
187
+
188
+ # ── Phase 4: Report generation ──────────────────────────────────────
189
+ written = _write_reports(session_db, result, output_dir, output, fmt)
190
+
191
+ # ── Phase 5: Clean up session ───────────────────────────────────────
192
+ session_db.delete_session(session_id)
193
+
194
+ # ── Summary table (printed after DB is closed) ──────────────────────────
195
+ _print_summary(result, written)
196
+
197
+
198
+ def _detect_clones(
199
+ session_db: SessionDB,
200
+ session_id: str,
201
+ detect_types: set[int],
202
+ threshold: float,
203
+ ) -> tuple[int, int, int]:
204
+ """Run clone detection and return (type1_count, type2_count, type3_count)."""
205
+ _console.print("[dim]Detecting clones…[/dim]")
206
+ return detect(session_db, session_id, detect_types, threshold)
207
+
208
+
209
+ def _write_reports(
210
+ session_db: SessionDB,
211
+ result: ScanResult,
212
+ output_dir: Path,
213
+ output: str,
214
+ fmt: str,
215
+ ) -> list[Path]:
216
+ """Write the requested report formats and return a list of written paths."""
217
+ written: list[Path] = []
218
+ if fmt in ("json", "both"):
219
+ written.append(
220
+ json_reporter.write(session_db, result, output_dir / f"{output}.json")
221
+ )
222
+ if fmt in ("html", "both"):
223
+ written.append(
224
+ html_reporter.write(session_db, result, output_dir / f"{output}.html")
225
+ )
226
+ return written
227
+
228
+
229
+ def _print_summary(result: ScanResult, written: list[Path]) -> None:
230
+ """Print the scan summary table and list of saved report paths."""
231
+ table = Table(title="Scan Summary", show_header=True, header_style="bold magenta")
232
+ table.add_column("Metric", style="dim", min_width=26)
233
+ table.add_column("Value", justify="right", style="bold")
234
+ table.add_row("Files scanned", str(result.files_scanned))
235
+ table.add_row("Fragments extracted", str(result.fragments_extracted))
236
+ table.add_row("[red]Type-1[/red] clone groups (exact)", str(result.type1_groups))
237
+ table.add_row(
238
+ "[yellow]Type-2[/yellow] clone groups (structural)", str(result.type2_groups)
239
+ )
240
+ table.add_row(
241
+ "[green]Type-3[/green] clone groups (near-duplicate)", str(result.type3_groups)
242
+ )
243
+ _console.print(table)
244
+ _console.print("\n[bold]Reports saved:[/bold]")
245
+ for dest in written:
246
+ _console.print(f" [cyan]•[/cyan] {dest}")
247
+
248
+
249
+ def _process_files(
250
+ files: list[tuple[Path, str]],
251
+ session_db: SessionDB,
252
+ session_id: str,
253
+ min_tokens: int,
254
+ ) -> int:
255
+ """Parse, extract, and hash every file; bulk-insert fragments into *session_db*.
256
+
257
+ Returns:
258
+ Total number of fragments extracted.
259
+ """
260
+ total = 0
261
+ with Progress(
262
+ SpinnerColumn(),
263
+ TextColumn("[progress.description]{task.description}"),
264
+ BarColumn(),
265
+ MofNCompleteColumn(),
266
+ console=_console,
267
+ transient=True,
268
+ ) as progress:
269
+ task = progress.add_task("Processing files…", total=len(files))
270
+ for file_path, language in files:
271
+ try:
272
+ source_bytes = file_path.read_bytes()
273
+ tree = ts_parser.parse(source_bytes, language)
274
+ if tree is not None:
275
+ frags = extractor.extract_fragments(
276
+ tree, source_bytes, file_path, language, session_id, min_tokens
277
+ )
278
+ fingerprint.hash_all(frags)
279
+ session_db.insert_many_fragments(frags)
280
+ total += len(frags)
281
+ except Exception as exc: # pylint: disable=broad-exception-caught
282
+ _console.print(f"[red]Error processing {file_path.name}: {exc}[/red]")
283
+ finally:
284
+ progress.advance(task)
285
+ return total
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main() # pylint: disable=no-value-for-parameter
codeecho/db.py ADDED
@@ -0,0 +1,289 @@
1
+ """
2
+ SQLite session database for storing intermediate clone detection results.
3
+
4
+ Each scan run creates a session row; all fragments and clone groups are foreign-keyed
5
+ to it so a single DELETE cascades all data. Call :meth:`SessionDB.delete_session`
6
+ after the reports have been written.
7
+
8
+ :author: Ron Webb
9
+ :since: 1.0.0
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import sqlite3
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from types import TracebackType
18
+
19
+ from codeecho.models import CloneGroup, Fragment
20
+
21
+ _logger = logging.getLogger("codeecho.db")
22
+
23
+ _DB_NAME: str = "codeecho.db"
24
+ _DEFAULT_DIR: Path = Path.home() / ".codeecho"
25
+
26
+
27
+ def get_db_path(db_dir: str | None = None) -> str:
28
+ """Return the absolute path to the codeecho SQLite database file.
29
+
30
+ Uses *db_dir* when provided, otherwise falls back to ``~/.codeecho``.
31
+
32
+ :param db_dir: Optional directory that overrides the default location.
33
+ :return: Absolute path to the ``codeecho.db`` file.
34
+ """
35
+ directory = Path(db_dir) if db_dir else _DEFAULT_DIR
36
+ return str(directory.resolve() / _DB_NAME)
37
+
38
+
39
+ _SCHEMA_SQL: str = """
40
+ CREATE TABLE IF NOT EXISTS sessions (
41
+ id TEXT PRIMARY KEY,
42
+ created_at TEXT NOT NULL,
43
+ scan_path TEXT NOT NULL,
44
+ config_json TEXT NOT NULL
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS fragments (
48
+ id TEXT PRIMARY KEY,
49
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
50
+ file_path TEXT NOT NULL,
51
+ language TEXT NOT NULL,
52
+ fragment_type TEXT NOT NULL,
53
+ start_line INTEGER NOT NULL,
54
+ end_line INTEGER NOT NULL,
55
+ token_count INTEGER NOT NULL DEFAULT 0,
56
+ raw_hash TEXT,
57
+ normalized_hash TEXT,
58
+ token_sequence TEXT NOT NULL DEFAULT '[]',
59
+ source_text TEXT NOT NULL DEFAULT ''
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS clone_groups (
63
+ id TEXT PRIMARY KEY,
64
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
65
+ clone_type INTEGER NOT NULL,
66
+ representative_hash TEXT,
67
+ similarity_score REAL
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS clone_group_members (
71
+ group_id TEXT NOT NULL REFERENCES clone_groups(id) ON DELETE CASCADE,
72
+ fragment_id TEXT NOT NULL REFERENCES fragments(id) ON DELETE CASCADE,
73
+ PRIMARY KEY (group_id, fragment_id)
74
+ );
75
+ """
76
+
77
+
78
+ def _row_to_fragment(row: sqlite3.Row) -> Fragment:
79
+ return Fragment(
80
+ fragment_id=row["id"],
81
+ session_id=row["session_id"],
82
+ file_path=row["file_path"],
83
+ language=row["language"],
84
+ fragment_type=row["fragment_type"],
85
+ start_line=row["start_line"],
86
+ end_line=row["end_line"],
87
+ token_count=row["token_count"],
88
+ raw_hash=row["raw_hash"],
89
+ normalized_hash=row["normalized_hash"],
90
+ token_sequence=json.loads(row["token_sequence"]),
91
+ source_text=row["source_text"],
92
+ )
93
+
94
+
95
+ class SessionDB:
96
+ """Context manager owning a SQLite connection for one scan session.
97
+
98
+ Usage::
99
+
100
+ with SessionDB() as db:
101
+ db.create_session(session_id, path, config)
102
+ db.insert_many_fragments(fragments)
103
+ ...
104
+ db.delete_session(session_id)
105
+ """
106
+
107
+ def __init__(self, db_path: Path | None = None) -> None:
108
+ self._path: Path = db_path or Path(get_db_path())
109
+ self._conn: sqlite3.Connection | None = None
110
+
111
+ # ------------------------------------------------------------------
112
+ # Context manager
113
+ # ------------------------------------------------------------------
114
+
115
+ def __enter__(self) -> "SessionDB":
116
+ self._path.parent.mkdir(parents=True, exist_ok=True)
117
+ self._conn = sqlite3.connect(str(self._path))
118
+ self._conn.row_factory = sqlite3.Row
119
+ self._conn.executescript(_SCHEMA_SQL)
120
+ self._conn.execute("PRAGMA foreign_keys = ON")
121
+ _logger.debug("Opened session DB at %s", self._path)
122
+ return self
123
+
124
+ def __exit__(
125
+ self,
126
+ exc_type: type[BaseException] | None,
127
+ exc_val: BaseException | None,
128
+ exc_tb: TracebackType | None,
129
+ ) -> None:
130
+ if self._conn is not None:
131
+ if exc_type is None:
132
+ self._conn.commit()
133
+ else:
134
+ self._conn.rollback()
135
+ self._conn.close()
136
+ self._conn = None
137
+
138
+ # ------------------------------------------------------------------
139
+ # Internal helpers
140
+ # ------------------------------------------------------------------
141
+
142
+ @property
143
+ def _connection(self) -> sqlite3.Connection:
144
+ if self._conn is None:
145
+ raise RuntimeError("SessionDB is not open; use it as a context manager.")
146
+ return self._conn
147
+
148
+ # ------------------------------------------------------------------
149
+ # Session management
150
+ # ------------------------------------------------------------------
151
+
152
+ def create_session(
153
+ self, session_id: str, scan_path: str, config: dict[str, object]
154
+ ) -> None:
155
+ """Insert a new scan session record."""
156
+ self._connection.execute(
157
+ "INSERT INTO sessions (id, created_at, scan_path, config_json) VALUES (?, ?, ?, ?)",
158
+ (
159
+ session_id,
160
+ datetime.now(timezone.utc).isoformat(),
161
+ scan_path,
162
+ json.dumps(config),
163
+ ),
164
+ )
165
+
166
+ def delete_session(self, session_id: str) -> None:
167
+ """Delete the session and all its fragments/groups via CASCADE."""
168
+ self._connection.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
169
+ _logger.debug("Session %s deleted from database.", session_id)
170
+
171
+ # ------------------------------------------------------------------
172
+ # Fragments
173
+ # ------------------------------------------------------------------
174
+
175
+ def insert_many_fragments(self, fragments: list[Fragment]) -> None:
176
+ """Bulk-insert a list of fully-populated Fragment objects."""
177
+ self._connection.executemany(
178
+ """
179
+ INSERT INTO fragments
180
+ (id, session_id, file_path, language, fragment_type,
181
+ start_line, end_line, token_count, raw_hash, normalized_hash,
182
+ token_sequence, source_text)
183
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
184
+ """,
185
+ [
186
+ (
187
+ f.fragment_id,
188
+ f.session_id,
189
+ f.file_path,
190
+ f.language,
191
+ f.fragment_type,
192
+ f.start_line,
193
+ f.end_line,
194
+ f.token_count,
195
+ f.raw_hash,
196
+ f.normalized_hash,
197
+ json.dumps(f.token_sequence),
198
+ f.source_text,
199
+ )
200
+ for f in fragments
201
+ ],
202
+ )
203
+
204
+ def get_fragments(self, session_id: str) -> list[Fragment]:
205
+ """Return all fragments for a session."""
206
+ rows = self._connection.execute(
207
+ "SELECT * FROM fragments WHERE session_id = ?", (session_id,)
208
+ ).fetchall()
209
+ return [_row_to_fragment(r) for r in rows]
210
+
211
+ def get_fragment_by_id(self, fragment_id: str) -> Fragment | None:
212
+ """Return a single Fragment by its ID, or None if not found."""
213
+ row = self._connection.execute(
214
+ "SELECT * FROM fragments WHERE id = ?", (fragment_id,)
215
+ ).fetchone()
216
+ return _row_to_fragment(row) if row else None
217
+
218
+ def get_fragments_by_ids(self, fragment_ids: list[str]) -> list[Fragment]:
219
+ """Fetch multiple fragments by ID in one query."""
220
+ if not fragment_ids:
221
+ return []
222
+ placeholders = ",".join("?" * len(fragment_ids))
223
+ rows = self._connection.execute(
224
+ f"SELECT * FROM fragments WHERE id IN ({placeholders})", fragment_ids
225
+ ).fetchall()
226
+ return [_row_to_fragment(r) for r in rows]
227
+
228
+ def count_fragments(self, session_id: str) -> int:
229
+ """Return the number of fragments stored for a session."""
230
+ row = self._connection.execute(
231
+ "SELECT COUNT(*) AS cnt FROM fragments WHERE session_id = ?", (session_id,)
232
+ ).fetchone()
233
+ return int(row["cnt"]) if row else 0
234
+
235
+ # ------------------------------------------------------------------
236
+ # Clone groups
237
+ # ------------------------------------------------------------------
238
+
239
+ def insert_clone_group(self, group: CloneGroup) -> None:
240
+ """Persist a CloneGroup and its member associations."""
241
+ self._connection.execute(
242
+ "INSERT INTO clone_groups (id, session_id, clone_type, representative_hash, similarity_score) "
243
+ "VALUES (?, ?, ?, ?, ?)",
244
+ (
245
+ group.group_id,
246
+ group.session_id,
247
+ group.clone_type,
248
+ group.representative_hash,
249
+ group.similarity_score,
250
+ ),
251
+ )
252
+ self._connection.executemany(
253
+ "INSERT INTO clone_group_members (group_id, fragment_id) VALUES (?, ?)",
254
+ [(group.group_id, fid) for fid in group.member_fragment_ids],
255
+ )
256
+
257
+ def get_clone_groups(self, session_id: str) -> list[CloneGroup]:
258
+ """Return all clone groups with member fragment IDs for a session."""
259
+ rows = self._connection.execute(
260
+ """
261
+ SELECT cg.id, cg.session_id, cg.clone_type,
262
+ cg.representative_hash, cg.similarity_score,
263
+ cgm.fragment_id
264
+ FROM clone_groups cg
265
+ LEFT JOIN clone_group_members cgm ON cgm.group_id = cg.id
266
+ WHERE cg.session_id = ?
267
+ ORDER BY cg.clone_type, cg.id
268
+ """,
269
+ (session_id,),
270
+ ).fetchall()
271
+ seen: dict[str, CloneGroup] = {}
272
+ for row in rows:
273
+ gid = row["id"]
274
+ if gid not in seen:
275
+ seen[gid] = CloneGroup(
276
+ group_id=gid,
277
+ session_id=row["session_id"],
278
+ clone_type=row["clone_type"],
279
+ representative_hash=row["representative_hash"],
280
+ similarity_score=row["similarity_score"],
281
+ member_fragment_ids=[],
282
+ )
283
+ if row["fragment_id"]:
284
+ seen[gid].member_fragment_ids.append(row["fragment_id"])
285
+ return list(seen.values())
286
+
287
+ def get_fragments_for_group(self, group: CloneGroup) -> list[Fragment]:
288
+ """Fetch Fragment objects belonging to a CloneGroup."""
289
+ return self.get_fragments_by_ids(group.member_fragment_ids)