loomweave-plugin-python 1.0.0__py3-none-any.whl → 1.2.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.
@@ -1,3 +1,3 @@
1
1
  """loomweave-plugin-python — Python language plugin for Loomweave."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.2.0"
@@ -3,7 +3,11 @@
3
3
  Walks a parsed Python file and emits one ``module`` entity per file plus
4
4
  one ``function`` entity per ``FunctionDef`` / ``AsyncFunctionDef`` and one
5
5
  ``class`` entity per ``ClassDef``. It also emits anchored scan-time
6
- ``imports``, ``calls``, and ``references`` candidate edges.
6
+ ``imports``, ``calls``, ``references``, ``inherits_from``, and ``decorates``
7
+ candidate edges. The last three share one resolution pass: base-class and
8
+ decorator expressions become ``base`` / ``decorator`` relation sites in the
9
+ same collector that gathers generic reference sites, and the site kind
10
+ selects the emitted edge kind (see ``reference_resolver.ReferenceSiteKind``).
7
11
 
8
12
  Entity shape matches the Rust host's ``RawEntity`` + ``RawSource``
9
13
  contract (``crates/loomweave-core/src/plugin/host.rs:132-154``)::
@@ -40,6 +44,12 @@ Behaviour (B.2 §3 Q1 supersedes Sprint-1 UQ-WP3-11 for module entities):
40
44
  with ``parse_status="syntax_error"`` plus one stderr log line
41
45
  (UQ-WP3-02). The run continues; WP4-era findings can later attach a
42
46
  ``LMWV-PY-SYNTAX-ERROR`` annotation.
47
+ - ``RecursionError``/``MemoryError`` from hostile or degenerate nesting
48
+ (deep BinOp/attribute chains, unary prefix runs) — whether raised by
49
+ ``ast.parse`` itself or by the post-parse walkers — → one degraded
50
+ module entity with ``parse_status="too_complex"`` plus a
51
+ ``LMWV-PY-TOO-COMPLEX`` plugin finding (clarion-f3eb3852d6; Rust
52
+ parse-guard parity, ADR-050). The plugin process survives.
43
53
  - Top-level ``__init__.py`` (where the dotted module name resolves to
44
54
  ``""``) is skipped with stderr; the entity-ID assembler rejects an
45
55
  empty ``canonical_qualified_name``.
@@ -85,6 +95,18 @@ if TYPE_CHECKING:
85
95
 
86
96
  _PLUGIN_ID = "python"
87
97
  _NOOP_CALL_RESOLVER = NoOpCallResolver()
98
+
99
+ # Deep-nesting degradation (clarion-f3eb3852d6; Rust parse-guard parity,
100
+ # ADR-050). CPython's tokenizer self-limits indentation (100) and paren
101
+ # nesting (~200) as SyntaxError, but indentation-free deep ASTs (BinOp
102
+ # chains, attribute chains, unary prefix runs) pass tokenization and blow
103
+ # up later: ast.parse raises RecursionError ("during ast construction") or
104
+ # MemoryError ("Parser stack overflowed"), and chains as shallow as ~350
105
+ # nodes blow Python-level NodeVisitor recursion in the post-parse walkers
106
+ # AFTER a clean parse. All three degrade to a `too_complex` module entity
107
+ # plus this plugin finding — escaping would fail the whole plugin run at
108
+ # the host's analyze_file boundary.
109
+ FINDING_TOO_COMPLEX = "LMWV-PY-TOO-COMPLEX"
88
110
  _NOOP_REFERENCE_RESOLVER = NoOpReferenceResolver()
89
111
 
90
112
 
@@ -180,7 +202,7 @@ class RawEntity(TypedDict):
180
202
  qualified_name: str
181
203
  source: EntitySource
182
204
  parent_id: NotRequired[str]
183
- parse_status: NotRequired[Literal["ok", "syntax_error"]]
205
+ parse_status: NotRequired[Literal["ok", "syntax_error", "too_complex"]]
184
206
  # entity_context evidence (clarion-460def6a51). Set on function/class
185
207
  # entities; omitted for modules. Rides the host's RawEntity `extra` flatten
186
208
  # into `properties_json`, so no host or storage schema change is needed.
@@ -323,7 +345,7 @@ def _build_module_entity(
323
345
  source: str,
324
346
  dotted_module: str,
325
347
  file_path: str,
326
- parse_status: Literal["ok", "syntax_error"],
348
+ parse_status: Literal["ok", "syntax_error", "too_complex"],
327
349
  docstring: str | None = None,
328
350
  ) -> RawEntity:
329
351
  """Build the per-file module entity (Q1 + Q4 resolutions)."""
@@ -424,6 +446,17 @@ def extract_with_stats( # noqa: PLR0913 - resolver seams + optional Wardline vo
424
446
  [],
425
447
  ExtractionStats(extractor_parse_latency_ms=parse_latency_ms),
426
448
  )
449
+ except (RecursionError, MemoryError) as exc:
450
+ # Deep AST construction (RecursionError) or PEG parser stack guard
451
+ # (MemoryError) — see FINDING_TOO_COMPLEX. Stack is unwound here.
452
+ return _too_complex_result(
453
+ source,
454
+ dotted_module,
455
+ file_path,
456
+ "parse",
457
+ exc,
458
+ _elapsed_ms(parse_started_ns),
459
+ )
427
460
  parse_latency_ms = _elapsed_ms(parse_started_ns)
428
461
 
429
462
  module_entity = _build_module_entity(
@@ -432,35 +465,51 @@ def extract_with_stats( # noqa: PLR0913 - resolver seams + optional Wardline vo
432
465
  entities: list[RawEntity] = [module_entity]
433
466
  edges: list[RawEdge] = []
434
467
  function_ids: list[str] = []
435
- walk_state = _WalkState(
436
- seen_ids={module_entity["id"]},
437
- file_path=file_path,
438
- exported_names=_module_export_names(tree),
439
- wardline_vocabulary=wardline_vocabulary,
440
- )
441
- _walk(
442
- tree,
443
- [tree],
444
- dotted_module,
445
- file_path,
446
- module_entity["id"],
447
- entities,
448
- edges,
449
- function_ids,
450
- walk_state,
451
- )
452
- edges.extend(
453
- _collect_import_edges(
454
- source,
468
+ try:
469
+ walk_state = _WalkState(
470
+ seen_ids={module_entity["id"]},
471
+ file_path=file_path,
472
+ exported_names=_module_export_names(tree),
473
+ wardline_vocabulary=wardline_vocabulary,
474
+ )
475
+ _walk(
455
476
  tree,
477
+ [tree],
456
478
  dotted_module,
479
+ file_path,
457
480
  module_entity["id"],
458
- is_package_module=is_package_module,
459
- ),
460
- )
461
- reference_sites = _collect_reference_sites(source, tree, dotted_module, module_entity["id"])
462
- call_stats = call_resolver.resolve_calls(file_path, function_ids)
463
- reference_stats = reference_resolver.resolve_references(file_path, reference_sites)
481
+ entities,
482
+ edges,
483
+ function_ids,
484
+ walk_state,
485
+ )
486
+ edges.extend(
487
+ _collect_import_edges(
488
+ source,
489
+ tree,
490
+ dotted_module,
491
+ module_entity["id"],
492
+ is_package_module=is_package_module,
493
+ ),
494
+ )
495
+ reference_sites = _collect_reference_sites(source, tree, dotted_module, module_entity["id"])
496
+ call_stats = call_resolver.resolve_calls(file_path, function_ids)
497
+ reference_stats = reference_resolver.resolve_references(file_path, reference_sites)
498
+ except (RecursionError, MemoryError) as exc:
499
+ # A tree can parse cleanly (the C parser's limit is deeper than the
500
+ # Python recursion limit) and still blow the recursive NodeVisitor
501
+ # walkers / pyright function index at ~350+ nesting. Degrade the
502
+ # whole file: partially-walked entities/edges are discarded so the
503
+ # emission is all-or-nothing per file.
504
+ return _too_complex_result(
505
+ source,
506
+ dotted_module,
507
+ file_path,
508
+ "extract",
509
+ exc,
510
+ parse_latency_ms,
511
+ ast.get_docstring(tree),
512
+ )
464
513
  edges.extend(cast("list[RawEdge]", call_stats.edges))
465
514
  edges.extend(cast("list[RawEdge]", reference_stats.edges))
466
515
  stats = ExtractionStats.from_resolution_results(call_stats, reference_stats)
@@ -469,6 +518,46 @@ def extract_with_stats( # noqa: PLR0913 - resolver seams + optional Wardline vo
469
518
  return ExtractResult(entities, edges, stats)
470
519
 
471
520
 
521
+ def _too_complex_result( # noqa: PLR0913 - degraded-path builder mirrors the syntax_error arm's inputs.
522
+ source: str,
523
+ dotted_module: str,
524
+ file_path: str,
525
+ phase: Literal["parse", "extract"],
526
+ exc: BaseException,
527
+ parse_latency_ms: int,
528
+ docstring: str | None = None,
529
+ ) -> ExtractResult:
530
+ """Degrade a nesting-bomb file to a `too_complex` module entity + finding.
531
+
532
+ Mirrors the Rust plugin's parse-guard degrade-to-finding pattern
533
+ (ADR-050): the file is ingested as a single degraded module entity, the
534
+ failure is visible as a `LMWV-PY-TOO-COMPLEX` plugin finding riding the
535
+ findings wire, and the plugin (and the host's run) survives.
536
+ """
537
+ sys.stderr.write(
538
+ f"loomweave-plugin-python: degrading {file_path}: nesting too complex "
539
+ f"during {phase} ({type(exc).__name__})\n",
540
+ )
541
+ stats = ExtractionStats(extractor_parse_latency_ms=parse_latency_ms)
542
+ stats.findings.append(
543
+ Finding(
544
+ subcode=FINDING_TOO_COMPLEX,
545
+ severity="warning",
546
+ message=(
547
+ f"{file_path}: source nesting too complex to extract "
548
+ f"({type(exc).__name__} during {phase}); file ingested as a "
549
+ "degraded module entity"
550
+ ),
551
+ metadata={"phase": phase, "exception": type(exc).__name__},
552
+ ),
553
+ )
554
+ return ExtractResult(
555
+ [_build_module_entity(source, dotted_module, file_path, "too_complex", docstring)],
556
+ [],
557
+ stats,
558
+ )
559
+
560
+
472
561
  def _elapsed_ms(started_ns: int) -> int:
473
562
  return max(1, (time.perf_counter_ns() - started_ns + 999_999) // 1_000_000)
474
563
 
@@ -710,6 +799,7 @@ class _ReferenceSiteCollector(ast.NodeVisitor):
710
799
  if _has_overload_decorator(node):
711
800
  return
712
801
  function_id = self._entity_id_for_scope("function", node)
802
+ self._collect_decorator_sites(node, function_id)
713
803
  self.owner_stack.append(function_id)
714
804
  self.bound_stack.append(_scope_local_names(node))
715
805
  self._visit_function_signature(node)
@@ -722,6 +812,16 @@ class _ReferenceSiteCollector(ast.NodeVisitor):
722
812
 
723
813
  def visit_ClassDef(self, node: ast.ClassDef) -> None:
724
814
  class_id = self._entity_id_for_scope("class", node)
815
+ self._collect_decorator_sites(node, class_id)
816
+ # Base expressions become `base` relation sites owned by the subclass
817
+ # (resolved into `inherits_from` edges). Call bases (`class X(make())`)
818
+ # are outside the relation envelope: the call's *result* is the base,
819
+ # so anchoring the callee would assert a false inheritance fact.
820
+ # Keyword arguments (`metaclass=...`) are likewise out of scope.
821
+ for base in node.bases:
822
+ anchor = _relation_anchor(base)
823
+ if anchor is not None:
824
+ self.sites.append(self._site_for_relation(anchor, class_id, "base"))
725
825
  self.owner_stack.append(class_id)
726
826
  self.bound_stack.append(_scope_local_names(node))
727
827
  self.parents.append(node)
@@ -793,6 +893,59 @@ class _ReferenceSiteCollector(ast.NodeVisitor):
793
893
  )
794
894
  return entity_id(_PLUGIN_ID, kind, qualified_name)
795
895
 
896
+ def _collect_decorator_sites(
897
+ self,
898
+ node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
899
+ owner_id: str,
900
+ ) -> None:
901
+ """Emit one ``decorator`` relation site per reducible decorator.
902
+
903
+ ``owner_id`` is the *decorated* entity (the site owner); the resolver
904
+ inverts direction at edge construction so the stored edge reads
905
+ ``decorator decorates decorated`` (`from_id` = decorator entity).
906
+ Factory decorators (``@app.route("/x")``) reduce to the callee path —
907
+ the factory is the entity that decorates, its arguments are not part
908
+ of the relation token.
909
+ """
910
+ for decorator in node.decorator_list:
911
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
912
+ anchor = _relation_anchor(target)
913
+ if anchor is not None:
914
+ self.sites.append(self._site_for_relation(anchor, owner_id, "decorator"))
915
+
916
+ def _site_for_relation(
917
+ self,
918
+ anchor: ast.Name | ast.Attribute,
919
+ from_id: str,
920
+ kind: Literal["base", "decorator"],
921
+ ) -> ReferenceSite:
922
+ """Build a relation site anchored on the full dotted path token.
923
+
924
+ The anchored byte range covers the whole path (Rust parity: the
925
+ implemented-trait PATH's span in ``impl Tr for Foo``), while the
926
+ pyright query position lands on the *final* attribute segment so
927
+ ``helpers.Base`` resolves the class, not the module prefix.
928
+ """
929
+ start_line = anchor.lineno - 1
930
+ end_line = (anchor.end_lineno or anchor.lineno) - 1
931
+ end_col = anchor.end_col_offset if anchor.end_col_offset is not None else anchor.col_offset
932
+ if isinstance(anchor, ast.Attribute):
933
+ query_line = end_line
934
+ query_byte_col = end_col - len(anchor.attr.encode("utf-8"))
935
+ else:
936
+ query_line = start_line
937
+ query_byte_col = anchor.col_offset
938
+ return ReferenceSite(
939
+ from_id=from_id,
940
+ line=query_line,
941
+ character=_byte_col_to_lsp_character(self.source_lines[query_line], query_byte_col),
942
+ end_line=end_line,
943
+ end_character=_byte_col_to_lsp_character(self.source_lines[end_line], end_col),
944
+ source_byte_start=self.line_starts[start_line] + anchor.col_offset,
945
+ source_byte_end=self.line_starts[end_line] + end_col,
946
+ kind=kind,
947
+ )
948
+
796
949
  def _site_for_name(self, node: ast.Name) -> ReferenceSite:
797
950
  line = node.lineno - 1
798
951
  end_line = (node.end_lineno or node.lineno) - 1
@@ -811,6 +964,23 @@ class _ReferenceSiteCollector(ast.NodeVisitor):
811
964
  )
812
965
 
813
966
 
967
+ def _relation_anchor(expr: ast.expr) -> ast.Name | ast.Attribute | None:
968
+ """Reduce a base/decorator expression to its resolvable path token.
969
+
970
+ ``Name`` and ``Attribute`` are the anchor itself; ``Subscript`` reduces to
971
+ its value (``Generic[T]`` → ``Generic``). Anything else (calls, literals,
972
+ conditional expressions) has no stable path token and yields no relation
973
+ site — the resolution-side precise-entity discipline would drop it anyway.
974
+ """
975
+ match expr:
976
+ case ast.Name() | ast.Attribute():
977
+ return expr
978
+ case ast.Subscript(value=value):
979
+ return _relation_anchor(value)
980
+ case _:
981
+ return None
982
+
983
+
814
984
  def _line_starts(source: str) -> tuple[int, ...]:
815
985
  starts = [0]
816
986
  total = 0
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import ast
4
4
  import ctypes
5
5
  import ctypes.util
6
+ import errno
6
7
  import json
7
8
  import math
8
9
  import os
@@ -41,6 +42,8 @@ FINDING_PYRIGHT_POISON_FRAME = "LMWV-PY-PYRIGHT-POISON-FRAME"
41
42
  FINDING_PYRIGHT_INIT_TIMEOUT = "LMWV-PY-PYRIGHT-INIT-TIMEOUT"
42
43
  FINDING_PYRIGHT_UNAVAILABLE = "LMWV-PY-PYRIGHT-UNAVAILABLE"
43
44
  FINDING_PYRIGHT_INSTALL_FAILURE = "LMWV-PY-PYRIGHT-INSTALL-FAILURE"
45
+ FINDING_PYRIGHT_SPAWN_DEFERRED = "LMWV-PY-PYRIGHT-SPAWN-DEFERRED"
46
+ FINDING_PYRIGHT_RESOURCE_EXHAUSTED = "LMWV-PY-PYRIGHT-RESOURCE-EXHAUSTED"
44
47
  FINDING_PYRIGHT_CALL_RESOLUTION_TIMEOUT = "LMWV-PY-CALL-RESOLUTION-TIMEOUT"
45
48
  FINDING_PYRIGHT_REFERENCE_RESOLUTION_TIMEOUT = "LMWV-PY-REFERENCE-RESOLUTION-TIMEOUT"
46
49
  FINDING_PYRIGHT_REFERENCE_SITE_CAP = "LMWV-PY-REFERENCE-SITE-CAP"
@@ -56,21 +59,44 @@ class PyrightRunState:
56
59
  consume ``ceil(N/25) * 3`` restarts instead of 3 for an entire analysis
57
60
  run. Pass the same ``PyrightRunState`` instance to every successive
58
61
  ``PyrightSession`` so the budget is enforced across the full run.
62
+
63
+ ``consecutive_spawn_deferrals`` tracks transient (resource-pressure) spawn
64
+ failures separately from the ``restart_count`` crash budget: it is reset to
65
+ zero on every successful spawn, so intermittent pressure never poisons the
66
+ run, while a sustained run of deferrals still terminates pyright once it
67
+ exceeds ``MAX_CONSECUTIVE_SPAWN_DEFERRALS``.
59
68
  """
60
69
 
61
70
  restart_count: int = 0
62
71
  disabled: bool = False
72
+ consecutive_spawn_deferrals: int = 0
63
73
 
64
74
 
65
75
  MAX_UNRESOLVED_CALLEE_EXPR_BYTES = 512
66
76
  MAX_PYRIGHT_RESTARTS_PER_RUN = 3
77
+ # A spawn that fails with one of these errnos is a *transient* resource-pressure
78
+ # condition (the host is momentarily out of process slots / memory), not a broken
79
+ # install. EAGAIN in particular is what a busy workstation returns from fork(2)
80
+ # when the per-UID RLIMIT_NPROC is hit. These are deferred-and-retried rather
81
+ # than treated as a permanent install failure.
82
+ _TRANSIENT_SPAWN_ERRNOS = frozenset({errno.EAGAIN, errno.ENOMEM, errno.EMFILE, errno.ENFILE})
83
+ # Upper bound on *consecutive* transient spawn deferrals before pyright is
84
+ # disabled for the run. Reset to zero on any successful spawn, so this only
85
+ # fires under sustained pressure, never on an intermittent blip. A failed fork
86
+ # costs microseconds, so retrying once per file across a large run is cheap.
87
+ MAX_CONSECUTIVE_SPAWN_DEFERRALS = 50
67
88
  MAX_REFERENCE_SITES_PER_FILE = 2000
68
89
  PYRIGHT_INIT_TIMEOUT_SECS = 30.0
69
90
  PYRIGHT_CALL_TIMEOUT_SECS = 5.0
70
- PYRIGHT_FILE_TIMEOUT_SECS = 3.0
91
+ # Per-file wall-clock budget for reference resolution. Large, heavily-typed
92
+ # files (e.g. numpy/torch-vectorised ML code) can starve a tighter budget and
93
+ # surface LMWV-PY-REFERENCE-RESOLUTION-TIMEOUT findings with edges left
94
+ # unresolved. Such files are rare enough that the extra ceiling is worth the
95
+ # more-complete graph.
96
+ PYRIGHT_FILE_TIMEOUT_SECS = 10.0
71
97
  STDERR_TAIL_LIMIT = 65536
72
98
  PYRIGHT_EXCLUDE_PATTERNS = [
73
- "**/.loomweave/**",
99
+ "**/.weft/**",
74
100
  "**/.git/**",
75
101
  "**/.hg/**",
76
102
  "**/.svn/**",
@@ -79,7 +105,7 @@ PYRIGHT_EXCLUDE_PATTERNS = [
79
105
  "**/__pycache__/**",
80
106
  "**/node_modules/**",
81
107
  ]
82
- PROJECT_LOCAL_EXTERNAL_DIRS = {".loomweave", ".git", ".hg", ".svn", ".jj", ".venv", "node_modules"}
108
+ PROJECT_LOCAL_EXTERNAL_DIRS = {".weft", ".git", ".hg", ".svn", ".jj", ".venv", "node_modules"}
83
109
 
84
110
 
85
111
  if TYPE_CHECKING:
@@ -144,6 +170,7 @@ class _FunctionIndex:
144
170
 
145
171
  @dataclass
146
172
  class _ReferenceEdgeAccumulator:
173
+ kind: Literal["references", "inherits_from", "decorates"]
147
174
  from_id: str
148
175
  to_id: str
149
176
  source_byte_start: int
@@ -151,6 +178,17 @@ class _ReferenceEdgeAccumulator:
151
178
  candidates: set[str]
152
179
 
153
180
 
181
+ # Site kind → emitted edge kind (clarion-43416be550). `name`/`annotation`
182
+ # sites keep producing `references`; the two relation kinds map onto the
183
+ # ontology kinds that were previously declared-but-dead for Python.
184
+ _EDGE_KIND_BY_SITE_KIND: dict[str, Literal["references", "inherits_from", "decorates"]] = {
185
+ "name": "references",
186
+ "annotation": "references",
187
+ "base": "inherits_from",
188
+ "decorator": "decorates",
189
+ }
190
+
191
+
154
192
  class PyrightSession:
155
193
  def __init__( # noqa: PLR0913 - knobs are tested lifecycle boundaries.
156
194
  self,
@@ -484,7 +522,7 @@ class PyrightSession:
484
522
  },
485
523
  )
486
524
  try:
487
- accumulators: dict[tuple[str, str], _ReferenceEdgeAccumulator] = {}
525
+ accumulators: dict[tuple[str, str, str], _ReferenceEdgeAccumulator] = {}
488
526
  lookup_cache: dict[
489
527
  tuple[str, str, str, int, int, int, int], tuple[list[str], bool]
490
528
  ] = {}
@@ -518,6 +556,7 @@ class PyrightSession:
518
556
  deadline=deadline,
519
557
  )
520
558
  saw_external = saw_external or fallback_external
559
+ candidate_ids = _filter_relation_candidates(site, candidate_ids)
521
560
  except LspTimeoutError as exc:
522
561
  self._record_finding(
523
562
  FINDING_PYRIGHT_REFERENCE_RESOLUTION_TIMEOUT,
@@ -568,7 +607,13 @@ class PyrightSession:
568
607
  },
569
608
  self._budgeted_timeout(deadline),
570
609
  )
