grapholog 0.5.1__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.
- grapholog/__init__.py +3 -0
- grapholog/__main__.py +3 -0
- grapholog/cache.py +199 -0
- grapholog/cli.py +144 -0
- grapholog/compare.py +141 -0
- grapholog/contracts.py +211 -0
- grapholog/data/grapholog.proto +13 -0
- grapholog/data/logic-profile.json +10 -0
- grapholog/data/workspace.html +52 -0
- grapholog/extraction.py +267 -0
- grapholog/extraction_contracts.py +84 -0
- grapholog/llm.py +143 -0
- grapholog/model.py +108 -0
- grapholog/planfile_bridge.py +670 -0
- grapholog/proto/__init__.py +1 -0
- grapholog/proto/grapholog_pb2.py +44 -0
- grapholog/proto/grapholog_pb2_grpc.py +146 -0
- grapholog/python_parser.py +140 -0
- grapholog/render.py +235 -0
- grapholog/resolve.py +166 -0
- grapholog/scan.py +190 -0
- grapholog/selection.py +25 -0
- grapholog/service.py +241 -0
- grapholog/similarity.py +51 -0
- grapholog/tickets_cli.py +98 -0
- grapholog/transports.py +147 -0
- grapholog/tree_parser.py +244 -0
- grapholog/workspace.py +298 -0
- grapholog/workspace_cli.py +110 -0
- grapholog/workspace_render.py +74 -0
- grapholog-0.5.1.dist-info/METADATA +681 -0
- grapholog-0.5.1.dist-info/RECORD +36 -0
- grapholog-0.5.1.dist-info/WHEEL +5 -0
- grapholog-0.5.1.dist-info/entry_points.txt +3 -0
- grapholog-0.5.1.dist-info/licenses/LICENSE +21 -0
- grapholog-0.5.1.dist-info/top_level.txt +1 -0
grapholog/__init__.py
ADDED
grapholog/__main__.py
ADDED
grapholog/cache.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Optional, disposable parse cache. Source bytes are always hashed again.
|
|
2
|
+
|
|
3
|
+
Entries contain a JSON header followed by a compact JSON payload. Only successful
|
|
4
|
+
path-independent parse data is stored; dependency resolution is never cached.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
from importlib import metadata, resources
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from typing import get_args, get_origin, get_type_hints
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .model import Call, File, Import, Logic, Symbol
|
|
22
|
+
|
|
23
|
+
FORMAT = "grapholog.parse-cache.v1"
|
|
24
|
+
MAX_ENTRY = 64 * 1024 * 1024
|
|
25
|
+
PARSE_FIELDS = ("lines", "parser", "status", "syntax_hash", "shape_hash", "fingerprints",
|
|
26
|
+
"nodes", "symbols", "imports", "calls", "logic", "notes")
|
|
27
|
+
RECORDS = {"symbols": Symbol, "imports": Import, "calls": Call, "logic": Logic}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=1)
|
|
31
|
+
def identity() -> dict:
|
|
32
|
+
versions = {}
|
|
33
|
+
for package in ("tree-sitter", "tree-sitter-javascript", "tree-sitter-typescript",
|
|
34
|
+
"tree-sitter-php", "tree-sitter-bash"):
|
|
35
|
+
try:
|
|
36
|
+
versions[package] = metadata.version(package)
|
|
37
|
+
except metadata.PackageNotFoundError:
|
|
38
|
+
versions[package] = "missing"
|
|
39
|
+
source = resources.files("grapholog")
|
|
40
|
+
return {"format": FORMAT, "engine": "python", "version": __version__,
|
|
41
|
+
"interpreter": [sys.implementation.name, *sys.version_info[:3]],
|
|
42
|
+
"packages": versions,
|
|
43
|
+
"code": {name: sha256(source.joinpath(name).read_bytes()).hexdigest()
|
|
44
|
+
for name in ("cache.py", "model.py", "python_parser.py", "tree_parser.py")}}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _encoded(value) -> bytes:
|
|
48
|
+
return json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@lru_cache(maxsize=None)
|
|
52
|
+
def _hints(cls):
|
|
53
|
+
return get_type_hints(cls)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _valid(value, kind) -> bool:
|
|
57
|
+
origin, args = get_origin(kind), get_args(kind)
|
|
58
|
+
if kind is str:
|
|
59
|
+
return isinstance(value, str)
|
|
60
|
+
if kind is int:
|
|
61
|
+
return type(value) is int and value >= 0
|
|
62
|
+
if kind is bool:
|
|
63
|
+
return type(value) is bool
|
|
64
|
+
if origin is list:
|
|
65
|
+
return isinstance(value, list) and all(_valid(v, args[0]) for v in value)
|
|
66
|
+
if origin is dict:
|
|
67
|
+
return isinstance(value, dict) and all(_valid(k, args[0]) and _valid(v, args[1]) for k, v in value.items())
|
|
68
|
+
if is_dataclass(kind):
|
|
69
|
+
hints = _hints(kind)
|
|
70
|
+
return isinstance(value, dict) and value.keys() == hints.keys() and all(_valid(value[k], v) for k, v in hints.items())
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _hex(value: str, size: int) -> bool:
|
|
75
|
+
return len(value) == size and all(c in "0123456789abcdef" for c in value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ParseCache:
|
|
79
|
+
def __init__(self, directory: str | Path | None):
|
|
80
|
+
self.root = None
|
|
81
|
+
self.namespace = ""
|
|
82
|
+
self.stats = {"enabled": False}
|
|
83
|
+
if directory is None:
|
|
84
|
+
return
|
|
85
|
+
path = Path(directory).expanduser().absolute()
|
|
86
|
+
self.stats = {"enabled": False, "directory": str(path), "hits": 0, "misses": 0,
|
|
87
|
+
"writes": 0, "invalid": 0, "errors": 0, "skipped_large": 0, "issues": []}
|
|
88
|
+
try:
|
|
89
|
+
if path.is_symlink():
|
|
90
|
+
raise OSError("Cache directory must not be a symlink")
|
|
91
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
92
|
+
self.root = path.resolve()
|
|
93
|
+
self.namespace = sha256(_encoded(identity())).hexdigest()
|
|
94
|
+
self.stats.update(enabled=True, directory=str(self.root), namespace=self.namespace)
|
|
95
|
+
except (OSError, ValueError) as exc:
|
|
96
|
+
self.root = None
|
|
97
|
+
self._error(exc)
|
|
98
|
+
|
|
99
|
+
def _error(self, error):
|
|
100
|
+
self.stats["errors"] += 1
|
|
101
|
+
if len(self.stats["issues"]) < 3:
|
|
102
|
+
self.stats["issues"].append(str(error))
|
|
103
|
+
|
|
104
|
+
def _entry(self, file: File) -> Path:
|
|
105
|
+
key = sha256((file.language + "\0" + file.sha256).encode()).hexdigest()
|
|
106
|
+
return self.root / "python-v1" / self.namespace / key[:2] / (key + ".jsonl")
|
|
107
|
+
|
|
108
|
+
def _safe_parents(self, path: Path, create: bool = False):
|
|
109
|
+
# Cache names are generated hashes, never source paths. Reject symlinked shards.
|
|
110
|
+
for folder in reversed(list(path.parents)):
|
|
111
|
+
if folder == self.root or self.root in folder.parents:
|
|
112
|
+
if create:
|
|
113
|
+
folder.mkdir(exist_ok=True, mode=0o700)
|
|
114
|
+
info = folder.lstat()
|
|
115
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
116
|
+
raise OSError("Cache parent is not a regular directory")
|
|
117
|
+
|
|
118
|
+
def load(self, file: File) -> bool:
|
|
119
|
+
if self.root is None:
|
|
120
|
+
return False
|
|
121
|
+
try:
|
|
122
|
+
path = self._entry(file)
|
|
123
|
+
self._safe_parents(path)
|
|
124
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
125
|
+
with os.fdopen(os.open(path, flags), "rb") as stream:
|
|
126
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
127
|
+
raise ValueError("Cache entry is not a regular file")
|
|
128
|
+
data = stream.read(MAX_ENTRY + 1)
|
|
129
|
+
if len(data) > MAX_ENTRY:
|
|
130
|
+
raise ValueError("Cache entry exceeds size limit")
|
|
131
|
+
head, payload = data.split(b"\n", 1)
|
|
132
|
+
if len(head) > 4096:
|
|
133
|
+
raise ValueError("Invalid cache header")
|
|
134
|
+
header = json.loads(head)
|
|
135
|
+
expected = {"schema": FORMAT, "namespace": self.namespace, "language": file.language,
|
|
136
|
+
"source_sha256": file.sha256, "payload_sha256": sha256(payload).hexdigest()}
|
|
137
|
+
if header != expected:
|
|
138
|
+
raise ValueError("Cache header or checksum mismatch")
|
|
139
|
+
result = json.loads(payload)
|
|
140
|
+
hints = _hints(File)
|
|
141
|
+
if (not isinstance(result, dict) or set(result) != set(PARSE_FIELDS)
|
|
142
|
+
or not all(_valid(result[k], hints[k]) for k in PARSE_FIELDS)):
|
|
143
|
+
raise ValueError("Invalid parse data")
|
|
144
|
+
expected_parser = "python.ast" if file.language == "python" else "tree-sitter:" + file.language
|
|
145
|
+
if (result["status"] != "parsed" or result["parser"] != expected_parser
|
|
146
|
+
or not all(_hex(result[k], 64) for k in ("syntax_hash", "shape_hash"))
|
|
147
|
+
or any(not _hex(s, 16) for s in result["fingerprints"])
|
|
148
|
+
or any(a >= b for a, b in zip(result["fingerprints"], result["fingerprints"][1:]))):
|
|
149
|
+
raise ValueError("Incompatible parse data")
|
|
150
|
+
if (any(i["targets"] or i["status"] != "unresolved" for i in result["imports"])
|
|
151
|
+
or any(c["target"] or c["resolution"] != "unresolved" for c in result["calls"])):
|
|
152
|
+
raise ValueError("Cache contains resolved dependencies")
|
|
153
|
+
# Apply only after validating the entire entry; no partially restored state.
|
|
154
|
+
for name, cls in RECORDS.items():
|
|
155
|
+
result[name] = [cls(**item) for item in result[name]]
|
|
156
|
+
for name in PARSE_FIELDS:
|
|
157
|
+
setattr(file, name, result[name])
|
|
158
|
+
self.stats["hits"] += 1
|
|
159
|
+
return True
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
pass
|
|
162
|
+
except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
|
|
163
|
+
self.stats["invalid"] += 1
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
self._error(exc)
|
|
166
|
+
self.stats["misses"] += 1
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def store(self, file: File):
|
|
170
|
+
if self.root is None or file.status != "parsed":
|
|
171
|
+
return
|
|
172
|
+
temporary = None
|
|
173
|
+
try:
|
|
174
|
+
data = {name: getattr(file, name) for name in PARSE_FIELDS}
|
|
175
|
+
for name in RECORDS:
|
|
176
|
+
data[name] = [asdict(item) for item in data[name]]
|
|
177
|
+
payload = _encoded(data)
|
|
178
|
+
header = _encoded({"schema": FORMAT, "namespace": self.namespace, "language": file.language,
|
|
179
|
+
"source_sha256": file.sha256, "payload_sha256": sha256(payload).hexdigest()})
|
|
180
|
+
if len(payload) + len(header) + 1 > MAX_ENTRY:
|
|
181
|
+
self.stats["skipped_large"] += 1
|
|
182
|
+
return
|
|
183
|
+
path = self._entry(file)
|
|
184
|
+
self._safe_parents(path, create=True)
|
|
185
|
+
fd, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
|
|
186
|
+
with os.fdopen(fd, "wb") as stream:
|
|
187
|
+
stream.write(header + b"\n")
|
|
188
|
+
stream.write(payload)
|
|
189
|
+
os.replace(temporary, path)
|
|
190
|
+
temporary = None
|
|
191
|
+
self.stats["writes"] += 1
|
|
192
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
193
|
+
self._error(exc)
|
|
194
|
+
finally:
|
|
195
|
+
if temporary is not None:
|
|
196
|
+
try:
|
|
197
|
+
os.unlink(temporary)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
grapholog/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .compare import compare, duplicates
|
|
10
|
+
from .render import render_compare, render_project, safe, to_dot
|
|
11
|
+
from .scan import scan
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def nonnegative(value):
|
|
15
|
+
number = int(value)
|
|
16
|
+
if number < 0:
|
|
17
|
+
raise argparse.ArgumentTypeError("must be >= 0")
|
|
18
|
+
return number
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def similarity(value):
|
|
22
|
+
number = float(value)
|
|
23
|
+
if not 0 <= number <= 1:
|
|
24
|
+
raise argparse.ArgumentTypeError("must be between 0 and 1")
|
|
25
|
+
return number
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parser():
|
|
29
|
+
p = argparse.ArgumentParser(prog="grapholog", description="Jednorazowa mapa plikow, skladni i zaleznosci; bez wykonywania kodu projektu.")
|
|
30
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
31
|
+
subs = p.add_subparsers(dest="command", required=True)
|
|
32
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
33
|
+
common.add_argument("--all", dest="all_files", action="store_true", help="wylacz domyslne wykluczenia i .graphologignore")
|
|
34
|
+
common.add_argument("--exclude", action="append", default=[], metavar="GLOB", help="wlasne wykluczenie; mozna powtarzac")
|
|
35
|
+
common.add_argument("--source-root", action="append", default=[], metavar="DIR", help="korzen importow Python wzgledem projektu; domyslnie . oraz src")
|
|
36
|
+
common.add_argument("--max-bytes", type=nonnegative, default=2 * 1024 * 1024, help="limit parsowania pliku; SHA-256 liczony zawsze; 0=bez limitu")
|
|
37
|
+
common.add_argument("--min-nodes", type=nonnegative, default=12, help="minimalna liczba wezlow dla grup podobienstwa skladni/funkcji")
|
|
38
|
+
common.add_argument("--cache-dir", type=Path, help="opcjonalny katalog cache parsera; pliki zrodlowe nadal sa odczytywane i haszowane")
|
|
39
|
+
common.add_argument("--profile", choices=("inventory", "logic"), default="inventory", help="logic pomija dane i pliki generowane przed odczytem; zachowuje manifesty i licencje")
|
|
40
|
+
common.add_argument("--ascii", action="store_true", help="widok zgodny z terminalem ASCII")
|
|
41
|
+
common.add_argument("--color", choices=("auto", "always", "never"), default="auto")
|
|
42
|
+
common.add_argument("--output", "-o", type=Path, help="zapis raportu do wskazanego pliku; bez nadpisania plikow zrodlowych")
|
|
43
|
+
common.add_argument("--strict", action="store_true", help="kod 2 przy niepelnej analizie obslugiwanych jezykow/bledach odczytu")
|
|
44
|
+
show = subs.add_parser("scan", parents=[common], help="pokaz mape jednego projektu")
|
|
45
|
+
show.add_argument("path", nargs="?", default=".")
|
|
46
|
+
show.add_argument("--format", choices=("terminal", "json", "dot"), default="terminal")
|
|
47
|
+
show.add_argument("--detail", choices=("files", "compact", "full"), default="compact")
|
|
48
|
+
diff = subs.add_parser("diff", parents=[common], help="porownaj dwa drzewa katalogow")
|
|
49
|
+
diff.add_argument("left")
|
|
50
|
+
diff.add_argument("right")
|
|
51
|
+
diff.add_argument("--format", choices=("terminal", "json"), default="terminal")
|
|
52
|
+
diff.add_argument("--threshold", type=similarity, default=0.70)
|
|
53
|
+
diff.add_argument("--max-pairs", type=nonnegative, default=20000, help="maks. porownan przy szukaniu podobnych niedopasowanych plikow")
|
|
54
|
+
diff.add_argument("--check", action="store_true", help="kod 1 jesli projekty roznia sie; przydatne w CI")
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def incomplete(project) -> bool:
|
|
59
|
+
return bool(project.warnings) or any(f.status in {"parse-error", "read-error", "changed-during-scan", "parser-missing"}
|
|
60
|
+
or (f.language != "other" and f.status in {"size-limit", "non-utf8", "binary"}) for f in project.files)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv=None):
|
|
64
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
65
|
+
if args_list and args_list[0] == "tickets":
|
|
66
|
+
from .tickets_cli import main as tickets_main
|
|
67
|
+
return tickets_main(args_list[1:])
|
|
68
|
+
if args_list and args_list[0] in {"workspace", "contracts", "serve"}:
|
|
69
|
+
from .workspace_cli import main as workspace_main
|
|
70
|
+
return workspace_main(args_list)
|
|
71
|
+
# `grapholog ./project` is shorthand for `grapholog scan ./project`.
|
|
72
|
+
if not args_list or (args_list[0] not in {"scan", "diff", "-h", "--help", "--version"}):
|
|
73
|
+
args_list.insert(0, "scan")
|
|
74
|
+
args = parser().parse_args(args_list)
|
|
75
|
+
try:
|
|
76
|
+
options = dict(all_files=args.all_files, exclude=args.exclude, max_bytes=args.max_bytes,
|
|
77
|
+
source_roots=args.source_root or None, cache_dir=args.cache_dir, profile=args.profile)
|
|
78
|
+
for root in args.source_root:
|
|
79
|
+
if Path(root).is_absolute() or ".." in Path(root).parts:
|
|
80
|
+
raise ValueError("--source-root must be a relative directory inside the project")
|
|
81
|
+
output = args.output.expanduser().absolute() if args.output else None
|
|
82
|
+
if output and output.exists():
|
|
83
|
+
raise ValueError(f"Output exists: {output}. Choose a new report path.")
|
|
84
|
+
if output and output.is_symlink():
|
|
85
|
+
raise ValueError("Output cannot be a symbolic link")
|
|
86
|
+
color = args.color == "always" or (args.color == "auto" and sys.stdout.isatty()
|
|
87
|
+
and "NO_COLOR" not in os.environ and output is None)
|
|
88
|
+
projects = []
|
|
89
|
+
def run(path):
|
|
90
|
+
opts = {**options, "exclude": list(options["exclude"])}
|
|
91
|
+
if output:
|
|
92
|
+
try:
|
|
93
|
+
opts["exclude"].append(output.resolve().relative_to(Path(path).expanduser().resolve()).as_posix())
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
project = scan(path, **opts)
|
|
97
|
+
projects.append(project)
|
|
98
|
+
return project
|
|
99
|
+
if args.command == "scan":
|
|
100
|
+
project = run(args.path)
|
|
101
|
+
if args.format == "json":
|
|
102
|
+
data = project.to_dict()
|
|
103
|
+
data["duplicates"] = duplicates([("", project)], args.min_nodes)
|
|
104
|
+
result = None
|
|
105
|
+
elif args.format == "dot":
|
|
106
|
+
result = to_dot(project)
|
|
107
|
+
else:
|
|
108
|
+
result = render_project(project, detail=args.detail, ascii_only=args.ascii,
|
|
109
|
+
color=color, min_nodes=args.min_nodes)
|
|
110
|
+
code = 0
|
|
111
|
+
else:
|
|
112
|
+
left, right = run(args.left), run(args.right)
|
|
113
|
+
data = compare(left, right, threshold=args.threshold, max_pairs=args.max_pairs, min_nodes=args.min_nodes)
|
|
114
|
+
result = None if args.format == "json" else render_compare(data, ascii_only=args.ascii, color=color)
|
|
115
|
+
code = 1 if args.check and data["has_differences"] else 0
|
|
116
|
+
def write_result(handle):
|
|
117
|
+
if result is None:
|
|
118
|
+
json.dump(data, handle, ensure_ascii=True, indent=2)
|
|
119
|
+
handle.write("\n")
|
|
120
|
+
else:
|
|
121
|
+
handle.write(result)
|
|
122
|
+
if output:
|
|
123
|
+
with output.open("x", encoding="utf-8") as handle:
|
|
124
|
+
write_result(handle)
|
|
125
|
+
print(f"Saved: {safe(output)}", file=sys.stderr)
|
|
126
|
+
else:
|
|
127
|
+
try:
|
|
128
|
+
write_result(sys.stdout)
|
|
129
|
+
except UnicodeEncodeError:
|
|
130
|
+
sys.stdout.write(result.encode(sys.stdout.encoding or "ascii", "backslashreplace").decode(sys.stdout.encoding or "ascii"))
|
|
131
|
+
if args.strict and any(incomplete(project) for project in projects):
|
|
132
|
+
return 2
|
|
133
|
+
return code
|
|
134
|
+
except BrokenPipeError:
|
|
135
|
+
# Avoid a second flush failure when piping a long report to head.
|
|
136
|
+
sys.stdout = open(os.devnull, "w")
|
|
137
|
+
return 0
|
|
138
|
+
except (OSError, ValueError) as exc:
|
|
139
|
+
print("grapholog: " + safe(exc), file=sys.stderr)
|
|
140
|
+
return 2
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
raise SystemExit(main())
|
grapholog/compare.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from .model import File, Project, symbol_ref
|
|
6
|
+
from .similarity import search
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _groups(items, key, minimum=2):
|
|
10
|
+
groups = defaultdict(list)
|
|
11
|
+
for item in items:
|
|
12
|
+
value = key(item)
|
|
13
|
+
if value:
|
|
14
|
+
groups[value].append(item)
|
|
15
|
+
return [group for _, group in sorted(groups.items(), key=lambda kv: str(kv[0])) if len(group) >= minimum]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def duplicates(projects: list[tuple[str, Project]], min_nodes: int = 12) -> dict:
|
|
19
|
+
records = [(prefix, file) for prefix, p in projects for file in p.files]
|
|
20
|
+
ref = lambda r: r[0] + r[1].path
|
|
21
|
+
result = {"exact_files": [], "syntax_files": [], "shape_candidates": [], "function_clones": [],
|
|
22
|
+
"function_shape_candidates": []}
|
|
23
|
+
for group in _groups(records, lambda r: (r[1].kind, r[1].sha256) if r[1].sha256 else None):
|
|
24
|
+
result["exact_files"].append(sorted(ref(r) for r in group))
|
|
25
|
+
parsed = [r for r in records if r[1].status == "parsed" and r[1].nodes >= min_nodes]
|
|
26
|
+
for group in _groups(parsed, lambda r: (r[1].parser, r[1].syntax_hash)):
|
|
27
|
+
if len({r[1].sha256 for r in group}) > 1:
|
|
28
|
+
result["syntax_files"].append(sorted(ref(r) for r in group))
|
|
29
|
+
for group in _groups(parsed, lambda r: (r[1].parser, r[1].shape_hash)):
|
|
30
|
+
if len({r[1].syntax_hash for r in group}) > 1:
|
|
31
|
+
result["shape_candidates"].append(sorted(ref(r) for r in group))
|
|
32
|
+
symbols = [(prefix, f, s) for prefix, f in records for s in f.symbols
|
|
33
|
+
if s.kind not in {"class", "interface", "type", "enum", "trait"}
|
|
34
|
+
and s.nodes >= min_nodes and s.body_hash and f.status == "parsed"]
|
|
35
|
+
sref = lambda r: r[0] + symbol_ref(r[1], r[2])
|
|
36
|
+
for group in _groups(symbols, lambda r: (r[1].parser, r[2].body_hash)):
|
|
37
|
+
result["function_clones"].append(sorted(sref(r) for r in group))
|
|
38
|
+
for group in _groups(symbols, lambda r: (r[1].parser, r[2].shape_hash)):
|
|
39
|
+
if len({r[2].body_hash for r in group}) > 1:
|
|
40
|
+
result["function_shape_candidates"].append(sorted(sref(r) for r in group))
|
|
41
|
+
return {k: sorted(v) for k, v in result.items()}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def compatible(a: File, b: File) -> bool:
|
|
45
|
+
return a.parser == b.parser and a.language == b.language and a.status == b.status == "parsed"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def file_status(a: File, b: File) -> str:
|
|
49
|
+
if a.kind != b.kind:
|
|
50
|
+
return "type-changed"
|
|
51
|
+
if not a.sha256 or not b.sha256:
|
|
52
|
+
return "unreadable"
|
|
53
|
+
if a.sha256 == b.sha256:
|
|
54
|
+
return "unchanged" if a.executable == b.executable else "mode-changed"
|
|
55
|
+
if compatible(a, b) and a.syntax_hash == b.syntax_hash:
|
|
56
|
+
return "syntax-equal"
|
|
57
|
+
if compatible(a, b) and a.shape_hash == b.shape_hash:
|
|
58
|
+
return "identifier-change-candidate"
|
|
59
|
+
return "changed"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _symbol_delta(a: File, b: File) -> dict:
|
|
63
|
+
# Repeated overloads/redefinitions are compared as ordered sequences per qualified name.
|
|
64
|
+
old, new = defaultdict(list), defaultdict(list)
|
|
65
|
+
for symbol in a.symbols:
|
|
66
|
+
old[symbol.qualname].append(symbol.syntax_hash)
|
|
67
|
+
for symbol in b.symbols:
|
|
68
|
+
new[symbol.qualname].append(symbol.syntax_hash)
|
|
69
|
+
return {"added": sorted(new.keys() - old.keys()), "removed": sorted(old.keys() - new.keys()),
|
|
70
|
+
"changed": sorted(k for k in old.keys() & new.keys() if old[k] != new[k])}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _edge_set(project: Project, rename: dict | None = None) -> set[tuple]:
|
|
74
|
+
rename = rename or {}
|
|
75
|
+
result = set()
|
|
76
|
+
for f in project.files:
|
|
77
|
+
for item in f.imports:
|
|
78
|
+
if item.targets:
|
|
79
|
+
for target in item.targets:
|
|
80
|
+
result.add((rename.get(f.path, f.path), item.kind, rename.get(target, target), "local"))
|
|
81
|
+
else:
|
|
82
|
+
result.add((rename.get(f.path, f.path), item.kind, item.module, item.status))
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compare(left: Project, right: Project, *, threshold: float = 0.70,
|
|
87
|
+
max_pairs: int = 20000, min_nodes: int = 12) -> dict:
|
|
88
|
+
old, new = {f.path: f for f in left.files}, {f.path: f for f in right.files}
|
|
89
|
+
pairs = [{"left": p, "right": p, "status": file_status(old[p], new[p])}
|
|
90
|
+
for p in sorted(old.keys() & new.keys())]
|
|
91
|
+
removed, added = set(old.keys() - new.keys()), set(new.keys() - old.keys())
|
|
92
|
+
# Pair only unambiguous 1:1 candidates. Many-to-many copies are duplicate groups.
|
|
93
|
+
for mode in ("bytes", "syntax"):
|
|
94
|
+
def key(f):
|
|
95
|
+
if mode == "bytes":
|
|
96
|
+
return (f.kind, f.sha256) if f.sha256 else None
|
|
97
|
+
return (f.parser, f.syntax_hash) if f.status == "parsed" and f.syntax_hash else None
|
|
98
|
+
a, b = defaultdict(list), defaultdict(list)
|
|
99
|
+
for path in removed:
|
|
100
|
+
if value := key(old[path]):
|
|
101
|
+
a[value].append(path)
|
|
102
|
+
for path in added:
|
|
103
|
+
if value := key(new[path]):
|
|
104
|
+
b[value].append(path)
|
|
105
|
+
for value in sorted(a.keys() & b.keys()):
|
|
106
|
+
if len(a[value]) == len(b[value]) == 1:
|
|
107
|
+
src, dst = a[value][0], b[value][0]
|
|
108
|
+
pairs.append({"left": src, "right": dst,
|
|
109
|
+
"status": "relocated-identical" if mode == "bytes" else "relocated-syntax-equal"})
|
|
110
|
+
removed.remove(src)
|
|
111
|
+
added.remove(dst)
|
|
112
|
+
for pair in pairs:
|
|
113
|
+
a, b = old[pair["left"]], new[pair["right"]]
|
|
114
|
+
pair["symbol_delta"] = _symbol_delta(a, b)
|
|
115
|
+
pair["executable_changed"] = a.executable != b.executable
|
|
116
|
+
for path in sorted(removed):
|
|
117
|
+
pairs.append({"left": path, "right": None, "status": "removed"})
|
|
118
|
+
for path in sorted(added):
|
|
119
|
+
pairs.append({"left": None, "right": path, "status": "added"})
|
|
120
|
+
pairs.sort(key=lambda p: (p["right"] or p["left"], p["status"]))
|
|
121
|
+
|
|
122
|
+
left_candidates = [old[p] for p in sorted(removed) if old[p].nodes >= min_nodes and old[p].fingerprints]
|
|
123
|
+
right_candidates = [new[p] for p in sorted(added) if new[p].nodes >= min_nodes and new[p].fingerprints]
|
|
124
|
+
similarities, search_stats = search(left_candidates, right_candidates, threshold, max_pairs)
|
|
125
|
+
mapping = {p["left"]: p["right"] for p in pairs if p["left"] and p["right"]}
|
|
126
|
+
old_edges, new_edges = _edge_set(left, mapping), _edge_set(right)
|
|
127
|
+
directory_added = sorted(set(right.directories) - set(left.directories))
|
|
128
|
+
directory_removed = sorted(set(left.directories) - set(right.directories))
|
|
129
|
+
result = {"schema": "grapholog.compare.v1", "left": left.to_dict(), "right": right.to_dict(),
|
|
130
|
+
"pairs": pairs, "counts": dict(sorted(Counter(p["status"] for p in pairs).items())),
|
|
131
|
+
"directories_added": directory_added, "directories_removed": directory_removed,
|
|
132
|
+
"dependencies_added": [list(e) for e in sorted(new_edges - old_edges)],
|
|
133
|
+
"dependencies_removed": [list(e) for e in sorted(old_edges - new_edges)],
|
|
134
|
+
"similarities": sorted(similarities, key=lambda p: (-p["score"], p["left"], p["right"])),
|
|
135
|
+
"similarity_search": search_stats,
|
|
136
|
+
"duplicates": duplicates([("A:", left), ("B:", right)], min_nodes),
|
|
137
|
+
"has_differences": any(p["status"] != "unchanged" for p in pairs) or bool(directory_added or directory_removed),
|
|
138
|
+
"limitations": ["Static candidates, not runtime or semantic equivalence.",
|
|
139
|
+
"Similarity search applies to unmatched removed/added files only.",
|
|
140
|
+
"Relocation means unambiguous equal content at different paths; it is not VCS history."]}
|
|
141
|
+
return result
|