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 ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """naja-scope: agent-facing MCP query layer over najaeda."""
3
+
4
+ __version__ = "0.1.0"
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)")