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 ADDED
@@ -0,0 +1,3 @@
1
+ """Read-only project maps and comparisons."""
2
+
3
+ __version__ = "0.5.1"
grapholog/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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