loomweave-plugin-python 1.5.1__py3-none-any.whl → 1.6.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.5.1"
3
+ __version__ = "1.6.0"
@@ -115,6 +115,9 @@ class FacetCoverage:
115
115
  class CallResolutionResult:
116
116
  edges: list[CallsRawEdge] = field(default_factory=list)
117
117
  unresolved_call_sites_total: int = 0
118
+ # Bare unshadowed-builtin calls dropped before persistence; disclosed so
119
+ # the omission is visible in run stats (clarion-8a862d8f7e).
120
+ unresolved_call_sites_skipped_builtin_total: int = 0
118
121
  unresolved_call_sites: list[UnresolvedCallSite] = field(default_factory=list)
119
122
  pyright_query_latency_ms: list[int] = field(default_factory=list)
120
123
  pyright_index_parse_latency_ms: list[int] = field(default_factory=list)
@@ -260,6 +260,7 @@ class ImportsEdgeProperties(TypedDict):
260
260
  @dataclass
261
261
  class ExtractionStats:
262
262
  unresolved_call_sites_total: int = 0
263
+ unresolved_call_sites_skipped_builtin_total: int = 0
263
264
  unresolved_call_sites: list[UnresolvedCallSite] = field(default_factory=list)
264
265
  reference_sites_total: int = 0
265
266
  references_resolved_total: int = 0
@@ -307,6 +308,9 @@ class ExtractionStats:
307
308
  calls_coverage=calls.coverage,
308
309
  references_coverage=references.coverage,
309
310
  unresolved_call_sites_total=calls.unresolved_call_sites_total,
311
+ unresolved_call_sites_skipped_builtin_total=(
312
+ calls.unresolved_call_sites_skipped_builtin_total
313
+ ),
310
314
  unresolved_call_sites=calls.unresolved_call_sites,
311
315
  reference_sites_total=references.reference_sites_total,
312
316
  references_resolved_total=references.references_resolved_total,
@@ -14,7 +14,12 @@ is decided by WHICH catch-site caught the failure, never by message text:
14
14
  host re-dispatches when the interpreter fingerprint changes.
15
15
  - ``pyright_timeout`` -- this file's own per-query or file budget expired
16
16
  while pyright stayed alive. Self-inflicted (``collateral=False``). A single
17
- timeout does not restart: the process is slow, not dead. A streak of
17
+ timeout does not restart: the process is slow, not dead. In the calls pass
18
+ a per-query timeout with file budget remaining skips ONLY that function
19
+ (its sites disclosed as unresolved, the abandoned computation cancelled via
20
+ ``$/cancelRequest``) and continues -- one pathological function no longer
21
+ forfeits the rest of the file (clarion-bf3986e301); the pass aborts, and
22
+ the facet degrades, only when the FILE budget itself is spent. A streak of
18
23
  ``MAX_CONSECUTIVE_TIMEOUT_FILES`` consecutive files whose calls pass timed
19
24
  out is read as a wedged-but-alive process and restarted once, charged to
20
25
  the run-level ``MAX_PYRIGHT_RESTARTS_PER_RUN`` budget; the streak's files
@@ -245,6 +250,15 @@ PYRIGHT_SHUTDOWN_TIMEOUT_SECS = 5.0
245
250
  # handing a small one a budget it will never use.
246
251
  PYRIGHT_FILE_TIMEOUT_BASE_SECS = 10.0
247
252
  PYRIGHT_FILE_TIMEOUT_PER_FUNCTION_SECS = 0.25
253
+ # Second scaling axis (clarion-bf3986e301): pyright's evaluation work tracks
254
+ # the amount of CODE it must type-check, not the def count. elspeth's
255
+ # tool_batch.py (2,354 lines, 17 defs -- one a 1,199-line nested closure) got
256
+ # base + 17 * 0.25 ~= 14 s under the per-function-only budget: starved the
257
+ # same way the flat budget starved ML files (clarion-7f527d3d32). The budget
258
+ # takes ``max(per_function * n_functions, per_line * n_lines)`` -- for the
259
+ # common shape (many small defs) the per-function term still governs, and the
260
+ # cap (90 s) still bounds the monster files.
261
+ PYRIGHT_FILE_TIMEOUT_PER_LINE_SECS = 0.03
248
262
  # The host watchdog (``DEFAULT_PLUGIN_FILE_TIMEOUT`` in
249
263
  # crates/loomweave-cli/src/analyze.rs) kills an ``analyze_file`` call at 120s.
250
264
  # Stay well under it so the plugin always hands back the partial evidence it
@@ -276,6 +290,21 @@ FILE_DEADLINE_TERMINAL_SAFETY_MARGIN_SECS = 15.0
276
290
  # A respawn with less real headroom than this is pointless: pyright's
277
291
  # initialize handshake rarely completes faster on a machine that just lost it.
278
292
  MIN_RESPAWN_HEADROOM_SECS = 5.0
293
+ # The raising sites spell this label; the calls-pass skip logic keys on it to
294
+ # tell "the FILE's budget is spent" (abort the pass) from "one query's grant
295
+ # expired" (skip the function and continue, clarion-bf3986e301).
296
+ FILE_BUDGET_EXPIRED_METHOD = "analyze_file budget"
297
+ # Write bound for the fire-and-forget ``$/cancelRequest`` notify after an
298
+ # abandoned query: a healthy pyright drains it in microseconds; a full pipe
299
+ # means cancellation is hopeless anyway and the suppressed write timeout
300
+ # leaves the file budget to backstop.
301
+ CANCEL_WRITE_DEADLINE_SECS = 2.0
302
+ # Last stderr bytes attached to a FINDING_PYRIGHT_RESTART's metadata
303
+ # (clarion-bf3986e301 direction 3): enough to carry a crash reason (OOM kill,
304
+ # node stack trace tail) without bloating the findings channel. The in-memory
305
+ # tail keeps ``STDERR_TAIL_LIMIT`` for future probes; findings carry the tail
306
+ # end of it.
307
+ STDERR_TAIL_FINDING_LIMIT_BYTES = 512
279
308
 
