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 +41 -0
- codeecho/__init__.py +22 -0
- codeecho/__main__.py +289 -0
- codeecho/db.py +289 -0
- codeecho/detector.py +246 -0
- codeecho/extractor.py +139 -0
- codeecho/fingerprint.py +38 -0
- codeecho/logging.ini +28 -0
- codeecho/models.py +52 -0
- codeecho/normalizer.py +519 -0
- codeecho/parser.py +94 -0
- codeecho/reporter/__init__.py +6 -0
- codeecho/reporter/html_reporter.py +212 -0
- codeecho/reporter/json_reporter.py +82 -0
- codeecho/scanner.py +109 -0
- codeecho-1.0.0.dist-info/METADATA +208 -0
- codeecho-1.0.0.dist-info/RECORD +20 -0
- codeecho-1.0.0.dist-info/WHEEL +4 -0
- codeecho-1.0.0.dist-info/entry_points.txt +3 -0
- codeecho-1.0.0.dist-info/licenses/LICENSE +21 -0
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)
|