loomweave-plugin-python 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/.gitignore +27 -5
  2. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/PKG-INFO +1 -1
  3. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/plugin.toml +18 -9
  4. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/pyproject.toml +1 -1
  5. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/__init__.py +1 -1
  6. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/extractor.py +199 -29
  7. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/pyright_session.py +154 -22
  8. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/qualname.py +3 -3
  9. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/reference_resolver.py +16 -2
  10. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/server.py +1 -1
  11. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/wardline_descriptor.py +24 -9
  12. loomweave_plugin_python-1.1.0/tests/test_dogfood_uniqueness.py +35 -0
  13. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_extractor.py +450 -2
  14. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_package.py +13 -5
  15. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_pyright_session.py +528 -1
  16. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_round_trip.py +1 -1
  17. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_server.py +6 -6
  18. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_wardline_descriptor.py +68 -8
  19. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/uv.lock +1 -1
  20. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/README.md +0 -0
  21. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/__main__.py +0 -0
  22. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/call_resolver.py +0 -0
  23. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/entity_id.py +0 -0
  24. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/py.typed +0 -0
  25. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/stdout_guard.py +0 -0
  26. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/__init__.py +0 -0
  27. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_entity_id.py +0 -0
  28. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_qualname.py +0 -0
  29. {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_stdout_guard.py +0 -0
@@ -1,9 +1,13 @@
1
1
  .filigree/
2
+ .filigree.conf
3
+ .weft/filigree/
2
4
  /target
3
5
  **/*.rs.bk
4
6
  Cargo.lock.bak
5
7
 
6
- # SQLite working files (project-level .loomweave/ is tracked per ADR-005)
8
+ # SQLite working files (project-level .weft/loomweave/ is a regenerable cache,
9
+ # ignored — ADR-005 as reversed by C1/weft-d822a7de2d; see the .weft/loomweave/
10
+ # entries below and the blanket .weft/ rule)
7
11
  *.db-journal
8
12
  *.db-wal
9
13
 
@@ -24,6 +28,10 @@ htmlcov/
24
28
 
25
29
  .env
26
30
 
31
+ # Codex/Claude harness debug logs at the repo root (one was committed by the
32
+ # authoring agent in c475e90; weft-ac59e8e730).
33
+ /error.log
34
+
27
35
  # Smoke-test result artifacts (per-run; archived separately at tag-cut)
28
36
  tests/e2e/external-operator-smoke-results-*.md
29
37
 
@@ -32,17 +40,31 @@ tests/e2e/external-operator-smoke-results-*.md
32
40
 
33
41
  # Loomweave runtime artifacts — the index DB, per-project instance fingerprint,
34
42
  # and analyze lock change on every run, so they are not tracked
35
- # (see .loomweave/.gitignore).
36
- .loomweave/loomweave.db
37
- .loomweave/instance_id
38
- .loomweave/loomweave.lock
43
+ # (see .weft/loomweave/.gitignore).
44
+ .weft/loomweave/loomweave.db
45
+ .weft/loomweave/instance_id
46
+ .weft/loomweave/loomweave.lock
47
+ .weft/loomweave/ephemeral.port
39
48
 
40
49
  # Documentation site build output (mkdocs `site_dir`, web/mkdocs.yml).
41
50
  /site-build/
42
51
 
52
+ # Wardline scan output written by `wardline scan` (raw, regenerated per run).
53
+ /findings.jsonl
54
+
43
55
  # Filigree-managed docs — a running filigree process rewrites its managed
44
56
  # instruction blocks in these every session; untracked to avoid diff churn
45
57
  # (filigree regenerates them on demand).
46
58
  AGENTS.md
47
59
  CLAUDE.md
48
60
  .agents/skills/filigree-workflow/SKILL.md
61
+
62
+ # Filigree issue tracker
63
+ .weft/
64
+
65
+ # Developer config / local tooling — not part of the solution
66
+ .agents/
67
+
68
+ # Local tool/federation config — not part of the solution
69
+ .mcp.json
70
+ loomweave.yaml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loomweave-plugin-python
3
- Version: 1.0.0
3
+ Version: 1.1.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
@@ -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.1.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"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "loomweave-plugin-python"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "Loomweave Python language plugin — v1.0 release"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """loomweave-plugin-python — Python language plugin for Loomweave."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.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