571
- return self._target_ids_from_locations(result)
610
+ # Relation sites (base/decorator) resolve to precise entities only:
611
+ # the module-id coarse fallback would mint nonsense facts like
612
+ # "class inherits_from module" for aliased bases.
613
+ return self._target_ids_from_locations(
614
+ result,
615
+ precise_only=site.kind in ("base", "decorator"),
616
+ )
572
617
 
573
618
  def _deadline_for_file(self, path: Path) -> float:
574
619
  return self._file_deadlines.setdefault(
@@ -591,19 +636,32 @@ class PyrightSession:
591
636
  def _file_budget_expired(self, deadline: float) -> bool:
592
637
  return deadline - time.monotonic() <= 0
593
638
 
594
- def _target_ids_from_locations(self, result: object) -> tuple[list[str], bool]:
639
+ def _target_ids_from_locations(
640
+ self,
641
+ result: object,
642
+ *,
643
+ precise_only: bool = False,
644
+ ) -> tuple[list[str], bool]:
595
645
  locations = result if isinstance(result, list) else [result]
596
646
  candidate_ids: set[str] = set()
597
647
  saw_external = False
598
648
  for location in locations:
599
- target_id, external = self._target_id_from_location(location)
649
+ target_id, external = self._target_id_from_location(
650
+ location,
651
+ precise_only=precise_only,
652
+ )
600
653
  if external:
601
654
  saw_external = True
602
655
  if target_id is not None:
603
656
  candidate_ids.add(target_id)
604
657
  return sorted(candidate_ids), saw_external
605
658
 
606
- def _target_id_from_location(self, location: object) -> tuple[str | None, bool]:
659
+ def _target_id_from_location(
660
+ self,
661
+ location: object,
662
+ *,
663
+ precise_only: bool = False,
664
+ ) -> tuple[str | None, bool]:
607
665
  if not isinstance(location, dict):
608
666
  return None, False
609
667
  raw_uri = location.get("uri")
@@ -625,6 +683,8 @@ class PyrightSession:
625
683
  key = _range_start_key(raw_range)
626
684
  if key is not None and key in target_index.entity_by_name_position:
627
685
  return target_index.entity_by_name_position[key], False
686
+ if precise_only:
687
+ return None, False
628
688
  return target_index.module_id, False
629
689
 
630
690
  def _ensure_process(self) -> bool:
@@ -711,14 +771,7 @@ class PyrightSession:
711
771
  preexec_fn=preexec_fn, # noqa: PLW1509
712
772
  )
