opencode-gbuild 0.5.0 → 0.6.1

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.
package/scripts/graph.py DELETED
@@ -1,239 +0,0 @@
1
- #!/usr/bin/env python3
2
- """gbuild graph utility: validate, topo-order, and query a graph.json feature graph.
3
-
4
- graph.json is a CypherLite GraphData file. It lives at
5
- `.gbuild/<feature>/db/graph.json` so CypherLite can open the `db/` folder as a
6
- JSON-store database (CypherLite opens a *directory*, not a bare `.json` file)
7
- and query it with Cypher. This module is the query layer (topo waves, frontier,
8
- status report) over that file. Format validation lives in validate_format.py
9
- (the single source of truth for the contract); this module imports and re-exports
10
- its validate() and enum sets.
11
-
12
- Stdlib only, no pip install required. Schema: ../reference/graph-format.md
13
-
14
- CLI:
15
- python3 graph.py <feature>/db/graph.json --waves print the topological wave decomposition
16
- python3 graph.py <feature>/db/graph.json --frontier print node ids ready to run right now
17
- python3 graph.py <feature>/db/graph.json --status print the full status report (default)
18
-
19
- Checkpoints are read from <feature>/nodes/<slug>.json by default, or --state-dir.
20
- """
21
- import argparse
22
- import json
23
- import sys
24
- from pathlib import Path
25
-
26
- # Format contract + enum sets live in validate_format (single definition site).
27
- from validate_format import (
28
- GraphError,
29
- SATISFIED_STATUSES,
30
- VALID_FAILURE_POLICIES,
31
- VALID_MODEL_TIERS,
32
- VALID_NODE_TYPES,
33
- VALID_TYPE_LABELS,
34
- validate,
35
- )
36
-
37
-
38
- # --- helpers over the GraphData shape ---------------------------------------
39
-
40
- def _labels(node):
41
- return set(node.get("labels", []))
42
-
43
-
44
- def _props(node):
45
- return node.get("properties", {}) if isinstance(node.get("properties"), dict) else {}
46
-
47
-
48
- def gbuild_nodes(graph):
49
- """GbuildNode-labelled nodes, as a list."""
50
- return [n for n in graph.get("nodes", []) if "GbuildNode" in _labels(n)]
51
-
52
-
53
- def node_by_id(graph, nid):
54
- for n in graph.get("nodes", []):
55
- if n.get("id") == nid:
56
- return n
57
- return None
58
-
59
-
60
- def node_by_slug(graph, slug):
61
- for n in gbuild_nodes(graph):
62
- if _props(n).get("slug") == slug:
63
- return n
64
- return None
65
-
66
-
67
- def slug_of(graph, nid):
68
- n = node_by_id(graph, nid)
69
- return _props(n).get("slug") if n else None
70
-
71
-
72
- def id_of_slug(graph, slug):
73
- n = node_by_slug(graph, slug)
74
- return n.get("id") if n else None
75
-
76
-
77
- def deps(graph, node_id):
78
- """node_ids this node depends on (DEPENDS_ON end_nodes)."""
79
- return [
80
- r["end_node"]
81
- for r in graph.get("relationships", [])
82
- if r.get("type") == "DEPENDS_ON" and r.get("start_node") == node_id
83
- ]
84
-
85
-
86
- def satisfies(graph, node_id):
87
- """acceptance node_ids this node covers (SATISFIES end_nodes)."""
88
- return [
89
- r["end_node"]
90
- for r in graph.get("relationships", [])
91
- if r.get("type") == "SATISFIES" and r.get("start_node") == node_id
92
- ]
93
-
94
-
95
- def feature(graph):
96
- """the single Feature node, or None."""
97
- for n in graph.get("nodes", []):
98
- if "Feature" in _labels(n):
99
- return n
100
- return None
101
-
102
-
103
- def acceptance_nodes(graph):
104
- return [n for n in graph.get("nodes", []) if "Acceptance" in _labels(n)]
105
-
106
-
107
- # --- core query layer --------------------------------------------------------
108
-
109
- def load_graph(path):
110
- with open(path) as f:
111
- return json.load(f)
112
-
113
-
114
- def topo_waves(graph):
115
- """Layer GbuildNodes into parallel-safe waves by slug.
116
-
117
- wave N contains every node whose DEPENDS_ON targets all resolved in <N.
118
- Returns a list of lists of slugs.
119
- """
120
- gn = gbuild_nodes(graph)
121
- # map node id -> slug, and slug -> dep slugs
122
- id_to_slug = {n["id"]: _props(n).get("slug") for n in gn}
123
- remaining = {id_to_slug[n["id"]]: n for n in gn}
124
- resolved = set()
125
- waves = []
126
-
127
- while remaining:
128
- wave = []
129
- for slug, node in remaining.items():
130
- dep_ids = deps(graph, node["id"])
131
- dep_slugs = {id_to_slug.get(d) for d in dep_ids}
132
- if dep_slugs.issubset(resolved):
133
- wave.append(slug)
134
- if not wave:
135
- raise GraphError(f"cycle detected among: {', '.join(sorted(remaining))}")
136
- wave.sort()
137
- waves.append(wave)
138
- for slug in wave:
139
- resolved.add(slug)
140
- del remaining[slug]
141
-
142
- return waves
143
-
144
-
145
- def read_checkpoint(state_dir, slug):
146
- path = Path(state_dir) / f"{slug}.json"
147
- if not path.exists():
148
- return {"status": "pending"}
149
- with open(path) as f:
150
- return json.load(f)
151
-
152
-
153
- def node_statuses(graph, state_dir):
154
- """slug -> status, for every GbuildNode."""
155
- return {
156
- _props(n).get("slug"): read_checkpoint(state_dir, _props(n).get("slug")).get("status", "pending")
157
- for n in gbuild_nodes(graph)
158
- }
159
-
160
-
161
- def compute_report(graph, state_dir):
162
- statuses = node_statuses(graph, state_dir)
163
- id_slug = {n["id"]: _props(n).get("slug") for n in gbuild_nodes(graph)}
164
- frontier, blocked, in_flight, completed, cancelled, failed = [], [], [], [], [], []
165
-
166
- for node in gbuild_nodes(graph):
167
- slug = _props(node).get("slug")
168
- status = statuses.get(slug, "pending")
169
- dep_ids = deps(graph, node["id"])
170
- dep_statuses = [statuses.get(id_slug.get(d), "pending") for d in dep_ids]
171
- if status == "completed":
172
- completed.append(slug)
173
- elif status == "cancelled":
174
- cancelled.append(slug)
175
- elif status == "in_progress":
176
- in_flight.append(slug)
177
- elif status == "failed":
178
- failed.append(slug)
179
- elif all(ds in SATISFIED_STATUSES for ds in dep_statuses):
180
- frontier.append(slug)
181
- else:
182
- blocked.append(slug)
183
-
184
- return {
185
- "frontier": sorted(frontier),
186
- "blocked": sorted(blocked),
187
- "in_flight": sorted(in_flight),
188
- "completed": sorted(completed),
189
- "cancelled": sorted(cancelled),
190
- "failed": sorted(failed),
191
- "waves": topo_waves(graph),
192
- }
193
-
194
-
195
- def compute_frontier(graph, state_dir):
196
- return compute_report(graph, state_dir)["frontier"]
197
-
198
-
199
- def default_state_dir(graph_path):
200
- """Checkpoints live in the sibling `nodes/` dir, next to the `db/` graph folder.
201
-
202
- The plan is `<feature>/db/graph.json` and state is `<feature>/nodes/`; the
203
- two are siblings so CypherLite's `nodes/` Parquet-shard detection never sees
204
- the checkpoint dir inside the folder it opens.
205
- """
206
- return Path(graph_path).resolve().parent.parent / "nodes"
207
-
208
-
209
- def main(argv=None):
210
- parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
211
- parser.add_argument("graph_path")
212
- parser.add_argument("--state-dir", default=None, help="checkpoint dir, default: <feature>/nodes")
213
- group = parser.add_mutually_exclusive_group()
214
- group.add_argument("--waves", action="store_true")
215
- group.add_argument("--frontier", action="store_true")
216
- group.add_argument("--status", action="store_true")
217
- args = parser.parse_args(argv)
218
-
219
- graph = load_graph(args.graph_path)
220
- try:
221
- validate(graph)
222
- except GraphError as e:
223
- print(f"invalid graph: {e}", file=sys.stderr)
224
- return 1
225
-
226
- state_dir = args.state_dir or default_state_dir(args.graph_path)
227
-
228
- if args.waves:
229
- print(json.dumps(topo_waves(graph)))
230
- elif args.frontier:
231
- print(json.dumps(compute_frontier(graph, state_dir)))
232
- else:
233
- print(json.dumps(compute_report(graph, state_dir), indent=2))
234
-
235
- return 0
236
-
237
-
238
- if __name__ == "__main__":
239
- sys.exit(main())
@@ -1,403 +0,0 @@
1
- import json
2
- import shutil
3
- import subprocess
4
- import sys
5
- import tempfile
6
- import unittest
7
- from pathlib import Path
8
-
9
- sys.path.insert(0, str(Path(__file__).resolve().parent))
10
-
11
- from graph import ( # noqa: E402
12
- GraphError,
13
- acceptance_nodes,
14
- compute_frontier,
15
- compute_report,
16
- gbuild_nodes,
17
- load_graph,
18
- satisfies,
19
- topo_waves,
20
- validate,
21
- )
22
- from validate_format import TYPE_TO_LABEL # noqa: E402
23
-
24
- PLUGIN_ROOT = Path(__file__).resolve().parent.parent
25
- TEMPLATE = PLUGIN_ROOT / "templates" / "graph.json"
26
- CYPHERLITE_BIN = shutil.which("cypherlite")
27
-
28
-
29
- def cypherlite_query(graph_path, query):
30
- """Run a Cypher query against a GraphData file the way gbuild does.
31
-
32
- CypherLite opens a directory store holding `graph.json`, not a bare `.json`
33
- file, so stage the file in a scratch dir as `graph.json` and open that.
34
- """
35
- with tempfile.TemporaryDirectory() as d:
36
- shutil.copyfile(graph_path, Path(d) / "graph.json")
37
- return subprocess.run(
38
- [CYPHERLITE_BIN, d, "-json", query],
39
- capture_output=True, text=True,
40
- )
41
-
42
-
43
- # --- canonical factories emitting GraphData ---------------------------------
44
-
45
- def _gbuild_node(nid, slug, ntype="code", deps=None, satisfies_acc=None, **overrides):
46
- """Build a GbuildNode dict. deps/satisfies_acc are slug/acceptance-id lists
47
- resolved to ids by graph_of; here we only stash the raw properties."""
48
- base = {
49
- "id": nid,
50
- "labels": ["GbuildNode", TYPE_TO_LABEL[ntype]],
51
- "properties": {
52
- "slug": slug,
53
- "title": slug,
54
- "type": ntype,
55
- "contract": {"input": {}, "output": {}},
56
- "acceptance": [f"{slug} produces its declared output"],
57
- "verify": None,
58
- "failure_policy": "retry",
59
- "model_tier": "cheap",
60
- },
61
- }
62
- base["properties"].update(overrides.pop("props", {}))
63
- base.update(overrides)
64
- base["_deps"] = deps or []
65
- base["_satisfies"] = satisfies_acc or []
66
- return base
67
-
68
-
69
- def graph_of(*nodes, acceptance=None, feature="test", destination="test",
70
- context="test", out_of_scope=None):
71
- """Assemble a full GraphData document.
72
-
73
- ids: Feature=1, then one Acceptance node per acceptance bullet, then
74
- GbuildNodes in the order given. Edges: HAS_ACCEPTANCE (feature->acceptance),
75
- SATISFIES (node->acceptance), DEPENDS_ON (node->node), all sequential.
76
- """
77
- out_nodes = []
78
- out_rels = []
79
- nid = 1
80
- rid = 1
81
-
82
- out_nodes.append({
83
- "id": nid,
84
- "labels": ["Feature"],
85
- "properties": {
86
- "feature": feature,
87
- "destination": destination,
88
- "context": context,
89
- "out_of_scope": out_of_scope or [],
90
- },
91
- })
92
- feature_id = nid
93
- nid += 1
94
-
95
- # acceptance nodes
96
- acc_id_by_key = {} # "a-1" -> node id
97
- for acc in (acceptance or []):
98
- out_nodes.append({
99
- "id": nid,
100
- "labels": ["Acceptance"],
101
- "properties": {"id": acc["id"], "text": acc["text"]},
102
- })
103
- out_rels.append({
104
- "id": rid, "type": "HAS_ACCEPTANCE",
105
- "start_node": feature_id, "end_node": nid, "properties": {},
106
- })
107
- acc_id_by_key[acc["id"]] = nid
108
- nid += 1
109
- rid += 1
110
-
111
- # gbuild nodes — assign ids, remember slug->id for edge wiring
112
- slug_to_id = {}
113
- staged = []
114
- for n in nodes:
115
- n = dict(n)
116
- n["id"] = nid
117
- slug_to_id[n["properties"]["slug"]] = nid
118
- staged.append(n)
119
- nid += 1
120
- # If no node declares any explicit satisfies, auto-wire every acceptance
121
- # to every gbuild node so coverage holds for structural-only test graphs.
122
- explicit_satisfies = any(n.get("_satisfies") for n in staged)
123
- # strip the _deps/_satisfies stashed props and wire edges
124
- for n in staged:
125
- raw_deps = n.pop("_deps", [])
126
- raw_satisfies = n.pop("_satisfies", [])
127
- for dep_slug in raw_deps:
128
- # allow dangling deps (end_node that doesn't exist) so validate can
129
- # reject them; unknown slugs get a sentinel id.
130
- out_rels.append({
131
- "id": rid, "type": "DEPENDS_ON",
132
- "start_node": n["id"], "end_node": slug_to_id.get(dep_slug, -1),
133
- "properties": {},
134
- })
135
- rid += 1
136
- if explicit_satisfies:
137
- for acc_key in raw_satisfies:
138
- out_rels.append({
139
- "id": rid, "type": "SATISFIES",
140
- "start_node": n["id"], "end_node": acc_id_by_key[acc_key],
141
- "properties": {},
142
- })
143
- rid += 1
144
- else:
145
- for acc_nid in acc_id_by_key.values():
146
- out_rels.append({
147
- "id": rid, "type": "SATISFIES",
148
- "start_node": n["id"], "end_node": acc_nid, "properties": {},
149
- })
150
- rid += 1
151
- out_nodes.extend(staged)
152
-
153
- return {
154
- "nodes": out_nodes,
155
- "relationships": out_rels,
156
- "indexes": [],
157
- "constraints": [],
158
- "next_node_id": nid,
159
- "next_rel_id": rid,
160
- }
161
-
162
-
163
- def node(slug, deps=None, satisfies=None, ntype="code", **overrides):
164
- return _gbuild_node(None, slug, ntype=ntype, deps=deps, satisfies_acc=satisfies, **overrides)
165
-
166
-
167
- def write_checkpoint(state_dir, slug, status, **fields):
168
- Path(state_dir).mkdir(parents=True, exist_ok=True)
169
- payload = {"status": status, **fields}
170
- (Path(state_dir) / f"{slug}.json").write_text(json.dumps(payload))
171
-
172
-
173
- ACC = [{"id": "a-1", "text": "feature bar"}]
174
-
175
-
176
- class ChainTests(unittest.TestCase):
177
- def test_waves_are_one_node_each(self):
178
- g = graph_of(node("a"), node("b", ["a"]), node("c", ["b"]), acceptance=ACC)
179
- validate(g)
180
- self.assertEqual(topo_waves(g), [["a"], ["b"], ["c"]])
181
-
182
- def test_frontier_is_only_the_root(self):
183
- g = graph_of(node("a"), node("b", ["a"]), node("c", ["b"]), acceptance=ACC)
184
- with tempfile.TemporaryDirectory() as d:
185
- self.assertEqual(compute_frontier(g, d), ["a"])
186
-
187
-
188
- class DiamondTests(unittest.TestCase):
189
- def setUp(self):
190
- self.g = graph_of(
191
- node("a"),
192
- node("b", ["a"]),
193
- node("c", ["a"]),
194
- node("d", ["b", "c"]),
195
- acceptance=ACC,
196
- )
197
- validate(self.g)
198
-
199
- def test_waves_group_b_and_c_together(self):
200
- self.assertEqual(topo_waves(self.g), [["a"], ["b", "c"], ["d"]])
201
-
202
- def test_b_and_c_both_blocked_until_a_completes(self):
203
- with tempfile.TemporaryDirectory() as d:
204
- report = compute_report(self.g, d)
205
- self.assertEqual(report["frontier"], ["a"])
206
- self.assertEqual(report["blocked"], ["b", "c", "d"])
207
-
208
- def test_b_and_c_become_frontier_together_once_a_completes(self):
209
- with tempfile.TemporaryDirectory() as d:
210
- write_checkpoint(d, "a", "completed")
211
- report = compute_report(self.g, d)
212
- self.assertEqual(report["frontier"], ["b", "c"])
213
- self.assertEqual(report["blocked"], ["d"])
214
-
215
- def test_d_waits_for_both_b_and_c(self):
216
- with tempfile.TemporaryDirectory() as d:
217
- write_checkpoint(d, "a", "completed")
218
- write_checkpoint(d, "b", "completed")
219
- report = compute_report(self.g, d)
220
- self.assertEqual(report["frontier"], ["c"])
221
- self.assertEqual(report["blocked"], ["d"])
222
-
223
- write_checkpoint(d, "c", "completed")
224
- report = compute_report(self.g, d)
225
- self.assertEqual(report["frontier"], ["d"])
226
- self.assertEqual(report["blocked"], [])
227
-
228
- def test_resume_skips_completed_nodes(self):
229
- with tempfile.TemporaryDirectory() as d:
230
- write_checkpoint(d, "a", "completed")
231
- write_checkpoint(d, "b", "completed")
232
- write_checkpoint(d, "c", "completed")
233
- report = compute_report(self.g, d)
234
- self.assertEqual(report["completed"], ["a", "b", "c"])
235
- self.assertEqual(report["frontier"], ["d"])
236
-
237
- def test_cancelled_dependency_counts_as_satisfied(self):
238
- with tempfile.TemporaryDirectory() as d:
239
- write_checkpoint(d, "a", "completed")
240
- write_checkpoint(d, "b", "cancelled")
241
- write_checkpoint(d, "c", "completed")
242
- report = compute_report(self.g, d)
243
- self.assertEqual(report["frontier"], ["d"])
244
-
245
-
246
- class ValidationTests(unittest.TestCase):
247
- def test_cycle_is_rejected(self):
248
- g = graph_of(node("a", ["b"]), node("b", ["a"]), acceptance=ACC)
249
- with self.assertRaises(GraphError):
250
- validate(g)
251
-
252
- def test_missing_dependency_is_rejected(self):
253
- g = graph_of(node("a", ["ghost"]), acceptance=ACC)
254
- with self.assertRaises(GraphError):
255
- validate(g)
256
-
257
- def test_empty_acceptance_is_rejected(self):
258
- n = node("a")
259
- n["properties"]["acceptance"] = []
260
- g = graph_of(n, acceptance=ACC)
261
- with self.assertRaises(GraphError):
262
- validate(g)
263
-
264
- def test_blank_acceptance_bullet_is_rejected(self):
265
- n = node("a")
266
- n["properties"]["acceptance"] = [" "]
267
- g = graph_of(n, acceptance=ACC)
268
- with self.assertRaises(GraphError):
269
- validate(g)
270
-
271
- def test_uncovered_acceptance_is_rejected(self):
272
- # acceptance a-2 has no SATISFIES edge
273
- g = graph_of(node("a", satisfies=["a-1"]),
274
- acceptance=ACC + [{"id": "a-2", "text": "uncovered"}])
275
- with self.assertRaises(GraphError):
276
- validate(g)
277
-
278
- def test_counter_invariant_is_checked(self):
279
- g = graph_of(node("a"), acceptance=ACC)
280
- g["next_node_id"] = 999
281
- with self.assertRaises(GraphError):
282
- validate(g)
283
-
284
- def test_satisfies_property_is_rejected(self):
285
- n = node("a")
286
- n["properties"]["satisfies"] = ["a-1"]
287
- g = graph_of(n, acceptance=ACC)
288
- with self.assertRaises(GraphError):
289
- validate(g)
290
-
291
-
292
- class TemplateTests(unittest.TestCase):
293
- def test_template_validates(self):
294
- g = load_graph(str(TEMPLATE))
295
- validate(g) # raises if invalid
296
-
297
- def test_template_waves(self):
298
- g = load_graph(str(TEMPLATE))
299
- self.assertEqual(topo_waves(g), [
300
- ["a-produce-shared-value"],
301
- ["b-consume-doubled", "c-consume-squared"],
302
- ["d-join-and-sum"],
303
- ])
304
-
305
- def test_template_frontier_is_root(self):
306
- g = load_graph(str(TEMPLATE))
307
- with tempfile.TemporaryDirectory() as d:
308
- self.assertEqual(compute_frontier(g, d), ["a-produce-shared-value"])
309
-
310
- def test_template_acceptance_coverage(self):
311
- g = load_graph(str(TEMPLATE))
312
- # every acceptance node is targeted by >=1 SATISFIES and 1 HAS_ACCEPTANCE
313
- acc_ids = {n["id"] for n in acceptance_nodes(g)}
314
- sat_targets = set()
315
- has_targets = set()
316
- for r in g["relationships"]:
317
- if r["type"] == "SATISFIES":
318
- sat_targets.add(r["end_node"])
319
- if r["type"] == "HAS_ACCEPTANCE":
320
- has_targets.add(r["end_node"])
321
- self.assertEqual(acc_ids, sat_targets)
322
- self.assertEqual(acc_ids, has_targets)
323
-
324
- def test_template_counters(self):
325
- g = load_graph(str(TEMPLATE))
326
- max_nid = max(n["id"] for n in g["nodes"])
327
- max_rid = max(r["id"] for r in g["relationships"])
328
- self.assertEqual(g["next_node_id"], max_nid + 1)
329
- self.assertEqual(g["next_rel_id"], max_rid + 1)
330
-
331
-
332
- @unittest.skipUnless(CYPHERLITE_BIN, "cypherlite binary not on PATH")
333
- class CypherLiteLoadTests(unittest.TestCase):
334
- def test_validator_cross_check_passes_on_template(self):
335
- # main() should exit 0 — stdlib checks pass AND cypherlite loads it.
336
- res = subprocess.run(
337
- ["python3", str(PLUGIN_ROOT / "scripts" / "validate_format.py"), str(TEMPLATE)],
338
- capture_output=True, text=True,
339
- )
340
- self.assertEqual(res.returncode, 0, res.stderr)
341
- self.assertIn("valid:", res.stdout)
342
-
343
- def test_validator_cross_check_can_be_disabled(self):
344
- res = subprocess.run(
345
- ["python3", str(PLUGIN_ROOT / "scripts" / "validate_format.py"),
346
- "--no-cypherlite", str(TEMPLATE)],
347
- capture_output=True, text=True,
348
- )
349
- self.assertEqual(res.returncode, 0, res.stderr)
350
- self.assertIn("valid:", res.stdout)
351
-
352
- def test_template_loads_and_queries(self):
353
- g = load_graph(str(TEMPLATE))
354
- q = "MATCH (n:GbuildNode) RETURN n.slug AS slug ORDER BY slug"
355
- res = cypherlite_query(TEMPLATE, q)
356
- self.assertEqual(res.returncode, 0, res.stderr)
357
- rows = json.loads(res.stdout)
358
- slugs = [r["slug"] for r in rows]
359
- self.assertEqual(slugs, [
360
- "a-produce-shared-value", "b-consume-doubled",
361
- "c-consume-squared", "d-join-and-sum",
362
- ])
363
-
364
- def test_sibling_nodes_dir_does_not_collide(self):
365
- # The plan lives in <feature>/db/ and checkpoints in <feature>/nodes/.
366
- # CypherLite treats a directory named `nodes` inside the opened store as
367
- # Parquet shards, so the checkpoint dir must stay outside `db/`.
368
- with tempfile.TemporaryDirectory() as root:
369
- feature = Path(root) / ".gbuild" / "example-diamond"
370
- db = feature / "db"
371
- nodes = feature / "nodes"
372
- db.mkdir(parents=True)
373
- nodes.mkdir(parents=True)
374
- shutil.copyfile(TEMPLATE, db / "graph.json")
375
- (nodes / "a-produce-shared-value.json").write_text('{"status": "completed"}')
376
- res = subprocess.run(
377
- [CYPHERLITE_BIN, str(db), "-json", "MATCH (n) RETURN count(n) AS c"],
378
- capture_output=True, text=True,
379
- )
380
- self.assertEqual(res.returncode, 0, res.stderr)
381
- self.assertEqual(json.loads(res.stdout)[0]["c"], 7)
382
-
383
- def test_template_transitive_deps(self):
384
- q = ("MATCH (n {slug:'d-join-and-sum'})-[:DEPENDS_ON*]->(d) "
385
- "RETURN DISTINCT d.slug AS slug ORDER BY slug")
386
- res = cypherlite_query(TEMPLATE, q)
387
- self.assertEqual(res.returncode, 0, res.stderr)
388
- rows = json.loads(res.stdout)
389
- self.assertEqual([r["slug"] for r in rows], [
390
- "a-produce-shared-value", "b-consume-doubled", "c-consume-squared",
391
- ])
392
-
393
- def test_template_acceptance_query(self):
394
- q = ("MATCH (f:Feature)-[:HAS_ACCEPTANCE]->(a:Acceptance) "
395
- "RETURN a.id AS id ORDER BY id")
396
- res = cypherlite_query(TEMPLATE, q)
397
- self.assertEqual(res.returncode, 0, res.stderr)
398
- rows = json.loads(res.stdout)
399
- self.assertEqual([r["id"] for r in rows], ["a-1", "a-2"])
400
-
401
-
402
- if __name__ == "__main__":
403
- unittest.main()