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.
@@ -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
+ )