713
773
  except OSError as exc:
714
- self._run_state.disabled = True
715
- self._record_finding(
716
- FINDING_PYRIGHT_INSTALL_FAILURE,
717
- "pyright-langserver failed to start",
718
- executable=executable,
719
- error=str(exc),
720
- )
721
- return False
774
+ return self._handle_spawn_oserror(exc, executable)
722
775
 
723
776
  self._process = process
724
777
  self._start_stderr_drain(process)
@@ -745,8 +798,57 @@ class PyrightSession:
745
798
  process.kill()
746
799
  process.wait(timeout=2)
747
800
  return False
801
+ # A clean spawn + handshake clears any accumulated transient-deferral
802
+ # pressure: the per-UID resource squeeze that caused earlier EAGAINs has
803
+ # eased, so the run is healthy again.
804
+ self._run_state.consecutive_spawn_deferrals = 0
748
805
  return True
749
806
 
807
+ def _handle_spawn_oserror(self, exc: OSError, executable: str) -> bool:
808
+ """Triage a ``subprocess.Popen`` failure into transient vs. permanent.
809
+
810
+ ``EAGAIN``/``ENOMEM``/``EMFILE``/``ENFILE`` are *transient*
811
+ resource-pressure errors: a busy host momentarily out of process slots,
812
+ memory, or file descriptors. The spawn is deferred — ``self._process``
813
+ stays ``None`` and ``disabled`` is left unset, so the next file retries a
814
+ fresh spawn — and only a sustained run of deferrals
815
+ (``MAX_CONSECUTIVE_SPAWN_DEFERRALS``) gives up. Any other errno (notably
816
+ ``ENOENT``/``EACCES``) is a genuine, permanent install defect and
817
+ disables pyright for the rest of the run.
818
+ """
819
+ if exc.errno in _TRANSIENT_SPAWN_ERRNOS:
820
+ self._run_state.consecutive_spawn_deferrals += 1
821
+ if self._run_state.consecutive_spawn_deferrals > MAX_CONSECUTIVE_SPAWN_DEFERRALS:
822
+ self._run_state.disabled = True
823
+ self._record_finding(
824
+ FINDING_PYRIGHT_RESOURCE_EXHAUSTED,
825
+ "pyright-langserver persistently unavailable under resource "
826
+ "pressure; skipping call resolution",
827
+ executable=executable,
828
+ consecutive_spawn_deferrals=self._run_state.consecutive_spawn_deferrals,
829
+ error=str(exc),
830
+ )
831
+ return False
832
+ # Emit one finding per pressure *episode* (the 0 -> 1 transition),
833
+ # not one per deferred file, so a busy run is not buried in findings.
834
+ if self._run_state.consecutive_spawn_deferrals == 1:
835
+ self._record_finding(
836
+ FINDING_PYRIGHT_SPAWN_DEFERRED,
837
+ "pyright-langserver spawn deferred under resource pressure; "
838
+ "will retry on subsequent files",
839
+ executable=executable,
840
+ error=str(exc),
841
+ )
842
+ return False
843
+ self._run_state.disabled = True
844
+ self._record_finding(
845
+ FINDING_PYRIGHT_INSTALL_FAILURE,
846
+ "pyright-langserver failed to start",
847
+ executable=executable,
848
+ error=str(exc),
849
+ )
850
+ return False
851
+
750
852
  def _initialize(self) -> None:
751
853
  result = self._request(
752
854
  "initialize",
@@ -1143,17 +1245,31 @@ def _has_overload_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> boo
1143
1245
 
1144
1246
 
1145
1247
  def _merge_reference_site(
1146
- accumulators: dict[tuple[str, str], _ReferenceEdgeAccumulator],
1248
+ accumulators: dict[tuple[str, str, str], _ReferenceEdgeAccumulator],
1147
1249
  site: ReferenceSite,
1148
1250
  candidate_ids: Sequence[str],
1149
1251
  ) -> None:
1252
+ """Fold one resolved site into the per-file edge accumulators.
1253
+
1254
+ The site kind selects the edge kind (``_EDGE_KIND_BY_SITE_KIND``).
1255
+ ``decorator`` sites invert direction: the site owner is the *decorated*
1256
+ entity, but the stored edge reads ``decorator decorates decorated``
1257
+ (ADR-051: from_id = decorator entity, to_id = decorated entity), so the
1258
+ resolved candidate becomes ``from_id``. Ambiguous candidates therefore
1259
+ list alternative decorators (from-side) rather than alternative targets.
1260
+ """
1150
1261
  sorted_candidates = sorted(set(candidate_ids))
1151
- to_id = sorted_candidates[0]
1152
- key = (site.from_id, to_id)
1262
+ edge_kind = _EDGE_KIND_BY_SITE_KIND[site.kind]
1263
+ if site.kind == "decorator":
1264
+ from_id, to_id = sorted_candidates[0], site.from_id
1265
+ else:
1266
+ from_id, to_id = site.from_id, sorted_candidates[0]
1267
+ key = (edge_kind, from_id, to_id)
1153
1268
  existing = accumulators.get(key)
1154
1269
  if existing is None:
1155
1270
  accumulators[key] = _ReferenceEdgeAccumulator(
1156
- from_id=site.from_id,
1271
+ kind=edge_kind,
1272
+ from_id=from_id,
1157
1273
  to_id=to_id,
1158
1274
  source_byte_start=site.source_byte_start,
1159
1275
  source_byte_end=site.source_byte_end,
@@ -1169,6 +1285,22 @@ def _merge_reference_site(
1169
1285
  existing.source_byte_end = site.source_byte_end
1170
1286
 
1171
1287
 
1288
+ def _filter_relation_candidates(site: ReferenceSite, candidate_ids: list[str]) -> list[str]:
1289
+ """Apply the relation-site target discipline (Rust derives/implements parity).
1290
+
1291
+ ``inherits_from`` targets must be class entities — a base name resolving
1292
+ to a function (factory alias, shadowing ``def``) is dropped rather than
1293
+ stored as a class-inherits-function fact, mirroring the Rust resolver's
1294
+ ``rust:trait:`` kind filter. Both relation kinds drop self-edges
1295
+ (``class X(X)`` resolving the in-definition name to itself).
1296
+ """
1297
+ if site.kind == "base":
1298
+ candidate_ids = [cid for cid in candidate_ids if cid.startswith("python:class:")]
1299
+ if site.kind in ("base", "decorator"):
1300
+ candidate_ids = [cid for cid in candidate_ids if cid != site.from_id]
1301
+ return candidate_ids
1302
+
1303
+
1172
1304
  def _reference_lookup_cache_key(
1173
1305
  site: ReferenceSite,
1174
1306
  source_bytes: bytes,
@@ -1186,7 +1318,7 @@ def _reference_lookup_cache_key(
1186
1318
 
1187
1319
 
1188
1320
  def _sorted_reference_accumulators(
1189
- accumulators: dict[tuple[str, str], _ReferenceEdgeAccumulator],
1321
+ accumulators: dict[tuple[str, str, str], _ReferenceEdgeAccumulator],
1190
1322
  ) -> list[_ReferenceEdgeAccumulator]:
1191
1323
  return sorted(
1192
1324
  accumulators.values(),
@@ -1204,7 +1336,7 @@ def _reference_accumulator_to_edge(
1204
1336
  ) -> ReferencesRawEdge:
1205
1337
  candidates = sorted(accumulator.candidates)
1206
1338
  edge: ReferencesRawEdge = {
1207
- "kind": "references",
1339
+ "kind": accumulator.kind,
1208
1340
  "from_id": accumulator.from_id,
1209
1341
  "to_id": accumulator.to_id,
1210
1342
  "source_byte_start": accumulator.source_byte_start,
@@ -19,9 +19,9 @@ the same bare Python ``__qualname__`` semantics that Wardline stores in its
19
19
  dotted module path elsewhere; ADR-018 requires cross-product joins to translate
20
20
  between those shapes instead of comparing strings directly.
21
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).
22
+ ``FunctionDef``, ``AsyncFunctionDef``, and ``ClassDef`` are all emitted
23
+ entities (class entities since WP3); every one of the three also acts as
24
+ a parent scope for qualname reconstruction.
25
25
  """
26
26
 
27
27
  from __future__ import annotations
@@ -10,7 +10,12 @@ if TYPE_CHECKING:
10
10
  from loomweave_plugin_python.call_resolver import Finding
11
11
 
12
12
 
13
- ReferenceSiteKind = Literal["name", "annotation"]
13
+ # ``name``/``annotation`` resolve into ``references`` edges; ``base`` and
14
+ # ``decorator`` are relation sites resolving into ``inherits_from`` /
15
+ # ``decorates`` edges respectively (clarion-43416be550). All four ride the
16
+ # same pyright resolution machinery (external-skip, per-file cap, lookup
17
+ # cache); the site kind selects the emitted edge kind.
18
+ ReferenceSiteKind = Literal["name", "annotation", "base", "decorator"]
14
19
 
15
20
 
16
21
  @dataclass(frozen=True)
@@ -30,7 +35,16 @@ class ReferencesEdgeProperties(TypedDict):
30
35
 
31
36
 
32
37
  class ReferencesRawEdge(TypedDict):
33
- kind: Literal["references"]
38
+ """Anchored edge produced by the reference-site resolution pass.
39
+
40
+ Despite the name (historic — B.5* shipped ``references`` alone), this is
41
+ the wire shape for all three site-derived edge kinds. ``candidates`` in
42
+ ``properties`` is present only on ambiguous edges; for ``decorates`` the
43
+ candidates are alternative *from*-side decorator entities (direction is
44
+ inverted relative to the site owner).
45
+ """
46
+
47
+ kind: Literal["references", "inherits_from", "decorates"]
34
48
  from_id: str
35
49
  to_id: str
36
50
  source_byte_start: int
@@ -34,7 +34,7 @@ from loomweave_plugin_python.pyright_session import PyrightRunState, PyrightSess
34
34
  from loomweave_plugin_python.stdout_guard import install_stdio
35
35
  from loomweave_plugin_python.wardline_descriptor import WardlineVocabulary, load_wardline_descriptor
36
36
 
37
- ONTOLOGY_VERSION = "0.7.0"
37
+ ONTOLOGY_VERSION = "0.8.0"
38
38
 
39
39
  # Plugin-side Content-Length sanity cap. Matches the host's ADR-021 §2b
40
40
  # default (8 MiB) so the plugin never emits a frame the host would kill us
@@ -4,7 +4,7 @@ This module deliberately reads descriptor files without importing Wardline.
4
4
  Wardline remains authoritative for the vocabulary; Loomweave records only the
5
5
  source-observed decorator facts it can derive from that descriptor.
6
6
 
7
- Two contract details below (``PROJECT_DESCRIPTOR_PATH`` and the descriptor
7
+ Two contract details below (``PROJECT_DESCRIPTOR_PATHS`` and the descriptor
8
8
  ``version`` semantics) are Loomweave-side assumptions pending Wardline's
9
9
  "Pre-Rust core hardening" Task B, which has not yet published the canonical
10
10
  project-local descriptor location or the ``schema: wardline.vocabulary/v1``
@@ -28,7 +28,18 @@ import yaml
28
28
  # location and descriptor-version semantics are not yet pinned by Wardline.
29
29
  # Tracked: filigree clarion-6ab5668d82.
30
30
  EXPECTED_DESCRIPTOR_VERSION = "wardline-generic-2"
31
- PROJECT_DESCRIPTOR_PATH = Path(".wardline/vocabulary.yaml")
31
+
32
+ # Weft store consolidation (ADR-046): sibling runtime state lives under the
33
+ # shared ``.weft/<member>/`` dotdir, so the Wardline descriptor is read only from
34
+ # the consolidated ``.weft/wardline/`` location. There is no fallback to the
35
+ # pre-consolidation ``.wardline/`` path: after the coordinated cutover every
36
+ # sibling is at ``.weft/`` by construction, so a descriptor found only on the
37
+ # legacy path means a mis-sequenced cutover; resolving it would silently bind a
38
+ # stale dir. Instead the project descriptor reads as absent and the loader falls
39
+ # through to the package descriptor (a loud, visible signal). Loomweave never
40
+ # writes a sibling's subtree — this is read-only.
41
+ WEFT_DESCRIPTOR_PATH = Path(".weft/wardline/vocabulary.yaml")
42
+ PROJECT_DESCRIPTOR_PATHS = (WEFT_DESCRIPTOR_PATH,)
32
43
 
33
44
  DescriptorSource = Literal["project", "package"]
34
45
  DescriptorStatus = Literal["enabled", "version_skew", "absent"]
@@ -97,13 +108,17 @@ def load_wardline_descriptor(project_root: Path | None) -> WardlineDescriptorSta
97
108
  def _read_project_descriptor(project_root: Path | None) -> str | None:
98
109
  if project_root is None:
99
110
  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
111
+ # Read only the consolidated .weft/wardline/ location (ADR-046); the
112
+ # pre-consolidation .wardline/ path is not consulted.
113
+ for relative in PROJECT_DESCRIPTOR_PATHS:
114
+ path = project_root / relative
115
+ if not path.is_file():
116
+ continue
117
+ try:
118
+ return path.read_text(encoding="utf-8")
119
+ except OSError:
120
+ return None
121
+ return None
107
122
 
108
123
 
109
124
  def _read_package_descriptor() -> str | None:
@@ -1,7 +1,7 @@
1
1
  [plugin]
2
2
  name = "loomweave-plugin-python"
3
3
  plugin_id = "python"
4
- version = "1.0.0"
4
+ version = "1.2.0"
5
5
  protocol_version = "1.0"
6
6
  # Bare basename per ADR-021 §Layer 1 + WP2 scrub commit eb0a41d — the host
7
7
  # refuses manifests whose `executable` carries any path component.
@@ -25,6 +25,11 @@ wardline_aware = true
25
25
  # v0.1 rejects `true` at initialize with LMWV-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY.
26
26
  reads_outside_project_root = false
27
27
 
28
+ # Declaring this sub-table marks the plugin as a language-server host: it spawns
29
+ # pyright-langserver (Node). The host detects this and leaves RLIMIT_NPROC
30
+ # uncapped for the plugin — RLIMIT_NPROC is per-UID-global and would otherwise
31
+ # fail pyright's fork() with EAGAIN on a busy workstation (see ADR-021,
32
+ # "process-count control", and host::effective_max_nproc).
28
33
  [capabilities.runtime.pyright]
29
34
  pin = "1.1.409"
30
35
 
@@ -32,19 +37,23 @@ pin = "1.1.409"
32
37
  # Sprint 2 B.2: classes + modules join the kind set. B.3 (ADR-026) adds
33
38
  # the first edge kind, `contains`. B.4* adds scan-time `calls` edges.
34
39
  # B.5* adds scan-time `references` edges. Phase 3 Task 3 adds scan-time
35
- # `imports` candidate edges.
40
+ # `imports` candidate edges. clarion-43416be550 adds scan-time
41
+ # `inherits_from` (subclass → base) + `decorates` (decorator → decorated)
42
+ # relation edges, resolved through the same pyright reference machinery.
36
43
  entity_kinds = ["function", "class", "module"]
37
- edge_kinds = ["contains", "calls", "references", "imports"]
44
+ edge_kinds = ["contains", "calls", "references", "imports", "inherits_from", "decorates"]
38
45
  # Per ADR-022: uppercase `LMWV-{PLUGIN_ID_UPPER}-`. Reserved at parse
39
46
  # against the LMWV-INFRA-* and LMWV-FACT-* namespaces.
40
47
  rule_id_prefix = "LMWV-PY-"
41
48
  # Bumps per ADR-027 when the entity/edge/rule set shifts. Phase 3 Task 3 is a
42
- # MINOR bump (additive `imports` edge kind). NOTE: ADR-007's summary-cache key
43
- # is the 5-tuple (entity_id, content_hash, prompt_template_id, model_tier,
44
- # guidance_fingerprint) — ontology_version is handshake-validation, NOT a
45
- # cache-key component. New edge rows miss the cache by component-1 of
46
- # the 5-tuple organically (no edges live in the 5-tuple yet anyway).
47
- ontology_version = "0.7.0"
49
+ # MINOR bump (additive `imports` edge kind); 0.8.0 is the additive
50
+ # `inherits_from` + `decorates` MINOR bump (clarion-43416be550). NOTE:
51
+ # ADR-007's summary-cache key is the 5-tuple (entity_id, content_hash,
52
+ # prompt_template_id, model_tier, guidance_fingerprint) — ontology_version is
53
+ # handshake-validation, NOT a cache-key component. New edge rows miss the
54
+ # cache by component-1 of the 5-tuple organically (no edges live in the
55
+ # 5-tuple yet anyway).
56
+ ontology_version = "0.8.0"
48
57
 
49
58
  [integrations.wardline]
50
59
  expected_descriptor_version = "wardline-generic-2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loomweave-plugin-python
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Loomweave Python language plugin — v1.0 release
5
5
  Author-email: John Morrissey <qacona@gmail.com>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -0,0 +1,17 @@
1
+ loomweave_plugin_python/__init__.py,sha256=XS_Benpj-Em19GvyBlzkxZN4irB4JyXjbNcDwpaqmxE,95
2
+ loomweave_plugin_python/__main__.py,sha256=oy1S1Ru7WdX6jcAVhsqNI25LxAbQq3jHBlETBt20fyM,376
3
+ loomweave_plugin_python/call_resolver.py,sha256=V6w5AEmWgjkKuWdgUPJTXStOavmmbyvjP0PQOeT3xkU,1692
4
+ loomweave_plugin_python/entity_id.py,sha256=l7_5yyh6WVH9xIzrQVCDb2GmBMhovOVmuQezcZIVf8k,2691
5
+ loomweave_plugin_python/extractor.py,sha256=3S98y0CTjapmNnkU_-E5ySUXDeGyXjzQQsIZGhxB3HM,56261
6
+ loomweave_plugin_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ loomweave_plugin_python/pyright_session.py,sha256=QWgXlOSgmN29iZ059sJE6hIbogi2mSCR3aTMFITXoyY,67579
8
+ loomweave_plugin_python/qualname.py,sha256=gk6N2e4tMvufnPgpVZLv--c9_9gW5y9YvJIwUArffFg,2102
9
+ loomweave_plugin_python/reference_resolver.py,sha256=hfcKX3WcDL26vEq7Ahdouq7WBs7mqCNy-rBvl8t97iI,2698
10
+ loomweave_plugin_python/server.py,sha256=_MdaoRE1D-4VMvqLw79JyIvBmyqpU540Grk9FIBXSZg,12329
11
+ loomweave_plugin_python/stdout_guard.py,sha256=tUzcXyvXuy3-dXB81_xCNk8KWE3mqtsWSge81KPDhKg,2007
12
+ loomweave_plugin_python/wardline_descriptor.py,sha256=9rU-DhN1USpsHW9hS6YeWFRgGL4xgBeG41Yr1LfK-M4,8136
13
+ loomweave_plugin_python-1.2.0.data/data/share/loomweave/plugins/python/plugin.toml,sha256=H6rj0AKyM4FaXSHiseFKLpjz86i-yOpWwEsHEE13ios,3580
14
+ loomweave_plugin_python-1.2.0.dist-info/METADATA,sha256=ISJLdfmtVv2iRluZlTWF_M56_-_9jgztQsXRBM9XVOA,2751
15
+ loomweave_plugin_python-1.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ loomweave_plugin_python-1.2.0.dist-info/entry_points.txt,sha256=X1DQOYU5o1z2lBOJoh9JgQR5pg9fd_Yd8KozTHdfZqA,82
17
+ loomweave_plugin_python-1.2.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- loomweave_plugin_python/__init__.py,sha256=wmv6wivp3tnzIGHWWG9eO2JiGwxYZW9uqvggUibOLlA,95
2
- loomweave_plugin_python/__main__.py,sha256=oy1S1Ru7WdX6jcAVhsqNI25LxAbQq3jHBlETBt20fyM,376
3
- loomweave_plugin_python/call_resolver.py,sha256=V6w5AEmWgjkKuWdgUPJTXStOavmmbyvjP0PQOeT3xkU,1692
4
- loomweave_plugin_python/entity_id.py,sha256=l7_5yyh6WVH9xIzrQVCDb2GmBMhovOVmuQezcZIVf8k,2691
5
- loomweave_plugin_python/extractor.py,sha256=niapsUWBbHlQXI17eYY6teJoQB4XxF-Nh2fcqa7dvro,48316
6
- loomweave_plugin_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- loomweave_plugin_python/pyright_session.py,sha256=0qXZFTzprefLYEaM7wMP6DjylR_ej9XqDbngQKIkvS4,60871
8
- loomweave_plugin_python/qualname.py,sha256=7JVETZnQkgU1FxPbyTmzSVZDBm_2SrXGZlqUOjB51W0,2090
9
- loomweave_plugin_python/reference_resolver.py,sha256=7r2QsM9tRWeYHeRD62-jBvByGkdLC-0AWYhhgKNBgV4,1871
10
- loomweave_plugin_python/server.py,sha256=UARexfFun59sOpFSMWP7t6baLcOoi98Thbc_Gq6JQ3Q,12329
11
- loomweave_plugin_python/stdout_guard.py,sha256=tUzcXyvXuy3-dXB81_xCNk8KWE3mqtsWSge81KPDhKg,2007
12
- loomweave_plugin_python/wardline_descriptor.py,sha256=49yYtIUdUfv4FGKyINql5yEZ2JTOUkl8sJXF3J6fPF8,7197
13
- loomweave_plugin_python-1.0.0.data/data/share/loomweave/plugins/python/plugin.toml,sha256=0H-_lBqflTkXsm_sqySmS6j0s7pGDQdDntkf1TcCRz0,2908
14
- loomweave_plugin_python-1.0.0.dist-info/METADATA,sha256=UzoJmnhdF1IOPaIuIBRvwAVxlfkg37zoss3xUBCYem0,2751
15
- loomweave_plugin_python-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
- loomweave_plugin_python-1.0.0.dist-info/entry_points.txt,sha256=X1DQOYU5o1z2lBOJoh9JgQR5pg9fd_Yd8KozTHdfZqA,82
17
- loomweave_plugin_python-1.0.0.dist-info/RECORD,,