devin-graph 0.1.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.
- devin_graph/__init__.py +1 -0
- devin_graph/cli.py +297 -0
- devin_graph/extract.py +300 -0
- devin_graph/identity.py +126 -0
- devin_graph/paths.py +92 -0
- devin_graph/query.py +234 -0
- devin_graph/store.py +273 -0
- devin_graph/view.py +144 -0
- devin_graph/vscdb.py +117 -0
- devin_graph-0.1.0.dist-info/METADATA +207 -0
- devin_graph-0.1.0.dist-info/RECORD +15 -0
- devin_graph-0.1.0.dist-info/WHEEL +5 -0
- devin_graph-0.1.0.dist-info/entry_points.txt +2 -0
- devin_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
- devin_graph-0.1.0.dist-info/top_level.txt +1 -0
devin_graph/identity.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Opaque machine identity + profile for provenance metadata.
|
|
2
|
+
|
|
3
|
+
Artifacts written by this tool carry ``machine_id`` + ``profile`` so output
|
|
4
|
+
produced on different machines (e.g. personal vs corporate) can be told
|
|
5
|
+
apart — without leaking hostnames, usernames or MAC addresses.
|
|
6
|
+
|
|
7
|
+
Resolution order:
|
|
8
|
+
|
|
9
|
+
- ``machine_id``: the ``DEVIN_MACHINE_ID`` env var wins; otherwise a random
|
|
10
|
+
``secrets.token_hex(8)`` persisted at
|
|
11
|
+
``<devin-config-dir>/.devin-ecosystem/machine-id`` (mode 0600, created on
|
|
12
|
+
first use). Never derived from hostname, username or hardware ids.
|
|
13
|
+
- ``profile``: the ``DEVIN_ECOSYSTEM_PROFILE`` env var
|
|
14
|
+
(``"corporate"`` | ``"personal"``), else ``devin-profile.json`` in the
|
|
15
|
+
Devin config dir, else ``"corporate"`` — fail-closed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import secrets
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
MACHINE_ID_ENV = "DEVIN_MACHINE_ID"
|
|
27
|
+
PROFILE_ENV = "DEVIN_ECOSYSTEM_PROFILE"
|
|
28
|
+
CONFIG_DIR_ENV = "DEVIN_CONFIG_DIR"
|
|
29
|
+
ECOSYSTEM_DIRNAME = ".devin-ecosystem"
|
|
30
|
+
MACHINE_ID_FILENAME = "machine-id"
|
|
31
|
+
PROFILE_FILENAME = "devin-profile.json"
|
|
32
|
+
PROFILES = ("corporate", "personal")
|
|
33
|
+
DEFAULT_PROFILE = "corporate" # fail-closed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def devin_config_dir(
|
|
37
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
38
|
+
) -> Path:
|
|
39
|
+
"""Devin Desktop's config root (``%APPDATA%/Devin``, ``~/.config/Devin``).
|
|
40
|
+
|
|
41
|
+
Same rule devin-backup's ``default_config_dir`` uses, so every devin-*
|
|
42
|
+
tool resolves the same ``.devin-ecosystem/machine-id`` on one machine.
|
|
43
|
+
"""
|
|
44
|
+
env = os.environ if environ is None else environ
|
|
45
|
+
plat = sys.platform if platform is None else platform
|
|
46
|
+
override = env.get(CONFIG_DIR_ENV)
|
|
47
|
+
if override:
|
|
48
|
+
return Path(override).expanduser()
|
|
49
|
+
if plat == "nt" or plat.startswith("win"):
|
|
50
|
+
appdata = env.get("APPDATA")
|
|
51
|
+
if appdata:
|
|
52
|
+
return Path(appdata) / "Devin"
|
|
53
|
+
return Path.home() / "AppData" / "Roaming" / "Devin"
|
|
54
|
+
if plat == "darwin":
|
|
55
|
+
return Path.home() / "Library" / "Application Support" / "devin"
|
|
56
|
+
config_home = Path(env.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
57
|
+
candidates = [config_home / "Devin", config_home / "devin"]
|
|
58
|
+
return next(
|
|
59
|
+
(c for c in candidates if (c / "User").is_dir()), candidates[0]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def machine_id(
|
|
64
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Stable opaque id for this machine (16 hex chars)."""
|
|
67
|
+
env = os.environ if environ is None else environ
|
|
68
|
+
override = (env.get(MACHINE_ID_ENV) or "").strip()
|
|
69
|
+
if override:
|
|
70
|
+
return override
|
|
71
|
+
path = (
|
|
72
|
+
devin_config_dir(env, platform)
|
|
73
|
+
/ ECOSYSTEM_DIRNAME
|
|
74
|
+
/ MACHINE_ID_FILENAME
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
existing = path.read_text(encoding="utf-8").strip()
|
|
78
|
+
except OSError:
|
|
79
|
+
existing = ""
|
|
80
|
+
if existing:
|
|
81
|
+
return existing
|
|
82
|
+
value = secrets.token_hex(8)
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path.write_text(value + "\n", encoding="utf-8")
|
|
85
|
+
try:
|
|
86
|
+
path.chmod(0o600)
|
|
87
|
+
except OSError: # filesystems without POSIX modes
|
|
88
|
+
pass
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _profile_from_file(
|
|
93
|
+
env: dict[str, str], plat: str
|
|
94
|
+
) -> str | None:
|
|
95
|
+
path = devin_config_dir(env, plat) / PROFILE_FILENAME
|
|
96
|
+
try:
|
|
97
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, ValueError):
|
|
99
|
+
return None
|
|
100
|
+
raw = data.get("profile") if isinstance(data, dict) else data
|
|
101
|
+
raw = str(raw or "").strip().lower()
|
|
102
|
+
return raw if raw in PROFILES else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def profile(
|
|
106
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
107
|
+
) -> str:
|
|
108
|
+
"""``"corporate"`` | ``"personal"`` — unknown values fail closed."""
|
|
109
|
+
env = os.environ if environ is None else environ
|
|
110
|
+
raw = (env.get(PROFILE_ENV) or "").strip().lower()
|
|
111
|
+
if raw in PROFILES:
|
|
112
|
+
return raw
|
|
113
|
+
if raw:
|
|
114
|
+
return DEFAULT_PROFILE # set but unrecognized — fail closed
|
|
115
|
+
plat = sys.platform if platform is None else platform
|
|
116
|
+
return _profile_from_file(env, plat) or DEFAULT_PROFILE
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def provenance(
|
|
120
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
121
|
+
) -> dict[str, str]:
|
|
122
|
+
"""``{"machine_id": ..., "profile": ...}`` for embedding in outputs."""
|
|
123
|
+
return {
|
|
124
|
+
"machine_id": machine_id(environ, platform),
|
|
125
|
+
"profile": profile(environ, platform),
|
|
126
|
+
}
|
devin_graph/paths.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Default locations for Devin's local session database.
|
|
2
|
+
|
|
3
|
+
Linux uses ``$XDG_DATA_HOME/devin/cli/sessions.db`` (default
|
|
4
|
+
``~/.local/share/devin/cli/sessions.db``), with older config-dir candidates as
|
|
5
|
+
fallbacks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
APP_DIRNAME = "devin"
|
|
15
|
+
SESSIONS_DB_RELPATH = ("cli", "sessions.db")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _roots(environ: dict[str, str], platform: str) -> list[Path]:
|
|
19
|
+
if platform.startswith("win"):
|
|
20
|
+
roots = []
|
|
21
|
+
if environ.get("APPDATA"):
|
|
22
|
+
roots.append(Path(environ["APPDATA"]))
|
|
23
|
+
roots.append(Path.home() / "AppData" / "Roaming")
|
|
24
|
+
elif platform == "darwin":
|
|
25
|
+
roots = [Path.home() / "Library" / "Application Support"]
|
|
26
|
+
else:
|
|
27
|
+
roots = [
|
|
28
|
+
Path(environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"),
|
|
29
|
+
Path(environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"),
|
|
30
|
+
Path.home(),
|
|
31
|
+
]
|
|
32
|
+
return list(dict.fromkeys(roots))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sessions_db_candidates(
|
|
36
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
37
|
+
) -> list[Path]:
|
|
38
|
+
"""Candidate paths for the CLI ``sessions.db``, newest layout first."""
|
|
39
|
+
env = os.environ if environ is None else environ
|
|
40
|
+
plat = sys.platform if platform is None else platform
|
|
41
|
+
return [
|
|
42
|
+
root.joinpath(APP_DIRNAME, *SESSIONS_DB_RELPATH)
|
|
43
|
+
for root in _roots(env, plat)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
VSCDB_RELPATH = ("User", "globalStorage", "state.vscdb")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def state_vscdb_candidates(
|
|
51
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
52
|
+
) -> list[Path]:
|
|
53
|
+
"""Candidate paths for the GUI ``state.vscdb`` (Electron storage)."""
|
|
54
|
+
env = os.environ if environ is None else environ
|
|
55
|
+
plat = sys.platform if platform is None else platform
|
|
56
|
+
if plat.startswith("win"):
|
|
57
|
+
roots = []
|
|
58
|
+
if env.get("APPDATA"):
|
|
59
|
+
roots.append(Path(env["APPDATA"]))
|
|
60
|
+
roots.append(Path.home() / "AppData" / "Roaming")
|
|
61
|
+
elif plat == "darwin":
|
|
62
|
+
roots = [Path.home() / "Library" / "Application Support"]
|
|
63
|
+
else:
|
|
64
|
+
roots = [
|
|
65
|
+
Path(env.get("XDG_CONFIG_HOME") or Path.home() / ".config"),
|
|
66
|
+
Path.home(),
|
|
67
|
+
]
|
|
68
|
+
names = ("Devin", "devin")
|
|
69
|
+
return [
|
|
70
|
+
r.joinpath(n, *VSCDB_RELPATH)
|
|
71
|
+
for r in dict.fromkeys(roots) for n in names
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_state_vscdb(
|
|
76
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
77
|
+
) -> Path | None:
|
|
78
|
+
"""First existing state.vscdb candidate, or ``None``."""
|
|
79
|
+
for candidate in state_vscdb_candidates(environ=environ, platform=platform):
|
|
80
|
+
if candidate.is_file():
|
|
81
|
+
return candidate
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def default_sessions_db(
|
|
86
|
+
environ: dict[str, str] | None = None, platform: str | None = None
|
|
87
|
+
) -> Path | None:
|
|
88
|
+
"""First existing candidate, or ``None`` when no store is present."""
|
|
89
|
+
for candidate in sessions_db_candidates(environ=environ, platform=platform):
|
|
90
|
+
if candidate.is_file():
|
|
91
|
+
return candidate
|
|
92
|
+
return None
|
devin_graph/query.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Canned queries over a built ``graph.db``.
|
|
2
|
+
|
|
3
|
+
Matching is forgiving: exact key first, then case-insensitive, then suffix
|
|
4
|
+
(so ``src/app.py`` finds ``/repo/alpha/src/app.py``). All functions return
|
|
5
|
+
plain JSON-ready dicts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from itertools import combinations
|
|
11
|
+
|
|
12
|
+
from devin_graph.extract import normalize_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _session_dict(gs, node) -> dict:
|
|
16
|
+
project = None
|
|
17
|
+
for e in gs.out_edges(node.id):
|
|
18
|
+
if e.kind == "runs_in":
|
|
19
|
+
project = e.dst.split(":", 1)[1]
|
|
20
|
+
return {
|
|
21
|
+
"id": node.key,
|
|
22
|
+
"title": node.attrs.get("title"),
|
|
23
|
+
"project": project,
|
|
24
|
+
"last_activity_at": node.attrs.get("last_activity_at"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _match_file_keys(gs, query: str) -> list[str]:
|
|
29
|
+
q = normalize_path(query) or query.strip()
|
|
30
|
+
keys = [n.key for n in gs.nodes("file")]
|
|
31
|
+
exact = [k for k in keys if k == q]
|
|
32
|
+
if exact:
|
|
33
|
+
return sorted(exact)
|
|
34
|
+
ci = [k for k in keys if k.casefold() == q.casefold()]
|
|
35
|
+
if ci:
|
|
36
|
+
return sorted(ci)
|
|
37
|
+
return sorted(k for k in keys if k.endswith("/" + q) or k == q)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _match_project_keys(gs, query: str) -> list[str]:
|
|
41
|
+
q = normalize_path(query) or query.strip()
|
|
42
|
+
nodes = gs.nodes("project")
|
|
43
|
+
exact = [n for n in nodes if n.key == q]
|
|
44
|
+
if exact:
|
|
45
|
+
return sorted(n.key for n in exact)
|
|
46
|
+
named = [n for n in nodes if n.attrs.get("name") == query.strip()]
|
|
47
|
+
ci = [n for n in nodes
|
|
48
|
+
if n.key.casefold() == q.casefold()
|
|
49
|
+
or (n.attrs.get("name") or "").casefold() == query.strip().casefold()]
|
|
50
|
+
found = {n.key for n in named + ci}
|
|
51
|
+
if found:
|
|
52
|
+
return sorted(found)
|
|
53
|
+
return sorted(n.key for n in nodes
|
|
54
|
+
if n.key.endswith("/" + q) or n.key == q)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _match_tool_keys(gs, query: str) -> list[str]:
|
|
58
|
+
q = query.strip()
|
|
59
|
+
return sorted(n.key for n in gs.nodes("tool")
|
|
60
|
+
if n.key == q or n.key.casefold() == q.casefold())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sessions_of_project(gs, project_key: str) -> list[str]:
|
|
64
|
+
return sorted(
|
|
65
|
+
e.src.split(":", 1)[1]
|
|
66
|
+
for e in gs.in_edges(f"project:{project_key}")
|
|
67
|
+
if e.kind == "runs_in"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sessions_for_file(gs, path: str) -> dict:
|
|
72
|
+
"""Which sessions touched ``path``, via which tool calls."""
|
|
73
|
+
file_keys = _match_file_keys(gs, path)
|
|
74
|
+
tool_calls = []
|
|
75
|
+
session_ids: set[str] = set()
|
|
76
|
+
for fk in file_keys:
|
|
77
|
+
for e in gs.in_edges(f"file:{fk}"):
|
|
78
|
+
if e.kind != "file_touched":
|
|
79
|
+
continue
|
|
80
|
+
tool_calls.append({
|
|
81
|
+
"id": e.src.split(":", 1)[1],
|
|
82
|
+
"session_id": e.owner,
|
|
83
|
+
"file": fk,
|
|
84
|
+
})
|
|
85
|
+
if e.owner:
|
|
86
|
+
session_ids.add(e.owner)
|
|
87
|
+
tool_calls.sort(key=lambda t: t["id"])
|
|
88
|
+
sessions = [
|
|
89
|
+
_session_dict(gs, gs.node(f"session:{sid}"))
|
|
90
|
+
for sid in sorted(session_ids)
|
|
91
|
+
if gs.node(f"session:{sid}") is not None
|
|
92
|
+
]
|
|
93
|
+
return {"query": path, "files": file_keys, "sessions": sessions,
|
|
94
|
+
"tool_calls": tool_calls}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sessions_for_tool(gs, name: str) -> dict:
|
|
98
|
+
"""Which sessions/projects used tool ``name``."""
|
|
99
|
+
session_ids: set[str] = set()
|
|
100
|
+
projects: set[str] = set()
|
|
101
|
+
for key in _match_tool_keys(gs, name):
|
|
102
|
+
for e in gs.in_edges(f"tool:{key}"):
|
|
103
|
+
if e.kind == "tool_used" and e.owner:
|
|
104
|
+
session_ids.add(e.owner)
|
|
105
|
+
sessions = [
|
|
106
|
+
_session_dict(gs, gs.node(f"session:{sid}"))
|
|
107
|
+
for sid in sorted(session_ids)
|
|
108
|
+
if gs.node(f"session:{sid}") is not None
|
|
109
|
+
]
|
|
110
|
+
for s in sessions:
|
|
111
|
+
if s["project"]:
|
|
112
|
+
projects.add(s["project"])
|
|
113
|
+
return {"tool": name, "sessions": sessions,
|
|
114
|
+
"projects": sorted(projects)}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def tools_for_project(gs, query: str) -> dict:
|
|
118
|
+
"""Tools used by sessions running in project ``query``."""
|
|
119
|
+
project_keys = _match_project_keys(gs, query)
|
|
120
|
+
session_ids = sorted({
|
|
121
|
+
sid for pk in project_keys for sid in _sessions_of_project(gs, pk)
|
|
122
|
+
})
|
|
123
|
+
tools: set[str] = set()
|
|
124
|
+
for sid in session_ids:
|
|
125
|
+
for e in gs.out_edges(f"session:{sid}"):
|
|
126
|
+
if e.kind == "tool_used":
|
|
127
|
+
tools.add(e.dst.split(":", 1)[1])
|
|
128
|
+
sessions = [
|
|
129
|
+
_session_dict(gs, gs.node(f"session:{sid}"))
|
|
130
|
+
for sid in session_ids
|
|
131
|
+
if gs.node(f"session:{sid}") is not None
|
|
132
|
+
]
|
|
133
|
+
return {"query": query, "projects": project_keys, "tools": sorted(tools),
|
|
134
|
+
"sessions": sessions}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def project_detail(gs, query: str) -> dict:
|
|
138
|
+
"""One project: its sessions, the tools they used, the files they touched."""
|
|
139
|
+
keys = _match_project_keys(gs, query)
|
|
140
|
+
if not keys:
|
|
141
|
+
return {"project": None, "sessions": [], "tools": [], "files": []}
|
|
142
|
+
project_key = keys[0]
|
|
143
|
+
base = tools_for_project(gs, project_key)
|
|
144
|
+
session_ids = {s["id"] for s in base["sessions"]}
|
|
145
|
+
files = sorted({
|
|
146
|
+
e.dst.split(":", 1)[1]
|
|
147
|
+
for e in gs.edges("file_touched")
|
|
148
|
+
if e.owner in session_ids
|
|
149
|
+
})
|
|
150
|
+
return {"project": project_key, "sessions": base["sessions"],
|
|
151
|
+
"tools": base["tools"], "files": files}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def projects_graph(gs) -> dict:
|
|
155
|
+
"""Project-to-project adjacency for visualization (D3-style).
|
|
156
|
+
|
|
157
|
+
Two projects link when they share a tool (both used it) or a file (both
|
|
158
|
+
touched it); ``weight`` = number of shared entities.
|
|
159
|
+
"""
|
|
160
|
+
projects = gs.nodes("project")
|
|
161
|
+
tools_by_project: dict[str, set[str]] = {p.key: set() for p in projects}
|
|
162
|
+
files_by_project: dict[str, set[str]] = {p.key: set() for p in projects}
|
|
163
|
+
|
|
164
|
+
for e in gs.edges("tool_used"):
|
|
165
|
+
proj = _project_of_session(gs, e.src.split(":", 1)[1])
|
|
166
|
+
if proj in tools_by_project:
|
|
167
|
+
tools_by_project[proj].add(e.dst.split(":", 1)[1])
|
|
168
|
+
for e in gs.edges("file_touched"):
|
|
169
|
+
proj = _project_of_session(gs, e.owner)
|
|
170
|
+
if proj in files_by_project:
|
|
171
|
+
files_by_project[proj].add(e.dst.split(":", 1)[1])
|
|
172
|
+
|
|
173
|
+
nodes = [{
|
|
174
|
+
"id": p.key,
|
|
175
|
+
"name": p.attrs.get("name"),
|
|
176
|
+
"sessions": _sessions_of_project(gs, p.key),
|
|
177
|
+
} for p in projects]
|
|
178
|
+
|
|
179
|
+
links = []
|
|
180
|
+
for a, b in combinations(sorted(tools_by_project), 2):
|
|
181
|
+
shared_tools = sorted(tools_by_project[a] & tools_by_project[b])
|
|
182
|
+
shared_files = sorted(files_by_project[a] & files_by_project[b])
|
|
183
|
+
if shared_tools or shared_files:
|
|
184
|
+
links.append({
|
|
185
|
+
"source": a, "target": b,
|
|
186
|
+
"weight": len(shared_tools) + len(shared_files),
|
|
187
|
+
"shared_tools": shared_tools,
|
|
188
|
+
"shared_files": shared_files,
|
|
189
|
+
})
|
|
190
|
+
return {"nodes": nodes, "links": links}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _project_of_session(gs, session_id: str | None) -> str | None:
|
|
194
|
+
if session_id is None:
|
|
195
|
+
return None
|
|
196
|
+
for e in gs.out_edges(f"session:{session_id}"):
|
|
197
|
+
if e.kind == "runs_in":
|
|
198
|
+
return e.dst.split(":", 1)[1]
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def shared_files(gs) -> dict:
|
|
203
|
+
"""Files touched by two or more distinct projects.
|
|
204
|
+
|
|
205
|
+
A file counts when ``file_touched`` edges link it to tool calls owned by
|
|
206
|
+
sessions running in different projects (``working_directory``). Each
|
|
207
|
+
entry carries the sorted project list and the session ids that touched
|
|
208
|
+
the file — useful both for collision audits and D3-style highlighting.
|
|
209
|
+
"""
|
|
210
|
+
projects_of_file: dict[str, set[str]] = {}
|
|
211
|
+
sessions_of_file: dict[str, set[str]] = {}
|
|
212
|
+
for e in gs.edges("file_touched"):
|
|
213
|
+
if not e.owner:
|
|
214
|
+
continue
|
|
215
|
+
project = _project_of_session(gs, e.owner)
|
|
216
|
+
if project is None:
|
|
217
|
+
continue
|
|
218
|
+
file_key = e.dst.split(":", 1)[1]
|
|
219
|
+
projects_of_file.setdefault(file_key, set()).add(project)
|
|
220
|
+
sessions_of_file.setdefault(file_key, set()).add(e.owner)
|
|
221
|
+
|
|
222
|
+
files = []
|
|
223
|
+
for file_key in sorted(projects_of_file):
|
|
224
|
+
projects = sorted(projects_of_file[file_key])
|
|
225
|
+
if len(projects) < 2:
|
|
226
|
+
continue
|
|
227
|
+
node = gs.node(f"file:{file_key}")
|
|
228
|
+
files.append({
|
|
229
|
+
"file": file_key,
|
|
230
|
+
"name": node.attrs.get("name") if node else None,
|
|
231
|
+
"projects": projects,
|
|
232
|
+
"sessions": sorted(sessions_of_file[file_key]),
|
|
233
|
+
})
|
|
234
|
+
return {"count": len(files), "files": files}
|