280
309
 
281
310
  def resolve_host_file_watchdog_secs(environ: Mapping[str, str] | None = None) -> float:
@@ -328,7 +357,7 @@ _TYPE_PARAMETER_NODE_NAMES = frozenset({"ParamSpec", "TypeVar", "TypeVarTuple"})
328
357
 
329
358
 
330
359
  if TYPE_CHECKING:
331
- from collections.abc import Callable, Mapping, Sequence
360
+ from collections.abc import Callable, Iterable, Mapping, Sequence
332
361
 
333
362
 
334
363
  class LspTimeoutError(TimeoutError):
@@ -460,6 +489,7 @@ class PyrightSession:
460
489
  call_timeout_secs: float = PYRIGHT_CALL_TIMEOUT_SECS,
461
490
  file_timeout_base_secs: float = PYRIGHT_FILE_TIMEOUT_BASE_SECS,
462
491
  file_timeout_per_function_secs: float = PYRIGHT_FILE_TIMEOUT_PER_FUNCTION_SECS,
492
+ file_timeout_per_line_secs: float = PYRIGHT_FILE_TIMEOUT_PER_LINE_SECS,
463
493
  file_timeout_cap_secs: float = PYRIGHT_FILE_TIMEOUT_CAP_SECS,
464
494
  max_restarts_per_run: int = MAX_PYRIGHT_RESTARTS_PER_RUN,
465
495
  max_total_restarts_per_run: int = MAX_TOTAL_PYRIGHT_RESTARTS_PER_RUN,
@@ -496,6 +526,7 @@ class PyrightSession:
496
526
  self.call_timeout_secs = call_timeout_secs
497
527
  self.file_timeout_base_secs = file_timeout_base_secs
498
528
  self.file_timeout_per_function_secs = file_timeout_per_function_secs
529
+ self.file_timeout_per_line_secs = file_timeout_per_line_secs
499
530
  self.file_timeout_cap_secs = file_timeout_cap_secs
500
531
  self.max_restarts_per_run = max_restarts_per_run
501
532
  self.max_total_restarts_per_run = max_total_restarts_per_run
@@ -511,6 +542,12 @@ class PyrightSession:
511
542
  self._stderr_thread: threading.Thread | None = None
512
543
  self._stderr_tail = bytearray()
513
544
  self._next_id = 1
545
+ # The id of the request currently awaiting its response, for
546
+ # ``$/cancelRequest`` after an abandoned per-query timeout
547
+ # (clarion-bf3986e301). Set by ``_request`` before the write, cleared
548
+ # on a completed round-trip; a timeout leaves it set for the skip
549
+ # path to consume.
550
+ self._request_id_in_flight: int | None = None
514
551
  self._findings: list[Finding] = []
515
552
  self._function_indexes: dict[Path, _FunctionIndex] = {}
516
553
  self._index_parse_latency_ms: list[int] = []
@@ -637,15 +674,21 @@ class PyrightSession:
637
674
  coverage=self._unavailable_coverage(),
638
675
  )
639
676
 
640
- deadline = self._deadline_for_file(path, len(index.functions))
677
+ deadline = self._deadline_for_file(
678
+ path,
679
+ len(index.functions),
680
+ index.source.count("\n") + 1,
681
+ )
641
682
  latency_started = time.perf_counter()
642
683
  coverage = FacetCoverage()
643
684
  try:
644
- edges, unresolved, unresolved_sites, abort = self._resolve_with_pyright(
645
- path,
646
- index,
647
- requested,
648
- deadline,
685
+ edges, unresolved, skipped_builtin, unresolved_sites, skipped_timeouts, abort = (
686
+ self._resolve_with_pyright(
687
+ path,
688
+ index,
689
+ requested,
690
+ deadline,
691
+ )
649
692
  )
650
693
  # These two arms remain reachable only from the ``didOpen`` notify
651
694
  # that precedes the per-function loop: nothing was visited yet, so
@@ -659,6 +702,7 @@ class PyrightSession:
659
702
  )
660
703
  edges = []
661
704
  unresolved = ast_call_sites_total
705
+ skipped_builtin = 0
662
706
  unresolved_sites = []
663
707
  coverage = FacetCoverage.degraded("pyright_timeout", transient=True)
664
708
  except (LspTransportClosedError, BrokenPipeError, OSError) as exc:
@@ -667,9 +711,19 @@ class PyrightSession:
667
711
  self._record_arrival_death(str(exc))
668
712
  edges = []
669
713
  unresolved = ast_call_sites_total
714
+ skipped_builtin = 0
670
715
  unresolved_sites = []
671
716
  coverage = FacetCoverage.degraded("pyright_restarting", transient=True, collateral=True)
672
717
  else:
718
+ if abort is None and skipped_timeouts and skipped_timeouts == len(requested):
719
+ # EVERY function timed out: nothing was genuinely answered, so
720
+ # a ``complete`` claim would be a lie -- and it would reset the
721
+ # consecutive-timeout streak the ADR-057 wedge breaker needs to
722
+ # spot a wedged-but-alive server that survives per-function
723
+ # skip-and-continue. Partial timeouts (some functions resolved)
724
+ # stay complete with the skipped functions disclosed as
725
+ # unresolved sites + findings.
726
+ coverage = FacetCoverage.degraded("pyright_timeout", transient=True)
673
727
  if abort is not None:
674
728
  if abort.reason == "pyright_timeout":
675
729
  self._record_finding(
@@ -692,6 +746,7 @@ class PyrightSession:
692
746
  return CallResolutionResult(
693
747
  edges=edges,
694
748
  unresolved_call_sites_total=unresolved,
749
+ unresolved_call_sites_skipped_builtin_total=skipped_builtin,
695
750
  unresolved_call_sites=unresolved_sites,
696
751
  pyright_query_latency_ms=[latency_ms],
697
752
  pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
@@ -814,7 +869,11 @@ class PyrightSession:
814
869
  coverage=self._unavailable_coverage(),
815
870
  )
816
871
 
817
- deadline = self._deadline_for_file(path, len(index.functions))
872
+ deadline = self._deadline_for_file(
873
+ path,
874
+ len(index.functions),
875
+ index.source.count("\n") + 1,
876
+ )
818
877
  latency_started = time.perf_counter()
819
878
  coverage = FacetCoverage()
820
879
  self._reference_pass_timed_out = False
@@ -881,7 +940,7 @@ class PyrightSession:
881
940
  index: _FunctionIndex,
882
941
  functions: Sequence[_FunctionInfo],
883
942
  deadline: float,
884
- ) -> tuple[list[CallsRawEdge], int, list[UnresolvedCallSite], _CallPassAbort | None]:
943
+ ) -> tuple[list[CallsRawEdge], int, int, list[UnresolvedCallSite], int, _CallPassAbort | None]:
885
944
  uri = path.as_uri()
