generflow-core 0.2.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.
- generflow_core/__init__.py +3 -0
- generflow_core/actions/__init__.py +22 -0
- generflow_core/actions/dispatcher.py +223 -0
- generflow_core/adapters/__init__.py +11 -0
- generflow_core/adapters/llm.py +186 -0
- generflow_core/api/__init__.py +5 -0
- generflow_core/api/app.py +494 -0
- generflow_core/api/prompt.py +64 -0
- generflow_core/cli.py +241 -0
- generflow_core/databind/__init__.py +30 -0
- generflow_core/databind/config.py +183 -0
- generflow_core/databind/resolver.py +306 -0
- generflow_core/hitl/__init__.py +22 -0
- generflow_core/hitl/gates.py +165 -0
- generflow_core/interop/__init__.py +257 -0
- generflow_core/observability/__init__.py +208 -0
- generflow_core/py.typed +0 -0
- generflow_core/registry/__init__.py +4 -0
- generflow_core/registry/registry.py +194 -0
- generflow_core/replay/__init__.py +189 -0
- generflow_core/spec/__init__.py +21 -0
- generflow_core/spec/ast.py +61 -0
- generflow_core/spec/diff.py +177 -0
- generflow_core/spec/parser.py +332 -0
- generflow_core/spec/update.py +136 -0
- generflow_core-0.2.0.dist-info/METADATA +161 -0
- generflow_core-0.2.0.dist-info/RECORD +30 -0
- generflow_core-0.2.0.dist-info/WHEEL +5 -0
- generflow_core-0.2.0.dist-info/entry_points.txt +3 -0
- generflow_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Rewind/replay: buffer SSE events as they arrive, allow scrubbing
|
|
2
|
+
through them at any speed, and export for debugging.
|
|
3
|
+
|
|
4
|
+
Storage is a flat list of events with monotonic timestamps. The
|
|
5
|
+
scrubber reconstructs the spec tree at any point in time.
|
|
6
|
+
|
|
7
|
+
Key uses:
|
|
8
|
+
- User hits "rewind" → events replay at 4× speed
|
|
9
|
+
- Developer debugs "what went wrong here?" → scrub to the moment
|
|
10
|
+
- Eval harness reproduces deterministic spec states
|
|
11
|
+
- The playground lets users see the LLM "thinking"
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Iterator
|
|
20
|
+
|
|
21
|
+
from ..spec import GFLangParser, Assignment, Component
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BufferedEvent:
|
|
26
|
+
seq: int # monotonic sequence number
|
|
27
|
+
ts: float # wall-clock time
|
|
28
|
+
type: str # session.start, spec.line, data.fill, update, etc.
|
|
29
|
+
data: dict = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EventBuffer:
|
|
33
|
+
"""In-memory buffer of SSE events for one session.
|
|
34
|
+
|
|
35
|
+
Supports:
|
|
36
|
+
- append(events) as they stream in
|
|
37
|
+
- iter() to walk all events
|
|
38
|
+
- replay(rate=4.0) to walk events at speed
|
|
39
|
+
- scrub(seq) to get the cumulative state at that point
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, max_size: int = 100_000) -> None:
|
|
43
|
+
self._events: list[BufferedEvent] = []
|
|
44
|
+
self._seq = 0
|
|
45
|
+
self._max_size = max_size
|
|
46
|
+
|
|
47
|
+
def append(self, event_type: str, data: dict | None = None) -> BufferedEvent:
|
|
48
|
+
self._seq += 1
|
|
49
|
+
ev = BufferedEvent(seq=self._seq, ts=time.time(), type=event_type, data=data or {})
|
|
50
|
+
self._events.append(ev)
|
|
51
|
+
if len(self._events) > self._max_size:
|
|
52
|
+
self._events.pop(0)
|
|
53
|
+
return ev
|
|
54
|
+
|
|
55
|
+
def __len__(self) -> int:
|
|
56
|
+
return len(self._events)
|
|
57
|
+
|
|
58
|
+
def __iter__(self) -> Iterator[BufferedEvent]:
|
|
59
|
+
return iter(self._events)
|
|
60
|
+
|
|
61
|
+
def slice(self, start: int = 0, end: int | None = None) -> list[BufferedEvent]:
|
|
62
|
+
return self._events[start:end]
|
|
63
|
+
|
|
64
|
+
def at(self, seq: int) -> list[BufferedEvent]:
|
|
65
|
+
"""All events up to and including seq."""
|
|
66
|
+
return [e for e in self._events if e.seq <= seq]
|
|
67
|
+
|
|
68
|
+
async def replay(self, rate: float = 1.0) -> Iterator[BufferedEvent]:
|
|
69
|
+
"""Yield events at `rate`× real-time speed.
|
|
70
|
+
|
|
71
|
+
rate=4.0 → 4× faster than original; rate=0.5 → half-speed.
|
|
72
|
+
"""
|
|
73
|
+
if not self._events:
|
|
74
|
+
return
|
|
75
|
+
start = self._events[0].ts
|
|
76
|
+
for i, ev in enumerate(self._events):
|
|
77
|
+
if i > 0:
|
|
78
|
+
original_delta = ev.ts - self._events[i - 1].ts
|
|
79
|
+
replay_delta = original_delta / rate
|
|
80
|
+
# cap at 100ms to avoid freezing on big gaps
|
|
81
|
+
if replay_delta > 0.1:
|
|
82
|
+
replay_delta = 0.1
|
|
83
|
+
# busy-wait sleep — caller should be async
|
|
84
|
+
time.sleep(replay_delta)
|
|
85
|
+
yield ev
|
|
86
|
+
|
|
87
|
+
def scrub_state(self, seq: int) -> dict:
|
|
88
|
+
"""Reconstruct the spec state at a given sequence point.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
{
|
|
92
|
+
"spec_lines": N,
|
|
93
|
+
"data_fills": M,
|
|
94
|
+
"updates": K,
|
|
95
|
+
"spec_text": "<reconstructed GF-Lang>",
|
|
96
|
+
"tree": { ... Component dict ... }
|
|
97
|
+
}
|
|
98
|
+
"""
|
|
99
|
+
events = self.at(seq)
|
|
100
|
+
spec_text = ""
|
|
101
|
+
data_fills: dict[str, Any] = {}
|
|
102
|
+
updates: list[dict] = []
|
|
103
|
+
for ev in events:
|
|
104
|
+
if ev.type == "spec.line":
|
|
105
|
+
node = ev.data.get("node", {})
|
|
106
|
+
spec_text += _node_to_gflang(node) + "\n"
|
|
107
|
+
elif ev.type == "update":
|
|
108
|
+
updates.append(ev.data)
|
|
109
|
+
# apply the update (best-effort, no validation)
|
|
110
|
+
path = ev.data.get("path", "")
|
|
111
|
+
value = ev.data.get("value")
|
|
112
|
+
if path and value is not None:
|
|
113
|
+
_apply_inline_update(spec_text, path, value)
|
|
114
|
+
elif ev.type == "data.fill":
|
|
115
|
+
ref = ev.data.get("ref")
|
|
116
|
+
if ref:
|
|
117
|
+
data_fills[ref] = ev.data.get("value")
|
|
118
|
+
# parse reconstructed text
|
|
119
|
+
parser = GFLangParser()
|
|
120
|
+
nodes = parser.feed_chunk(spec_text)
|
|
121
|
+
return {
|
|
122
|
+
"seq": seq,
|
|
123
|
+
"spec_lines": sum(1 for e in events if e.type == "spec.line"),
|
|
124
|
+
"data_fills": len(data_fills),
|
|
125
|
+
"updates": len(updates),
|
|
126
|
+
"data_by_ref": data_fills,
|
|
127
|
+
"spec_text": spec_text,
|
|
128
|
+
"parsed_nodes": [n.to_dict() for n in nodes],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def export(self) -> dict:
|
|
132
|
+
"""Export the full buffer as JSON (for debugging / sharing)."""
|
|
133
|
+
return {
|
|
134
|
+
"version": 1,
|
|
135
|
+
"event_count": len(self._events),
|
|
136
|
+
"first_ts": self._events[0].ts if self._events else None,
|
|
137
|
+
"last_ts": self._events[-1].ts if self._events else None,
|
|
138
|
+
"events": [{"seq": e.seq, "ts": e.ts, "type": e.type, "data": e.data} for e in self._events],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _node_to_gflang(node: dict) -> str:
|
|
143
|
+
"""Reconstruct GF-Lang text from a serialized Component dict."""
|
|
144
|
+
name = node.get("name", "Text")
|
|
145
|
+
kwargs = node.get("kwargs", {})
|
|
146
|
+
args = node.get("args", [])
|
|
147
|
+
children = node.get("children", [])
|
|
148
|
+
parts = []
|
|
149
|
+
for k, v in kwargs.items():
|
|
150
|
+
parts.append(f"{k}={_render_gflang_value(v)}")
|
|
151
|
+
children_str = ""
|
|
152
|
+
if children:
|
|
153
|
+
kids = [_node_to_gflang(c) for c in children]
|
|
154
|
+
children_str = "[\n " + ",\n ".join(kids) + ",\n]"
|
|
155
|
+
if children_str:
|
|
156
|
+
if parts:
|
|
157
|
+
return f"{name}({', '.join(parts)},\n{children_str})"
|
|
158
|
+
return f"{name}({children_str})"
|
|
159
|
+
if parts:
|
|
160
|
+
return f"{name}({', '.join(parts)})"
|
|
161
|
+
return f"{name}()"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _render_gflang_value(v: Any) -> str:
|
|
165
|
+
if v is None:
|
|
166
|
+
return "null"
|
|
167
|
+
if isinstance(v, bool):
|
|
168
|
+
return "true" if v else "false"
|
|
169
|
+
if isinstance(v, (int, float)):
|
|
170
|
+
return str(v)
|
|
171
|
+
if isinstance(v, str):
|
|
172
|
+
if any(c in v for c in " \t\"\\'"):
|
|
173
|
+
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
174
|
+
return v
|
|
175
|
+
if isinstance(v, dict):
|
|
176
|
+
# looks like a serialized Component
|
|
177
|
+
if "name" in v and ("kwargs" in v or "args" in v):
|
|
178
|
+
return _node_to_gflang(v)
|
|
179
|
+
return json.dumps(v)
|
|
180
|
+
if isinstance(v, list):
|
|
181
|
+
return "[" + ", ".join(_render_gflang_value(x) for x in v) + "]"
|
|
182
|
+
return str(v)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _apply_inline_update(text: str, path: str, value: Any) -> None:
|
|
186
|
+
"""Best-effort: mutate `text` by replacing the value at `path`."""
|
|
187
|
+
# not implemented in v1 (use spec.update.apply_update on the parsed tree)
|
|
188
|
+
# this is a placeholder for future expansion
|
|
189
|
+
pass
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""GF-Lang spec module — public API."""
|
|
2
|
+
from .ast import Assignment, Component, Literal, Ref
|
|
3
|
+
from .diff import DiffEntry, diff_specs, summarize_diff
|
|
4
|
+
from .parser import GFLangParser, ParseError, count_tokens
|
|
5
|
+
from .update import Update, apply_update, parse_update
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Assignment",
|
|
9
|
+
"Component",
|
|
10
|
+
"DiffEntry",
|
|
11
|
+
"Literal",
|
|
12
|
+
"Ref",
|
|
13
|
+
"Update",
|
|
14
|
+
"GFLangParser",
|
|
15
|
+
"ParseError",
|
|
16
|
+
"apply_update",
|
|
17
|
+
"count_tokens",
|
|
18
|
+
"diff_specs",
|
|
19
|
+
"parse_update",
|
|
20
|
+
"summarize_diff",
|
|
21
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""GF-Lang AST node types."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Literal:
|
|
10
|
+
value: Union[str, int, float, bool, list, None]
|
|
11
|
+
|
|
12
|
+
def to_dict(self) -> Any:
|
|
13
|
+
return self.value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Component:
|
|
18
|
+
name: str
|
|
19
|
+
args: list = field(default_factory=list)
|
|
20
|
+
kwargs: dict = field(default_factory=dict)
|
|
21
|
+
children: list = field(default_factory=list) # child Components (not positional args)
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
return {
|
|
25
|
+
"type": "component",
|
|
26
|
+
"name": self.name,
|
|
27
|
+
"args": [_to_dict(a) for a in self.args],
|
|
28
|
+
"kwargs": {k: _to_dict(v) for k, v in self.kwargs.items()},
|
|
29
|
+
"children": [_to_dict(c) for c in self.children],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Ref:
|
|
35
|
+
name: str # the part after $
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {"type": "ref", "name": self.name}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Assignment:
|
|
43
|
+
identifier: str
|
|
44
|
+
value: Any # Component | Literal | Ref
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"type": "assignment",
|
|
49
|
+
"id": self.identifier,
|
|
50
|
+
"value": _to_dict(self.value),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _to_dict(node: Any) -> Any:
|
|
55
|
+
if hasattr(node, "to_dict"):
|
|
56
|
+
return node.to_dict()
|
|
57
|
+
if isinstance(node, dict):
|
|
58
|
+
return {k: _to_dict(v) for k, v in node.items()}
|
|
59
|
+
if isinstance(node, list):
|
|
60
|
+
return [_to_dict(x) for x in node]
|
|
61
|
+
return node
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Spec diffing: compare two GF-Lang specs and produce a structural diff.
|
|
2
|
+
|
|
3
|
+
Useful for:
|
|
4
|
+
- Regression testing ("did the prompt change break this UI?")
|
|
5
|
+
- Prompt evaluation ("what changed between version A and B?")
|
|
6
|
+
- Showing users "the AI just changed this field"
|
|
7
|
+
|
|
8
|
+
Diff output format: list of {op, path, before, after} entries.
|
|
9
|
+
Ops: "added", "removed", "changed", "moved".
|
|
10
|
+
|
|
11
|
+
Inspired by RFC 6902 (JSON Patch) but adapted for the spec tree.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, asdict
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .ast import Assignment, Component, Literal, Ref
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DiffEntry:
|
|
23
|
+
op: str # "added" | "removed" | "changed" | "moved"
|
|
24
|
+
path: str # e.g. "root.children[2].kwargs.value"
|
|
25
|
+
before: Any = None
|
|
26
|
+
after: Any = None
|
|
27
|
+
note: str = ""
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"op": self.op,
|
|
32
|
+
"path": self.path,
|
|
33
|
+
"before": _serialize(self.before),
|
|
34
|
+
"after": _serialize(self.after),
|
|
35
|
+
"note": self.note,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _serialize(v: Any) -> Any:
|
|
40
|
+
"""Make AST nodes JSON-friendly."""
|
|
41
|
+
if v is None or isinstance(v, (str, int, float, bool)):
|
|
42
|
+
return v
|
|
43
|
+
if isinstance(v, (Component, Assignment, Literal, Ref)):
|
|
44
|
+
return v.to_dict()
|
|
45
|
+
if isinstance(v, dict):
|
|
46
|
+
return {k: _serialize(val) for k, val in v.items()}
|
|
47
|
+
if isinstance(v, list):
|
|
48
|
+
return [_serialize(x) for x in v]
|
|
49
|
+
return str(v)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def diff_specs(before_text: str, after_text: str) -> list[dict]:
|
|
53
|
+
"""Compare two GF-Lang spec strings and return a diff.
|
|
54
|
+
|
|
55
|
+
Parses both specs, then walks in lockstep producing entries for each
|
|
56
|
+
structural difference. If a spec fails to parse, the whole thing
|
|
57
|
+
is reported as one entry.
|
|
58
|
+
"""
|
|
59
|
+
from .parser import GFLangParser
|
|
60
|
+
|
|
61
|
+
p = GFLangParser()
|
|
62
|
+
a = p.feed_chunk(before_text)
|
|
63
|
+
b = p.feed_chunk(after_text)
|
|
64
|
+
|
|
65
|
+
# unwrap Assignment(root = ...) → root Component
|
|
66
|
+
a_root = _root(a)
|
|
67
|
+
b_root = _root(b)
|
|
68
|
+
if a_root is None or b_root is None:
|
|
69
|
+
return [DiffEntry(
|
|
70
|
+
op="error",
|
|
71
|
+
path="$",
|
|
72
|
+
before=before_text[:80] + "..." if len(before_text) > 80 else before_text,
|
|
73
|
+
after=after_text[:80] + "..." if len(after_text) > 80 else after_text,
|
|
74
|
+
note="could not parse one or both specs",
|
|
75
|
+
).to_dict()]
|
|
76
|
+
out: list[DiffEntry] = []
|
|
77
|
+
_walk(a_root, b_root, "root", out)
|
|
78
|
+
return [e.to_dict() for e in out]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _root(nodes: list) -> Component | None:
|
|
82
|
+
if not nodes:
|
|
83
|
+
return None
|
|
84
|
+
n = nodes[0]
|
|
85
|
+
if isinstance(n, Assignment):
|
|
86
|
+
n = n.value
|
|
87
|
+
return n if isinstance(n, Component) else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _walk(a: Any, b: Any, path: str, out: list[DiffEntry]) -> None:
|
|
91
|
+
"""Recursively compare two AST subtrees."""
|
|
92
|
+
if type(a) != type(b):
|
|
93
|
+
out.append(DiffEntry(op="changed", path=path, before=a, after=b, note=f"type changed {type(a).__name__}→{type(b).__name__}"))
|
|
94
|
+
return
|
|
95
|
+
if isinstance(a, Component):
|
|
96
|
+
if a.name != b.name:
|
|
97
|
+
out.append(DiffEntry(op="changed", path=f"{path}.name", before=a.name, after=b.name, note="component renamed"))
|
|
98
|
+
# compare kwargs
|
|
99
|
+
a_keys = set(a.kwargs.keys())
|
|
100
|
+
b_keys = set(b.kwargs.keys())
|
|
101
|
+
for k in sorted(a_keys - b_keys):
|
|
102
|
+
out.append(DiffEntry(op="removed", path=f"{path}.kwargs.{k}", before=_serialize(a.kwargs[k])))
|
|
103
|
+
for k in sorted(b_keys - a_keys):
|
|
104
|
+
out.append(DiffEntry(op="added", path=f"{path}.kwargs.{k}", after=_serialize(b.kwargs[k])))
|
|
105
|
+
for k in sorted(a_keys & b_keys):
|
|
106
|
+
_walk(a.kwargs[k], b.kwargs[k], f"{path}.kwargs.{k}", out)
|
|
107
|
+
# compare children
|
|
108
|
+
_walk_children(a, b, path, out)
|
|
109
|
+
elif isinstance(a, Literal):
|
|
110
|
+
if a.value != b.value:
|
|
111
|
+
out.append(DiffEntry(op="changed", path=path, before=_serialize(a.value), after=_serialize(b.value)))
|
|
112
|
+
elif isinstance(a, Ref):
|
|
113
|
+
if a.name != b.name:
|
|
114
|
+
out.append(DiffEntry(op="changed", path=path, before=a.name, after=b.name, note="ref renamed"))
|
|
115
|
+
elif isinstance(a, dict):
|
|
116
|
+
a_keys = set(a.keys())
|
|
117
|
+
b_keys = set(b.keys())
|
|
118
|
+
for k in sorted(a_keys - b_keys):
|
|
119
|
+
out.append(DiffEntry(op="removed", path=f"{path}.{k}", before=_serialize(a[k])))
|
|
120
|
+
for k in sorted(b_keys - a_keys):
|
|
121
|
+
out.append(DiffEntry(op="added", path=f"{path}.{k}", after=_serialize(b[k])))
|
|
122
|
+
for k in sorted(a_keys & b_keys):
|
|
123
|
+
_walk(a[k], b[k], f"{path}.{k}", out)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _walk_children(a: Component, b: Component, path: str, out: list[DiffEntry]) -> None:
|
|
127
|
+
"""Compare children arrays (positional + list-of-children)."""
|
|
128
|
+
# gather child lists: each Component has children as the last positional arg
|
|
129
|
+
a_children = _children(a)
|
|
130
|
+
b_children = _children(b)
|
|
131
|
+
if len(a_children) != len(b_children):
|
|
132
|
+
out.append(DiffEntry(
|
|
133
|
+
op="changed", path=f"{path}.children",
|
|
134
|
+
before=f"{len(a_children)} children",
|
|
135
|
+
after=f"{len(b_children)} children",
|
|
136
|
+
note="child count changed",
|
|
137
|
+
))
|
|
138
|
+
n = min(len(a_children), len(b_children))
|
|
139
|
+
for i in range(n):
|
|
140
|
+
_walk(a_children[i], b_children[i], f"{path}.children[{i}]", out)
|
|
141
|
+
if len(a_children) > n:
|
|
142
|
+
for i in range(n, len(a_children)):
|
|
143
|
+
out.append(DiffEntry(op="removed", path=f"{path}.children[{i}]", before=_serialize(a_children[i])))
|
|
144
|
+
if len(b_children) > n:
|
|
145
|
+
for i in range(n, len(b_children)):
|
|
146
|
+
out.append(DiffEntry(op="added", path=f"{path}.children[{i}]", after=_serialize(b_children[i])))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _children(comp: Component) -> list:
|
|
150
|
+
"""Extract the list of child Components.
|
|
151
|
+
|
|
152
|
+
Prefers the new `.children` field on Component. Falls back to the
|
|
153
|
+
legacy `args[-1]` Literal-wrapped list for backward compatibility
|
|
154
|
+
with code that hasn't been updated yet.
|
|
155
|
+
"""
|
|
156
|
+
if hasattr(comp, "children") and comp.children:
|
|
157
|
+
return comp.children
|
|
158
|
+
if not comp.args:
|
|
159
|
+
return []
|
|
160
|
+
last = comp.args[-1]
|
|
161
|
+
if isinstance(last, Literal) and isinstance(last.value, list):
|
|
162
|
+
return last.value
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def summarize_diff(entries: list[dict]) -> dict:
|
|
167
|
+
"""Produce a one-line summary of a diff: counts + the most-impacted path."""
|
|
168
|
+
if not entries:
|
|
169
|
+
return {"count": 0, "added": 0, "removed": 0, "changed": 0, "top_path": None}
|
|
170
|
+
counts = {"added": 0, "removed": 0, "changed": 0, "moved": 0, "error": 0}
|
|
171
|
+
for e in entries:
|
|
172
|
+
op = e["op"]
|
|
173
|
+
counts[op] = counts.get(op, 0) + 1
|
|
174
|
+
# pick the shallowest changed/added path with a non-trivial value
|
|
175
|
+
interesting = [e for e in entries if e["op"] in ("changed", "added", "removed")]
|
|
176
|
+
top = interesting[0]["path"] if interesting else None
|
|
177
|
+
return {"count": len(entries), **counts, "top_path": top}
|