devin-graph 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (33) hide show
  1. {devin_graph-0.1.1/src/devin_graph.egg-info → devin_graph-0.1.2}/PKG-INFO +14 -1
  2. {devin_graph-0.1.1 → devin_graph-0.1.2}/README.md +13 -0
  3. {devin_graph-0.1.1 → devin_graph-0.1.2}/pyproject.toml +1 -1
  4. devin_graph-0.1.2/src/devin_graph/__init__.py +1 -0
  5. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/cli.py +78 -0
  6. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/query.py +100 -0
  7. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/store.py +16 -4
  8. {devin_graph-0.1.1 → devin_graph-0.1.2/src/devin_graph.egg-info}/PKG-INFO +14 -1
  9. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/SOURCES.txt +1 -0
  10. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_cli.py +5 -2
  11. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_offline_core.py +4 -1
  12. devin_graph-0.1.2/tests/test_orphans_blame.py +160 -0
  13. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_store.py +4 -1
  14. devin_graph-0.1.1/src/devin_graph/__init__.py +0 -1
  15. {devin_graph-0.1.1 → devin_graph-0.1.2}/LICENSE +0 -0
  16. {devin_graph-0.1.1 → devin_graph-0.1.2}/setup.cfg +0 -0
  17. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/extract.py +0 -0
  18. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/identity.py +0 -0
  19. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/mcp_server.py +0 -0
  20. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/paths.py +0 -0
  21. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/view.py +0 -0
  22. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/vscdb.py +0 -0
  23. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/dependency_links.txt +0 -0
  24. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/entry_points.txt +0 -0
  25. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/requires.txt +0 -0
  26. {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/top_level.txt +0 -0
  27. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_extract.py +0 -0
  28. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_mcp.py +0 -0
  29. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_paths.py +0 -0
  30. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_query.py +0 -0
  31. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_skill.py +0 -0
  32. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_smoke.py +0 -0
  33. {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_vscdb.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devin-graph
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Knowledge graph over Devin sessions — projects, files, tools and decisions as queryable nodes.
5
5
  Author: Icaro0310
6
6
  License: MIT
@@ -97,6 +97,10 @@ devin-graph query projects-graph --graph graph.db --json
97
97
 
98
98
  # D3-friendly dump: {"meta", "nodes", "edges"}
99
99
  devin-graph export --format json --graph graph.db --out graph.json
100
+
101
+ # diagnostics: edge-less nodes (report-only) and per-session blame
102
+ devin-graph orphans --graph graph.db
103
+ devin-graph blame-session <id-or-prefix> --graph graph.db
100
104
  ```
101
105
 
102
106
  Matching is forgiving (`src/app.py` finds `/repo/alpha/src/app.py`);
@@ -105,6 +109,15 @@ by two or more distinct projects, with the project and session lists —
105
109
  exportable as JSON like the other queries. The source DB is opened
106
110
  `mode=ro` and never written — tests assert its hash is unchanged.
107
111
 
112
+ `orphans` lists nodes with no incident edges (id, kind, owner, a
113
+ `prunable` flag marking the subset the next `build` would drop —
114
+ shared, ownerless nodes; owned orphans persist with their owner). It is
115
+ report-only and never deletes. `blame-session <id>` shows what a
116
+ session produced and touched: its owned nodes and edges plus the
117
+ resolved files, tools, projects and commits reachable through them,
118
+ with the extraction-ledger timestamp. `<id>` accepts a unique prefix;
119
+ a missing or ambiguous match exits 2 and lists candidates.
120
+
108
121
  ## Works with Devin alone (Devin-only mode)
109
122
 
110
123
  devin-graph builds `graph.db` locally from Devin's session stores — the whole
@@ -80,6 +80,10 @@ devin-graph query projects-graph --graph graph.db --json
80
80
 
81
81
  # D3-friendly dump: {"meta", "nodes", "edges"}
82
82
  devin-graph export --format json --graph graph.db --out graph.json
83
+
84
+ # diagnostics: edge-less nodes (report-only) and per-session blame
85
+ devin-graph orphans --graph graph.db
86
+ devin-graph blame-session <id-or-prefix> --graph graph.db
83
87
  ```
84
88
 
85
89
  Matching is forgiving (`src/app.py` finds `/repo/alpha/src/app.py`);
@@ -88,6 +92,15 @@ by two or more distinct projects, with the project and session lists —
88
92
  exportable as JSON like the other queries. The source DB is opened
89
93
  `mode=ro` and never written — tests assert its hash is unchanged.
90
94
 
95
+ `orphans` lists nodes with no incident edges (id, kind, owner, a
96
+ `prunable` flag marking the subset the next `build` would drop —
97
+ shared, ownerless nodes; owned orphans persist with their owner). It is
98
+ report-only and never deletes. `blame-session <id>` shows what a
99
+ session produced and touched: its owned nodes and edges plus the
100
+ resolved files, tools, projects and commits reachable through them,
101
+ with the extraction-ledger timestamp. `<id>` accepts a unique prefix;
102
+ a missing or ambiguous match exits 2 and lists candidates.
103
+
91
104
  ## Works with Devin alone (Devin-only mode)
92
105
 
93
106
  devin-graph builds `graph.db` locally from Devin's session stores — the whole
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devin-graph"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Knowledge graph over Devin sessions — projects, files, tools and decisions as queryable nodes."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -13,6 +13,8 @@ from devin_internals.parsers import SessionsStore
13
13
  from devin_graph import __version__
14
14
  from devin_graph.paths import default_sessions_db, default_state_vscdb
15
15
  from devin_graph.query import (
16
+ blame_session,
17
+ orphaned_nodes,
16
18
  project_detail,
17
19
  projects_graph,
18
20
  sessions_for_file,
@@ -110,6 +112,24 @@ def build_parser() -> argparse.ArgumentParser:
110
112
  sq.add_argument("--json", action="store_true")
111
113
  sq.set_defaults(func=_cmd_query)
112
114
 
115
+ o = sub.add_parser(
116
+ "orphans", help="nodes with no incident edges — report-only, "
117
+ "never deletes (prunable flags what the next build would drop)")
118
+ _add_graph(o)
119
+ o.add_argument("--json", action="store_true")
120
+ o.set_defaults(func=_cmd_orphans)
121
+
122
+ bl = sub.add_parser(
123
+ "blame-session",
124
+ help="what a session produced/touched in the graph — owned "
125
+ "nodes and edges plus the files, tools, projects and commits "
126
+ "they reach")
127
+ bl.add_argument(
128
+ "session_id", help="session id or unique prefix")
129
+ _add_graph(bl)
130
+ bl.add_argument("--json", action="store_true")
131
+ bl.set_defaults(func=_cmd_blame)
132
+
113
133
  e = sub.add_parser("export", help="dump nodes+edges (D3-friendly)")
114
134
  e.add_argument("--format", choices=["json"], default="json")
115
135
  _add_graph(e)
@@ -226,6 +246,64 @@ def _print_query(what: str, data: dict) -> None:
226
246
  print(f"call: {t['id']} → {t['file']}")
227
247
 
228
248
 
249
+ def _cmd_orphans(args: argparse.Namespace) -> int:
250
+ with _open_graph(args.graph) as gs:
251
+ data = orphaned_nodes(gs)
252
+ if args.json:
253
+ print(json.dumps(data, ensure_ascii=False, indent=2))
254
+ return 0
255
+ if not data["nodes"]:
256
+ print("no orphaned nodes")
257
+ return 0
258
+ for n in data["nodes"]:
259
+ label = n.get("name") or ""
260
+ keep = "" if n["prunable"] else " (owned — kept by prune)"
261
+ print(f"{n['id']} {label}{keep}".rstrip())
262
+ print(f"{data['count']} orphan(s)")
263
+ return 0
264
+
265
+
266
+ def _cmd_blame(args: argparse.Namespace) -> int:
267
+ with _open_graph(args.graph) as gs:
268
+ data = blame_session(gs, args.session_id)
269
+ if data["session"] is None:
270
+ cand = data.get("candidates") or []
271
+ hint = f" — candidates: {', '.join(cand[:10])}" if cand else ""
272
+ print(
273
+ f"devin-graph: no session matches {args.session_id!r}{hint}",
274
+ file=sys.stderr,
275
+ )
276
+ return 2
277
+ if args.json:
278
+ print(json.dumps(data, ensure_ascii=False, indent=2))
279
+ return 0
280
+ s = data["session"]
281
+ print(f"session: {data['session_id']} {s.get('title') or 'Untitled'}")
282
+ if data["projects"]:
283
+ print("project: " + ", ".join(data["projects"]))
284
+ c = data["counts"]
285
+ print(
286
+ f"produced: {c['nodes']} node(s), {c['edges']} edge(s) · "
287
+ f"touched: {c['files']} file(s), {c['tools']} tool(s), "
288
+ f"{c['commits']} commit(s)"
289
+ )
290
+ if data["nodes_owned"]:
291
+ owned = [n for n in data["nodes_owned"] if n["kind"] == "tool_call"]
292
+ if owned:
293
+ print(f"tool calls ({len(owned)}):")
294
+ for n in owned:
295
+ print(f" {n['key']}")
296
+ if data["files"]:
297
+ print(f"files ({len(data['files'])}):")
298
+ for f in data["files"]:
299
+ print(f" {f}")
300
+ if data["tools"]:
301
+ print("tools: " + ", ".join(data["tools"]))
302
+ for cm in data["commits"]:
303
+ print(f"commit {cm['sha'][:7]} ({cm['relation']})")
304
+ return 0
305
+
306
+
229
307
  def _cmd_export(args: argparse.Namespace) -> int:
230
308
  with _open_graph(args.graph) as gs:
231
309
  data = gs.export()
@@ -232,3 +232,103 @@ def shared_files(gs) -> dict:
232
232
  "sessions": sorted(sessions_of_file[file_key]),
233
233
  })
234
234
  return {"count": len(files), "files": files}
235
+
236
+
237
+ def orphaned_nodes(gs) -> dict:
238
+ """Nodes with no incident edges — report-only, never deletes.
239
+
240
+ ``prunable`` marks the subset the builder's orphan prune would drop
241
+ on the next run (shared nodes — ``owner IS NULL`` — with no edges);
242
+ owned orphans (e.g. a session with no tool calls and no project)
243
+ carry provenance through their owner and persist until that owner
244
+ is re-extracted or removed.
245
+ """
246
+ endpoints = {e.src for e in gs.edges()} | {e.dst for e in gs.edges()}
247
+ nodes = [
248
+ {
249
+ "id": n.id,
250
+ "kind": n.kind,
251
+ "key": n.key,
252
+ "owner": n.owner,
253
+ "name": n.attrs.get("name") or n.attrs.get("title"),
254
+ "prunable": n.owner is None,
255
+ }
256
+ for n in gs.nodes()
257
+ if n.id not in endpoints
258
+ ]
259
+ return {"count": len(nodes), "nodes": nodes}
260
+
261
+
262
+ def blame_session(gs, query: str) -> dict:
263
+ """What graph content session ``query`` produced or touched.
264
+
265
+ ``produced`` = nodes and edges the extraction stamped with this
266
+ session as ``owner`` (the session node itself, its tool_call nodes,
267
+ and every edge it emitted); ``touched`` = the shared entities those
268
+ edges reach — files, tools, projects, commits. ``session`` is
269
+ ``None`` when ``query`` matches nothing or is an ambiguous prefix —
270
+ ``candidates`` then lists session keys for the CLI to hint at.
271
+ """
272
+ node = gs.node(f"session:{query}")
273
+ candidates: list[str] = []
274
+ if node is None:
275
+ candidates = sorted(
276
+ n.key for n in gs.nodes("session") if n.key.startswith(query)
277
+ )
278
+ if len(candidates) == 1:
279
+ node = gs.node(f"session:{candidates[0]}")
280
+ if node is None:
281
+ return {
282
+ "query": query,
283
+ "session": None,
284
+ "candidates": candidates[:20],
285
+ }
286
+ sid = node.key
287
+ owned_nodes = [n for n in gs.nodes() if n.owner == sid]
288
+ owned_edges = [e for e in gs.edges() if e.owner == sid]
289
+ files = sorted(
290
+ {e.dst.split(":", 1)[1] for e in owned_edges
291
+ if e.kind == "file_touched"}
292
+ )
293
+ tools = sorted(
294
+ {e.dst.split(":", 1)[1] for e in owned_edges
295
+ if e.kind == "tool_used"}
296
+ )
297
+ projects = sorted(
298
+ {e.dst.split(":", 1)[1] for e in owned_edges
299
+ if e.kind == "runs_in"}
300
+ )
301
+ commits = [
302
+ {"sha": sha, "relation": rel}
303
+ for sha, rel in sorted(
304
+ {
305
+ (e.dst.split(":", 1)[1], e.kind) # produced | referenced
306
+ for e in owned_edges
307
+ if e.kind in ("produced", "referenced")
308
+ }
309
+ )
310
+ ]
311
+ return {
312
+ "session_id": sid,
313
+ "session": _session_dict(gs, node),
314
+ "extracted_at": gs.extracted_at(sid),
315
+ "projects": projects,
316
+ "files": files,
317
+ "tools": tools,
318
+ "commits": commits,
319
+ "nodes_owned": [
320
+ {"id": n.id, "kind": n.kind, "key": n.key}
321
+ for n in owned_nodes
322
+ ],
323
+ "edges_owned": [
324
+ {"kind": e.kind, "src": e.src, "dst": e.dst}
325
+ for e in owned_edges
326
+ ],
327
+ "counts": {
328
+ "nodes": len(owned_nodes),
329
+ "edges": len(owned_edges),
330
+ "files": len(files),
331
+ "tools": len(tools),
332
+ "commits": len(commits),
333
+ },
334
+ }
@@ -60,6 +60,7 @@ class StoredNode:
60
60
  kind: str
61
61
  key: str
62
62
  attrs: dict
63
+ owner: str | None = None # session_id that produced the node
63
64
 
64
65
 
65
66
  @dataclass(frozen=True)
@@ -118,13 +119,14 @@ class GraphStore:
118
119
  # -- read API ------------------------------------------------------------
119
120
 
120
121
  def nodes(self, kind: str | None = None) -> list[StoredNode]:
121
- sql = "SELECT id, kind, key, attrs FROM nodes"
122
+ sql = "SELECT id, kind, key, attrs, owner FROM nodes"
122
123
  args: tuple = ()
123
124
  if kind is not None:
124
125
  sql += " WHERE kind = ?"
125
126
  args = (kind,)
126
127
  return [
127
- StoredNode(r["id"], r["kind"], r["key"], json.loads(r["attrs"]))
128
+ StoredNode(r["id"], r["kind"], r["key"],
129
+ json.loads(r["attrs"]), r["owner"])
128
130
  for r in self._con.execute(sql + " ORDER BY id", args)
129
131
  ]
130
132
 
@@ -142,13 +144,13 @@ class GraphStore:
142
144
 
143
145
  def node(self, node_id: str) -> StoredNode | None:
144
146
  row = self._con.execute(
145
- "SELECT id, kind, key, attrs FROM nodes WHERE id = ?",
147
+ "SELECT id, kind, key, attrs, owner FROM nodes WHERE id = ?",
146
148
  (node_id,),
147
149
  ).fetchone()
148
150
  if row is None:
149
151
  return None
150
152
  return StoredNode(row["id"], row["kind"], row["key"],
151
- json.loads(row["attrs"]))
153
+ json.loads(row["attrs"]), row["owner"])
152
154
 