886
945
  self._notify(
887
946
  "textDocument/didOpen",
@@ -898,9 +957,18 @@ class PyrightSession:
898
957
  try:
899
958
  edges: list[CallsRawEdge] = []
900
959
  unresolved_total = 0
960
+ skipped_builtin_total = 0
901
961
  unresolved_sites: list[UnresolvedCallSite] = []
902
962
  abort: _CallPassAbort | None = None
963
+ skipped_function_timeouts = 0
903
964
  remaining_start = len(functions)
965
+ # A bare call to an unshadowed builtin (``len(x)``, ``str(v)``,
966
+ # ``isinstance(...)``) can never resolve to a project entity, so
967
+ # it is neither an unresolved site worth persisting nor evidence
968
+ # of a resolution gap. Same shadowing-aware oracle the references
969
+ # pass uses; a star import disables it for the file
970
+ # (clarion-8a862d8f7e).
971
+ builtin_names = frozenset(_reference_fast_path_names(index.tree))
904
972
  for position, function in enumerate(functions):
905
973
  try:
906
974
  function_edges, resolved_ranges = self._resolve_function_with_pyright(
@@ -910,20 +978,62 @@ class PyrightSession:
910
978
  deadline,
911
979
  )
912
980
  except LspTimeoutError as exc:
913
- abort = _CallPassAbort("pyright_timeout", exc.method, str(exc))
914
- remaining_start = position
915
- break
981
+ if (
982
+ isinstance(exc, LspWriteTimeoutError)
983
+ or exc.method == FILE_BUDGET_EXPIRED_METHOD
984
+ or self._file_budget_expired(deadline)
985
+ ):
986
+ # The FILE's window is spent (or the pipe itself timed
987
+ # out, where a "next function" would block the same
988
+ # way): abort with the evidence gathered so far.
989
+ abort = _CallPassAbort("pyright_timeout", exc.method, str(exc))
990
+ remaining_start = position
991
+ break
992
+ # One query's grant expired with file budget remaining:
993
+ # this function alone is unresolved. Cancel the abandoned
994
+ # computation so pyright can serve the next function, and
995
+ # continue the pass (clarion-bf3986e301) -- one
996
+ # pathological function no longer forfeits the file.
997
+ self._cancel_in_flight_request()
998
+ self._record_finding(
999
+ FINDING_PYRIGHT_CALL_RESOLUTION_TIMEOUT,
1000
+ f"pyright query timed out: {exc.method}; skipped "
1001
+ f"{function.entity_id} and continued the calls pass",
1002
+ method=exc.method,
1003
+ function=function.entity_id,
1004
+ skipped_function=True,
1005
+ )
1006
+ counted, skipped = _unresolved_call_site_total_for_function(
1007
+ function,
1008
+ set(),
1009
+ builtin_names,
1010
+ )
1011
+ unresolved_total += counted
1012
+ skipped_builtin_total += skipped
1013
+ unresolved_sites.extend(
1014
+ _unresolved_call_sites_for_function(index, function, set(), builtin_names),
1015
+ )
1016
+ skipped_function_timeouts += 1
1017
+ continue
916
1018
  except (LspTransportClosedError, BrokenPipeError, OSError) as exc:
917
1019
  abort = _CallPassAbort(self._in_flight_failure_reason(exc), None, str(exc))
918
1020
  remaining_start = position
919
1021
  break
920
1022
  edges.extend(function_edges)
921
- unresolved_total += _unresolved_call_site_total_for_function(
1023
+ counted, skipped = _unresolved_call_site_total_for_function(
922
1024
  function,
923
1025
  resolved_ranges,
1026
+ builtin_names,
924
1027
  )
1028
+ unresolved_total += counted
1029
+ skipped_builtin_total += skipped
925
1030
  unresolved_sites.extend(
926
- _unresolved_call_sites_for_function(index, function, resolved_ranges),
1031
+ _unresolved_call_sites_for_function(
1032
+ index,
1033
+ function,
1034
+ resolved_ranges,
1035
+ builtin_names,
1036
+ ),
927
1037
  )
928
1038
  if abort is not None:
929
1039
  # INVARIANT: this fallback is only correct because a function is
@@ -938,11 +1048,24 @@ class PyrightSession:
938
1048
  # its sites unresolved); track "functions with edges appended"
939
1049
  # explicitly instead of relying on ``position`` if you add one.
940
1050
  for function in functions[remaining_start:]:
941
- unresolved_total += _unresolved_call_site_total_for_function(function, set())
1051
+ counted, skipped = _unresolved_call_site_total_for_function(
1052
+ function,
1053
+ set(),
1054
+ builtin_names,
1055
+ )
1056
+ unresolved_total += counted
1057
+ skipped_builtin_total += skipped
942
1058
  unresolved_sites.extend(
943
- _unresolved_call_sites_for_function(index, function, set()),
1059
+ _unresolved_call_sites_for_function(index, function, set(), builtin_names),
944
1060
  )
945
- return edges, unresolved_total, unresolved_sites, abort
1061
+ return (
1062
+ edges,
1063
+ unresolved_total,
1064
+ skipped_builtin_total,
1065
+ unresolved_sites,
1066
+ skipped_function_timeouts,
1067
+ abort,
1068
+ )
946
1069
  finally:
947
1070
  # The pipe may be the very thing that just failed: a notify over a
948
1071
  # dead transport must not raise and discard the evidence above.
@@ -1024,7 +1147,7 @@ class PyrightSession:
1024
1147
  edge["properties"] = {"candidates": candidate_ids}
1025
1148
  function_edges.append(edge)
1026
1149
 
1027
- return function_edges, set(grouped)
1150
+ return function_edges, _resolved_call_site_keys(function, grouped)
1028
1151
 
1029
1152
  def _resolve_references_with_pyright(
1030
1153
  self,
@@ -1184,11 +1307,11 @@ class PyrightSession:
1184
1307
  """The clock every deadline lives on (monotonic; overridable in tests)."""
1185
1308
  return time.monotonic()
1186
1309
 
1187
- def _deadline_for_file(self, path: Path, n_functions: int) -> float:
1310
+ def _deadline_for_file(self, path: Path, n_functions: int, n_lines: int) -> float:
1188
1311
  existing = self._file_deadlines.get(path)
1189
1312
  if existing is not None:
1190
1313
  return existing
1191
- deadline = self._now() + self._file_timeout_for(n_functions)
1314
+ deadline = self._now() + self._file_timeout_for(n_functions, n_lines)
1192
1315
  anchor = self._file_started_at.get(path)
1193
1316
  if anchor is not None:
1194
1317
  # A spawn paid before this point (first file, or a restart) has
@@ -1214,15 +1337,17 @@ class PyrightSession:
1214
1337
  return math.inf
1215
1338
  return self._watchdog_ceiling_for(anchor) - self._now()
1216
1339
 
1217
- def _file_timeout_for(self, n_functions: int) -> float:
1218
- budget = self.file_timeout_base_secs + self.file_timeout_per_function_secs * n_functions
1219
- return min(budget, self.file_timeout_cap_secs)
1340
+ def _file_timeout_for(self, n_functions: int, n_lines: int) -> float:
1341
+ scaled = max(
1342
+ self.file_timeout_per_function_secs * n_functions,
1343
+ self.file_timeout_per_line_secs * n_lines,
1344
+ )
1345
+ return min(self.file_timeout_base_secs + scaled, self.file_timeout_cap_secs)
1220
1346
 
1221
1347
  def _budgeted_timeout(self, deadline: float) -> float:
1222
1348
  remaining = deadline - self._now()
1223
1349
  if remaining <= 0:
1224
- method = "analyze_file budget"
1225
- raise LspTimeoutError(method)
1350
+ raise LspTimeoutError(FILE_BUDGET_EXPIRED_METHOD)
1226
1351
  return min(self.call_timeout_secs, remaining)
1227
1352
 
1228
1353
  def _write_deadline(self, deadline: float) -> float:
@@ -1242,8 +1367,7 @@ class PyrightSession:
1242
1367
 
1243
1368
  def _ensure_file_budget(self, deadline: float) -> None:
1244
1369
  if self._file_budget_expired(deadline):
1245
- method = "analyze_file budget"
1246
- raise LspTimeoutError(method)
1370
+ raise LspTimeoutError(FILE_BUDGET_EXPIRED_METHOD)
1247
1371
 
1248
1372
  def _file_budget_expired(self, deadline: float) -> bool:
1249
1373
  return deadline - self._now() <= 0
@@ -1384,6 +1508,7 @@ class PyrightSession:
1384
1508
  file_attributed_restart_count=self._run_state.file_attributed_restart_count,
1385
1509
  attribution="arrival",
1386
1510
  reason=reason,
1511
+ stderr_tail=self._stderr_tail_snapshot(),
1387
1512
  )
1388
1513
 
1389
1514
  def _charge_run_level_restart(self, reason: str, *, attribution: str) -> bool:
@@ -1495,8 +1620,14 @@ class PyrightSession:
1495
1620
  """
1496
1621
  state = self._run_state
1497
1622
  state.file_attributed_restart_count += 1
1623
+ # Snapshot before any restart path terminates the process, which
1624
+ # clears the tail (clarion-bf3986e301: attribute the NEXT crash class
1625
+ # without a manual probe).
1626
+ stderr_tail = self._stderr_tail_snapshot()
1498
1627
  if self._total_restart_budget_exceeded():
1499
- self._record_in_flight_restart_finding(reason, outcome="cap_exceeded")
1628
+ self._record_in_flight_restart_finding(
1629
+ reason, outcome="cap_exceeded", stderr_tail=stderr_tail
1630
+ )
1500
1631
  self._trip_total_restart_cap(reason)
1501
1632
  return
1502
1633
  outcome = self._restart_process_for_file(path)
@@ -1505,9 +1636,11 @@ class PyrightSession:
1505
1636
  # spawn path already triaged it -- a transient deferral (next file
1506
1637
  # retries) or a permanent disable with its OWN ``disabled_reason``.
1507
1638
  state.file_attributed_respawn_failure_count += 1
1508
- self._record_in_flight_restart_finding(reason, outcome=outcome)
1639
+ self._record_in_flight_restart_finding(reason, outcome=outcome, stderr_tail=stderr_tail)
1509
1640
 
1510
- def _record_in_flight_restart_finding(self, reason: str, *, outcome: str) -> None:
1641
+ def _record_in_flight_restart_finding(
1642
+ self, reason: str, *, outcome: str, stderr_tail: str
1643
+ ) -> None:
1511
1644
  self._record_finding(
1512
1645
  FINDING_PYRIGHT_RESTART,
1513
1646
  f"pyright subprocess died during this file's request; {outcome}",
@@ -1516,6 +1649,7 @@ class PyrightSession:
1516
1649
  attribution="in_flight",
1517
1650
  outcome=outcome,
1518
1651
  reason=reason,
1652
+ stderr_tail=stderr_tail,
1519
1653
  )
1520
1654
 
1521
1655
  def _restart_process_for_file(self, path: Path) -> _RestartOutcome:
@@ -1563,9 +1697,20 @@ class PyrightSession:
1563
1697
  process = self._process
1564
1698
  self._process = None
1565
1699
  if process is not None and process.poll() is None:
1700
+ # The venv's ``pyright-langserver`` is a Python wrapper around a
1701
+ # ``node`` grandchild; killing the wrapper alone leaves node
1702
+ # behind, reparented to pid 1 and -- when wedged, which is why we
1703
+ # are killing it -- spinning forever (clarion-ebf404dfbb). Walk
1704
+ # the descendants BEFORE the kill (they are reparented the moment
1705
+ # the wrapper dies), then kill wrapper and descendants.
1706
+ pid = getattr(process, "pid", None)
1707
+ descendants = _descendant_pids(pid) if isinstance(pid, int) else []
1566
1708
  process.kill()
1567
1709
  with contextlib.suppress(subprocess.TimeoutExpired):
1568
1710
  process.wait(timeout=2)
1711
+ for pid in descendants:
1712
+ with contextlib.suppress(ProcessLookupError, PermissionError):
1713
+ os.kill(pid, signal.SIGKILL)
1569
1714
  if self._stderr_thread is not None:
1570
1715
  self._stderr_thread.join(timeout=2)
1571
1716
  self._stderr_thread = None
@@ -1818,6 +1963,7 @@ class PyrightSession:
1818
1963
  # One deadline for the whole request, write included: the write is the
1819
1964
  # half that blocked on elspeth (clarion-e3ab8a4131).
1820
1965
  deadline = self._now() + timeout_secs
1966
+ self._request_id_in_flight = request_id
1821
1967
  self._write_message(
1822
1968
  {
1823
1969
  "jsonrpc": "2.0",
@@ -1843,6 +1989,7 @@ class PyrightSession:
1843
1989
  continue
1844
1990
  if response.get("id") != request_id:
1845
1991
  continue
1992
+ self._request_id_in_flight = None
1846
1993
  if "error" in response:
1847
1994
  raise LspTransportClosedError(str(response["error"]))
1848
1995
  process.poll()
@@ -2001,8 +2148,13 @@ class PyrightSession:
2001
2148
  if index.parse_status == "syntax_error":
2002
2149
  return None
2003
2150
  key = _range_start_key(raw_selection)
2004
- if key is not None and key in index.by_name_position:
2005
- return index.by_name_position[key].entity_id
2151
+ # Functions AND classes: pyright reports ``Name(...)`` naming a class
2152
+ # as an outgoing call whose target item IS the class (selection range
2153
+ # on the class name) -- never its ``__init__`` -- so an instantiation
2154
+ # is a ``calls`` edge to the class entity (clarion-e5224c3aff). The
2155
+ # function-only index dropped every such target as unresolved.
2156
+ if key is not None and key in index.entity_by_name_position:
2157
+ return index.entity_by_name_position[key]
2006
2158
  return _containing_function_id(index, raw_selection)
2007
2159
 
2008
2160
  def _is_internal_project_path(self, path: Path) -> bool:
@@ -2022,6 +2174,42 @@ class PyrightSession:
2022
2174
  self._index_parse_latency_ms.append(index.parse_latency_ms)
2023
2175
  return index
2024
2176
 
2177
+ def _cancel_in_flight_request(self) -> None:
2178
+ """Fire-and-forget ``$/cancelRequest`` for an abandoned query.
2179
+
2180
+ pyright honours LSP cancellation between evaluation steps, so telling
2181
+ it the caller stopped waiting lets it drop the pathological
2182
+ computation and answer the NEXT function's query instead of chewing
2183
+ through the rest of the file's budget behind our back
2184
+ (clarion-bf3986e301). Best-effort by design: a transport already
2185
+ broken, or a pipe already full, means the file budget backstops --
2186
+ every failure here is suppressed. (A partial-write timeout still
2187
+ invalidates the frame inside ``_write_message``, so a corrupted
2188
+ stream is put down rather than limped on.)
2189
+ """
2190
+ request_id = self._request_id_in_flight
2191
+ self._request_id_in_flight = None
2192
+ if request_id is None:
2193
+ return
2194
+ with contextlib.suppress(
2195
+ LspTimeoutError, LspTransportClosedError, BrokenPipeError, OSError
2196
+ ):
2197
+ self._notify(
2198
+ "$/cancelRequest",
2199
+ {"id": request_id},
2200
+ deadline=self._now() + CANCEL_WRITE_DEADLINE_SECS,
2201
+ )
2202
+
2203
+ def _stderr_tail_snapshot(self) -> str:
2204
+ """The last stderr bytes of the CURRENT process, finding-sized.
2205
+
2206
+ Callers must snapshot BEFORE ``_terminate_process``, which clears the
2207
+ tail so two processes' stderr never mix in one finding.
2208
+ """
2209
+ return bytes(self._stderr_tail[-STDERR_TAIL_FINDING_LIMIT_BYTES:]).decode(
2210
+ "utf-8", "replace"
2211
+ )
2212
+
2025
2213
  def _record_finding(self, subcode: str, message: str, **metadata: object) -> None:
2026
2214
  self._findings.append(
2027
2215
  {
@@ -2375,6 +2563,38 @@ def _reference_accumulator_to_edge(
2375
2563
  return edge
2376
2564
 
2377
2565
 
2566
+ def _descendant_pids(pid: int) -> list[int]:
2567
+ """Every live descendant of ``pid`` via ``/proc/<pid>/task/*/children``.
2568
+
2569
+ Linux only (``CONFIG_PROC_CHILDREN``, on by default); elsewhere, or once
2570
+ the process is gone, the walk yields nothing and only the direct child
2571
+ is killed -- the pre-existing behaviour.
2572
+ """
2573
+ found: list[int] = []
2574
+ frontier = [pid]
2575
+ while frontier:
2576
+ parent = frontier.pop()
2577
+ task_dir = Path("/proc") / str(parent) / "task"
2578
+ try:
2579
+ tasks = list(task_dir.iterdir())
2580
+ except OSError:
2581
+ continue
2582
+ for task in tasks:
2583
+ try:
2584
+ listed = (task / "children").read_text(encoding="ascii").split()
2585
+ except OSError:
2586
+ continue
2587
+ for raw in listed:
2588
+ try:
2589
+ child = int(raw)
2590
+ except ValueError:
2591
+ continue
2592
+ if child not in found:
2593
+ found.append(child)
2594
+ frontier.append(child)
2595
+ return found
2596
+
2597
+
2378
2598
  def _function_call_sites(
2379
2599
  node: ast.FunctionDef | ast.AsyncFunctionDef,
2380
2600
  source_lines: Sequence[str],
@@ -2385,27 +2605,88 @@ def _function_call_sites(
2385
2605
  return visitor.call_sites
2386
2606
 
2387
2607
 
2608
+ def _is_builtin_call_site(call_site: _CallSite, builtin_names: frozenset[str]) -> bool:
2609
+ """A bare ``Name(...)`` call whose name is an unshadowed builtin in this file.
2610
+
2611
+ Attribute calls (``pytest.raises``) are deliberately NOT matched: they may
2612
+ resolve to an external module (counted by the external skip) or, under a
2613
+ rebinding, to a project entity -- only pyright can tell.
2614
+ """
2615
+ return call_site.callee_expr in builtin_names
2616
+
2617
+
2618
+ def _resolved_call_site_keys(
2619
+ function: _FunctionInfo,
2620
+ resolved_ranges: Iterable[tuple[int, int, int, int]],
2621
+ ) -> set[tuple[int, int, int, int]]:
2622
+ """The AST call-site keys the resolved ranges account for.
2623
+
2624
+ pyright anchors an outgoing call's ``fromRange`` on the callee's terminal
2625
+ token (``helper`` in ``self.helper()``), while ``_CallSite`` spans the
2626
+ whole callee expression (``self.helper``). Matching the two by equality
2627
+ only ever succeeded for bare ``Name()`` calls, so every resolved attribute
2628
+ call was also reported as an unresolved site -- breaking the partition
2629
+ (resolved + unresolved + skipped builtins == AST call sites) the host and
2630
+ ``entity_callers_list``'s ``traversal_complete`` depend on. A range
2631
+ resolves the SMALLEST call site containing it: in ``Svc().helper()`` the
2632
+ ``Svc`` token claims the inner site and ``helper`` the outer one.
2633
+ """
2634
+ sites_by_line: dict[int, list[_CallSite]] = {}
2635
+ for site in function.call_sites:
2636
+ for line in range(site.line, site.end_line + 1):
2637
+ sites_by_line.setdefault(line, []).append(site)
2638
+ resolved: set[tuple[int, int, int, int]] = set()
2639
+ for start_line, start_character, end_line, end_character in resolved_ranges:
2640
+ best: _CallSite | None = None
2641
+ for site in sites_by_line.get(start_line, ()):
2642
+ if (site.line, site.character) > (start_line, start_character):
2643
+ continue
2644
+ if (end_line, end_character) > (site.end_line, site.end_character):
2645
+ continue
2646
+ if best is None or (site.end_line - site.line, site.end_character - site.character) < (
2647
+ best.end_line - best.line,
2648
+ best.end_character - best.character,
2649
+ ):
2650
+ best = site
2651
+ if best is not None:
2652
+ resolved.add((best.line, best.character, best.end_line, best.end_character))
2653
+ return resolved
2654
+
2655
+
2388
2656
  def _unresolved_call_site_total_for_function(
2389
2657
  function: _FunctionInfo,
2390
2658
  resolved_ranges: set[tuple[int, int, int, int]],
2391
- ) -> int:
2392
- return sum(
2393
- 1
2394
- for call_site in function.call_sites
2395
- if (
2659
+ builtin_names: frozenset[str] = frozenset(),
2660
+ ) -> tuple[int, int]:
2661
+ """``(unresolved, skipped_builtin)`` for one function's call sites.
2662
+
2663
+ The two are disjoint and, with the resolved sites, partition the AST call
2664
+ sites; the host treats the unresolved-site LIST as authoritative only when
2665
+ its length equals ``unresolved``, so a builtin skip must leave both.
2666
+ """
2667
+ unresolved = 0
2668
+ skipped_builtin = 0
2669
+ for call_site in function.call_sites:
2670
+ range_key = (
2396
2671
  call_site.line,
2397
2672
  call_site.character,
2398
2673
  call_site.end_line,
2399
2674
  call_site.end_character,
2400
2675
  )
2401
- not in resolved_ranges
2402
- )
2676
+ if range_key in resolved_ranges:
2677
+ continue
2678
+ if _is_builtin_call_site(call_site, builtin_names):
2679
+ skipped_builtin += 1
2680
+ continue
2681
+ unresolved += 1
2682
+ return unresolved, skipped_builtin
2403
2683
 
2404
2684
 
2405
2685
  def _unresolved_call_sites_for_function(
2406
2686
  index: _FunctionIndex,
2407
2687
  function: _FunctionInfo,
2408
2688
  resolved_ranges: set[tuple[int, int, int, int]],
2689
+ builtin_names: frozenset[str] = frozenset(),
2409
2690
  ) -> list[UnresolvedCallSite]:
2410
2691
  unresolved: list[UnresolvedCallSite] = []
2411
2692
  for site_ordinal, call_site in enumerate(function.call_sites):
@@ -2417,6 +2698,8 @@ def _unresolved_call_sites_for_function(
2417
2698
  )
2418
2699
  if range_key in resolved_ranges:
2419
2700
  continue
2701
+ if _is_builtin_call_site(call_site, builtin_names):
2702
+ continue
2420
2703
  if len(call_site.callee_expr.encode("utf-8")) > MAX_UNRESOLVED_CALLEE_EXPR_BYTES:
2421
2704
  continue
2422
2705
  start_byte = _position_to_byte(index, call_site.line, call_site.character)
@@ -35,7 +35,7 @@ from loomweave_plugin_python.pyright_session import PyrightRunState, PyrightSess
35
35
  from loomweave_plugin_python.stdout_guard import install_stdio
36
36
  from loomweave_plugin_python.wardline_descriptor import WardlineVocabulary, load_wardline_descriptor
37
37
 
38
- ONTOLOGY_VERSION = "0.12.0"
38
+ ONTOLOGY_VERSION = "0.13.0"
39
39
 
40
40
  # Plugin-side Content-Length sanity cap. Matches the host's ADR-021 §2b
41
41
  # default (8 MiB) so the plugin never emits a frame the host would kill us
@@ -212,6 +212,7 @@ def handle_analyze_file(params: dict[str, Any], state: ServerState) -> dict[str,
212
212
  """Read the requested file, extract entities + edges, return AnalyzeFileResult shape."""
213
213
  empty_stats = {
214
214
  "unresolved_call_sites_total": 0,
215
+ "unresolved_call_sites_skipped_builtin_total": 0,
215
216
  "unresolved_call_sites": [],
216
217
  "reference_sites_total": 0,
217
218
  "references_resolved_total": 0,
@@ -258,6 +259,9 @@ def handle_analyze_file(params: dict[str, Any], state: ServerState) -> dict[str,
258
259
  state.pyright_files_since_restart = 0
259
260
  stats = {
260
261
  "unresolved_call_sites_total": result.stats.unresolved_call_sites_total,
262
+ "unresolved_call_sites_skipped_builtin_total": (
263
+ result.stats.unresolved_call_sites_skipped_builtin_total
264
+ ),
261
265
  "unresolved_call_sites": result.stats.unresolved_call_sites,
262
266
  "reference_sites_total": result.stats.reference_sites_total,
263
267
  "references_resolved_total": result.stats.references_resolved_total,
@@ -1,7 +1,7 @@
1
1
  [plugin]
2
2
  name = "loomweave-plugin-python"
3
3
  plugin_id = "python"
4
- version = "1.5.1"
4
+ version = "1.6.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.
@@ -57,13 +57,17 @@ rule_id_prefix = "LMWV-PY-"
57
57
  # runner-wrapper, `sys.argv`, and argparse `cli-command` tagging; 0.12.0
58
58
  # declares the complete static classifier set so supported-empty results are
59
59
  # distinguishable from unsupported classifiers and forces unchanged files to
60
- # re-dispatch. Classifier declarations do not create entity-tag rows. NOTE:
60
+ # re-dispatch. Classifier declarations do not create entity-tag rows. 0.13.0
61
+ # widens `calls` targets to class entities: an instantiation ``Name(...)``
62
+ # is a call to the class (clarion-e5224c3aff) -- a semantic widening of an
63
+ # existing kind, bumped so unchanged files re-dispatch and pick up the edges.
64
+ # NOTE:
61
65
  # ADR-007's summary-cache key is the 5-tuple (entity_id, content_hash,
62
66
  # prompt_template_id, model_tier, guidance_fingerprint) — ontology_version is
63
67
  # handshake-validation, NOT a cache-key component. New edge rows miss the
64
68
  # cache by component-1 of the 5-tuple organically (no edges live in the
65
69
  # 5-tuple yet anyway).
66
- ontology_version = "0.12.0"
70
+ ontology_version = "0.13.0"
67
71
  classifier_tags = [
68
72
  "cli-command",
69
73
  "data-model",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: loomweave-plugin-python
3
- Version: 1.5.1
3
+ Version: 1.6.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,18 @@
1
+ loomweave_plugin_python/__init__.py,sha256=5mhialMRwU5-O9sz_IX8_mGzsMX1RYvQJAwawhAXlr0,95
2
+ loomweave_plugin_python/__main__.py,sha256=oy1S1Ru7WdX6jcAVhsqNI25LxAbQq3jHBlETBt20fyM,376
3
+ loomweave_plugin_python/call_resolver.py,sha256=sc4b-h1CLcaPF1wu7CpQ5nDh75O7xaZokqe_PRS8bcE,5130
4
+ loomweave_plugin_python/entity_id.py,sha256=l7_5yyh6WVH9xIzrQVCDb2GmBMhovOVmuQezcZIVf8k,2691
5
+ loomweave_plugin_python/extractor.py,sha256=hGjyLvYGJF4XbyvAfwxnkfQQB6h4U0--uT-ndUwvbGE,102488
6
+ loomweave_plugin_python/interpreter.py,sha256=mWrxnNN6eMQUR0TUT4uxA5bzL48lyqR7KHgtEF7T8os,4320
7
+ loomweave_plugin_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ loomweave_plugin_python/pyright_session.py,sha256=Yf-5W8qWX1hC3kv1mjshrxOS0MGC1sKKivJG1r-gMK0,139286
9
+ loomweave_plugin_python/qualname.py,sha256=gk6N2e4tMvufnPgpVZLv--c9_9gW5y9YvJIwUArffFg,2102
10
+ loomweave_plugin_python/reference_resolver.py,sha256=yp9Ff59A130mEn5txCh7jpVcpjQjy1EmXFoUhdyA4as,2830
11
+ loomweave_plugin_python/server.py,sha256=wiTi98XaFa3LKo_OnGtRWpwM4UuOYXHePS6wwzu3FlM,14079
12
+ loomweave_plugin_python/stdout_guard.py,sha256=tUzcXyvXuy3-dXB81_xCNk8KWE3mqtsWSge81KPDhKg,2007
13
+ loomweave_plugin_python/wardline_descriptor.py,sha256=4TX7PCZLyYVI3dd4xT98ueNEcYpUd8OV0oyOJO0wseE,16354
14
+ loomweave_plugin_python-1.6.0.data/data/share/loomweave/plugins/python/plugin.toml,sha256=O0onhlYaXi3mdt7XBsuAnsXg2xYOSEEkpzFgreyZ4FE,5218
15
+ loomweave_plugin_python-1.6.0.dist-info/METADATA,sha256=CyALXx3Vp0cWUG6jlBff_rXB1F7x8l9LJMwgX5JrPpw,4885
16
+ loomweave_plugin_python-1.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ loomweave_plugin_python-1.6.0.dist-info/entry_points.txt,sha256=X1DQOYU5o1z2lBOJoh9JgQR5pg9fd_Yd8KozTHdfZqA,82
18
+ loomweave_plugin_python-1.6.0.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- loomweave_plugin_python/__init__.py,sha256=kMw1zndW_xa7Jd0dYOYPhgeFazYz1eeXt5CsZkl_-Us,95
2
- loomweave_plugin_python/__main__.py,sha256=oy1S1Ru7WdX6jcAVhsqNI25LxAbQq3jHBlETBt20fyM,376
3
- loomweave_plugin_python/call_resolver.py,sha256=JorafhgCDP8LlHJFcBiWbeU8_2HdBLtwsfgdDHZtUiM,4931
4
- loomweave_plugin_python/entity_id.py,sha256=l7_5yyh6WVH9xIzrQVCDb2GmBMhovOVmuQezcZIVf8k,2691
5
- loomweave_plugin_python/extractor.py,sha256=7sYCohPMvYV39WTO8gZW5iDqIEGqvoOVgHijbR148ng,102292
6
- loomweave_plugin_python/interpreter.py,sha256=mWrxnNN6eMQUR0TUT4uxA5bzL48lyqR7KHgtEF7T8os,4320
7
- loomweave_plugin_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- loomweave_plugin_python/pyright_session.py,sha256=VdtdHdzAQGGKf0x1p8kBhea_sdv4ISUqMHgNiEX0RKg,125134
9
- loomweave_plugin_python/qualname.py,sha256=gk6N2e4tMvufnPgpVZLv--c9_9gW5y9YvJIwUArffFg,2102
10
- loomweave_plugin_python/reference_resolver.py,sha256=yp9Ff59A130mEn5txCh7jpVcpjQjy1EmXFoUhdyA4as,2830
11
- loomweave_plugin_python/server.py,sha256=WBaiEQwrhw8iA8m5FyOuJQ_ydRLJJ1GIwa64DOFHQ1w,13884
12
- loomweave_plugin_python/stdout_guard.py,sha256=tUzcXyvXuy3-dXB81_xCNk8KWE3mqtsWSge81KPDhKg,2007
13
- loomweave_plugin_python/wardline_descriptor.py,sha256=4TX7PCZLyYVI3dd4xT98ueNEcYpUd8OV0oyOJO0wseE,16354
14
- loomweave_plugin_python-1.5.1.data/data/share/loomweave/plugins/python/plugin.toml,sha256=Zh4v74rc03Ha9BGIqJx2NuxP5ZM8tyGnWD6Kf_rCtF4,4981
15
- loomweave_plugin_python-1.5.1.dist-info/METADATA,sha256=kNzUfBMP5OgHwHSycUd6JIctPbRwLb2bNCxW8fCqtUg,4885
16
- loomweave_plugin_python-1.5.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
- loomweave_plugin_python-1.5.1.dist-info/entry_points.txt,sha256=X1DQOYU5o1z2lBOJoh9JgQR5pg9fd_Yd8KozTHdfZqA,82
18
- loomweave_plugin_python-1.5.1.dist-info/RECORD,,