sidegraph 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.
- sidegraph/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Engine seam — the ONLY package where Graphify specifics may live.
|
|
2
|
+
|
|
3
|
+
The portable core (``sidegraph.schema`` / ``store`` / ``retrieval`` / ``server``) depends
|
|
4
|
+
on the :class:`~sidegraph.engine.reader.GraphifyReader` protocol here and nothing else about
|
|
5
|
+
the engine. A Graphify release breaks at most this package. Do not add a second engine
|
|
6
|
+
adapter — keep the seam clean so one is *possible* later without reshaping the core.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""``GraphifyReader`` — the engine seam: the ONLY module that parses Graphify's graph.json.
|
|
2
|
+
|
|
3
|
+
graph.json-only (no Graphify MCP): resolve/neighbors/communities/subgraph are computed
|
|
4
|
+
locally from the node-link graph. Read-only — never writes graph.json. Version is never
|
|
5
|
+
mtime: it's the embedded ``built_at_commit`` field with a content hash always folded in
|
|
6
|
+
(``f"{commit}:{hash}"``), or just the content hash (``f"content:{hash}"``) when
|
|
7
|
+
``built_at_commit`` is absent — folding the hash in even when a commit is present is what
|
|
8
|
+
catches dirty-tree rebuilds that change graph.json without changing the commit. See
|
|
9
|
+
``docs/integrations/graphify.md``.
|
|
10
|
+
|
|
11
|
+
``community_labels()`` additionally reads the engine's optional ``.graphify_labels.json``
|
|
12
|
+
sidecar (written by Graphify's cluster-only/label commands, sibling to ``graph.json``) —
|
|
13
|
+
this module is the ONLY place allowed to know that file's name/shape (see
|
|
14
|
+
``docs/concepts/mind-model.md#domain-lifecycle``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
from collections import deque
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from ..schema import Descriptor, canonicalize
|
|
29
|
+
|
|
30
|
+
# Node file types that can carry anchors. Concepts (LLM-extracted thematic entities) and
|
|
31
|
+
# rationale nodes (recorded reasoning, code AST or LLM prose) anchor exactly like code
|
|
32
|
+
# symbols / doc headings: name+file descriptor, Tier-2 leaf + Tier-1 community. `image`
|
|
33
|
+
# stays out — not name-anchorable — never-guess extends to file types.
|
|
34
|
+
ANCHORABLE_FILE_TYPES = frozenset({"code", "document", "concept", "rationale"})
|
|
35
|
+
|
|
36
|
+
# Sidecar written by Graphify's cluster-only/label commands, sibling to graph.json:
|
|
37
|
+
# {"<community_id>": "<human label>", ...}. Optional — absent on graphs built without a
|
|
38
|
+
# labeling pass (verified empirically: present on code/adr/doc corpora rebuilt with
|
|
39
|
+
# `graphify update --cluster-only`/label commands, absent on a plain semantic-only build).
|
|
40
|
+
_LABELS_FILENAME = ".graphify_labels.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NodeRef(BaseModel):
|
|
44
|
+
node_id: str
|
|
45
|
+
name: str
|
|
46
|
+
norm_name: str
|
|
47
|
+
file_type: str
|
|
48
|
+
file_path: str | None = None
|
|
49
|
+
line: str | None = None
|
|
50
|
+
community: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Community(BaseModel):
|
|
54
|
+
community_id: str
|
|
55
|
+
members: list[str]
|
|
56
|
+
god_node: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Subgraph(BaseModel):
|
|
60
|
+
nodes: list[NodeRef]
|
|
61
|
+
edges: list[tuple[str, str, str]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ResolveResult(BaseModel):
|
|
65
|
+
status: str # "resolved" | "ambiguous" | "unresolved"
|
|
66
|
+
node_id: str | None = None
|
|
67
|
+
candidates: list[str] = []
|
|
68
|
+
community: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RationaleNode(BaseModel):
|
|
72
|
+
node_id: str
|
|
73
|
+
text: str # the node's label — engine truncates ~80 chars, that's fine
|
|
74
|
+
file_path: str | None = None
|
|
75
|
+
community: str | None = None
|
|
76
|
+
targets: list[NodeRef] = [] # what this rationale explains
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_node(raw: dict) -> NodeRef:
|
|
80
|
+
community = raw.get("community")
|
|
81
|
+
return NodeRef(
|
|
82
|
+
node_id=str(raw.get("id")),
|
|
83
|
+
name=str(raw.get("label", "")),
|
|
84
|
+
norm_name=str(raw.get("norm_label", raw.get("label", ""))),
|
|
85
|
+
file_type=str(raw.get("file_type", "")),
|
|
86
|
+
file_path=raw.get("source_file"),
|
|
87
|
+
line=raw.get("source_location"),
|
|
88
|
+
community=None if community is None else str(community),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GraphifyReader:
|
|
93
|
+
"""Reads ``graphify-out/graph.json`` read-only and answers graph queries locally."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, path: str | Path) -> None:
|
|
96
|
+
self.path = Path(path)
|
|
97
|
+
self._raw = self._load()
|
|
98
|
+
self._nodes = [_to_node(n) for n in self._raw.get("nodes", [])]
|
|
99
|
+
self._by_id = {n.node_id: n for n in self._nodes}
|
|
100
|
+
self._links = self._raw.get("links", [])
|
|
101
|
+
# Lazy, memoized indices — each is built at most once, from a single O(V)/O(E) pass,
|
|
102
|
+
# the first time its owning method is called (not in __init__: a reader that never
|
|
103
|
+
# calls e.g. resolve() shouldn't pay for an index it never needs). See neighbors(),
|
|
104
|
+
# resolve(), nodes_in_file() below — each used to re-scan self._links/self._nodes in
|
|
105
|
+
# full on EVERY call, which is fine for a single lookup but catastrophic for callers
|
|
106
|
+
# that loop over many nodes (e.g. rationale_nodes() calling neighbors() once per
|
|
107
|
+
# rationale node, or import_rationales() calling resolve() per anchor candidate): on
|
|
108
|
+
# a 113K-node/232K-edge graph with 23K rationale nodes this was billions of
|
|
109
|
+
# comparisons in pure Python and never returned. Building the index once turns each
|
|
110
|
+
# of those into O(degree)/O(matches) per call instead of O(E)/O(V).
|
|
111
|
+
self._adjacency: dict[str, list[tuple[str, str]]] | None = None
|
|
112
|
+
self._resolve_idx: dict[str, list[NodeRef]] | None = None
|
|
113
|
+
self._file_idx: dict[str, list[NodeRef]] | None = None
|
|
114
|
+
|
|
115
|
+
def _load(self, retries: int = 3) -> dict:
|
|
116
|
+
last: Exception | None = None
|
|
117
|
+
for _ in range(max(1, retries)):
|
|
118
|
+
text = self.path.read_text()
|
|
119
|
+
try:
|
|
120
|
+
raw = json.loads(text)
|
|
121
|
+
except json.JSONDecodeError as e: # graph.json write is non-atomic
|
|
122
|
+
last = e
|
|
123
|
+
time.sleep(0.2)
|
|
124
|
+
continue
|
|
125
|
+
self._content_hash = hashlib.sha256(text.encode()).hexdigest()[:12]
|
|
126
|
+
return raw
|
|
127
|
+
raise last # type: ignore[misc]
|
|
128
|
+
|
|
129
|
+
def graph_version(self) -> str:
|
|
130
|
+
"""``f"{commit}:{content_hash}"`` when the engine stamped a ``built_at_commit``,
|
|
131
|
+
else ``f"content:{content_hash}"``. The content hash is folded in even when a commit
|
|
132
|
+
is present: Graphify rewrites graph.json without bumping built_at_commit whenever the
|
|
133
|
+
working tree is dirty (e.g. an uncommitted rename), and a bare commit would then miss
|
|
134
|
+
that the content changed and let sync report "up to date" while serving stale anchors.
|
|
135
|
+
One-time upgrade note: stores written before this change stamped
|
|
136
|
+
``last_synced_graph_version`` in the old bare-commit format, so the first sync after
|
|
137
|
+
upgrading sees a differing version string and reruns once — self-healing, and intended.
|
|
138
|
+
"""
|
|
139
|
+
commit = self._raw.get("built_at_commit")
|
|
140
|
+
if commit:
|
|
141
|
+
return f"{commit}:{self._content_hash}"
|
|
142
|
+
return f"content:{self._content_hash}"
|
|
143
|
+
|
|
144
|
+
def list_nodes(self) -> list[NodeRef]:
|
|
145
|
+
return list(self._nodes)
|
|
146
|
+
|
|
147
|
+
def get_node(self, node_id: str) -> NodeRef | None:
|
|
148
|
+
return self._by_id.get(node_id)
|
|
149
|
+
|
|
150
|
+
def _file_index(self) -> dict[str, list[NodeRef]]:
|
|
151
|
+
"""``file_path -> anchorable nodes`` in that file, built once from a single pass
|
|
152
|
+
over ``self._nodes`` (memoized). Backs ``nodes_in_file()``."""
|
|
153
|
+
if self._file_idx is None:
|
|
154
|
+
idx: dict[str, list[NodeRef]] = {}
|
|
155
|
+
for n in self._nodes:
|
|
156
|
+
if n.file_type in ANCHORABLE_FILE_TYPES and n.file_path is not None:
|
|
157
|
+
idx.setdefault(n.file_path, []).append(n)
|
|
158
|
+
self._file_idx = idx
|
|
159
|
+
return self._file_idx
|
|
160
|
+
|
|
161
|
+
def nodes_in_file(self, file_path: str) -> list[NodeRef]:
|
|
162
|
+
"""All anchorable nodes (code symbols / doc headings) whose source_file matches."""
|
|
163
|
+
return list(self._file_index().get(file_path, []))
|
|
164
|
+
|
|
165
|
+
def _resolve_index(self) -> dict[str, list[NodeRef]]:
|
|
166
|
+
"""``canonicalize(name) -> anchorable nodes`` with that canonical name, built once
|
|
167
|
+
from a single pass over ``self._nodes`` (memoized). Backs ``resolve()``."""
|
|
168
|
+
if self._resolve_idx is None:
|
|
169
|
+
idx: dict[str, list[NodeRef]] = {}
|
|
170
|
+
for n in self._nodes:
|
|
171
|
+
if n.file_type in ANCHORABLE_FILE_TYPES:
|
|
172
|
+
idx.setdefault(canonicalize(n.name), []).append(n)
|
|
173
|
+
self._resolve_idx = idx
|
|
174
|
+
return self._resolve_idx
|
|
175
|
+
|
|
176
|
+
def resolve(self, desc: Descriptor) -> ResolveResult:
|
|
177
|
+
target = canonicalize(desc.name)
|
|
178
|
+
matches = list(self._resolve_index().get(target, []))
|
|
179
|
+
if desc.file_path is not None:
|
|
180
|
+
matches = [n for n in matches if n.file_path == desc.file_path]
|
|
181
|
+
if len(matches) == 1:
|
|
182
|
+
m = matches[0]
|
|
183
|
+
return ResolveResult(status="resolved", node_id=m.node_id, community=m.community)
|
|
184
|
+
if not matches:
|
|
185
|
+
return ResolveResult(status="unresolved")
|
|
186
|
+
comms = {m.community for m in matches if m.community is not None}
|
|
187
|
+
shared = comms.pop() if len(comms) == 1 else None
|
|
188
|
+
return ResolveResult(
|
|
189
|
+
status="ambiguous",
|
|
190
|
+
candidates=[m.node_id for m in matches],
|
|
191
|
+
community=shared,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _relation(self, link: dict) -> str:
|
|
195
|
+
return str(link.get("relation") or link.get("type") or "")
|
|
196
|
+
|
|
197
|
+
def _adjacency_index(self) -> dict[str, list[tuple[str, str]]]:
|
|
198
|
+
"""``node_id -> [(other_node_id, relation), ...]`` across both edge endpoints,
|
|
199
|
+
built once from a single pass over ``self._links`` (memoized). Backs ``neighbors()``.
|
|
200
|
+
|
|
201
|
+
Mirrors the per-node-id "which end is the other one" derivation the old per-call
|
|
202
|
+
scan used, so that iterating ``adjacency[node_id]`` yields exactly the (other,
|
|
203
|
+
relation) pairs a full scan filtered to ``node_id`` would have yielded, in the same
|
|
204
|
+
link order — including a self-loop edge (source == target) contributing exactly one
|
|
205
|
+
entry, same as before.
|
|
206
|
+
"""
|
|
207
|
+
if self._adjacency is None:
|
|
208
|
+
adj: dict[str, list[tuple[str, str]]] = {}
|
|
209
|
+
for link in self._links:
|
|
210
|
+
rel = self._relation(link)
|
|
211
|
+
s, t = str(link.get("source")), str(link.get("target"))
|
|
212
|
+
adj.setdefault(s, []).append((t, rel))
|
|
213
|
+
if t != s:
|
|
214
|
+
adj.setdefault(t, []).append((s, rel))
|
|
215
|
+
self._adjacency = adj
|
|
216
|
+
return self._adjacency
|
|
217
|
+
|
|
218
|
+
def neighbors(self, node_id: str, relations: list[str] | None = None) -> list[NodeRef]:
|
|
219
|
+
out: list[NodeRef] = []
|
|
220
|
+
seen: set[str] = set()
|
|
221
|
+
for other, rel in self._adjacency_index().get(node_id, []):
|
|
222
|
+
if relations is not None and rel not in relations:
|
|
223
|
+
continue
|
|
224
|
+
if other in seen:
|
|
225
|
+
continue
|
|
226
|
+
node = self._by_id.get(other)
|
|
227
|
+
if node is not None:
|
|
228
|
+
seen.add(other)
|
|
229
|
+
out.append(node)
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
def rationale_nodes(self) -> list[RationaleNode]:
|
|
233
|
+
"""All ``rationale`` nodes (recorded reasoning), with targets resolved.
|
|
234
|
+
|
|
235
|
+
This is the ONLY place that knows the ``rationale_for`` (code AST pass: rationale
|
|
236
|
+
--rationale_for--> code, verified empirically 492/492 edges on a real corpus) and
|
|
237
|
+
``references`` (LLM prose pass, same direction) relation names. ``neighbors()``
|
|
238
|
+
already resolves "the other end of the edge" regardless of source/target order, so
|
|
239
|
+
this stays defensive to either orientation without extra bookkeeping. Falls back to
|
|
240
|
+
``references`` only when a node has zero ``rationale_for`` edges; non-anchorable
|
|
241
|
+
targets (e.g. ``image``) are dropped from the result either way.
|
|
242
|
+
"""
|
|
243
|
+
out: list[RationaleNode] = []
|
|
244
|
+
for n in self._nodes:
|
|
245
|
+
if n.file_type != "rationale":
|
|
246
|
+
continue
|
|
247
|
+
targets = self.neighbors(n.node_id, relations=["rationale_for"])
|
|
248
|
+
if not targets:
|
|
249
|
+
targets = self.neighbors(n.node_id, relations=["references"])
|
|
250
|
+
targets = [t for t in targets if t.file_type in ANCHORABLE_FILE_TYPES]
|
|
251
|
+
out.append(
|
|
252
|
+
RationaleNode(
|
|
253
|
+
node_id=n.node_id,
|
|
254
|
+
text=n.name,
|
|
255
|
+
file_path=n.file_path,
|
|
256
|
+
community=n.community,
|
|
257
|
+
targets=targets,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
def containing(self, node_id: str) -> str | None:
|
|
263
|
+
node = self._by_id.get(node_id)
|
|
264
|
+
return node.community if node is not None else None
|
|
265
|
+
|
|
266
|
+
def communities(self) -> list[Community]:
|
|
267
|
+
degree: dict[str, int] = {}
|
|
268
|
+
for link in self._links:
|
|
269
|
+
for end in (str(link.get("source")), str(link.get("target"))):
|
|
270
|
+
degree[end] = degree.get(end, 0) + 1
|
|
271
|
+
groups: dict[str, list[str]] = {}
|
|
272
|
+
for n in self._nodes:
|
|
273
|
+
if n.community is not None:
|
|
274
|
+
groups.setdefault(n.community, []).append(n.node_id)
|
|
275
|
+
out: list[Community] = []
|
|
276
|
+
for cid, members in groups.items():
|
|
277
|
+
god = max(members, key=lambda m: degree.get(m, 0)) if members else None
|
|
278
|
+
out.append(Community(community_id=cid, members=members, god_node=god))
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
def community_labels(self) -> dict[str, str]:
|
|
282
|
+
"""Community id -> human label/summary, from the optional ``.graphify_labels.json``
|
|
283
|
+
sidecar next to ``graph.json`` (see module docstring). Purely additive: bootstrap
|
|
284
|
+
falls back to a god-node-derived name when a community has no entry here, or when
|
|
285
|
+
this method returns ``{}`` outright (no labeling pass run on this corpus).
|
|
286
|
+
|
|
287
|
+
Defensive by construction — a missing file, unreadable/malformed JSON, or a
|
|
288
|
+
non-dict payload all yield ``{}`` rather than raising, since this is best-effort
|
|
289
|
+
engine metadata, not a required input (unlike ``graph.json`` itself, whose read
|
|
290
|
+
failures ARE fatal in ``__init__``). Non-string values are dropped rather than
|
|
291
|
+
coerced, since a label is display text, not just anything JSON allows here.
|
|
292
|
+
Whitespace-only strings are dropped too: a blank label is worthless as a title
|
|
293
|
+
source and, left in, crashes a caller like ``bootstrap_domains`` downstream
|
|
294
|
+
(``Domain.title`` rejects blank text) — better every caller sees "no label here"
|
|
295
|
+
and falls back cleanly.
|
|
296
|
+
"""
|
|
297
|
+
label_path = self.path.parent / _LABELS_FILENAME
|
|
298
|
+
if not label_path.exists():
|
|
299
|
+
return {}
|
|
300
|
+
try:
|
|
301
|
+
raw = json.loads(label_path.read_text())
|
|
302
|
+
except (OSError, json.JSONDecodeError):
|
|
303
|
+
return {}
|
|
304
|
+
if not isinstance(raw, dict):
|
|
305
|
+
return {}
|
|
306
|
+
return {str(k): v for k, v in raw.items() if isinstance(v, str) and v.strip()}
|
|
307
|
+
|
|
308
|
+
def subgraph(self, seed_ids: list[str], budget: int) -> Subgraph:
|
|
309
|
+
adj: dict[str, list[tuple[str, str]]] = {}
|
|
310
|
+
for link in self._links:
|
|
311
|
+
s, t = str(link.get("source")), str(link.get("target"))
|
|
312
|
+
rel = self._relation(link)
|
|
313
|
+
adj.setdefault(s, []).append((t, rel))
|
|
314
|
+
adj.setdefault(t, []).append((s, rel))
|
|
315
|
+
picked: dict[str, NodeRef] = {}
|
|
316
|
+
q: deque[str] = deque()
|
|
317
|
+
for sid in seed_ids:
|
|
318
|
+
if sid in self._by_id and sid not in picked:
|
|
319
|
+
picked[sid] = self._by_id[sid]
|
|
320
|
+
q.append(sid)
|
|
321
|
+
if len(picked) >= budget:
|
|
322
|
+
break
|
|
323
|
+
while q and len(picked) < budget:
|
|
324
|
+
cur = q.popleft()
|
|
325
|
+
for other, _rel in adj.get(cur, []):
|
|
326
|
+
if other not in picked and other in self._by_id:
|
|
327
|
+
picked[other] = self._by_id[other]
|
|
328
|
+
q.append(other)
|
|
329
|
+
if len(picked) >= budget:
|
|
330
|
+
break
|
|
331
|
+
keep = set(picked)
|
|
332
|
+
edges = [
|
|
333
|
+
(str(link.get("source")), str(link.get("target")), self._relation(link))
|
|
334
|
+
for link in self._links
|
|
335
|
+
if str(link.get("source")) in keep and str(link.get("target")) in keep
|
|
336
|
+
]
|
|
337
|
+
return Subgraph(nodes=list(picked.values()), edges=edges)
|
|
338
|
+
|
|
339
|
+
def changed_files(self, since: str | None = None) -> list[str]:
|
|
340
|
+
"""Repo-relative paths changed in the last commit (git diff HEAD~1 HEAD).
|
|
341
|
+
|
|
342
|
+
Uses the repo containing graph.json. Best-effort: returns [] on any git error.
|
|
343
|
+
"""
|
|
344
|
+
repo = self.path.parent.parent
|
|
345
|
+
try:
|
|
346
|
+
out = subprocess.run(
|
|
347
|
+
["git", "-C", str(repo), "diff", "--name-only", "HEAD~1", "HEAD"],
|
|
348
|
+
capture_output=True,
|
|
349
|
+
text=True,
|
|
350
|
+
)
|
|
351
|
+
return [p for p in out.stdout.split("\n") if p] if out.returncode == 0 else []
|
|
352
|
+
except (OSError, subprocess.SubprocessError):
|
|
353
|
+
return []
|