naja-scope 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.
- naja_scope/__init__.py +4 -0
- naja_scope/api.py +519 -0
- naja_scope/cards.py +190 -0
- naja_scope/cone.py +205 -0
- naja_scope/connectivity.py +121 -0
- naja_scope/errors.py +41 -0
- naja_scope/intent.py +157 -0
- naja_scope/loader.py +134 -0
- naja_scope/paging.py +59 -0
- naja_scope/resolve.py +248 -0
- naja_scope/server.py +226 -0
- naja_scope/session.py +175 -0
- naja_scope/snl.py +452 -0
- naja_scope/source_index.py +35 -0
- naja_scope-0.1.0.dist-info/METADATA +161 -0
- naja_scope-0.1.0.dist-info/RECORD +20 -0
- naja_scope-0.1.0.dist-info/WHEEL +5 -0
- naja_scope-0.1.0.dist-info/entry_points.txt +2 -0
- naja_scope-0.1.0.dist-info/licenses/LICENSE +201 -0
- naja_scope-0.1.0.dist-info/top_level.txt +1 -0
naja_scope/__init__.py
ADDED
naja_scope/api.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Tool implementations. server.py registers these as MCP tools; tests call
|
|
3
|
+
them directly. Every list is paginated, every blob capped (CLAUDE.md rule)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import fnmatch
|
|
9
|
+
import io
|
|
10
|
+
import os
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from najaeda import naja
|
|
14
|
+
|
|
15
|
+
from . import cards as cards_mod
|
|
16
|
+
from . import cone as cone_mod
|
|
17
|
+
from . import connectivity
|
|
18
|
+
from . import loader
|
|
19
|
+
from . import snl
|
|
20
|
+
from .errors import ScopeError
|
|
21
|
+
from .paging import clamp_limit, paginate
|
|
22
|
+
from .resolve import (Resolved, describe, resolve_path, source_range,
|
|
23
|
+
source_ref)
|
|
24
|
+
from .session import SESSION
|
|
25
|
+
|
|
26
|
+
MAX_SOURCE_LINES = 120
|
|
27
|
+
DEFAULT_SOURCE_CONTEXT = 3
|
|
28
|
+
QUERY_OUTPUT_CAP = 8000
|
|
29
|
+
# Declaration-aware get_source: how far above a net/term declaration to look for
|
|
30
|
+
# an inline enum/struct/typedef block whose members to pull in.
|
|
31
|
+
_DECL_MAX_UP = 20
|
|
32
|
+
_DECL_KEYWORDS = ("enum", "struct", "union", "typedef")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# -- lifecycle ----------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def _summary(node: snl.InstNode) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"name": node.name,
|
|
40
|
+
"model": node.model_name,
|
|
41
|
+
"children": node.child_count(),
|
|
42
|
+
"terms": sum(1 for _ in node.design.getTerms()),
|
|
43
|
+
"nets": sum(1 for _ in node.design.getNets()),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Env opt-in: auto-load the warm intent layer on every load, so a deployment can
|
|
48
|
+
# enable get_intent without changing each load call (cf. NAJA_SCOPE_DISABLE_PYTHON).
|
|
49
|
+
_INTENT_ENV = "NAJA_SCOPE_INTENT"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _auto_intent() -> bool:
|
|
53
|
+
return os.environ.get(_INTENT_ENV, "").lower() in ("1", "true", "yes", "on")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _attach_intent(out: dict, explicit: bool) -> dict:
|
|
57
|
+
"""Bring the warm intent layer up for a COLD load (snapshot) by
|
|
58
|
+
re-elaborating from the persisted load_spec, and annotate `out`.
|
|
59
|
+
|
|
60
|
+
`explicit` (intent=True passed by the caller) surfaces failures; the env
|
|
61
|
+
opt-in is best-effort — a missing flist must not fail the load, only leave
|
|
62
|
+
get_intent in its graceful "not loaded" state. (Warm SystemVerilog loads
|
|
63
|
+
enable intent inline via keep_ast_link and do not use this path.)
|
|
64
|
+
"""
|
|
65
|
+
if not (explicit or _auto_intent()):
|
|
66
|
+
return out
|
|
67
|
+
try:
|
|
68
|
+
SESSION.load_intent()
|
|
69
|
+
out["intent_loaded"] = SESSION.intent_available
|
|
70
|
+
except ScopeError as e:
|
|
71
|
+
if explicit:
|
|
72
|
+
raise
|
|
73
|
+
out["intent_loaded"] = False
|
|
74
|
+
out["intent_note"] = e.message
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def status() -> dict:
|
|
79
|
+
if not SESSION.has_top():
|
|
80
|
+
return {"loaded": False}
|
|
81
|
+
top = SESSION.require_top()
|
|
82
|
+
out = {
|
|
83
|
+
"loaded": True,
|
|
84
|
+
"top": _summary(top),
|
|
85
|
+
"loaded_files": SESSION.loaded_files[:20],
|
|
86
|
+
# Phase-2 intent layer (get_intent): warm-only, so report whether it is
|
|
87
|
+
# live in this session and whether the inputs to (re)load it are known.
|
|
88
|
+
"intent_loaded": SESSION.intent_available,
|
|
89
|
+
"intent_loadable": bool((SESSION.load_spec or {}).get("flist")
|
|
90
|
+
or (SESSION.load_spec or {}).get("files")),
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_systemverilog(files: Optional[List[str]] = None,
|
|
96
|
+
flist: Optional[str] = None,
|
|
97
|
+
top: Optional[str] = None,
|
|
98
|
+
keep_assigns: bool = True,
|
|
99
|
+
intent: bool = False) -> dict:
|
|
100
|
+
# Warm load: retain the slang AST link inline (keep_ast_link) when intent is
|
|
101
|
+
# requested — no separate elaboration step. Off by default (the Compilation
|
|
102
|
+
# is GB-class on large designs).
|
|
103
|
+
want = bool(intent or _auto_intent())
|
|
104
|
+
top_instance = SESSION.load_systemverilog(files or [], flist=flist,
|
|
105
|
+
top=top,
|
|
106
|
+
keep_assigns=keep_assigns,
|
|
107
|
+
keep_ast_link=want)
|
|
108
|
+
out = {"top": _summary(top_instance)}
|
|
109
|
+
if want:
|
|
110
|
+
out["intent_loaded"] = SESSION.intent_available
|
|
111
|
+
if intent and not SESSION.intent_available:
|
|
112
|
+
out["intent_note"] = ("intent requested but the live AST link is "
|
|
113
|
+
"unavailable in this naja build.")
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_intent(flist: Optional[str] = None, files: Optional[List[str]] = None,
|
|
118
|
+
top: Optional[str] = None, env: Optional[dict] = None) -> dict:
|
|
119
|
+
"""Make the warm intent layer available (naja's in-engine SNL↔slang link).
|
|
120
|
+
A no-op if a load already retained it; otherwise re-elaborates WITH the link
|
|
121
|
+
from the inputs captured at SNL load (or the flist/top passed here). A cold
|
|
122
|
+
snapshot does not carry them, so they must be supplied there."""
|
|
123
|
+
SESSION.require_top()
|
|
124
|
+
SESSION.load_intent(flist=flist, files=files, top=top, env=env)
|
|
125
|
+
return {"intent_loaded": SESSION.intent_available}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_verilog(files: List[str], keep_assigns: bool = True,
|
|
129
|
+
allow_unknown_designs: bool = False) -> dict:
|
|
130
|
+
top_instance = SESSION.load_verilog(
|
|
131
|
+
files, keep_assigns=keep_assigns,
|
|
132
|
+
allow_unknown_designs=allow_unknown_designs)
|
|
133
|
+
return {"top": _summary(top_instance)}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_liberty(files: List[str]) -> dict:
|
|
137
|
+
loader.load_liberty(files)
|
|
138
|
+
return {"ok": True}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_primitives(name: Optional[str] = None,
|
|
142
|
+
file: Optional[str] = None) -> dict:
|
|
143
|
+
if name:
|
|
144
|
+
loader.load_primitives(name)
|
|
145
|
+
elif file:
|
|
146
|
+
loader.load_primitives_from_file(file)
|
|
147
|
+
else:
|
|
148
|
+
raise ScopeError("Provide 'name' (xilinx|yosys) or 'file'.")
|
|
149
|
+
return {"ok": True}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def save_snapshot(directory: str) -> dict:
|
|
153
|
+
return SESSION.save_snapshot(directory)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load_snapshot(directory: str, intent: bool = False) -> dict:
|
|
157
|
+
top_instance = SESSION.load_snapshot(directory)
|
|
158
|
+
# intent re-elaborates from the flist persisted in the snapshot sidecar
|
|
159
|
+
# (the Compilation itself never serializes — see intent.py).
|
|
160
|
+
return _attach_intent({"top": _summary(top_instance)}, explicit=intent)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def reset_universe() -> dict:
|
|
164
|
+
SESSION.reset()
|
|
165
|
+
return {"ok": True}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# -- navigation ----------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def resolve(path: str, kind: Optional[str] = None,
|
|
171
|
+
limit: Optional[int] = None) -> dict:
|
|
172
|
+
limit = clamp_limit(limit, default=20)
|
|
173
|
+
matches = resolve_path(SESSION, path, kind=kind)
|
|
174
|
+
described = [describe(m, SESSION) for m in matches[:limit]]
|
|
175
|
+
return {"matches": described,
|
|
176
|
+
"truncated": len(matches) > limit}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def find(pattern: str, kind: str = "any", limit: Optional[int] = None,
|
|
180
|
+
cursor: Optional[str] = None) -> dict:
|
|
181
|
+
"""DFS over the hierarchy matching names (and full paths if the pattern
|
|
182
|
+
contains a dot). kind: instance|net|port|module|any."""
|
|
183
|
+
if kind not in ("instance", "net", "port", "module", "any"):
|
|
184
|
+
raise ScopeError("kind must be instance|net|port|module|any.")
|
|
185
|
+
top = SESSION.require_top()
|
|
186
|
+
top_name = top.name
|
|
187
|
+
is_path_pattern = "." in pattern
|
|
188
|
+
|
|
189
|
+
def matches_name(name: str, path: str) -> bool:
|
|
190
|
+
if is_path_pattern:
|
|
191
|
+
return fnmatch.fnmatchcase(path, pattern) or fnmatch.fnmatchcase(
|
|
192
|
+
path, f"{top_name}.{pattern}")
|
|
193
|
+
return fnmatch.fnmatchcase(name, pattern)
|
|
194
|
+
|
|
195
|
+
def walk():
|
|
196
|
+
if kind in ("module", "any"):
|
|
197
|
+
for design in snl.iter_designs():
|
|
198
|
+
name = design.getName()
|
|
199
|
+
if matches_name(name, name):
|
|
200
|
+
yield {"kind": "module", "name": name}
|
|
201
|
+
stack = [top]
|
|
202
|
+
while stack:
|
|
203
|
+
node = stack.pop()
|
|
204
|
+
path = node.path
|
|
205
|
+
if kind in ("net", "any"):
|
|
206
|
+
for net in node.design.getNets():
|
|
207
|
+
n = net.getName()
|
|
208
|
+
if not n:
|
|
209
|
+
continue
|
|
210
|
+
p = f"{path}.{n}"
|
|
211
|
+
if matches_name(n, p):
|
|
212
|
+
yield {"kind": "net", "path": p,
|
|
213
|
+
"width": snl.obj_width(net)}
|
|
214
|
+
if kind in ("port", "any"):
|
|
215
|
+
for term in node.design.getTerms():
|
|
216
|
+
n = term.getName()
|
|
217
|
+
p = f"{path}.{n}"
|
|
218
|
+
if matches_name(n, p):
|
|
219
|
+
yield {"kind": "term", "path": p,
|
|
220
|
+
"dir": snl.direction_str(term.getDirection())}
|
|
221
|
+
children = []
|
|
222
|
+
for child in snl.child_nodes(node):
|
|
223
|
+
if kind in ("instance", "any") and matches_name(
|
|
224
|
+
child.name, child.path):
|
|
225
|
+
yield {"kind": "instance", "path": child.path,
|
|
226
|
+
"model": child.model_name}
|
|
227
|
+
if not child.is_leaf():
|
|
228
|
+
children.append(child)
|
|
229
|
+
stack.extend(reversed(children))
|
|
230
|
+
|
|
231
|
+
page, envelope = paginate(walk(), limit=limit, cursor=cursor)
|
|
232
|
+
return {"pattern": pattern, "kind": kind, "matches": page, **envelope}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_hierarchy(path: Optional[str] = None, depth: int = 1,
|
|
236
|
+
limit: Optional[int] = None,
|
|
237
|
+
cursor: Optional[str] = None) -> dict:
|
|
238
|
+
depth = max(1, min(depth, 5))
|
|
239
|
+
limit = clamp_limit(limit, default=20, maximum=100)
|
|
240
|
+
if path:
|
|
241
|
+
matches = resolve_path(SESSION, path, kind="instance")
|
|
242
|
+
root = matches[0]
|
|
243
|
+
else:
|
|
244
|
+
top = SESSION.require_top()
|
|
245
|
+
root = Resolved("instance", top, top.path, top)
|
|
246
|
+
|
|
247
|
+
def node(resolved: Resolved, level: int) -> dict:
|
|
248
|
+
inst = resolved.obj
|
|
249
|
+
out = {"name": inst.name or resolved.path, "model": inst.model_name}
|
|
250
|
+
src = source_ref(resolved, SESSION)
|
|
251
|
+
if src:
|
|
252
|
+
out["src"] = src
|
|
253
|
+
if inst.is_leaf():
|
|
254
|
+
out["leaf"] = True
|
|
255
|
+
return out
|
|
256
|
+
total = inst.child_count()
|
|
257
|
+
out["children_total"] = total
|
|
258
|
+
if level >= depth:
|
|
259
|
+
return out
|
|
260
|
+
# Enumerate only the non-assign children (real submodules + leaf
|
|
261
|
+
# primitives); `assign` glue — the overwhelming majority on a lowered
|
|
262
|
+
# top — is summarized as a count, never dumped. leaf/non-leaf stays a
|
|
263
|
+
# separate axis: each child carries its own `leaf` flag so the agent can
|
|
264
|
+
# tell the 10 submodules from the leaf primitives.
|
|
265
|
+
na = list(snl.non_assign_child_nodes(inst))
|
|
266
|
+
out["assign_count"] = total - len(na)
|
|
267
|
+
out["non_assign_total"] = len(na)
|
|
268
|
+
# Pagination cursor only at the root (unambiguous there); deeper levels
|
|
269
|
+
# fall back to a truncation count.
|
|
270
|
+
page, envelope = paginate(na, limit=limit,
|
|
271
|
+
cursor=cursor if level == 0 else None)
|
|
272
|
+
children = [node(Resolved("instance", c, c.path, inst), level + 1)
|
|
273
|
+
for c in page]
|
|
274
|
+
out["children"] = children
|
|
275
|
+
if level == 0:
|
|
276
|
+
out["next_cursor"] = envelope["next_cursor"]
|
|
277
|
+
out["has_more"] = envelope["has_more"]
|
|
278
|
+
elif envelope["has_more"]:
|
|
279
|
+
out["children_truncated"] = len(na) - len(children)
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
return {"root": node(root, 0), "depth": depth}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# -- connectivity ---------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _resolve_single(path: str, kinds=("term", "net")) -> Resolved:
|
|
288
|
+
matches = resolve_path(SESSION, path)
|
|
289
|
+
for kind in kinds:
|
|
290
|
+
for m in matches:
|
|
291
|
+
if m.kind == kind:
|
|
292
|
+
return m
|
|
293
|
+
return matches[0]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def get_drivers(path: str, limit: Optional[int] = None) -> dict:
|
|
297
|
+
resolved = _resolve_single(path)
|
|
298
|
+
return connectivity.endpoints(resolved, SESSION, "drivers",
|
|
299
|
+
clamp_limit(limit))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_loads(path: str, limit: Optional[int] = None) -> dict:
|
|
303
|
+
resolved = _resolve_single(path)
|
|
304
|
+
return connectivity.endpoints(resolved, SESSION, "loads",
|
|
305
|
+
clamp_limit(limit))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def trace_cone(path: str, direction: str,
|
|
309
|
+
max_frontier: int = cone_mod.DEFAULT_MAX_FRONTIER) -> dict:
|
|
310
|
+
resolved = _resolve_single(path)
|
|
311
|
+
return cone_mod.trace_cone(resolved, SESSION, direction,
|
|
312
|
+
max_frontier=max_frontier)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# -- source -----------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
def get_source(path: str, context_lines: int = DEFAULT_SOURCE_CONTEXT) -> dict:
|
|
318
|
+
context_lines = max(0, min(context_lines, 20))
|
|
319
|
+
matches = resolve_path(SESSION, path)
|
|
320
|
+
resolved = matches[0]
|
|
321
|
+
rng = source_range(resolved)
|
|
322
|
+
if rng is None:
|
|
323
|
+
return {
|
|
324
|
+
"object": resolved.path,
|
|
325
|
+
"error": "No source range known for this object.",
|
|
326
|
+
"hint": ("Source ranges come from SystemVerilog loading; "
|
|
327
|
+
"gate-level Verilog without source info has none."),
|
|
328
|
+
}
|
|
329
|
+
file_path = SESSION.find_source_file(rng.file)
|
|
330
|
+
if file_path is None:
|
|
331
|
+
return {"object": resolved.path, "src": rng.to_ref(),
|
|
332
|
+
"error": f"Source file not found: {rng.file}"}
|
|
333
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
334
|
+
lines = f.readlines()
|
|
335
|
+
start = max(1, rng.line - context_lines)
|
|
336
|
+
end = min(len(lines), rng.end_line + context_lines)
|
|
337
|
+
# Declaration-aware upward window: a net/term source range is a *declaration*
|
|
338
|
+
# (e.g. `} state_q, state_d;`). When an inline `enum`/`struct`/`typedef` block
|
|
339
|
+
# opens just above it, pull that block in so its members (FSM state names,
|
|
340
|
+
# etc.) are visible in one call. Stops at a blank line, and does nothing when
|
|
341
|
+
# the type is referenced (e.g. a package typedef `riscv::priv_lvl_t x;`) with
|
|
342
|
+
# no block above — exactly the case that needs the phase-2 intent layer.
|
|
343
|
+
if resolved.kind in ("net", "term"):
|
|
344
|
+
lo = max(1, rng.line - _DECL_MAX_UP)
|
|
345
|
+
probe = rng.line
|
|
346
|
+
while probe > lo:
|
|
347
|
+
probe -= 1
|
|
348
|
+
stripped = lines[probe - 1].strip()
|
|
349
|
+
if not stripped:
|
|
350
|
+
break
|
|
351
|
+
if any(stripped.startswith(kw) or f" {kw} " in stripped
|
|
352
|
+
for kw in _DECL_KEYWORDS):
|
|
353
|
+
start = min(start, probe)
|
|
354
|
+
break
|
|
355
|
+
selected = lines[start - 1:end]
|
|
356
|
+
truncated = False
|
|
357
|
+
if len(selected) > MAX_SOURCE_LINES:
|
|
358
|
+
head = selected[:MAX_SOURCE_LINES // 2]
|
|
359
|
+
tail = selected[-MAX_SOURCE_LINES // 2:]
|
|
360
|
+
omitted = len(selected) - len(head) - len(tail)
|
|
361
|
+
selected = head + [f"... ({omitted} lines omitted) ...\n"] + tail
|
|
362
|
+
truncated = True
|
|
363
|
+
return {
|
|
364
|
+
"object": resolved.path,
|
|
365
|
+
"file": file_path,
|
|
366
|
+
"start": start,
|
|
367
|
+
"end": end,
|
|
368
|
+
"src": rng.to_ref(),
|
|
369
|
+
"text": "".join(selected),
|
|
370
|
+
"truncated": truncated,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# -- summaries ---------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def get_module_card(module: str) -> dict:
|
|
377
|
+
return cards_mod.module_card(SESSION, module)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _model_label(name: str) -> str:
|
|
381
|
+
return name or "(unnamed)"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _safe_seq(model) -> bool:
|
|
385
|
+
try:
|
|
386
|
+
return bool(model.isSequential())
|
|
387
|
+
except Exception:
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _collect_model_stats(design, memo: dict) -> dict:
|
|
392
|
+
"""Hierarchical stats memoized per model. Deliberately avoids
|
|
393
|
+
najaeda.stats: its is_basic_primitive crashes the process on multi-output
|
|
394
|
+
primitives like naja_fa (see NAJAEDA_NOTES.md)."""
|
|
395
|
+
model_id = design.getID()
|
|
396
|
+
if model_id in memo:
|
|
397
|
+
return memo[model_id]
|
|
398
|
+
entry = {
|
|
399
|
+
"model": _model_label(design.getName()),
|
|
400
|
+
"assigns": 0,
|
|
401
|
+
"leaf_by_model": {},
|
|
402
|
+
"children_by_model": {},
|
|
403
|
+
"flat_leaves": 0,
|
|
404
|
+
"flat_sequential": 0,
|
|
405
|
+
}
|
|
406
|
+
memo[model_id] = entry
|
|
407
|
+
for child in design.getInstances():
|
|
408
|
+
model = child.getModel()
|
|
409
|
+
if model.isAssign():
|
|
410
|
+
entry["assigns"] += 1
|
|
411
|
+
elif model.isLeaf():
|
|
412
|
+
name = _model_label(model.getName())
|
|
413
|
+
entry["leaf_by_model"][name] = (
|
|
414
|
+
entry["leaf_by_model"].get(name, 0) + 1)
|
|
415
|
+
entry["flat_leaves"] += 1
|
|
416
|
+
if _safe_seq(model):
|
|
417
|
+
entry["flat_sequential"] += 1
|
|
418
|
+
else:
|
|
419
|
+
sub = _collect_model_stats(model, memo)
|
|
420
|
+
name = _model_label(model.getName())
|
|
421
|
+
entry["children_by_model"][name] = (
|
|
422
|
+
entry["children_by_model"].get(name, 0) + 1)
|
|
423
|
+
entry["flat_leaves"] += sub["flat_leaves"]
|
|
424
|
+
entry["flat_sequential"] += sub["flat_sequential"]
|
|
425
|
+
return entry
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def get_stats(path: Optional[str] = None, limit: Optional[int] = None,
|
|
429
|
+
cursor: Optional[str] = None) -> dict:
|
|
430
|
+
limit = clamp_limit(limit, default=25)
|
|
431
|
+
if path:
|
|
432
|
+
matches = resolve_path(SESSION, path, kind="instance")
|
|
433
|
+
design = matches[0].obj.design
|
|
434
|
+
else:
|
|
435
|
+
design = SESSION.require_top().design
|
|
436
|
+
memo: dict = {}
|
|
437
|
+
root = _collect_model_stats(design, memo)
|
|
438
|
+
models = sorted(memo.values(), key=lambda m: -m["flat_leaves"])
|
|
439
|
+
for m in models:
|
|
440
|
+
m["leaf_by_model"] = dict(sorted(
|
|
441
|
+
m["leaf_by_model"].items(), key=lambda kv: -kv[1])[:12])
|
|
442
|
+
page, envelope = paginate(models, limit=limit, cursor=cursor)
|
|
443
|
+
return {
|
|
444
|
+
"root_model": root["model"],
|
|
445
|
+
"flat_leaves": root["flat_leaves"],
|
|
446
|
+
"flat_sequential": root["flat_sequential"],
|
|
447
|
+
"models": page,
|
|
448
|
+
"total_models": len(models),
|
|
449
|
+
**envelope,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# -- intent layer (phase 2) -----------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
def get_intent(ref: str, want: str = "auto") -> dict:
|
|
456
|
+
"""Query the living-intent layer for source-level facts erased by lowering.
|
|
457
|
+
|
|
458
|
+
Warm-only (DESIGN.md "Snapshot asymmetry"): if the intent layer is not
|
|
459
|
+
loaded, this degrades gracefully — the structural source range is still
|
|
460
|
+
available via get_source.
|
|
461
|
+
"""
|
|
462
|
+
if not SESSION.intent_available:
|
|
463
|
+
return {"intent_loaded": False,
|
|
464
|
+
"note": ("intent layer not loaded; source range is available "
|
|
465
|
+
"via get_source. Load a warm SystemVerilog session "
|
|
466
|
+
"with the AST link (load_systemverilog intent=True, "
|
|
467
|
+
"NAJA_SCOPE_INTENT=1, or load_intent).")}
|
|
468
|
+
ip = SESSION.intent
|
|
469
|
+
try:
|
|
470
|
+
if want == "auto":
|
|
471
|
+
return ip.describe(ref)
|
|
472
|
+
if want == "type":
|
|
473
|
+
return {"intent": "type", **ip.get_type(ref)}
|
|
474
|
+
if want in ("fsm_states", "enum"):
|
|
475
|
+
return {"intent": "fsm_states", **ip.get_fsm_states(ref)}
|
|
476
|
+
if want == "parameters":
|
|
477
|
+
return {"intent": "parameters", **ip.get_parameters(ref)}
|
|
478
|
+
raise ScopeError(
|
|
479
|
+
f"unknown want={want!r}; use auto|type|fsm_states|parameters")
|
|
480
|
+
except ScopeError as e:
|
|
481
|
+
return e.to_dict()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# -- escape hatch ---------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
def query_python(code: str) -> dict:
|
|
487
|
+
"""Run najaeda/naja query code against the live session (prep hook 3).
|
|
488
|
+
Read-only by convention; output capped."""
|
|
489
|
+
if os.environ.get("NAJA_SCOPE_DISABLE_PYTHON"):
|
|
490
|
+
raise ScopeError("query_python is disabled "
|
|
491
|
+
"(NAJA_SCOPE_DISABLE_PYTHON is set).")
|
|
492
|
+
SESSION.require_top()
|
|
493
|
+
buf = io.StringIO()
|
|
494
|
+
# Raw-only escape hatch: `naja` (PySNL bindings), `snl` (naja-scope's raw
|
|
495
|
+
# helper layer: InstNode, top_node, iter_designs, equipotentials), and the
|
|
496
|
+
# live session. No high-level najaeda.netlist here.
|
|
497
|
+
env = {"naja": naja, "snl": snl, "session": SESSION,
|
|
498
|
+
"top": snl.top_node()}
|
|
499
|
+
result_repr = None
|
|
500
|
+
with contextlib.redirect_stdout(buf):
|
|
501
|
+
try:
|
|
502
|
+
try:
|
|
503
|
+
result_repr = repr(eval(code, env))
|
|
504
|
+
except SyntaxError:
|
|
505
|
+
exec(code, env)
|
|
506
|
+
except Exception as e: # report, don't crash the server
|
|
507
|
+
return {"error": f"{type(e).__name__}: {e}",
|
|
508
|
+
"stdout": _cap(buf.getvalue())}
|
|
509
|
+
out = {"stdout": _cap(buf.getvalue())}
|
|
510
|
+
if result_repr is not None:
|
|
511
|
+
out["result"] = _cap(result_repr)
|
|
512
|
+
return out
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _cap(text: str) -> str:
|
|
516
|
+
if len(text) <= QUERY_OUTPUT_CAP:
|
|
517
|
+
return text
|
|
518
|
+
return (text[:QUERY_OUTPUT_CAP]
|
|
519
|
+
+ f"\n... (truncated, {len(text) - QUERY_OUTPUT_CAP} chars omitted)")
|