153
155
  def out_edges(self, node_id: str) -> list[StoredEdge]:
154
156
  return [
@@ -172,6 +174,16 @@ class GraphStore:
172
174
  )
173
175
  ]
174
176
 
177
+ def extracted_at(self, session_id: str) -> int | None:
178
+ """Ledger timestamp for ``session_id`` — when it was last
179
+ (re)extracted into this graph, or ``None``."""
180
+ row = self._con.execute(
181
+ "SELECT last_activity_at FROM extracted_sessions"
182
+ " WHERE session_id = ?",
183
+ (session_id,),
184
+ ).fetchone()
185
+ return row["last_activity_at"] if row else None
186
+
175
187
  # -- build ---------------------------------------------------------------
176
188
 
177
189
  def build(self, sessions_store,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devin-graph
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Knowledge graph over Devin sessions — projects, files, tools and decisions as queryable nodes.
5
5
  Author: Icaro0310
6
6
  License: MIT
@@ -97,6 +97,10 @@ devin-graph query projects-graph --graph graph.db --json
97
97
 
98
98
  # D3-friendly dump: {"meta", "nodes", "edges"}
99
99
  devin-graph export --format json --graph graph.db --out graph.json
100
+
101
+ # diagnostics: edge-less nodes (report-only) and per-session blame
102
+ devin-graph orphans --graph graph.db
103
+ devin-graph blame-session <id-or-prefix> --graph graph.db
100
104
  ```
101
105
 
102
106
  Matching is forgiving (`src/app.py` finds `/repo/alpha/src/app.py`);
@@ -105,6 +109,15 @@ by two or more distinct projects, with the project and session lists —
105
109
  exportable as JSON like the other queries. The source DB is opened
106
110
  `mode=ro` and never written — tests assert its hash is unchanged.
107
111
 
112
+ `orphans` lists nodes with no incident edges (id, kind, owner, a
113
+ `prunable` flag marking the subset the next `build` would drop —
114
+ shared, ownerless nodes; owned orphans persist with their owner). It is
115
+ report-only and never deletes. `blame-session <id>` shows what a
116
+ session produced and touched: its owned nodes and edges plus the
117
+ resolved files, tools, projects and commits reachable through them,
118
+ with the extraction-ledger timestamp. `<id>` accepts a unique prefix;
119
+ a missing or ambiguous match exits 2 and lists candidates.
120
+
108
121
  ## Works with Devin alone (Devin-only mode)
109
122
 
110
123
  devin-graph builds `graph.db` locally from Devin's session stores — the whole
@@ -21,6 +21,7 @@ tests/test_cli.py
21
21
  tests/test_extract.py
22
22
  tests/test_mcp.py
23
23
  tests/test_offline_core.py
24
+ tests/test_orphans_blame.py
24
25
  tests/test_paths.py
25
26
  tests/test_query.py
26
27
  tests/test_skill.py
@@ -7,6 +7,7 @@ import pytest
7
7
  from conftest import ALPHA
8
8
  from devin_graph.cli import main
9
9
  from devin_internals.fixtures import create_sessions_db
10
+ from devin_internals.schema import LATEST_KNOWN_SCHEMA
10
11
 
11
12
 
12
13
  @pytest.fixture
@@ -123,7 +124,9 @@ def test_cli_export_stdout(built, capsys):
123
124
  assert main(["export", "--format", "json", "--graph", str(graph)]) == 0
124
125
  data = json.loads(capsys.readouterr().out)
125
126
  assert len(data["nodes"]) == 20 and len(data["edges"]) == 25
126
- assert data["meta"]["schema_version"] == 17
127
+ # fixture emits the installed spec's latest schema (v17 at 0.3.1,
128
+ # v18 at 0.3.3) — pin to the constant, not a literal
129
+ assert data["meta"]["schema_version"] == LATEST_KNOWN_SCHEMA
127
130
 
128
131
 
129
132
  def test_cli_export_out_file(built, tmp_path, capsys):
@@ -145,7 +148,7 @@ def test_cli_missing_sessions_db(tmp_path, capsys):
145
148
 
146
149
 
147
150
  def test_cli_unknown_schema_fails_loud(tmp_path, capsys):
148
- bad = create_sessions_db(tmp_path / "future.db", schema_version=18)
151
+ bad = create_sessions_db(tmp_path / "future.db", schema_version=LATEST_KNOWN_SCHEMA + 1)
149
152
  with pytest.raises(SystemExit) as exc:
150
153
  main(["build", "--sessions-db", str(bad),
151
154
  "--graph", str(tmp_path / "g.db")])
@@ -26,6 +26,7 @@ import socket
26
26
 
27
27
  import pytest
28
28
  from devin_graph.cli import main
29
+ from devin_internals.schema import LATEST_KNOWN_SCHEMA
29
30
 
30
31
 
31
32
  class OfflineCoreError(RuntimeError):
@@ -67,7 +68,9 @@ def test_build_and_export_offline(sessions_db, tmp_path, capsys):
67
68
  assert main(["export", "--format", "json", "--graph", str(graph)]) == 0
68
69
  data = json.loads(capsys.readouterr().out)
69
70
  assert len(data["nodes"]) > 0 and len(data["edges"]) > 0
70
- assert data["meta"]["schema_version"] == 17
71
+ # fixture emits the installed spec's latest schema (v17 at 0.3.1,
72
+ # v18 at 0.3.3) — pin to the constant, not a literal
73
+ assert data["meta"]["schema_version"] == LATEST_KNOWN_SCHEMA
71
74
 
72
75
 
73
76
  def test_query_offline(sessions_db, tmp_path, capsys):
@@ -0,0 +1,160 @@
1
+ """``graph orphans`` + ``graph blame-session`` — report-only diagnostics."""
2
+
3
+ import json
4
+ import sqlite3
5
+
6
+ import pytest
7
+ from conftest import ALPHA
8
+ from devin_graph.cli import main
9
+ from devin_graph.query import blame_session, orphaned_nodes
10
+ from devin_graph.store import GraphStore
11
+ from devin_internals.parsers import SessionsStore
12
+
13
+
14
+ @pytest.fixture
15
+ def graph_path(sessions_db, tmp_path):
16
+ p = tmp_path / "graph.db"
17
+ with SessionsStore(sessions_db) as ss, GraphStore(p) as gs:
18
+ gs.build(ss)
19
+ return p
20
+
21
+
22
+ def _insert_orphan(graph_path, node_id="file:/ghost", owner=None):
23
+ """An edgeless shared node straight into the db — the fixture graph
24
+ prunes real orphans at build time, so the only way to see one is to
25
+ write it after the build."""
26
+ con = sqlite3.connect(graph_path)
27
+ with con:
28
+ con.execute(
29
+ "INSERT INTO nodes(id, kind, key, owner, attrs)"
30
+ " VALUES (?, ?, ?, ?, '{}')",
31
+ (node_id, "file", node_id.split(":", 1)[1], owner),
32
+ )
33
+ con.close()
34
+
35
+
36
+ # -- orphaned_nodes ------------------------------------------------------------
37
+
38
+
39
+ def test_orphans_empty_in_healthy_graph(graph_path):
40
+ with GraphStore(graph_path, readonly=True) as gs:
41
+ data = orphaned_nodes(gs)
42
+ assert data == {"count": 0, "nodes": []}
43
+
44
+
45
+ def test_orphans_reports_and_marks_prunable(graph_path):
46
+ _insert_orphan(graph_path)
47
+ with GraphStore(graph_path, readonly=True) as gs:
48
+ data = orphaned_nodes(gs)
49
+ assert data["count"] == 1
50
+ (n,) = data["nodes"]
51
+ assert n["id"] == "file:/ghost"
52
+ assert n["kind"] == "file" and n["owner"] is None
53
+ assert n["prunable"] is True
54
+
55
+
56
+ def test_orphans_report_only_never_deletes(graph_path):
57
+ _insert_orphan(graph_path)
58
+ with GraphStore(graph_path, readonly=True) as gs:
59
+ orphaned_nodes(gs)
60
+ con = sqlite3.connect(graph_path)
61
+ try:
62
+ assert con.execute(
63
+ "SELECT COUNT(*) FROM nodes WHERE id = 'file:/ghost'"
64
+ ).fetchone()[0] == 1
65
+ finally:
66
+ con.close()
67
+
68
+
69
+ def test_owned_orphan_not_prunable(graph_path):
70
+ # an owner-carried orphan persists until its owner re-extracts —
71
+ # report it, but flag it as kept-by-prune.
72
+ _insert_orphan(graph_path, "session:ghost-sess", owner="ghost-sess")
73
+ with GraphStore(graph_path, readonly=True) as gs:
74
+ data = orphaned_nodes(gs)
75
+ (n,) = data["nodes"]
76
+ assert n["prunable"] is False and n["owner"] == "ghost-sess"
77
+
78
+
79
+ # -- blame_session --------------------------------------------------------------
80
+
81
+
82
+ def test_blame_session_contents(graph_path):
83
+ with GraphStore(graph_path, readonly=True) as gs:
84
+ data = blame_session(gs, "sess-a")
85
+ s = data["session"]
86
+ assert s["title"] == "Alpha work"
87
+ assert data["projects"] == [ALPHA]
88
+ assert data["extracted_at"] is not None
89
+ # produced: 1 session node + 3 tool_call nodes
90
+ kinds = [n["kind"] for n in data["nodes_owned"]]
91
+ assert kinds.count("session") == 1 and kinds.count("tool_call") == 3
92
+ assert data["counts"]["nodes"] == 4
93
+ # touched: the read of ALPHA/src/app.py (resolve joins it under cwd)
94
+ assert f"{ALPHA}/src/app.py" in data["files"]
95
+ assert data["counts"]["files"] == len(data["files"])
96
+ # edges: runs_in + 3× made_call + call_used/tool_used/file_touched
97
+ assert data["counts"]["edges"] == len(data["edges_owned"])
98
+ assert any(e["kind"] == "runs_in" for e in data["edges_owned"])
99
+ assert any(e["kind"] == "file_touched" for e in data["edges_owned"])
100
+
101
+
102
+ def test_blame_session_no_match(graph_path):
103
+ with GraphStore(graph_path, readonly=True) as gs:
104
+ data = blame_session(gs, "zzz")
105
+ assert data["session"] is None
106
+ assert data["candidates"] == []
107
+
108
+
109
+ def test_blame_session_ambiguous_prefix(graph_path):
110
+ with GraphStore(graph_path, readonly=True) as gs:
111
+ data = blame_session(gs, "sess")
112
+ assert data["session"] is None
113
+ assert set(data["candidates"]) == {"sess-a", "sess-b", "sess-c"}
114
+
115
+
116
+ # -- CLI ------------------------------------------------------------------------
117
+
118
+
119
+ def test_cli_orphans_empty(graph_path, capsys):
120
+ assert main(["orphans", "--graph", str(graph_path)]) == 0
121
+ assert "no orphaned nodes" in capsys.readouterr().out
122
+
123
+
124
+ def test_cli_orphans_json(graph_path, capsys):
125
+ _insert_orphan(graph_path)
126
+ assert main(["orphans", "--graph", str(graph_path), "--json"]) == 0
127
+ data = json.loads(capsys.readouterr().out)
128
+ assert data["count"] == 1
129
+ assert data["nodes"][0]["id"] == "file:/ghost"
130
+
131
+
132
+ def test_cli_blame_text(graph_path, capsys):
133
+ assert main(["blame-session", "sess-b", "--graph", str(graph_path)]) == 0
134
+ out = capsys.readouterr().out
135
+ assert "Beta work" in out
136
+ assert "produced:" in out and "touched:" in out
137
+ assert f"{ALPHA}/src/app.py" in out # shared file sess-b also read
138
+
139
+
140
+ def test_cli_blame_json(graph_path, capsys):
141
+ assert main(
142
+ ["blame-session", "sess-c", "--graph", str(graph_path), "--json"]
143
+ ) == 0
144
+ data = json.loads(capsys.readouterr().out)
145
+ assert data["session_id"] == "sess-c"
146
+ assert data["session"]["title"] == "Alpha docs"
147
+ assert data["counts"]["nodes"] >= 2
148
+
149
+
150
+ def test_cli_blame_no_match_exit_2(graph_path, capsys):
151
+ rc = main(["blame-session", "nope", "--graph", str(graph_path)])
152
+ assert rc == 2
153
+ assert "no session matches" in capsys.readouterr().err
154
+
155
+
156
+ def test_cli_blame_ambiguous_lists_candidates(graph_path, capsys):
157
+ rc = main(["blame-session", "sess", "--graph", str(graph_path)])
158
+ assert rc == 2
159
+ err = capsys.readouterr().err
160
+ assert "sess-a" in err and "sess-b" in err
@@ -6,6 +6,7 @@ import sqlite3
6
6
  from conftest import ALPHA, BETA, add_tool_call, bump_last_activity, delete_session
7
7
  from devin_graph.store import GraphStore
8
8
  from devin_internals.parsers import SessionsStore
9
+ from devin_internals.schema import LATEST_KNOWN_SCHEMA
9
10
 
10
11
  EXPECTED_NODE_COUNTS = {
11
12
  "session": 3, "project": 2, "tool": 4, "tool_call": 6, "file": 5}
@@ -95,7 +96,9 @@ def test_export_json_shape(sessions_db, tmp_path):
95
96
  data = gs.export()
96
97
 
97
98
  assert set(data) == {"meta", "nodes", "edges"}
98
- assert data["meta"]["schema_version"] == 17
99
+ # fixture emits the installed spec's latest schema (v17 at 0.3.1,
100
+ # v18 at 0.3.3) — pin to the constant, not a literal
101
+ assert data["meta"]["schema_version"] == LATEST_KNOWN_SCHEMA
99
102
  json.dumps(data) # must be JSON-serializable
100
103
 
101
104
  node = next(n for n in data["nodes"] if n["id"] == "session:sess-a")
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
File without changes
File without changes