opencode-gbuild 0.5.0 → 0.6.0

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.
@@ -1,378 +0,0 @@
1
- #!/usr/bin/env python3
2
- """gbuild graph format validator — the authoritative contract for graph.json.
3
-
4
- graph.json is a CypherLite GraphData file, living at
5
- `.gbuild/<feature>/db/graph.json` so CypherLite can open the `db/` folder as a
6
- JSON-store database (`cypherlite .gbuild/<feature>/db "MATCH ..."`).
7
- This module checks it is both CypherLite-loadable and gbuild-correct: the
8
- GraphData envelope, node shapes (Feature / Acceptance / GbuildNode), the three
9
- relationship types (DEPENDS_ON / HAS_ACCEPTANCE / SATISFIES), id/counter
10
- invariants, acyclicity, and coverage. Stdlib only — same no-install rule as
11
- graph.py.
12
-
13
- Single source of truth for the format: graph.py imports VALID_* and
14
- SATISFIED_STATUSES from here, and its validate() calls validate() below.
15
-
16
- CLI:
17
- python3 validate_format.py <feature>/db/graph.json
18
- exit 0 + "valid: <feature>" on success
19
- exit 1 + "invalid: <errors joined>" on failure
20
- """
21
- import argparse
22
- import json
23
- import re
24
- import sys
25
- from pathlib import Path
26
-
27
- # --- enum sets (single definition site; graph.py imports these) -------------
28
-
29
- VALID_NODE_TYPES = {"research", "decision", "code", "test", "verify", "chore"}
30
- VALID_FAILURE_POLICIES = {"retry", "fallback", "skip", "repair", "escalate", "stop"}
31
- VALID_MODEL_TIERS = {"cheap", "strong"}
32
- SATISFIED_STATUSES = {"completed", "cancelled"}
33
-
34
- # PascalCase type label <-> lowercase type property. Order matches VALID_NODE_TYPES.
35
- TYPE_TO_LABEL = {
36
- "research": "Research",
37
- "decision": "Decision",
38
- "code": "Code",
39
- "test": "Test",
40
- "verify": "Verify",
41
- "chore": "Chore",
42
- }
43
- VALID_TYPE_LABELS = set(TYPE_TO_LABEL.values())
44
-
45
- VALID_REL_TYPES = {"DEPENDS_ON", "HAS_ACCEPTANCE", "SATISFIES"}
46
-
47
- SLUG_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
48
- ACCEPTANCE_ID_RE = re.compile(r"^a-\d+$")
49
-
50
-
51
- class GraphError(Exception):
52
- pass
53
-
54
-
55
- def _is_int(v):
56
- return isinstance(v, int) and not isinstance(v, bool)
57
-
58
-
59
- def _is_str_list(v):
60
- return isinstance(v, list) and all(isinstance(x, str) for x in v)
61
-
62
-
63
- def validate(graph):
64
- """Raise GraphError with every problem found, joined, rather than the first.
65
-
66
- Collects all structural, shape, relationship, counter, acyclicity, and
67
- coverage errors into one message so a single validate pass tells the author
68
- everything to fix.
69
- """
70
- errors = []
71
-
72
- if not isinstance(graph, dict):
73
- raise GraphError("graph root is not an object")
74
-
75
- # --- GraphData envelope ---------------------------------------------------
76
- for key in ("nodes", "relationships", "indexes", "constraints"):
77
- if not isinstance(graph.get(key), list):
78
- errors.append(f"top-level {key!r} must be a list")
79
- for key in ("next_node_id", "next_rel_id"):
80
- if not _is_int(graph.get(key)):
81
- errors.append(f"top-level {key!r} must be an integer")
82
-
83
- nodes = graph.get("nodes") if isinstance(graph.get("nodes"), list) else []
84
- rels = graph.get("relationships") if isinstance(graph.get("relationships"), list) else []
85
-
86
- if not nodes:
87
- errors.append("graph has no nodes")
88
-
89
- # --- node shape + id uniqueness ------------------------------------------
90
- node_ids = set()
91
- for n in nodes:
92
- if not isinstance(n, dict):
93
- errors.append("a node is not an object")
94
- continue
95
- nid = n.get("id")
96
- if not _is_int(nid):
97
- errors.append("a node has a missing or non-integer id")
98
- continue
99
- if nid in node_ids:
100
- errors.append(f"duplicate node id: {nid}")
101
- node_ids.add(nid)
102
- if not isinstance(n.get("labels"), list) or not all(isinstance(l, str) for l in n.get("labels", [])):
103
- errors.append(f"node {nid}: labels must be a list of strings")
104
- if not isinstance(n.get("properties"), dict):
105
- errors.append(f"node {nid}: properties must be an object")
106
-
107
- by_id = {n["id"]: n for n in nodes if isinstance(n, dict) and _is_int(n.get("id"))}
108
- labels_by_id = {nid: set(n.get("labels", [])) for nid, n in by_id.items()}
109
- props_by_id = {nid: n.get("properties", {}) for nid, n in by_id.items() if isinstance(n.get("properties"), dict)}
110
-
111
- # --- Feature node --------------------------------------------------------
112
- feature_nodes = [nid for nid, ls in labels_by_id.items() if "Feature" in ls]
113
- if len(feature_nodes) != 1:
114
- errors.append(f"expected exactly one Feature node, found {len(feature_nodes)}")
115
- feature_id = feature_nodes[0] if feature_nodes else None
116
- if feature_id is not None:
117
- p = props_by_id.get(feature_id, {})
118
- for key in ("feature", "destination", "context"):
119
- if not isinstance(p.get(key), str):
120
- errors.append(f"Feature node: {key!r} must be a string")
121
- if not _is_str_list(p.get("out_of_scope")):
122
- errors.append("Feature node: 'out_of_scope' must be a list of strings")
123
- if "acceptance" in p:
124
- errors.append("Feature node must not carry an 'acceptance' property (use Acceptance nodes)")
125
-
126
- # --- Acceptance nodes ----------------------------------------------------
127
- acceptance_nodes = {} # id property -> node id
128
- for nid, ls in labels_by_id.items():
129
- if "Acceptance" not in ls:
130
- continue
131
- p = props_by_id.get(nid, {})
132
- aid = p.get("id")
133
- if not isinstance(aid, str) or not ACCEPTANCE_ID_RE.match(aid):
134
- errors.append(f"node {nid} (Acceptance): 'id' must be a string like 'a-1'")
135
- elif aid in acceptance_nodes:
136
- errors.append(f"duplicate Acceptance id: {aid}")
137
- else:
138
- acceptance_nodes[aid] = nid
139
- if not isinstance(p.get("text"), str) or not p.get("text", "").strip():
140
- errors.append(f"node {nid} (Acceptance): 'text' must be a non-blank string")
141
-
142
- # --- GbuildNode shape ----------------------------------------------------
143
- gbuild_nodes = {} # slug -> node id
144
- for nid, ls in labels_by_id.items():
145
- if "GbuildNode" not in ls:
146
- continue
147
- p = props_by_id.get(nid, {})
148
- slug = p.get("slug")
149
- if not isinstance(slug, str) or not SLUG_RE.match(slug):
150
- errors.append(f"node {nid} (GbuildNode): 'slug' must be a kebab-case string")
151
- elif slug in gbuild_nodes:
152
- errors.append(f"duplicate GbuildNode slug: {slug}")
153
- else:
154
- gbuild_nodes[slug] = nid
155
- if not isinstance(p.get("title"), str):
156
- errors.append(f"node {nid} (GbuildNode): 'title' must be a string")
157
- ntype = p.get("type")
158
- if ntype not in VALID_NODE_TYPES:
159
- errors.append(f"node {nid} (GbuildNode): invalid type {ntype!r}")
160
- contract = p.get("contract")
161
- if not isinstance(contract, dict) or not isinstance(contract.get("input"), dict) or not isinstance(contract.get("output"), dict):
162
- errors.append(f"node {nid} (GbuildNode): 'contract' must be {{input:{{}},output:{{}}}}")
163
- acc = p.get("acceptance")
164
- if not isinstance(acc, list) or len(acc) == 0:
165
- errors.append(f"node {nid} (GbuildNode): 'acceptance' must be a non-empty list")
166
- elif any(not str(c).strip() for c in acc):
167
- errors.append(f"node {nid} (GbuildNode): 'acceptance' must not contain blank entries")
168
- if p.get("verify") is not None and not isinstance(p.get("verify"), str):
169
- errors.append(f"node {nid} (GbuildNode): 'verify' must be a string or null")
170
- if p.get("failure_policy") not in VALID_FAILURE_POLICIES:
171
- errors.append(f"node {nid} (GbuildNode): invalid failure_policy {p.get('failure_policy')!r}")
172
- if p.get("model_tier") not in VALID_MODEL_TIERS:
173
- errors.append(f"node {nid} (GbuildNode): invalid model_tier {p.get('model_tier')!r}")
174
- if "satisfies" in p:
175
- errors.append(f"node {nid} (GbuildNode): must not carry a 'satisfies' property (use SATISFIES edges)")
176
- # type-label consistency
177
- if ntype in TYPE_TO_LABEL:
178
- expected_label = TYPE_TO_LABEL[ntype]
179
- if expected_label not in ls:
180
- errors.append(f"node {nid} (GbuildNode): type {ntype!r} requires label {expected_label!r}")
181
- extra_type_labels = (ls & VALID_TYPE_LABELS) - {expected_label}
182
- if extra_type_labels:
183
- errors.append(f"node {nid} (GbuildNode): type labels {extra_type_labels} do not match type {ntype!r}")
184
-
185
- gbuild_ids = {nid for nid, ls in labels_by_id.items() if "GbuildNode" in ls}
186
-
187
- # --- relationship shape --------------------------------------------------
188
- rel_ids = set()
189
- depends_edges = [] # (start, end) for acyclicity
190
- has_acc_ends = set() # acceptance node ids targeted by HAS_ACCEPTANCE
191
- satisfies_ends = set() # acceptance node ids targeted by SATISFIES
192
- depends_targets = set() # gbuild node ids that are depended upon (chore coverage)
193
- for r in rels:
194
- if not isinstance(r, dict):
195
- errors.append("a relationship is not an object")
196
- continue
197
- rid = r.get("id")
198
- if not _is_int(rid):
199
- errors.append("a relationship has a missing or non-integer id")
200
- continue
201
- if rid in rel_ids:
202
- errors.append(f"duplicate relationship id: {rid}")
203
- rel_ids.add(rid)
204
- rtype = r.get("type")
205
- if rtype not in VALID_REL_TYPES:
206
- errors.append(f"relationship {rid}: invalid type {rtype!r}")
207
- continue
208
- s, e = r.get("start_node"), r.get("end_node")
209
- if s not in by_id:
210
- errors.append(f"relationship {rid}: start_node {s!r} does not exist")
211
- if e not in by_id:
212
- errors.append(f"relationship {rid}: end_node {e!r} does not exist")
213
- continue
214
- if not isinstance(r.get("properties"), dict):
215
- errors.append(f"relationship {rid}: properties must be an object")
216
- # type-specific endpoint constraints
217
- if rtype == "DEPENDS_ON":
218
- if s not in gbuild_ids:
219
- errors.append(f"relationship {rid} (DEPENDS_ON): start_node {s} must be a GbuildNode")
220
- if e not in gbuild_ids:
221
- errors.append(f"relationship {rid} (DEPENDS_ON): end_node {e} must be a GbuildNode")
222
- else:
223
- depends_edges.append((s, e))
224
- depends_targets.add(e)
225
- elif rtype == "HAS_ACCEPTANCE":
226
- if feature_id is not None and s != feature_id:
227
- errors.append(f"relationship {rid} (HAS_ACCEPTANCE): start_node {s} must be the Feature node")
228
- if "Acceptance" not in labels_by_id.get(e, set()):
229
- errors.append(f"relationship {rid} (HAS_ACCEPTANCE): end_node {e} must be an Acceptance node")
230
- else:
231
- has_acc_ends.add(e)
232
- elif rtype == "SATISFIES":
233
- if s not in gbuild_ids:
234
- errors.append(f"relationship {rid} (SATISFIES): start_node {s} must be a GbuildNode")
235
- if "Acceptance" not in labels_by_id.get(e, set()):
236
- errors.append(f"relationship {rid} (SATISFIES): end_node {e} must be an Acceptance node")
237
- else:
238
- satisfies_ends.add(e)
239
-
240
- # --- counter invariant ---------------------------------------------------
241
- if nodes and _is_int(graph.get("next_node_id")):
242
- expected_n = max(n["id"] for n in nodes if isinstance(n, dict) and _is_int(n.get("id"))) + 1
243
- if graph["next_node_id"] != expected_n:
244
- errors.append(f"next_node_id must be {expected_n} (max node id + 1), got {graph['next_node_id']}")
245
- if rels and _is_int(graph.get("next_rel_id")):
246
- expected_r = max(r["id"] for r in rels if isinstance(r, dict) and _is_int(r.get("id"))) + 1
247
- if graph["next_rel_id"] != expected_r:
248
- errors.append(f"next_rel_id must be {expected_r} (max rel id + 1), got {graph['next_rel_id']}")
249
-
250
- # --- acyclicity ---------------------------------------------------------
251
- if depends_edges and not any("DEPENDS_ON" in e for e in errors):
252
- cycle = _detect_cycle(gbuild_ids, depends_edges)
253
- if cycle:
254
- errors.append(f"cycle detected among: {', '.join(sorted(str(c) for c in cycle))}")
255
-
256
- # --- coverage -----------------------------------------------------------
257
- for aid, anid in acceptance_nodes.items():
258
- if anid not in satisfies_ends:
259
- errors.append(f"Acceptance {aid!r} (node {anid}) is not covered by any SATISFIES edge")
260
- if anid not in has_acc_ends:
261
- errors.append(f"Acceptance {aid!r} (node {anid}) is not linked by any HAS_ACCEPTANCE edge")
262
- # every chore must be depended on
263
- for nid in gbuild_ids:
264
- p = props_by_id.get(nid, {})
265
- if p.get("type") == "chore" and nid not in depends_targets:
266
- errors.append(f"chore node {nid} (slug {p.get('slug')!r}) is not depended on by any node")
267
-
268
- if errors:
269
- raise GraphError("; ".join(errors))
270
-
271
-
272
- def _detect_cycle(node_ids, edges):
273
- """Return a set of node ids involved in a cycle, or empty set if acyclic."""
274
- adj = {nid: [] for nid in node_ids}
275
- for s, e in edges:
276
- adj.setdefault(s, []).append(e)
277
- WHITE, GRAY, BLACK = 0, 1, 2
278
- color = {nid: WHITE for nid in node_ids}
279
- in_cycle = set()
280
-
281
- def dfs(u, stack):
282
- color[u] = GRAY
283
- stack.append(u)
284
- for v in adj.get(u, []):
285
- if v not in color:
286
- continue
287
- if color[v] == GRAY:
288
- # cycle: from v back to u along the stack
289
- idx = stack.index(v)
290
- in_cycle.update(stack[idx:])
291
- elif color[v] == WHITE:
292
- dfs(v, stack)
293
- stack.pop()
294
- color[u] = BLACK
295
-
296
- for nid in node_ids:
297
- if color.get(nid) == WHITE:
298
- dfs(nid, [])
299
- return in_cycle
300
-
301
-
302
- def _cypherlite_cross_check(graph_path):
303
- """If cypherlite is on PATH, confirm CypherLite itself loads the graph and
304
- can run a trivial query. Catches serde/encoding issues the stdlib walk can't
305
- (e.g. a top-level shape CypherLite's GraphData loader rejects). Returns an
306
- error string on failure, None on success or when cypherlite is absent.
307
-
308
- CypherLite opens a *directory* store, not a bare `.json` file. To avoid
309
- writing a WAL into the plan's `db/` folder during validation, copy the file
310
- into a throwaway dir as `graph.json` and open that.
311
-
312
- Absence is silent — cypherlite is an optional consumer, not a dependency.
313
- """
314
- import shutil
315
- import subprocess
316
- import tempfile
317
- bin_path = shutil.which("cypherlite")
318
- if bin_path is None:
319
- return None # not installed — skip, not a failure
320
- with tempfile.TemporaryDirectory() as scratch:
321
- shutil.copyfile(graph_path, Path(scratch) / "graph.json")
322
- res = subprocess.run(
323
- [bin_path, scratch, "-json", "MATCH (n) RETURN count(n) AS c"],
324
- capture_output=True, text=True, timeout=30,
325
- )
326
- # cypherlite returns exit 0 even on some load failures (it prints the error
327
- # to stderr), so check both the exit code AND the output for failure markers.
328
- out = (res.stderr + res.stdout).strip()
329
- if res.returncode != 0:
330
- return f"cypherlite refused to load the file: {out or 'unknown error'}"
331
- if "Failed to open database" in out or "failed to parse JSON" in out:
332
- return f"cypherlite refused to load the file: {out}"
333
- return None
334
-
335
-
336
- def main(argv=None):
337
- parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
338
- parser.add_argument("graph_path")
339
- parser.add_argument("--no-cypherlite", action="store_true",
340
- help="skip the optional cypherlite load cross-check even if the binary is present")
341
- args = parser.parse_args(argv)
342
-
343
- try:
344
- with open(args.graph_path) as f:
345
- graph = json.load(f)
346
- except (OSError, json.JSONDecodeError) as e:
347
- print(f"invalid: cannot read {args.graph_path}: {e}", file=sys.stderr)
348
- return 1
349
-
350
- try:
351
- validate(graph)
352
- except GraphError as e:
353
- print(f"invalid: {e}", file=sys.stderr)
354
- return 1
355
-
356
- # Optional cross-check: confirm CypherLite itself loads the file (catches
357
- # serde/encoding issues the stdlib checks above can't). Skipped silently if
358
- # cypherlite isn't installed — it's an optional consumer, not a dependency.
359
- if not args.no_cypherlite:
360
- cl_err = _cypherlite_cross_check(args.graph_path)
361
- if cl_err is not None:
362
- print(f"invalid: {cl_err}", file=sys.stderr)
363
- return 1
364
-
365
- # best-effort feature slug for the success line
366
- feature = "?"
367
- for n in graph.get("nodes", []):
368
- if isinstance(n, dict) and "Feature" in n.get("labels", []):
369
- p = n.get("properties", {})
370
- if isinstance(p.get("feature"), str):
371
- feature = p["feature"]
372
- break
373
- print(f"valid: {feature}")
374
- return 0
375
-
376
-
377
- if __name__ == "__main__":
378
- sys.exit(main())
@@ -1,110 +0,0 @@
1
- {
2
- "nodes": [
3
- {
4
- "id": 1,
5
- "labels": ["Feature"],
6
- "properties": {
7
- "feature": "example-diamond",
8
- "destination": "A tiny worked example: A produces a shared value, B and C each consume it independently, D joins both.",
9
- "context": "Ships with the plugin as the toy graph used in gbuild's own verification steps. Not a real feature.",
10
- "out_of_scope": ["Anything beyond demonstrating fan-out and join scheduling."]
11
- }
12
- },
13
- {
14
- "id": 2,
15
- "labels": ["Acceptance"],
16
- "properties": { "id": "a-1", "text": "run dispatches B and C concurrently, not one after the other" }
17
- },
18
- {
19
- "id": 3,
20
- "labels": ["Acceptance"],
21
- "properties": { "id": "a-2", "text": "D does not start until both B and C are checkpointed complete" }
22
- },
23
- {
24
- "id": 4,
25
- "labels": ["GbuildNode", "Chore"],
26
- "properties": {
27
- "slug": "a-produce-shared-value",
28
- "title": "Produce the shared value B and C both need",
29
- "type": "chore",
30
- "contract": { "input": {}, "output": { "value": "<integer>" } },
31
- "acceptance": ["writes a JSON object with a single integer field 'value' to its checkpoint output"],
32
- "verify": null,
33
- "failure_policy": "retry",
34
- "model_tier": "cheap"
35
- }
36
- },
37
- {
38
- "id": 5,
39
- "labels": ["GbuildNode", "Code"],
40
- "properties": {
41
- "slug": "b-consume-doubled",
42
- "title": "Consume the shared value, produce its double",
43
- "type": "code",
44
- "contract": {
45
- "input": { "value": "<integer, from a-produce-shared-value.output.value>" },
46
- "output": { "doubled": "<integer>" }
47
- },
48
- "acceptance": ["output.doubled equals a's output.value multiplied by 2"],
49
- "verify": null,
50
- "failure_policy": "repair",
51
- "model_tier": "cheap"
52
- }
53
- },
54
- {
55
- "id": 6,
56
- "labels": ["GbuildNode", "Code"],
57
- "properties": {
58
- "slug": "c-consume-squared",
59
- "title": "Consume the shared value, produce its square",
60
- "type": "code",
61
- "contract": {
62
- "input": { "value": "<integer, from a-produce-shared-value.output.value>" },
63
- "output": { "squared": "<integer>" }
64
- },
65
- "acceptance": ["output.squared equals a's output.value raised to the power of 2"],
66
- "verify": null,
67
- "failure_policy": "repair",
68
- "model_tier": "cheap"
69
- }
70
- },
71
- {
72
- "id": 7,
73
- "labels": ["GbuildNode", "Code"],
74
- "properties": {
75
- "slug": "d-join-and-sum",
76
- "title": "Join B and C, produce their sum",
77
- "type": "code",
78
- "contract": {
79
- "input": {
80
- "doubled": "<integer, from b-consume-doubled.output.doubled>",
81
- "squared": "<integer, from c-consume-squared.output.squared>"
82
- },
83
- "output": { "sum": "<integer>" }
84
- },
85
- "acceptance": [
86
- "output.sum equals b's output.doubled plus c's output.squared",
87
- "only runs after both b-consume-doubled and c-consume-squared are checkpointed completed"
88
- ],
89
- "verify": null,
90
- "failure_policy": "repair",
91
- "model_tier": "cheap"
92
- }
93
- }
94
- ],
95
- "relationships": [
96
- { "id": 1, "type": "HAS_ACCEPTANCE", "start_node": 1, "end_node": 2, "properties": {} },
97
- { "id": 2, "type": "HAS_ACCEPTANCE", "start_node": 1, "end_node": 3, "properties": {} },
98
- { "id": 3, "type": "SATISFIES", "start_node": 5, "end_node": 2, "properties": {} },
99
- { "id": 4, "type": "SATISFIES", "start_node": 6, "end_node": 2, "properties": {} },
100
- { "id": 5, "type": "SATISFIES", "start_node": 7, "end_node": 3, "properties": {} },
101
- { "id": 6, "type": "DEPENDS_ON", "start_node": 5, "end_node": 4, "properties": {} },
102
- { "id": 7, "type": "DEPENDS_ON", "start_node": 6, "end_node": 4, "properties": {} },
103
- { "id": 8, "type": "DEPENDS_ON", "start_node": 7, "end_node": 5, "properties": {} },
104
- { "id": 9, "type": "DEPENDS_ON", "start_node": 7, "end_node": 6, "properties": {} }
105
- ],
106
- "indexes": [],
107
- "constraints": [],
108
- "next_node_id": 8,
109
- "next_rel_id": 10
110
- }