loomweave-plugin-python 1.0.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.
- loomweave_plugin_python/__init__.py +3 -0
- loomweave_plugin_python/__main__.py +15 -0
- loomweave_plugin_python/call_resolver.py +65 -0
- loomweave_plugin_python/entity_id.py +75 -0
- loomweave_plugin_python/extractor.py +1312 -0
- loomweave_plugin_python/py.typed +0 -0
- loomweave_plugin_python/pyright_session.py +1655 -0
- loomweave_plugin_python/qualname.py +48 -0
- loomweave_plugin_python/reference_resolver.py +70 -0
- loomweave_plugin_python/server.py +310 -0
- loomweave_plugin_python/stdout_guard.py +62 -0
- loomweave_plugin_python/wardline_descriptor.py +197 -0
- loomweave_plugin_python-1.0.0.data/data/share/loomweave/plugins/python/plugin.toml +71 -0
- loomweave_plugin_python-1.0.0.dist-info/METADATA +73 -0
- loomweave_plugin_python-1.0.0.dist-info/RECORD +17 -0
- loomweave_plugin_python-1.0.0.dist-info/WHEEL +4 -0
- loomweave_plugin_python-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""L7 qualname reconstruction matching Python's ``__qualname__`` semantics.
|
|
2
|
+
|
|
3
|
+
Python's ``__qualname__`` is only bound at runtime, after the function or
|
|
4
|
+
class definition has been executed; Loomweave's static analyser has to
|
|
5
|
+
reconstruct the same string from the AST and the chain of parent scopes.
|
|
6
|
+
|
|
7
|
+
Rules (CPython language reference, "``__qualname__``"):
|
|
8
|
+
|
|
9
|
+
- Module-level function/class: qualname == name.
|
|
10
|
+
- Class-nested (class body contains a function/class): qualname prepends
|
|
11
|
+
the enclosing class names joined by ``.`` with no separator marker.
|
|
12
|
+
- Function-nested (function body contains a function/class): qualname
|
|
13
|
+
prepends ``parent.<locals>.`` — the ``<locals>`` marker distinguishes a
|
|
14
|
+
closure from a method.
|
|
15
|
+
|
|
16
|
+
The L7 lock-in (``wp3-python-plugin.md §L7``) is that Loomweave reconstructs
|
|
17
|
+
the same bare Python ``__qualname__`` semantics that Wardline stores in its
|
|
18
|
+
``FingerprintEntry.qualified_name`` field. Loomweave entity names prepend the
|
|
19
|
+
dotted module path elsewhere; ADR-018 requires cross-product joins to translate
|
|
20
|
+
between those shapes instead of comparing strings directly.
|
|
21
|
+
|
|
22
|
+
Sprint 1 covers ``FunctionDef`` and ``AsyncFunctionDef`` as emitted
|
|
23
|
+
entities; ``ClassDef`` is recognised as a parent scope only (class
|
|
24
|
+
entities are WP3-feature-complete scope).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import ast
|
|
30
|
+
|
|
31
|
+
Scope = ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reconstruct_qualname(node: Scope, parents: list[ast.AST]) -> str:
|
|
35
|
+
"""Return Python's ``__qualname__`` for ``node`` given its AST parent chain.
|
|
36
|
+
|
|
37
|
+
``parents`` is ordered from outermost (typically the ``ast.Module``) to
|
|
38
|
+
the immediate parent. Non-scope ancestors (e.g. ``Module``,
|
|
39
|
+
``ast.If`` bodies, ``ast.With`` bodies) are skipped — they do not
|
|
40
|
+
contribute to ``__qualname__``.
|
|
41
|
+
"""
|
|
42
|
+
name = node.name
|
|
43
|
+
for ancestor in reversed(parents):
|
|
44
|
+
if isinstance(ancestor, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
45
|
+
name = f"{ancestor.name}.<locals>.{name}"
|
|
46
|
+
elif isinstance(ancestor, ast.ClassDef):
|
|
47
|
+
name = f"{ancestor.name}.{name}"
|
|
48
|
+
return name
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING, Literal, NotRequired, Protocol, TypedDict
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from loomweave_plugin_python.call_resolver import Finding
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ReferenceSiteKind = Literal["name", "annotation"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ReferenceSite:
|
|
18
|
+
from_id: str
|
|
19
|
+
line: int
|
|
20
|
+
character: int
|
|
21
|
+
end_line: int
|
|
22
|
+
end_character: int
|
|
23
|
+
source_byte_start: int
|
|
24
|
+
source_byte_end: int
|
|
25
|
+
kind: ReferenceSiteKind
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReferencesEdgeProperties(TypedDict):
|
|
29
|
+
candidates: list[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReferencesRawEdge(TypedDict):
|
|
33
|
+
kind: Literal["references"]
|
|
34
|
+
from_id: str
|
|
35
|
+
to_id: str
|
|
36
|
+
source_byte_start: int
|
|
37
|
+
source_byte_end: int
|
|
38
|
+
confidence: Literal["resolved", "ambiguous"]
|
|
39
|
+
properties: NotRequired[ReferencesEdgeProperties]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ReferenceResolutionResult:
|
|
44
|
+
edges: list[ReferencesRawEdge] = field(default_factory=list)
|
|
45
|
+
reference_sites_total: int = 0
|
|
46
|
+
references_resolved_total: int = 0
|
|
47
|
+
references_skipped_external_total: int = 0
|
|
48
|
+
references_skipped_cap_total: int = 0
|
|
49
|
+
unresolved_reference_sites_total: int = 0
|
|
50
|
+
pyright_query_latency_ms: list[int] = field(default_factory=list)
|
|
51
|
+
pyright_index_parse_latency_ms: list[int] = field(default_factory=list)
|
|
52
|
+
findings: list[Finding] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReferenceResolver(Protocol):
|
|
56
|
+
def resolve_references(
|
|
57
|
+
self,
|
|
58
|
+
file_path: str | Path,
|
|
59
|
+
sites: Sequence[ReferenceSite],
|
|
60
|
+
) -> ReferenceResolutionResult: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NoOpReferenceResolver:
|
|
64
|
+
def resolve_references(
|
|
65
|
+
self,
|
|
66
|
+
file_path: str | Path,
|
|
67
|
+
sites: Sequence[ReferenceSite],
|
|
68
|
+
) -> ReferenceResolutionResult:
|
|
69
|
+
_ = (file_path, sites)
|
|
70
|
+
return ReferenceResolutionResult()
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""WP2 L4 JSON-RPC server speaking Content-Length framing.
|
|
2
|
+
|
|
3
|
+
Implements the five L4 methods — ``initialize``, ``initialized``,
|
|
4
|
+
``analyze_file``, ``shutdown``, ``exit`` — exactly matching the Rust host's
|
|
5
|
+
typed request/response contracts in ``crates/loomweave-core/src/plugin/protocol.rs``.
|
|
6
|
+
|
|
7
|
+
Response shapes (required by the Rust host's typed deserialise path):
|
|
8
|
+
|
|
9
|
+
- ``initialize`` → ``{name, version, ontology_version, capabilities}``
|
|
10
|
+
(``InitializeResult``; WP2 scrub commit ``1ac32b1`` validates
|
|
11
|
+
``ontology_version`` is non-empty).
|
|
12
|
+
- ``analyze_file`` → ``{entities: [...]}`` (``AnalyzeFileResult``).
|
|
13
|
+
- ``shutdown`` → ``{}`` (empty ``ShutdownResult`` struct — *not* ``null``).
|
|
14
|
+
- ``initialized`` / ``exit`` — notifications, no response.
|
|
15
|
+
|
|
16
|
+
Task 2 shipped the dispatch skeleton with ``analyze_file`` returning an empty
|
|
17
|
+
entity list. The current plugin advertises its Wardline descriptor state during
|
|
18
|
+
``initialize`` and emits Wardline-derived semantic signals when a compatible
|
|
19
|
+
descriptor is available.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import IO, Any
|
|
30
|
+
|
|
31
|
+
from loomweave_plugin_python import __version__
|
|
32
|
+
from loomweave_plugin_python.extractor import extract_with_stats
|
|
33
|
+
from loomweave_plugin_python.pyright_session import PyrightRunState, PyrightSession
|
|
34
|
+
from loomweave_plugin_python.stdout_guard import install_stdio
|
|
35
|
+
from loomweave_plugin_python.wardline_descriptor import WardlineVocabulary, load_wardline_descriptor
|
|
36
|
+
|
|
37
|
+
ONTOLOGY_VERSION = "0.7.0"
|
|
38
|
+
|
|
39
|
+
# Plugin-side Content-Length sanity cap. Matches the host's ADR-021 §2b
|
|
40
|
+
# default (8 MiB) so the plugin never emits a frame the host would kill us
|
|
41
|
+
# for. Oversize outbound payloads trip this before reaching the wire.
|
|
42
|
+
MAX_CONTENT_LENGTH = 8 * 1024 * 1024
|
|
43
|
+
MAX_FILES_PER_PYRIGHT_SESSION = 25
|
|
44
|
+
|
|
45
|
+
# JSON-RPC 2.0 error codes (§5.1) plus LSP-style server extensions.
|
|
46
|
+
_ERR_INVALID_REQUEST = -32600
|
|
47
|
+
_ERR_METHOD_NOT_FOUND = -32601
|
|
48
|
+
_ERR_INTERNAL = -32603
|
|
49
|
+
_ERR_NOT_INITIALIZED = -32002
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProtocolError(RuntimeError):
|
|
53
|
+
"""Unrecoverable framing or envelope error; the server loop exits."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ServerState:
|
|
58
|
+
"""Handshake + shutdown + project-root state across the dispatch loop."""
|
|
59
|
+
|
|
60
|
+
initialized: bool = False
|
|
61
|
+
shutdown_requested: bool = False
|
|
62
|
+
project_root: Path | None = field(default=None)
|
|
63
|
+
pyright: PyrightSession | None = field(default=None)
|
|
64
|
+
pyright_files_since_restart: int = 0
|
|
65
|
+
pyright_run_state: PyrightRunState = field(default_factory=PyrightRunState)
|
|
66
|
+
wardline_vocabulary: WardlineVocabulary | None = field(default=None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_frame(stream: IO[bytes]) -> dict[str, Any] | None:
|
|
70
|
+
"""Read one Content-Length-framed JSON object. Returns ``None`` on EOF."""
|
|
71
|
+
headers: dict[str, str] = {}
|
|
72
|
+
while True:
|
|
73
|
+
line = stream.readline()
|
|
74
|
+
if not line:
|
|
75
|
+
return None
|
|
76
|
+
if line in (b"\r\n", b"\n"):
|
|
77
|
+
break
|
|
78
|
+
try:
|
|
79
|
+
decoded = line.decode("ascii").rstrip("\r\n")
|
|
80
|
+
except UnicodeDecodeError as exc:
|
|
81
|
+
msg = "malformed non-ASCII header line"
|
|
82
|
+
raise ProtocolError(msg) from exc
|
|
83
|
+
if ":" not in decoded:
|
|
84
|
+
msg = f"malformed header line: {decoded!r}"
|
|
85
|
+
raise ProtocolError(msg)
|
|
86
|
+
name, value = decoded.split(":", 1)
|
|
87
|
+
headers[name.strip().lower()] = value.strip()
|
|
88
|
+
|
|
89
|
+
raw_length = headers.get("content-length")
|
|
90
|
+
if raw_length is None:
|
|
91
|
+
msg = "missing Content-Length header"
|
|
92
|
+
raise ProtocolError(msg)
|
|
93
|
+
try:
|
|
94
|
+
length = int(raw_length)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
msg = f"Content-Length not an integer: {raw_length!r}"
|
|
97
|
+
raise ProtocolError(msg) from exc
|
|
98
|
+
if length < 0 or length > MAX_CONTENT_LENGTH:
|
|
99
|
+
msg = f"Content-Length out of range: {length}"
|
|
100
|
+
raise ProtocolError(msg)
|
|
101
|
+
|
|
102
|
+
body = stream.read(length)
|
|
103
|
+
if len(body) != length:
|
|
104
|
+
msg = f"short read: expected {length} bytes, got {len(body)}"
|
|
105
|
+
raise ProtocolError(msg)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
parsed = json.loads(body)
|
|
109
|
+
except json.JSONDecodeError as exc:
|
|
110
|
+
msg = f"invalid JSON body: {exc}"
|
|
111
|
+
raise ProtocolError(msg) from exc
|
|
112
|
+
|
|
113
|
+
if not isinstance(parsed, dict):
|
|
114
|
+
msg = f"expected JSON object at frame root, got {type(parsed).__name__}"
|
|
115
|
+
raise ProtocolError(msg)
|
|
116
|
+
return parsed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def write_frame(stream: IO[bytes], payload: dict[str, Any]) -> None:
|
|
120
|
+
"""Serialise ``payload`` as one Content-Length-framed JSON frame."""
|
|
121
|
+
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
122
|
+
if len(body) > MAX_CONTENT_LENGTH:
|
|
123
|
+
msg = f"outbound frame exceeds MAX_CONTENT_LENGTH ({len(body)} > {MAX_CONTENT_LENGTH})"
|
|
124
|
+
raise ProtocolError(msg)
|
|
125
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
126
|
+
stream.write(header)
|
|
127
|
+
stream.write(body)
|
|
128
|
+
stream.flush()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _success(request_id: Any, result: Any) -> dict[str, Any]:
|
|
132
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
136
|
+
return {
|
|
137
|
+
"jsonrpc": "2.0",
|
|
138
|
+
"id": request_id,
|
|
139
|
+
"error": {"code": code, "message": message},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def handle_initialize(params: dict[str, Any], state: ServerState) -> dict[str, Any]:
|
|
144
|
+
"""Return the plugin's identity + capabilities; capture ``project_root``."""
|
|
145
|
+
root_raw = params.get("project_root")
|
|
146
|
+
if isinstance(root_raw, str) and root_raw:
|
|
147
|
+
state.project_root = Path(root_raw).resolve()
|
|
148
|
+
wardline = load_wardline_descriptor(state.project_root)
|
|
149
|
+
state.wardline_vocabulary = wardline.vocabulary
|
|
150
|
+
if wardline.status == "absent":
|
|
151
|
+
sys.stderr.write(
|
|
152
|
+
"loomweave-plugin-python: Wardline vocabulary descriptor unavailable; "
|
|
153
|
+
"continuing without Wardline annotation metadata\n",
|
|
154
|
+
)
|
|
155
|
+
return {
|
|
156
|
+
"name": "loomweave-plugin-python",
|
|
157
|
+
"version": __version__,
|
|
158
|
+
"ontology_version": ONTOLOGY_VERSION,
|
|
159
|
+
"capabilities": {"wardline": wardline.as_capability()},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_module_path(file_path_raw: str, state: ServerState) -> str:
|
|
164
|
+
"""Compute the entity ``module_path`` relative to ``project_root``.
|
|
165
|
+
|
|
166
|
+
The host sends absolute paths (see ``crates/loomweave-cli/src/analyze.rs``
|
|
167
|
+
— ``project_root`` is canonicalised and file entries are built by
|
|
168
|
+
``entry.path()`` joins). To produce the expected L7 qualified names
|
|
169
|
+
(``pkg.module.func`` rather than ``tmp.xyz.demo.func``), the plugin
|
|
170
|
+
relativises each incoming path against the ``project_root`` captured
|
|
171
|
+
at ``initialize``.
|
|
172
|
+
"""
|
|
173
|
+
path = Path(file_path_raw)
|
|
174
|
+
if state.project_root is not None and path.is_absolute():
|
|
175
|
+
try:
|
|
176
|
+
return str(path.resolve().relative_to(state.project_root))
|
|
177
|
+
except ValueError:
|
|
178
|
+
# Outside project_root — host's jail should have caught this.
|
|
179
|
+
# Fall back to the raw path so the host's logs show the drift.
|
|
180
|
+
return file_path_raw
|
|
181
|
+
return file_path_raw
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def handle_analyze_file(params: dict[str, Any], state: ServerState) -> dict[str, Any]:
|
|
185
|
+
"""Read the requested file, extract entities + edges, return AnalyzeFileResult shape."""
|
|
186
|
+
empty_stats = {
|
|
187
|
+
"unresolved_call_sites_total": 0,
|
|
188
|
+
"unresolved_call_sites": [],
|
|
189
|
+
"reference_sites_total": 0,
|
|
190
|
+
"references_resolved_total": 0,
|
|
191
|
+
"references_skipped_external_total": 0,
|
|
192
|
+
"references_skipped_cap_total": 0,
|
|
193
|
+
"unresolved_reference_sites_total": 0,
|
|
194
|
+
"pyright_query_latency_ms": [],
|
|
195
|
+
"pyright_index_parse_latency_ms": [],
|
|
196
|
+
"extractor_parse_latency_ms": 0,
|
|
197
|
+
}
|
|
198
|
+
file_path_raw = params.get("file_path")
|
|
199
|
+
if not isinstance(file_path_raw, str):
|
|
200
|
+
return {"entities": [], "edges": [], "stats": empty_stats}
|
|
201
|
+
path = Path(file_path_raw)
|
|
202
|
+
if state.pyright is None:
|
|
203
|
+
state.pyright = PyrightSession(
|
|
204
|
+
state.project_root or path.parent,
|
|
205
|
+
run_state=state.pyright_run_state,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
source = path.read_text(encoding="utf-8")
|
|
209
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
210
|
+
sys.stderr.write(f"loomweave-plugin-python: cannot read {file_path_raw}: {exc}\n")
|
|
211
|
+
return {"entities": [], "edges": [], "stats": empty_stats}
|
|
212
|
+
# Emit source.file_path exactly as received so the host's jail check
|
|
213
|
+
# (which canonicalises against project_root) sees the original path.
|
|
214
|
+
# Derive qualified-name dotting from the project-relative form.
|
|
215
|
+
module_prefix = _resolve_module_path(file_path_raw, state)
|
|
216
|
+
result = extract_with_stats(
|
|
217
|
+
source,
|
|
218
|
+
file_path_raw,
|
|
219
|
+
module_prefix_path=module_prefix,
|
|
220
|
+
call_resolver=state.pyright,
|
|
221
|
+
reference_resolver=state.pyright,
|
|
222
|
+
wardline_vocabulary=state.wardline_vocabulary,
|
|
223
|
+
)
|
|
224
|
+
state.pyright_files_since_restart += 1
|
|
225
|
+
if state.pyright_files_since_restart >= MAX_FILES_PER_PYRIGHT_SESSION:
|
|
226
|
+
state.pyright.close()
|
|
227
|
+
state.pyright = None
|
|
228
|
+
state.pyright_files_since_restart = 0
|
|
229
|
+
stats = {
|
|
230
|
+
"unresolved_call_sites_total": result.stats.unresolved_call_sites_total,
|
|
231
|
+
"unresolved_call_sites": result.stats.unresolved_call_sites,
|
|
232
|
+
"reference_sites_total": result.stats.reference_sites_total,
|
|
233
|
+
"references_resolved_total": result.stats.references_resolved_total,
|
|
234
|
+
"references_skipped_external_total": result.stats.references_skipped_external_total,
|
|
235
|
+
"references_skipped_cap_total": result.stats.references_skipped_cap_total,
|
|
236
|
+
"unresolved_reference_sites_total": result.stats.unresolved_reference_sites_total,
|
|
237
|
+
"pyright_query_latency_ms": result.stats.pyright_query_latency_ms,
|
|
238
|
+
"pyright_index_parse_latency_ms": result.stats.pyright_index_parse_latency_ms,
|
|
239
|
+
"extractor_parse_latency_ms": result.stats.extractor_parse_latency_ms,
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
"entities": result.entities,
|
|
243
|
+
"edges": result.edges,
|
|
244
|
+
"stats": stats,
|
|
245
|
+
"findings": result.stats.findings,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
Handler = Callable[[dict[str, Any], ServerState], dict[str, Any]]
|
|
250
|
+
|
|
251
|
+
_HANDLERS: dict[str, Handler] = {
|
|
252
|
+
"initialize": handle_initialize,
|
|
253
|
+
"analyze_file": handle_analyze_file,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def dispatch(frame: dict[str, Any], state: ServerState) -> dict[str, Any] | None:
|
|
258
|
+
"""Process one frame; return the response envelope to send, or ``None``."""
|
|
259
|
+
method = frame.get("method")
|
|
260
|
+
params_raw = frame.get("params")
|
|
261
|
+
params: dict[str, Any] = params_raw if isinstance(params_raw, dict) else {}
|
|
262
|
+
request_id = frame.get("id")
|
|
263
|
+
|
|
264
|
+
if method == "initialized":
|
|
265
|
+
state.initialized = True
|
|
266
|
+
return None
|
|
267
|
+
if method == "exit":
|
|
268
|
+
return None
|
|
269
|
+
if method == "shutdown":
|
|
270
|
+
state.shutdown_requested = True
|
|
271
|
+
if state.pyright is not None:
|
|
272
|
+
state.pyright.close()
|
|
273
|
+
state.pyright = None
|
|
274
|
+
return _success(request_id, {})
|
|
275
|
+
if not isinstance(method, str):
|
|
276
|
+
return _error(request_id, _ERR_INVALID_REQUEST, f"invalid method: {method!r}")
|
|
277
|
+
if method == "analyze_file" and not state.initialized:
|
|
278
|
+
return _error(request_id, _ERR_NOT_INITIALIZED, "analyze_file before initialized")
|
|
279
|
+
handler = _HANDLERS.get(method)
|
|
280
|
+
if handler is None:
|
|
281
|
+
return _error(request_id, _ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
|
282
|
+
try:
|
|
283
|
+
result = handler(params, state)
|
|
284
|
+
except Exception as exc: # noqa: BLE001 - dispatch boundary: any handler bug becomes a response
|
|
285
|
+
return _error(request_id, _ERR_INTERNAL, f"handler failed: {exc}")
|
|
286
|
+
return _success(request_id, result)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def serve(stdin: IO[bytes], stdout: IO[bytes]) -> int:
|
|
290
|
+
"""Run the dispatch loop until EOF or ``exit`` notification."""
|
|
291
|
+
state = ServerState()
|
|
292
|
+
while True:
|
|
293
|
+
frame = read_frame(stdin)
|
|
294
|
+
if frame is None:
|
|
295
|
+
return 0
|
|
296
|
+
method = frame.get("method")
|
|
297
|
+
response = dispatch(frame, state)
|
|
298
|
+
if response is not None:
|
|
299
|
+
write_frame(stdout, response)
|
|
300
|
+
if method == "exit":
|
|
301
|
+
return 0 if state.shutdown_requested else 1
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def main() -> int:
|
|
305
|
+
"""Install stdout discipline, run the server loop, translate errors to exit codes."""
|
|
306
|
+
stdin, stdout = install_stdio()
|
|
307
|
+
try:
|
|
308
|
+
return serve(stdin, stdout)
|
|
309
|
+
except ProtocolError:
|
|
310
|
+
return 1
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Stdout discipline for JSON-RPC plugins (WP2 UQ-WP2-08 plugin-side resolution).
|
|
2
|
+
|
|
3
|
+
The Loomweave plugin protocol reserves ``stdout`` for Content-Length-framed
|
|
4
|
+
JSON-RPC frames. A stray ``print()`` or library-emitted message on stdout
|
|
5
|
+
would corrupt the framing parser on the host side and trip either the
|
|
6
|
+
Content-Length ceiling or the JSON decoder.
|
|
7
|
+
|
|
8
|
+
``install_stdio()`` captures the real ``stdin``/``stdout`` byte streams,
|
|
9
|
+
replaces ``sys.stdout`` with a guard that raises ``StdoutGuardError`` on
|
|
10
|
+
any write, and returns the captured ``(stdin, stdout)`` pair for the
|
|
11
|
+
server to use. Callers must invoke this exactly once, before reading or
|
|
12
|
+
writing any framed data.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from typing import IO
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StdoutGuardError(RuntimeError):
|
|
22
|
+
"""Raised when Python code writes to the guarded stdout after ``install_stdio``."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _GuardedTextStdout:
|
|
26
|
+
"""``sys.stdout`` replacement that refuses every write.
|
|
27
|
+
|
|
28
|
+
Only implements the attributes and methods CPython routinely looks up
|
|
29
|
+
on ``sys.stdout`` — enough to surface the guard error clearly instead of
|
|
30
|
+
failing with ``AttributeError`` first.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
encoding = "utf-8"
|
|
34
|
+
errors = "strict"
|
|
35
|
+
|
|
36
|
+
def write(self, _data: str) -> int:
|
|
37
|
+
msg = (
|
|
38
|
+
"plugin stdout is reserved for JSON-RPC framing; "
|
|
39
|
+
"write to sys.stderr for diagnostics or raise an exception"
|
|
40
|
+
)
|
|
41
|
+
raise StdoutGuardError(msg)
|
|
42
|
+
|
|
43
|
+
def writelines(self, _lines: object) -> None:
|
|
44
|
+
self.write("")
|
|
45
|
+
|
|
46
|
+
def flush(self) -> None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
def isatty(self) -> bool:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def fileno(self) -> int:
|
|
53
|
+
msg = "guarded stdout has no fileno"
|
|
54
|
+
raise StdoutGuardError(msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install_stdio() -> tuple[IO[bytes], IO[bytes]]:
|
|
58
|
+
"""Reserve stdout for JSON-RPC; return the real ``(stdin, stdout)`` byte streams."""
|
|
59
|
+
real_stdin = sys.stdin.buffer
|
|
60
|
+
real_stdout = sys.stdout.buffer
|
|
61
|
+
sys.stdout = _GuardedTextStdout()
|
|
62
|
+
return real_stdin, real_stdout
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Wardline NG-25 vocabulary descriptor reader.
|
|
2
|
+
|
|
3
|
+
This module deliberately reads descriptor files without importing Wardline.
|
|
4
|
+
Wardline remains authoritative for the vocabulary; Loomweave records only the
|
|
5
|
+
source-observed decorator facts it can derive from that descriptor.
|
|
6
|
+
|
|
7
|
+
Two contract details below (``PROJECT_DESCRIPTOR_PATH`` and the descriptor
|
|
8
|
+
``version`` semantics) are Loomweave-side assumptions pending Wardline's
|
|
9
|
+
"Pre-Rust core hardening" Task B, which has not yet published the canonical
|
|
10
|
+
project-local descriptor location or the ``schema: wardline.vocabulary/v1``
|
|
11
|
+
format-version field. The parser ignores unknown top-level keys, so a future
|
|
12
|
+
``schema`` field is tolerated without change; acting on it (format-version
|
|
13
|
+
compatibility decisions) is deferred until Task B pins the contract. Confirm
|
|
14
|
+
both assumptions against the Wardline descriptor ADR when it lands
|
|
15
|
+
(tracked: filigree clarion-6ab5668d82).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from importlib import metadata
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Literal, cast
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
# PO-confirm against Wardline Task B (descriptor ADR) — canonical project-local
|
|
28
|
+
# location and descriptor-version semantics are not yet pinned by Wardline.
|
|
29
|
+
# Tracked: filigree clarion-6ab5668d82.
|
|
30
|
+
EXPECTED_DESCRIPTOR_VERSION = "wardline-generic-2"
|
|
31
|
+
PROJECT_DESCRIPTOR_PATH = Path(".wardline/vocabulary.yaml")
|
|
32
|
+
|
|
33
|
+
DescriptorSource = Literal["project", "package"]
|
|
34
|
+
DescriptorStatus = Literal["enabled", "version_skew", "absent"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DescriptorEntry:
|
|
39
|
+
canonical_name: str
|
|
40
|
+
group: int
|
|
41
|
+
attrs: dict[str, str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class WardlineVocabulary:
|
|
46
|
+
version: str
|
|
47
|
+
source: DescriptorSource
|
|
48
|
+
confidence_basis: Literal["descriptor", "descriptor_version_skew"]
|
|
49
|
+
entries_by_name: dict[str, DescriptorEntry]
|
|
50
|
+
|
|
51
|
+
def entry_for_decorator(self, qualified_name: str) -> DescriptorEntry | None:
|
|
52
|
+
return self.entries_by_name.get(qualified_name.rsplit(".", 1)[-1])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class WardlineDescriptorState:
|
|
57
|
+
status: DescriptorStatus
|
|
58
|
+
expected_version: str = EXPECTED_DESCRIPTOR_VERSION
|
|
59
|
+
descriptor_version: str | None = None
|
|
60
|
+
source: DescriptorSource | None = None
|
|
61
|
+
reason: str | None = None
|
|
62
|
+
vocabulary: WardlineVocabulary | None = None
|
|
63
|
+
|
|
64
|
+
def as_capability(self) -> dict[str, str]:
|
|
65
|
+
if self.status == "absent":
|
|
66
|
+
capability = {"status": "absent"}
|
|
67
|
+
if self.reason:
|
|
68
|
+
capability["reason"] = self.reason
|
|
69
|
+
return capability
|
|
70
|
+
capability = {
|
|
71
|
+
"status": self.status,
|
|
72
|
+
"descriptor_version": self.descriptor_version or "",
|
|
73
|
+
"source": self.source or "",
|
|
74
|
+
}
|
|
75
|
+
if self.status == "version_skew":
|
|
76
|
+
capability["expected_version"] = self.expected_version
|
|
77
|
+
return capability
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _DescriptorError(ValueError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_wardline_descriptor(project_root: Path | None) -> WardlineDescriptorState:
|
|
85
|
+
"""Resolve and parse the Wardline descriptor, degrading on every failure."""
|
|
86
|
+
project_text = _read_project_descriptor(project_root)
|
|
87
|
+
if project_text is not None:
|
|
88
|
+
return _state_from_text(project_text, "project")
|
|
89
|
+
|
|
90
|
+
package_text = _read_package_descriptor()
|
|
91
|
+
if package_text is not None:
|
|
92
|
+
return _state_from_text(package_text, "package")
|
|
93
|
+
|
|
94
|
+
return WardlineDescriptorState(status="absent", reason="not_found")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_project_descriptor(project_root: Path | None) -> str | None:
|
|
98
|
+
if project_root is None:
|
|
99
|
+
return None
|
|
100
|
+
path = project_root / PROJECT_DESCRIPTOR_PATH
|
|
101
|
+
if not path.is_file():
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return path.read_text(encoding="utf-8")
|
|
105
|
+
except OSError:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_package_descriptor() -> str | None:
|
|
110
|
+
try:
|
|
111
|
+
files = metadata.files("wardline")
|
|
112
|
+
except metadata.PackageNotFoundError:
|
|
113
|
+
return None
|
|
114
|
+
if files is None:
|
|
115
|
+
return None
|
|
116
|
+
for package_file in files:
|
|
117
|
+
if str(package_file).replace("\\", "/").endswith("wardline/core/vocabulary.yaml"):
|
|
118
|
+
try:
|
|
119
|
+
return cast("str", cast("Any", package_file.locate()).read_text(encoding="utf-8"))
|
|
120
|
+
except OSError:
|
|
121
|
+
return None
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _state_from_text(text: str, source: DescriptorSource) -> WardlineDescriptorState:
|
|
126
|
+
try:
|
|
127
|
+
descriptor = yaml.safe_load(text)
|
|
128
|
+
vocabulary = _parse_descriptor(descriptor, source)
|
|
129
|
+
except (OSError, yaml.YAMLError, _DescriptorError):
|
|
130
|
+
return WardlineDescriptorState(status="absent", reason="invalid_descriptor")
|
|
131
|
+
if vocabulary.version != EXPECTED_DESCRIPTOR_VERSION:
|
|
132
|
+
return WardlineDescriptorState(
|
|
133
|
+
status="version_skew",
|
|
134
|
+
descriptor_version=vocabulary.version,
|
|
135
|
+
source=source,
|
|
136
|
+
vocabulary=WardlineVocabulary(
|
|
137
|
+
version=vocabulary.version,
|
|
138
|
+
source=source,
|
|
139
|
+
confidence_basis="descriptor_version_skew",
|
|
140
|
+
entries_by_name=vocabulary.entries_by_name,
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
return WardlineDescriptorState(
|
|
144
|
+
status="enabled",
|
|
145
|
+
descriptor_version=vocabulary.version,
|
|
146
|
+
source=source,
|
|
147
|
+
vocabulary=vocabulary,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_descriptor(descriptor: Any, source: DescriptorSource) -> WardlineVocabulary:
|
|
152
|
+
if not isinstance(descriptor, dict):
|
|
153
|
+
msg = "descriptor root must be a mapping"
|
|
154
|
+
raise _DescriptorError(msg)
|
|
155
|
+
version = descriptor.get("version")
|
|
156
|
+
entries = descriptor.get("entries")
|
|
157
|
+
if not isinstance(version, str) or not isinstance(entries, list):
|
|
158
|
+
msg = "descriptor must carry string version and list entries"
|
|
159
|
+
raise _DescriptorError(msg)
|
|
160
|
+
|
|
161
|
+
entries_by_name: dict[str, DescriptorEntry] = {}
|
|
162
|
+
for raw_entry in entries:
|
|
163
|
+
entry = _parse_entry(raw_entry)
|
|
164
|
+
if entry.canonical_name in entries_by_name:
|
|
165
|
+
msg = f"duplicate Wardline descriptor entry: {entry.canonical_name}"
|
|
166
|
+
raise _DescriptorError(msg)
|
|
167
|
+
entries_by_name[entry.canonical_name] = entry
|
|
168
|
+
return WardlineVocabulary(
|
|
169
|
+
version=version,
|
|
170
|
+
source=source,
|
|
171
|
+
confidence_basis="descriptor",
|
|
172
|
+
entries_by_name=entries_by_name,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_entry(raw_entry: Any) -> DescriptorEntry:
|
|
177
|
+
if not isinstance(raw_entry, dict):
|
|
178
|
+
msg = "descriptor entry must be a mapping"
|
|
179
|
+
raise _DescriptorError(msg)
|
|
180
|
+
canonical_name = raw_entry.get("canonical_name")
|
|
181
|
+
group = raw_entry.get("group")
|
|
182
|
+
attrs = raw_entry.get("attrs")
|
|
183
|
+
if not isinstance(canonical_name, str) or not isinstance(group, int):
|
|
184
|
+
msg = "descriptor entry must carry canonical_name and group"
|
|
185
|
+
raise _DescriptorError(msg)
|
|
186
|
+
if not isinstance(attrs, dict):
|
|
187
|
+
msg = "descriptor entry attrs must be a mapping"
|
|
188
|
+
raise _DescriptorError(msg)
|
|
189
|
+
for key, value in attrs.items():
|
|
190
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
191
|
+
msg = "descriptor attrs must map strings to strings"
|
|
192
|
+
raise _DescriptorError(msg)
|
|
193
|
+
return DescriptorEntry(
|
|
194
|
+
canonical_name=canonical_name,
|
|
195
|
+
group=group,
|
|
196
|
+
attrs=cast("dict[str, str]", dict(attrs)),
|
|
197
|
+
)
|