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.
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/.gitignore +27 -5
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/PKG-INFO +1 -1
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/plugin.toml +18 -9
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/pyproject.toml +1 -1
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/__init__.py +1 -1
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/extractor.py +199 -29
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/pyright_session.py +154 -22
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/qualname.py +3 -3
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/reference_resolver.py +16 -2
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/server.py +1 -1
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/wardline_descriptor.py +24 -9
- loomweave_plugin_python-1.1.0/tests/test_dogfood_uniqueness.py +35 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_extractor.py +450 -2
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_package.py +13 -5
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_pyright_session.py +528 -1
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_round_trip.py +1 -1
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_server.py +6 -6
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_wardline_descriptor.py +68 -8
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/uv.lock +1 -1
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/README.md +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/__main__.py +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/call_resolver.py +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/entity_id.py +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/py.typed +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/src/loomweave_plugin_python/stdout_guard.py +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/__init__.py +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_entity_id.py +0 -0
- {loomweave_plugin_python-1.0.0 → loomweave_plugin_python-1.1.0}/tests/test_qualname.py +0 -0
- {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
|
|
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,7 +1,7 @@
|
|
|
1
1
|
[plugin]
|
|
2
2
|
name = "loomweave-plugin-python"
|
|
3
3
|
plugin_id = "python"
|
|
4
|
-
version = "1.
|
|
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).
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
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"
|
|
@@ -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``,
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|