arkitekt-lsp 1.0.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .coverage
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.5
2
+ Name: arkitekt-lsp
3
+ Version: 1.0.0
4
+ Summary: the Arkitekt language server: your app's own `arkitekt check`, underlined in the BSX of every blok while you write it
5
+ Author-email: jhnnsrs <jhnnsrs@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: arkitekt-spec>=6.0
9
+ Requires-Dist: arkitekt>=11.2
10
+ Requires-Dist: fakts>=6.1
11
+ Requires-Dist: koil>=3.3.6
12
+ Requires-Dist: pygls>=2.1
13
+ Requires-Dist: rath>=4
14
+ Description-Content-Type: text/markdown
15
+
16
+ # arkitekt-lsp
17
+
18
+ The Arkitekt language server. It shows what `arkitekt check` finds in your app, underlined in
19
+ the BSX of the blok it is about, in any editor that speaks LSP.
20
+
21
+ ```bash
22
+ uv add --dev arkitekt-lsp # in the app's project: the server imports the app with its environment
23
+ ```
24
+
25
+ The editor starts `arkitekt-lsp` (stdio) from that environment. Then:
26
+
27
+ - **on save** of a Python file it runs `arkitekt check --json-out` for the app the file belongs
28
+ to, in a child process, and publishes every finding at its blok's string literal: the node's
29
+ tag, or the line and column of a parse error. Findings follow their blok as the code moves;
30
+ a blok edited since the check drops them until the next save, and is checked as you type;
31
+ - **as you type** what its BSX says, against what the last check said the blok declares: its
32
+ `deps.*`, `args.*` and locals. A blok no check has seen yet is judged by the language alone, and
33
+ a query's value always waits for the next save.
34
+
35
+ All of this is local: the server talks to nothing.
36
+
37
+ ## Who would satisfy your dependencies (`--connect`)
38
+
39
+ ```bash
40
+ arkitekt-lsp --connect # or the setting arkitekt.server.connect
41
+ arkitekt-lsp --server https://my.hub # or arkitekt.server.url; the default is https://go.arkitekt.live
42
+ ```
43
+
44
+ A flag overrides what the editor configures.
45
+
46
+ With the flag, the server also logs in, as an app of its own, with a token that can only read
47
+ (`rekuest_read`). It registers no agent. For each dependency protocol your app declares it asks who
48
+ would satisfy it, nothing registered, so a protocol you just wrote is asked about on save:
49
+
50
+ - above the protocol, a count: `3 agents · 2 connected`, and the slots nobody offers;
51
+ - a warning (information, for an optional one) when too few do. Never an error: what is missing
52
+ may simply not be running yet;
53
+ - on hover of the protocol, or of `deps.<key>` in a blok, the agents and the count per slot.
54
+
55
+ A login nobody approves or a server that is gone leaves the server local, and says so.
56
+
57
+ Which app: the saved file when it declares one (`App(`), else the `arkitekt.target` setting, else
58
+ the project's default (`ARKITEKT_APP`, or `app.py`). A project is the nearest directory with a
59
+ `pyproject.toml`.
60
+
61
+ Settings (initialization options, or `workspace/didChangeConfiguration` under `arkitekt`):
62
+ `check.onSave` (default true), `check.timeout` (seconds, default 120), `target`.
63
+
64
+ Besides the standard protocol it answers `bsx/regions` (`{textDocument: {uri}}` →
65
+ `{regions: [{start, end, value, root, context, kind, exact}]}`), the blok literals of a document,
66
+ which the VS Code extension uses to complete BSX inside Python strings. It notifies
67
+ `arkitekt/status` (`{state: checking|ok|failed, app, errors?, warnings?, message?}`) and takes the
68
+ notification `arkitekt/check` (`{textDocument: {uri}}`) to check now.
@@ -0,0 +1,53 @@
1
+ # arkitekt-lsp
2
+
3
+ The Arkitekt language server. It shows what `arkitekt check` finds in your app, underlined in
4
+ the BSX of the blok it is about, in any editor that speaks LSP.
5
+
6
+ ```bash
7
+ uv add --dev arkitekt-lsp # in the app's project: the server imports the app with its environment
8
+ ```
9
+
10
+ The editor starts `arkitekt-lsp` (stdio) from that environment. Then:
11
+
12
+ - **on save** of a Python file it runs `arkitekt check --json-out` for the app the file belongs
13
+ to, in a child process, and publishes every finding at its blok's string literal: the node's
14
+ tag, or the line and column of a parse error. Findings follow their blok as the code moves;
15
+ a blok edited since the check drops them until the next save, and is checked as you type;
16
+ - **as you type** what its BSX says, against what the last check said the blok declares: its
17
+ `deps.*`, `args.*` and locals. A blok no check has seen yet is judged by the language alone, and
18
+ a query's value always waits for the next save.
19
+
20
+ All of this is local: the server talks to nothing.
21
+
22
+ ## Who would satisfy your dependencies (`--connect`)
23
+
24
+ ```bash
25
+ arkitekt-lsp --connect # or the setting arkitekt.server.connect
26
+ arkitekt-lsp --server https://my.hub # or arkitekt.server.url; the default is https://go.arkitekt.live
27
+ ```
28
+
29
+ A flag overrides what the editor configures.
30
+
31
+ With the flag, the server also logs in, as an app of its own, with a token that can only read
32
+ (`rekuest_read`). It registers no agent. For each dependency protocol your app declares it asks who
33
+ would satisfy it, nothing registered, so a protocol you just wrote is asked about on save:
34
+
35
+ - above the protocol, a count: `3 agents · 2 connected`, and the slots nobody offers;
36
+ - a warning (information, for an optional one) when too few do. Never an error: what is missing
37
+ may simply not be running yet;
38
+ - on hover of the protocol, or of `deps.<key>` in a blok, the agents and the count per slot.
39
+
40
+ A login nobody approves or a server that is gone leaves the server local, and says so.
41
+
42
+ Which app: the saved file when it declares one (`App(`), else the `arkitekt.target` setting, else
43
+ the project's default (`ARKITEKT_APP`, or `app.py`). A project is the nearest directory with a
44
+ `pyproject.toml`.
45
+
46
+ Settings (initialization options, or `workspace/didChangeConfiguration` under `arkitekt`):
47
+ `check.onSave` (default true), `check.timeout` (seconds, default 120), `target`.
48
+
49
+ Besides the standard protocol it answers `bsx/regions` (`{textDocument: {uri}}` →
50
+ `{regions: [{start, end, value, root, context, kind, exact}]}`), the blok literals of a document,
51
+ which the VS Code extension uses to complete BSX inside Python strings. It notifies
52
+ `arkitekt/status` (`{state: checking|ok|failed, app, errors?, warnings?, message?}`) and takes the
53
+ notification `arkitekt/check` (`{textDocument: {uri}}`) to check now.
@@ -0,0 +1,4 @@
1
+ """The Arkitekt language server: ``arkitekt check`` underlined in the BSX of every blok.
2
+
3
+ Run ``arkitekt-lsp`` (stdio) from the app's environment; see :mod:`arkitekt_lsp.server`.
4
+ """
@@ -0,0 +1,5 @@
1
+ """``python -m arkitekt_lsp``: the same as ``arkitekt-lsp``."""
2
+
3
+ from arkitekt_lsp.server import main
4
+
5
+ main()
@@ -0,0 +1,308 @@
1
+ """The flagged part of the server: who would satisfy a dependency, asked of rekuest, read-only.
2
+
3
+ Everything else the server does is local. Only with ``--connect`` (or ``arkitekt.server.connect``)
4
+ does it log in, at ``https://go.arkitekt.live`` unless told otherwise (``--server``), as an app of
5
+ its own: not the app being checked, and with a client that asks for ``rekuest_read`` and nothing
6
+ else. It registers no agent. A dependency needs nothing registered either: what the check says the
7
+ app declares (the report's ``protocols``, a blok's ``scope``) is a set of demands, and
8
+ ``agents(filters: {actionDemands, stateDemands})`` answers them. An agent has to meet every demand,
9
+ each possibly with a different implementation, which is what a dependency asks for.
10
+
11
+ Whatever goes wrong here (a login nobody approves, a server that is gone) leaves the server local.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ import re
18
+ import time
19
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
20
+ from dataclasses import dataclass
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from arkitekt_spec.actions import ActionDemandInput, StateDemandInput
24
+
25
+ if TYPE_CHECKING:
26
+ from arkitekt import DeviceCodeChallenge
27
+
28
+ from arkitekt_lsp.rekuest_read import RekuestRead
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ DEFAULT_URL = "https://go.arkitekt.live"
33
+ """The coordination server asked when none is named."""
34
+ LOGIN_TIMEOUT = 300.0
35
+ QUERY_TIMEOUT = 10.0
36
+ FRESH_FOR = 30.0
37
+ """How long an answer about a dependency holds; a manual refresh drops it."""
38
+ AGENT_LIMIT = 100
39
+ """How many agents are listed per dependency: a count of this many reads as "at least"."""
40
+
41
+ # -- what to ask
42
+
43
+
44
+ def _demand(demand: Any, model: Any) -> dict[str, Any]: # noqa: ANN401
45
+ """A report's demand (snake_case) as the server's input spells it."""
46
+ return model.model_validate(demand).model_dump(mode="json", by_alias=True, exclude_none=True)
47
+
48
+
49
+ def _identity(dependency: Mapping[str, Any]) -> dict[str, Any]:
50
+ filters: dict[str, Any] = {"distinct": True}
51
+ if dependency.get("app"):
52
+ filters["appIdentifier"] = dependency["app"]
53
+ # As an assign resolves it: no version, an empty one or `*` is any version, else exactly that.
54
+ if dependency.get("version") not in (None, "", "*"):
55
+ filters["versionNumber"] = dependency["version"]
56
+ return filters
57
+
58
+
59
+ def _slots(dependency: Mapping[str, Any]) -> list[tuple[str, str, bool, dict[str, Any]]]:
60
+ """Each slot with a demand: its key, what it is (``actionDemands``/``stateDemands``), whether it
61
+ is optional, and its demand. A slot without a demand asks nothing (and the server refuses one)."""
62
+ slots: list[tuple[str, str, bool, dict[str, Any]]] = []
63
+ for slot in dependency.get("action_dependencies") or ():
64
+ if slot.get("demand"):
65
+ demand = _demand(slot["demand"], ActionDemandInput)
66
+ slots.append((slot["key"], "actionDemands", bool(slot.get("optional")), demand))
67
+ for slot in dependency.get("state_dependencies") or ():
68
+ if slot.get("demand"):
69
+ demand = _demand(slot["demand"], StateDemandInput)
70
+ slots.append((slot["key"], "stateDemands", bool(slot.get("optional")), demand))
71
+ return slots
72
+
73
+
74
+ def filters_for(dependency: Mapping[str, Any]) -> dict[str, Any]:
75
+ """The ``AgentFilter`` of the agents that satisfy ``dependency``: every slot it requires."""
76
+ filters = _identity(dependency)
77
+ for _, field, optional, demand in _slots(dependency):
78
+ if not optional:
79
+ filters.setdefault(field, []).append(demand)
80
+ return filters
81
+
82
+
83
+ def slot_filters(dependency: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
84
+ """Per slot, the ``AgentFilter`` of the agents that meet that slot alone: what says which is unmet."""
85
+ return {
86
+ key: {**_identity(dependency), field: [demand]}
87
+ for key, field, _, demand in _slots(dependency)
88
+ }
89
+
90
+
91
+ # -- what it answered
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class AgentInfo:
96
+ id: str
97
+ name: str
98
+ connected: bool
99
+ app: str | None = None
100
+ version: str | None = None
101
+
102
+ @classmethod
103
+ def of(cls, agent: Mapping[str, Any]) -> "AgentInfo":
104
+ return cls(
105
+ id=str(agent["id"]),
106
+ name=str(agent["name"]),
107
+ connected=bool(agent["connected"]),
108
+ app=(agent.get("app") or {}).get("identifier"),
109
+ version=(agent.get("release") or {}).get("version"),
110
+ )
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Availability:
115
+ """Who would satisfy a dependency, as the server said it just now."""
116
+
117
+ agents: tuple[AgentInfo, ...]
118
+ slots: Mapping[str, int]
119
+ """Per slot with a demand, how many agents meet it alone."""
120
+ optional_slots: frozenset[str] = frozenset()
121
+
122
+ @property
123
+ def connected(self) -> int:
124
+ return sum(1 for agent in self.agents if agent.connected)
125
+
126
+
127
+ def _count(count: int) -> str:
128
+ return f"{count}+" if count >= AGENT_LIMIT else str(count)
129
+
130
+
131
+ def lens_title(availability: Availability) -> str:
132
+ """One line above the protocol: ``3 agents · 2 connected``, and the slots nobody meets."""
133
+ total = len(availability.agents)
134
+ if total == 0:
135
+ title = "no agents"
136
+ else:
137
+ title = f"{_count(total)} agent{'' if total == 1 else 's'} · {_count(availability.connected)} connected"
138
+ unmet = [key for key, count in availability.slots.items() if count == 0 and key not in availability.optional_slots]
139
+ if unmet:
140
+ title += " · nobody offers " + ", ".join(unmet)
141
+ return title
142
+
143
+
144
+ def problem(dependency: Mapping[str, Any], availability: Availability) -> tuple[str, str] | None:
145
+ """The level and message when too few agents satisfy ``dependency``; never an error, because
146
+ what is missing may simply not be running yet."""
147
+ needed = dependency.get("min_viable_instances") or 1
148
+ total = len(availability.agents)
149
+ if total >= needed:
150
+ return None
151
+ unmet = [key for key, count in availability.slots.items() if count == 0 and key not in availability.optional_slots]
152
+ where = f" Nobody offers: {', '.join(unmet)}." if unmet else ""
153
+ level = "info" if dependency.get("optional") else "warning"
154
+ if total == 0:
155
+ return level, f"No agent satisfies this dependency.{where}"
156
+ return level, f"{total} agent{'' if total == 1 else 's'} satisfy this dependency, it asks for at least {needed}.{where}"
157
+
158
+
159
+ def hover_markdown(name: str, availability: Availability) -> str:
160
+ """What hovering a dependency tells: the agents, then each slot."""
161
+ lines = [f"**{name}**: {lens_title(availability)}", ""]
162
+ for agent in availability.agents[:10]:
163
+ at = f" ({agent.app}{f'@{agent.version}' if agent.version else ''})" if agent.app else ""
164
+ lines.append(f"- {'●' if agent.connected else '○'} `{agent.name}`{at}")
165
+ if len(availability.agents) > 10:
166
+ lines.append(f"- … {_count(len(availability.agents))} in all")
167
+ if availability.slots:
168
+ lines.extend(("", "| slot | agents |", "|---|---|"))
169
+ lines.extend(
170
+ f"| `{key}`{' (optional)' if key in availability.optional_slots else ''} | {_count(count)} |"
171
+ for key, count in availability.slots.items()
172
+ )
173
+ return "\n".join(lines)
174
+
175
+
176
+ def class_span(source: str, name: str) -> tuple[int, int] | None:
177
+ """Where ``class <name>`` names it in ``source`` as it reads now (the report's line may have moved)."""
178
+ match = re.search(rf"^[ \t]*class[ \t]+({re.escape(name.rsplit('.', 1)[-1])})\b", source, re.MULTILINE)
179
+ return match.span(1) if match else None
180
+
181
+
182
+ def dependency_key_at(text: str, index: int) -> str | None:
183
+ """The ``<key>`` of the ``deps.<key>`` at ``index`` of a blok's BSX, if the cursor is on one."""
184
+ for match in re.finditer(r"\bdeps\.([A-Za-z_]\w*)", text):
185
+ if match.start() <= index <= match.end():
186
+ return match.group(1)
187
+ return None
188
+
189
+
190
+ # -- the connection
191
+
192
+
193
+ Status = Callable[..., None]
194
+ Login = Callable[["DeviceCodeChallenge"], Awaitable[None]]
195
+
196
+
197
+ class Connection:
198
+ """One read-only login at ``url``, and what it has answered lately.
199
+
200
+ Args:
201
+ url: The coordination server.
202
+ status: Told every change: ``status(state, message=...)``, a state of ``connecting``,
203
+ ``login``, ``connected`` or ``offline``.
204
+ login: Shows the user a device code challenge.
205
+ """
206
+
207
+ def __init__(self, url: str, status: Status, login: Login, version: str = "0.0.0") -> None:
208
+ self.url = url
209
+ self.status = status
210
+ self.login = login
211
+ self.version = version
212
+ self.runtime: Any = None
213
+ self.client: RekuestRead | None = None
214
+ self.answers: dict[str, tuple[float, Availability]] = {}
215
+
216
+ @property
217
+ def connected(self) -> bool:
218
+ return self.client is not None
219
+
220
+ async def _show_login(self, challenge: "DeviceCodeChallenge") -> None:
221
+ self.status("login", message=f"Approve this editor, code {challenge.user_code}")
222
+ await self.login(challenge)
223
+
224
+ async def start(self) -> None:
225
+ """Log in and connect; on any failure, stay local and say why."""
226
+ self.status("connecting")
227
+ try:
228
+ # Only here: a server that is not asked to connect never loads any of this.
229
+ from arkitekt import connect
230
+
231
+ from arkitekt_lsp.rekuest_read import RekuestRead, lsp_app
232
+
233
+ runtime = connect(
234
+ lsp_app(self.version), url=self.url, headless=True, device_code_hook=self._show_login, mesh=False
235
+ )
236
+ await asyncio.wait_for(runtime.__aenter__(), LOGIN_TIMEOUT)
237
+ except Exception as error: # noqa: BLE001 - whatever it was, the server stays local
238
+ logger.warning("Could not connect to %s: %s", self.url, error)
239
+ self.status("offline", message=f"{type(error).__name__}: {error}" if str(error) else type(error).__name__)
240
+ return
241
+ self.runtime = runtime
242
+ self.client = runtime.require(RekuestRead)
243
+ self.status("connected")
244
+
245
+ async def stop(self) -> None:
246
+ runtime, self.runtime, self.client = self.runtime, None, None
247
+ self.answers.clear()
248
+ if runtime is not None:
249
+ try:
250
+ await runtime.__aexit__(None, None, None)
251
+ except Exception: # noqa: BLE001 - leaving is best effort
252
+ logger.exception("Leaving the connection failed")
253
+
254
+ def forget(self) -> None:
255
+ """Drop what it knows, so the next question reaches the server."""
256
+ self.answers.clear()
257
+
258
+ async def availability(self, dependency: Mapping[str, Any]) -> Availability | None:
259
+ """Who would satisfy ``dependency`` now; None while there is no answer (offline, too slow)."""
260
+ client = self.client
261
+ if client is None:
262
+ return None
263
+ key = json.dumps(dependency, sort_keys=True, default=str)
264
+ known = self.answers.get(key)
265
+ if known is not None and time.monotonic() - known[0] < FRESH_FOR:
266
+ return known[1]
267
+ slots = slot_filters(dependency)
268
+ try:
269
+ agents, *counts = await asyncio.wait_for(
270
+ asyncio.gather(
271
+ client.aagents(filters_for(dependency), AGENT_LIMIT),
272
+ *(client.aagents(filters, AGENT_LIMIT) for filters in slots.values()),
273
+ ),
274
+ QUERY_TIMEOUT,
275
+ )
276
+ except Exception as error: # noqa: BLE001 - no answer is an answer: nothing to say
277
+ logger.warning("Asking %s about a dependency failed: %s", self.url, error)
278
+ self.status("offline", message=f"{type(error).__name__}: {error}" if str(error) else type(error).__name__)
279
+ return None
280
+ self.status("connected")
281
+ answer = Availability(
282
+ agents=tuple(AgentInfo.of(agent) for agent in agents),
283
+ slots={slot: len(found) for slot, found in zip(slots, counts)},
284
+ optional_slots=frozenset(slot for slot, _, optional, _ in _slots(dependency) if optional),
285
+ )
286
+ self.answers[key] = (time.monotonic(), answer)
287
+ return answer
288
+
289
+
290
+ def declared_protocols(report: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]:
291
+ """The report's dependency protocols; a version 1 report has none."""
292
+ return report.get("protocols") or ()
293
+
294
+
295
+ __all__ = [
296
+ "DEFAULT_URL",
297
+ "AgentInfo",
298
+ "Availability",
299
+ "Connection",
300
+ "class_span",
301
+ "declared_protocols",
302
+ "dependency_key_at",
303
+ "filters_for",
304
+ "hover_markdown",
305
+ "lens_title",
306
+ "problem",
307
+ "slot_filters",
308
+ ]
@@ -0,0 +1,100 @@
1
+ """What a blok's BSX says about itself while it is typed, before any check has seen it.
2
+
3
+ ``arkitekt check`` imports the app, so it runs on save. Between saves an edited blok is checked
4
+ here, in process, with the validator ``arkitekt check`` runs (:func:`check_blok_tree`): the
5
+ language and the catalogs, and what the last check said the blok declares (its scope, see
6
+ :mod:`.scopes`). A blok no check has seen has an empty scope, so beyond what the parser refuses
7
+ only the findings that do not depend on what the blok declares are told; a reference to a
8
+ dependency, a local or a query waits for the check.
9
+ """
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, replace
13
+ from typing import Any
14
+
15
+ from arkitekt_spec.blok.bsx import parse_bsx_with_spans
16
+ from arkitekt_spec.blok.catalogs import default_view
17
+ from arkitekt_spec.blok.vocabulary import BlokError
18
+ from arkitekt_spec.declare.blok.registry import catalogs_of
19
+ from arkitekt_spec.declare.blok.validate import BlokScope, check_blok_tree, place
20
+
21
+ from arkitekt_lsp.placement import Span, place_in_literal
22
+ from arkitekt_lsp.regions import BlokLiteral
23
+ from arkitekt_lsp.scopes import to_blok_scope
24
+
25
+ SCOPE_FREE = frozenset(
26
+ {"bsx", "expr", "callback", "palette", "props", "foreach", "context", "logo", "catalog", "toast"}
27
+ )
28
+ """The diagnostic families (a code's first segment) an empty scope cannot get wrong."""
29
+ UNSCOPED = frozenset({"query"})
30
+ """The families a check's scope still cannot judge: a query's value is typed by its service's
31
+ schema, which the report does not carry."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LiveFinding:
36
+ """A finding of one blok literal, placed in the Python source."""
37
+
38
+ span: Span
39
+ level: str
40
+ code: str
41
+ message: str
42
+ hint: str | None = None
43
+
44
+
45
+ def _scope_free(code: str) -> bool:
46
+ return code.split(".", 1)[0] in SCOPE_FREE
47
+
48
+
49
+ def _judged_by_scope(code: str) -> bool:
50
+ return code.split(".", 1)[0] not in UNSCOPED
51
+
52
+
53
+ def live_findings(literal: BlokLiteral, scope: Mapping[str, Any] | None = None) -> list[LiveFinding]:
54
+ """What the language and the default catalogs find in ``literal``, without the app.
55
+
56
+ With ``scope`` (what the last check said this blok declares) a reference is judged too.
57
+ """
58
+ view = default_view()
59
+ try:
60
+ tree, spans = parse_bsx_with_spans(literal.value, view=view)
61
+ except BlokError as error:
62
+ # The parser knows no scope: whatever it refuses is the text's own.
63
+ where = {"line": error.line, "column": error.column, "node": error.node}
64
+ return [
65
+ LiveFinding(
66
+ span=place_in_literal(literal, {key: value for key, value in where.items() if value is not None}),
67
+ level="error",
68
+ code=error.code,
69
+ message=error.message,
70
+ hint=error.hint,
71
+ )
72
+ ]
73
+ # Every finding, errors too: one the empty scope gets wrong must not hide the rest.
74
+ # The catalogs it draws from, as the app's own check takes them when none are declared.
75
+ if scope is not None:
76
+ checked = to_blok_scope(scope)
77
+ checked = replace(checked, catalogs=checked.catalogs or catalogs_of(tree, view))
78
+ told = _judged_by_scope
79
+ else:
80
+ checked = BlokScope(kind=literal.kind, context=literal.context, catalogs=catalogs_of(tree, view))
81
+ told = _scope_free
82
+ findings = [place(finding, spans) for finding in check_blok_tree(tree, checked, view)]
83
+ placed: list[LiveFinding] = []
84
+ for finding in findings:
85
+ if not told(finding.code):
86
+ continue
87
+ where = {"line": finding.line, "column": finding.column, "node": finding.path}
88
+ placed.append(
89
+ LiveFinding(
90
+ span=place_in_literal(literal, {key: value for key, value in where.items() if value is not None}),
91
+ level=finding.level,
92
+ code=finding.code,
93
+ message=finding.message,
94
+ hint=finding.hint,
95
+ )
96
+ )
97
+ return placed
98
+
99
+
100
+ __all__ = ["SCOPE_FREE", "UNSCOPED", "LiveFinding", "live_findings"]