loomweave-plugin-python 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1312 @@
1
+ """AST → entity extractor for the Python plugin (Sprint 2 / B.2).
2
+
3
+ Walks a parsed Python file and emits one ``module`` entity per file plus
4
+ one ``function`` entity per ``FunctionDef`` / ``AsyncFunctionDef`` and one
5
+ ``class`` entity per ``ClassDef``. It also emits anchored scan-time
6
+ ``imports``, ``calls``, and ``references`` candidate edges.
7
+
8
+ Entity shape matches the Rust host's ``RawEntity`` + ``RawSource``
9
+ contract (``crates/loomweave-core/src/plugin/host.rs:132-154``)::
10
+
11
+ {
12
+ "id": "python:function:...",
13
+ "kind": "function",
14
+ "qualified_name": "pkg.module.func",
15
+ "source": {
16
+ "file_path": "pkg/module.py",
17
+ "source_range": {
18
+ "start_line": 1, "start_col": 0,
19
+ "end_line": 3, "end_col": 4,
20
+ },
21
+ },
22
+ }
23
+
24
+ ``source.file_path`` lands in the host's path jail (canonicalised +
25
+ checked against ``project_root``); any other source-side fields flow
26
+ through ``RawSource.extra`` (serde flatten) and are bounded by
27
+ ``MAX_ENTITY_EXTRA_BYTES`` (64 KiB). ``qualified_name`` is the dotted
28
+ module prefix joined to Python's own ``__qualname__`` (reconstructed
29
+ per L7). The file_path passed on the wire may be absolute (what the
30
+ host sent) while the prefix used for qualified-name dotting can be the
31
+ relativised form — the two are decoupled via ``extract``'s
32
+ ``module_prefix_path`` kwarg.
33
+
34
+ Behaviour (B.2 §3 Q1 supersedes Sprint-1 UQ-WP3-11 for module entities):
35
+
36
+ - Every analyzed file produces exactly one ``module`` entity. Empty
37
+ files and comment-only files emit one with ``parse_status="ok"``.
38
+ Zero *function* entities for empty files still holds (UQ-WP3-11).
39
+ - ``SyntaxError`` during ``ast.parse`` → one degraded module entity
40
+ with ``parse_status="syntax_error"`` plus one stderr log line
41
+ (UQ-WP3-02). The run continues; WP4-era findings can later attach a
42
+ ``LMWV-PY-SYNTAX-ERROR`` annotation.
43
+ - Top-level ``__init__.py`` (where the dotted module name resolves to
44
+ ``""``) is skipped with stderr; the entity-ID assembler rejects an
45
+ empty ``canonical_qualified_name``.
46
+ - Paths starting with ``src/`` have the prefix stripped (UQ-WP3-05).
47
+ - ``pkg/__init__.py`` files yield qualified_names rooted at ``pkg``
48
+ (not ``pkg.__init__``) — UQ-WP3-06.
49
+
50
+ Module-entity ``source_range`` is a whole-file cover with ``end_col=0``
51
+ as a sentinel for module entities only — class and function entities
52
+ carry real ``ast.*.end_col_offset`` data, so consumers must NOT infer
53
+ column semantics by analogy across kinds.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import ast
59
+ import sys
60
+ import time
61
+ from dataclasses import dataclass, field
62
+ from pathlib import PurePosixPath
63
+ from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast
64
+
65
+ from loomweave_plugin_python.call_resolver import (
66
+ CallResolutionResult,
67
+ CallResolver,
68
+ CallsEdgeProperties,
69
+ Finding,
70
+ NoOpCallResolver,
71
+ UnresolvedCallSite,
72
+ )
73
+ from loomweave_plugin_python.entity_id import entity_id
74
+ from loomweave_plugin_python.qualname import reconstruct_qualname
75
+ from loomweave_plugin_python.reference_resolver import (
76
+ NoOpReferenceResolver,
77
+ ReferenceResolutionResult,
78
+ ReferenceResolver,
79
+ ReferencesEdgeProperties,
80
+ ReferenceSite,
81
+ )
82
+
83
+ if TYPE_CHECKING:
84
+ from loomweave_plugin_python.wardline_descriptor import WardlineVocabulary
85
+
86
+ _PLUGIN_ID = "python"
87
+ _NOOP_CALL_RESOLVER = NoOpCallResolver()
88
+ _NOOP_REFERENCE_RESOLVER = NoOpReferenceResolver()
89
+
90
+
91
+ class SourceRange(TypedDict):
92
+ start_line: int
93
+ start_col: int
94
+ end_line: int
95
+ end_col: int
96
+
97
+
98
+ class EntitySource(TypedDict):
99
+ file_path: str
100
+ source_range: SourceRange
101
+
102
+
103
+ class DefinitionSpan(TypedDict):
104
+ """Sub-ranges within a function/class entity (clarion-460def6a51).
105
+
106
+ ``decl_line`` is the line of the ``def``/``class`` keyword (Python 3.8+
107
+ ``node.lineno``). ``body_line_start`` is the line of the first body
108
+ statement (a docstring counts). The two ``decorator_*`` keys are present
109
+ only when the entity is decorated; ``decorator_line_start`` is the
110
+ topmost decorator line and matches the entity's expanded
111
+ ``source_range.start_line``. They let a reader explain *why* a given
112
+ line resolved to this entity (decorator vs declaration vs body) without
113
+ re-reading source.
114
+ """
115
+
116
+ decl_line: int
117
+ body_line_start: NotRequired[int]
118
+ decorator_line_start: NotRequired[int]
119
+ decorator_line_end: NotRequired[int]
120
+
121
+
122
+ # SEI signature schema version (ADR-038 REQ-C-01). Mirrors plugin.toml's
123
+ # `[signature] schema_version`; bumped when a per-kind shape changes
124
+ # incompatibly. The value is stamped into every emitted signature's ``v`` field
125
+ # so a consumer can detect a version change as a changed signature.
126
+ SIGNATURE_SCHEMA_VERSION = 1
127
+
128
+
129
+ class FunctionSignature(TypedDict):
130
+ """SEI signature for a ``function`` entity (ADR-038 REQ-C-01).
131
+
132
+ ``params`` is the ordered parameter list rendered as ``name`` or
133
+ ``name: annotation`` strings (positional-only, positional, ``*args``,
134
+ keyword-only, ``**kwargs`` in source order). ``return_ann`` is the unparsed
135
+ return annotation or ``None``.
136
+ """
137
+
138
+ v: int
139
+ params: list[str]
140
+ return_ann: str | None
141
+
142
+
143
+ class ClassSignature(TypedDict):
144
+ """SEI signature for a ``class`` entity (ADR-038 REQ-C-01): the unparsed
145
+ base-class expressions in declaration order."""
146
+
147
+ v: int
148
+ bases: list[str]
149
+
150
+
151
+ class WardlineDecoratorMetadata(TypedDict):
152
+ canonical_name: str
153
+ qualified_name: str
154
+ group: int
155
+ attrs: dict[str, str]
156
+ line: int
157
+
158
+
159
+ class WardlineEntityMetadata(TypedDict):
160
+ descriptor_version: str
161
+ confidence_basis: Literal["descriptor", "descriptor_version_skew"]
162
+ decorators: list[WardlineDecoratorMetadata]
163
+
164
+
165
+ class RawEntity(TypedDict):
166
+ """Wire shape matching the Rust host's RawEntity contract.
167
+
168
+ ``parse_status`` is set on module entities only and rides through the
169
+ host's ``serde(flatten) extra`` map. Class and function entities omit
170
+ it; the field is ``NotRequired`` to keep mypy --strict happy.
171
+
172
+ ``parent_id`` is a B.3 addition (ADR-026 decision 2): the dual-encoded
173
+ half of the parent/contains relationship. Omitted entirely for module
174
+ entities (they have no parent within the file); set on every
175
+ function/class entity.
176
+ """
177
+
178
+ id: str
179
+ kind: str # "function" | "class" | "module"; not narrowed to keep extension cheap.
180
+ qualified_name: str
181
+ source: EntitySource
182
+ parent_id: NotRequired[str]
183
+ parse_status: NotRequired[Literal["ok", "syntax_error"]]
184
+ # entity_context evidence (clarion-460def6a51). Set on function/class
185
+ # entities; omitted for modules. Rides the host's RawEntity `extra` flatten
186
+ # into `properties_json`, so no host or storage schema change is needed.
187
+ definition: NotRequired[DefinitionSpan]
188
+ # SEI signature (ADR-038 REQ-C-01 / Wave 1). A plugin-declared, versioned
189
+ # JSON object the core stores verbatim and compares by string equality as
190
+ # the matcher's move-case input. Set on function/class entities; omitted for
191
+ # modules (the move case abstains — fail closed). Typed top-level field on
192
+ # the host's RawEntity, not routed through `extra`.
193
+ signature: NotRequired[FunctionSignature | ClassSignature]
194
+ # WS5b catalogue/reachability categorisations. Typed top-level because the
195
+ # core denormalises these into `entity_tags`; unknown/empty means no signal.
196
+ tags: NotRequired[list[str]]
197
+ # Short natural-language text used by analyze-time semantic embeddings.
198
+ docstring: NotRequired[str]
199
+ # Wardline descriptor-backed source-observed decorator facts. Wardline owns
200
+ # the vocabulary; Loomweave stores only the annotation facts seen on entities.
201
+ wardline: NotRequired[WardlineEntityMetadata]
202
+
203
+
204
+ class RawEdge(TypedDict):
205
+ """Wire shape matching the Rust host's RawEdge contract (B.3 / ADR-026).
206
+
207
+ Source range fields are NotRequired and omitted entirely for structural
208
+ kinds (``contains``); anchored kinds (``calls``, etc.) include them when
209
+ the language reaches that part of the ontology in later sprints.
210
+ """
211
+
212
+ kind: str
213
+ from_id: str
214
+ to_id: str
215
+ source_byte_start: NotRequired[int]
216
+ source_byte_end: NotRequired[int]
217
+ confidence: NotRequired[Literal["resolved", "ambiguous", "inferred"]]
218
+ properties: NotRequired[CallsEdgeProperties | ReferencesEdgeProperties | ImportsEdgeProperties]
219
+
220
+
221
+ class ImportsEdgeProperties(TypedDict):
222
+ imported_name: str
223
+ import_style: Literal["import", "from_import"]
224
+ level: int
225
+ type_only: NotRequired[bool]
226
+ scope: NotRequired[Literal["function"]]
227
+
228
+
229
+ @dataclass
230
+ class ExtractionStats:
231
+ unresolved_call_sites_total: int = 0
232
+ unresolved_call_sites: list[UnresolvedCallSite] = field(default_factory=list)
233
+ reference_sites_total: int = 0
234
+ references_resolved_total: int = 0
235
+ references_skipped_external_total: int = 0
236
+ references_skipped_cap_total: int = 0
237
+ unresolved_reference_sites_total: int = 0
238
+ pyright_query_latency_ms: list[int] = field(default_factory=list)
239
+ pyright_index_parse_latency_ms: list[int] = field(default_factory=list)
240
+ extractor_parse_latency_ms: int = 0
241
+ findings: list[Finding] = field(default_factory=list)
242
+ duplicate_entities_dropped_total: int = 0
243
+
244
+ @classmethod
245
+ def from_resolution_results(
246
+ cls,
247
+ calls: CallResolutionResult,
248
+ references: ReferenceResolutionResult,
249
+ ) -> ExtractionStats:
250
+ return cls(
251
+ unresolved_call_sites_total=calls.unresolved_call_sites_total,
252
+ unresolved_call_sites=calls.unresolved_call_sites,
253
+ reference_sites_total=references.reference_sites_total,
254
+ references_resolved_total=references.references_resolved_total,
255
+ references_skipped_external_total=references.references_skipped_external_total,
256
+ references_skipped_cap_total=references.references_skipped_cap_total,
257
+ unresolved_reference_sites_total=references.unresolved_reference_sites_total,
258
+ pyright_query_latency_ms=[
259
+ *calls.pyright_query_latency_ms,
260
+ *references.pyright_query_latency_ms,
261
+ ],
262
+ pyright_index_parse_latency_ms=[
263
+ *calls.pyright_index_parse_latency_ms,
264
+ *references.pyright_index_parse_latency_ms,
265
+ ],
266
+ findings=[*calls.findings, *references.findings],
267
+ )
268
+
269
+
270
+ @dataclass
271
+ class ExtractResult:
272
+ entities: list[RawEntity]
273
+ edges: list[RawEdge]
274
+ stats: ExtractionStats
275
+
276
+
277
+ def _module_source_range(source: str) -> SourceRange:
278
+ """Whole-file cover for module entities (Q4 resolution, B.2 §3 Q4).
279
+
280
+ Uniform formula regardless of ``parse_status``: ``end_line =
281
+ source.count('\\n') + 1``, ``end_col = 0``. The ``end_col = 0`` value
282
+ is a sentinel for module entities only — it means "end-of-file," NOT
283
+ "column 0 of the last line." Class and function entities use real
284
+ ``ast.*.end_col_offset`` data; consumers must not infer column
285
+ semantics by analogy across kinds.
286
+ """
287
+ return {
288
+ "start_line": 1,
289
+ "start_col": 0,
290
+ "end_line": source.count("\n") + 1,
291
+ "end_col": 0,
292
+ }
293
+
294
+
295
+ def module_dotted_name(module_path: str) -> str:
296
+ """Derive the dotted module prefix from a root-relative source path.
297
+
298
+ Rules:
299
+ - Leading ``src/`` is stripped (UQ-WP3-05).
300
+ - The ``.py`` suffix is dropped.
301
+ - ``__init__`` filenames collapse to their containing package
302
+ (UQ-WP3-06: ``pkg/__init__.py`` → ``pkg``).
303
+ - Path separators become ``.``.
304
+
305
+ ``module_path`` itself remains unchanged; it's stored on the entity
306
+ as a property so WP4 can still find the file on disk.
307
+ """
308
+ parts = list(PurePosixPath(module_path).parts)
309
+ if parts and parts[0] == "src":
310
+ parts = parts[1:]
311
+ if parts:
312
+ last = parts[-1]
313
+ if last.endswith(".py"):
314
+ stem = last[:-3]
315
+ if stem == "__init__":
316
+ parts = parts[:-1]
317
+ else:
318
+ parts[-1] = stem
319
+ return ".".join(parts)
320
+
321
+
322
+ def _build_module_entity(
323
+ source: str,
324
+ dotted_module: str,
325
+ file_path: str,
326
+ parse_status: Literal["ok", "syntax_error"],
327
+ docstring: str | None = None,
328
+ ) -> RawEntity:
329
+ """Build the per-file module entity (Q1 + Q4 resolutions)."""
330
+ entity: RawEntity = {
331
+ "id": entity_id(_PLUGIN_ID, "module", dotted_module),
332
+ "kind": "module",
333
+ "qualified_name": dotted_module,
334
+ "source": {
335
+ "file_path": file_path,
336
+ "source_range": _module_source_range(source),
337
+ },
338
+ "parse_status": parse_status,
339
+ }
340
+ _attach_optional_entity_metadata(entity, docstring=docstring, tags=[])
341
+ return entity
342
+
343
+
344
+ def extract( # noqa: PLR0913 - resolver seams + optional Wardline vocabulary are caller-owned.
345
+ source: str,
346
+ file_path: str,
347
+ *,
348
+ module_prefix_path: str | None = None,
349
+ call_resolver: CallResolver = _NOOP_CALL_RESOLVER,
350
+ reference_resolver: ReferenceResolver = _NOOP_REFERENCE_RESOLVER,
351
+ wardline_vocabulary: WardlineVocabulary | None = None,
352
+ ) -> tuple[list[RawEntity], list[RawEdge]]:
353
+ result = extract_with_stats(
354
+ source,
355
+ file_path,
356
+ module_prefix_path=module_prefix_path,
357
+ call_resolver=call_resolver,
358
+ reference_resolver=reference_resolver,
359
+ wardline_vocabulary=wardline_vocabulary,
360
+ )
361
+ return result.entities, result.edges
362
+
363
+
364
+ def extract_with_stats( # noqa: PLR0913 - resolver seams + optional Wardline vocabulary are caller-owned.
365
+ source: str,
366
+ file_path: str,
367
+ *,
368
+ module_prefix_path: str | None = None,
369
+ call_resolver: CallResolver = _NOOP_CALL_RESOLVER,
370
+ reference_resolver: ReferenceResolver = _NOOP_REFERENCE_RESOLVER,
371
+ wardline_vocabulary: WardlineVocabulary | None = None,
372
+ ) -> ExtractResult:
373
+ """Return extracted entities/edges plus resolver observability stats.
374
+
375
+ Always emits exactly one module entity (B.2 Q1) prepended to the
376
+ entity list; functions and classes follow. B.3 also emits one
377
+ ``contains`` edge per non-module entity (immediate-parent → child),
378
+ plus a ``parent_id`` field on each non-module entity (the dual
379
+ encoding from ADR-026 decision 2). Module entities have no parent
380
+ within the file, so they omit ``parent_id`` and have no contains edge.
381
+
382
+ ``file_path`` lands in each entity's ``source.file_path`` verbatim.
383
+ ``module_prefix_path`` (default: same as ``file_path``) is the path
384
+ whose dotted form prefixes every entity's ``qualified_name`` —
385
+ callers can supply a project-relative path here while keeping
386
+ ``file_path`` absolute so the host's path jail validates the
387
+ original path.
388
+
389
+ Same-id collisions (PEP-484 ``@overload`` stubs, ``singledispatch``
390
+ ``def _(...):`` sequences, intentional redefinitions) are resolved at
391
+ the emit boundary so the host's ``UNIQUE(entities.id)`` never trips
392
+ mid-run. ``@overload`` stubs are recognised and dropped *before* the
393
+ walk descends — their bodies (``...``) carry only type-checker hints
394
+ so signature references are also suppressed. Any other duplicates
395
+ that survive (e.g. aliased ``from typing import overload as o``,
396
+ ``singledispatch.register`` users writing ``def _():`` repeatedly)
397
+ are deduplicated first-wins with a stderr line per drop and
398
+ ``ExtractionStats.duplicate_entities_dropped_total`` bumped per drop.
399
+ """
400
+ prefix_source = module_prefix_path if module_prefix_path is not None else file_path
401
+ dotted_module = module_dotted_name(prefix_source)
402
+ is_package_module = PurePosixPath(prefix_source).name == "__init__.py"
403
+
404
+ # Top-level __init__.py would resolve to "" — entity_id() rejects that
405
+ # (crates/loomweave-core/src/entity_id.rs:97-101). Skip with stderr.
406
+ if not dotted_module:
407
+ sys.stderr.write(
408
+ f"loomweave-plugin-python: skipping {file_path}: "
409
+ f"top-level __init__.py has no package name\n",
410
+ )
411
+ return ExtractResult([], [], ExtractionStats())
412
+
413
+ parse_started_ns = time.perf_counter_ns()
414
+ try:
415
+ tree = ast.parse(source)
416
+ except SyntaxError as exc:
417
+ parse_latency_ms = _elapsed_ms(parse_started_ns)
418
+ sys.stderr.write(
419
+ f"loomweave-plugin-python: skipping {file_path}: syntax error at "
420
+ f"line {exc.lineno}: {exc.msg}\n",
421
+ )
422
+ return ExtractResult(
423
+ [_build_module_entity(source, dotted_module, file_path, "syntax_error")],
424
+ [],
425
+ ExtractionStats(extractor_parse_latency_ms=parse_latency_ms),
426
+ )
427
+ parse_latency_ms = _elapsed_ms(parse_started_ns)
428
+
429
+ module_entity = _build_module_entity(
430
+ source, dotted_module, file_path, "ok", ast.get_docstring(tree)
431
+ )
432
+ entities: list[RawEntity] = [module_entity]
433
+ edges: list[RawEdge] = []
434
+ 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,
455
+ tree,
456
+ dotted_module,
457
+ 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)
464
+ edges.extend(cast("list[RawEdge]", call_stats.edges))
465
+ edges.extend(cast("list[RawEdge]", reference_stats.edges))
466
+ stats = ExtractionStats.from_resolution_results(call_stats, reference_stats)
467
+ stats.extractor_parse_latency_ms = parse_latency_ms
468
+ stats.duplicate_entities_dropped_total = walk_state.duplicate_entities_dropped
469
+ return ExtractResult(entities, edges, stats)
470
+
471
+
472
+ def _elapsed_ms(started_ns: int) -> int:
473
+ return max(1, (time.perf_counter_ns() - started_ns + 999_999) // 1_000_000)
474
+
475
+
476
+ def _collect_import_edges(
477
+ source: str,
478
+ tree: ast.Module,
479
+ dotted_module: str,
480
+ module_entity_id: str,
481
+ *,
482
+ is_package_module: bool,
483
+ ) -> list[RawEdge]:
484
+ collector = _ImportEdgeCollector(
485
+ source,
486
+ dotted_module,
487
+ module_entity_id,
488
+ is_package_module=is_package_module,
489
+ )
490
+ collector.visit(tree)
491
+ return collector.edges
492
+
493
+
494
+ class _ImportEdgeCollector(ast.NodeVisitor):
495
+ def __init__(
496
+ self,
497
+ source: str,
498
+ dotted_module: str,
499
+ module_entity_id: str,
500
+ *,
501
+ is_package_module: bool,
502
+ ) -> None:
503
+ self.source = source
504
+ self.dotted_module = dotted_module
505
+ self.module_entity_id = module_entity_id
506
+ self.is_package_module = is_package_module
507
+ self.edges: list[RawEdge] = []
508
+ self._function_depth = 0
509
+ self._type_only_depth = 0
510
+
511
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
512
+ self._function_depth += 1
513
+ try:
514
+ self.generic_visit(node)
515
+ finally:
516
+ self._function_depth -= 1
517
+
518
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
519
+ self._function_depth += 1
520
+ try:
521
+ self.generic_visit(node)
522
+ finally:
523
+ self._function_depth -= 1
524
+
525
+ def visit_If(self, node: ast.If) -> None:
526
+ if _is_type_checking_guard(node.test):
527
+ self._type_only_depth += 1
528
+ try:
529
+ for child in node.body:
530
+ self.visit(child)
531
+ finally:
532
+ self._type_only_depth -= 1
533
+ for child in node.orelse:
534
+ self.visit(child)
535
+ return
536
+ self.generic_visit(node)
537
+
538
+ def visit_Import(self, node: ast.Import) -> None:
539
+ source_byte_start, source_byte_end = _node_byte_range(self.source, node)
540
+ for alias in node.names:
541
+ self.edges.append(
542
+ self._edge(
543
+ target_module=alias.name,
544
+ imported_name=alias.name,
545
+ import_style="import",
546
+ level=0,
547
+ source_range=(source_byte_start, source_byte_end),
548
+ ),
549
+ )
550
+
551
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
552
+ source_byte_start, source_byte_end = _node_byte_range(self.source, node)
553
+ for alias in node.names:
554
+ target_module = _import_from_target(
555
+ self.dotted_module,
556
+ node.module,
557
+ node.level,
558
+ alias.name,
559
+ is_package_module=self.is_package_module,
560
+ )
561
+ if target_module is None:
562
+ continue
563
+ self.edges.append(
564
+ self._edge(
565
+ target_module=target_module,
566
+ imported_name=alias.name,
567
+ import_style="from_import",
568
+ level=node.level,
569
+ source_range=(source_byte_start, source_byte_end),
570
+ ),
571
+ )
572
+
573
+ def _edge(
574
+ self,
575
+ *,
576
+ target_module: str,
577
+ imported_name: str,
578
+ import_style: Literal["import", "from_import"],
579
+ level: int,
580
+ source_range: tuple[int, int],
581
+ ) -> RawEdge:
582
+ source_byte_start, source_byte_end = source_range
583
+ properties: ImportsEdgeProperties = {
584
+ "imported_name": imported_name,
585
+ "import_style": import_style,
586
+ "level": level,
587
+ }
588
+ if self._type_only_depth > 0:
589
+ properties["type_only"] = True
590
+ if self._function_depth > 0:
591
+ properties["scope"] = "function"
592
+ return {
593
+ "kind": "imports",
594
+ "from_id": self.module_entity_id,
595
+ "to_id": entity_id(_PLUGIN_ID, "module", target_module),
596
+ "source_byte_start": source_byte_start,
597
+ "source_byte_end": source_byte_end,
598
+ "confidence": "resolved",
599
+ "properties": properties,
600
+ }
601
+
602
+
603
+ def _is_type_checking_guard(expr: ast.expr) -> bool:
604
+ if isinstance(expr, ast.Name):
605
+ return expr.id == "TYPE_CHECKING"
606
+ if isinstance(expr, ast.Attribute):
607
+ return (
608
+ expr.attr == "TYPE_CHECKING"
609
+ and isinstance(expr.value, ast.Name)
610
+ and expr.value.id == "typing"
611
+ )
612
+ if isinstance(expr, ast.BoolOp):
613
+ if isinstance(expr.op, ast.And):
614
+ return any(_is_type_checking_guard(value) for value in expr.values)
615
+ if isinstance(expr.op, ast.Or):
616
+ return all(_is_type_checking_guard(value) for value in expr.values)
617
+ return False
618
+
619
+
620
+ def _import_from_target(
621
+ dotted_module: str,
622
+ module: str | None,
623
+ level: int,
624
+ imported_name: str,
625
+ *,
626
+ is_package_module: bool,
627
+ ) -> str | None:
628
+ if level == 0:
629
+ return module
630
+
631
+ base_parts = _relative_import_base_parts(
632
+ dotted_module,
633
+ level,
634
+ is_package_module=is_package_module,
635
+ )
636
+ if base_parts is None:
637
+ return None
638
+
639
+ target_parts = [*base_parts]
640
+ if module:
641
+ target_parts.extend(part for part in module.split(".") if part)
642
+ elif imported_name != "*":
643
+ target_parts.append(imported_name)
644
+
645
+ return ".".join(target_parts) if target_parts else None
646
+
647
+
648
+ def _relative_import_base_parts(
649
+ dotted_module: str,
650
+ level: int,
651
+ *,
652
+ is_package_module: bool,
653
+ ) -> list[str] | None:
654
+ all_parts = dotted_module.split(".")
655
+ package_parts = all_parts if is_package_module else all_parts[:-1]
656
+ keep = len(package_parts) - (level - 1)
657
+ if keep < 0:
658
+ return None
659
+ return package_parts[:keep]
660
+
661
+
662
+ def _node_byte_range(source: str, node: ast.Import | ast.ImportFrom) -> tuple[int, int]:
663
+ line_starts = _line_starts(source)
664
+ start_line = node.lineno - 1
665
+ end_line = (node.end_lineno or node.lineno) - 1
666
+ end_col = node.end_col_offset if node.end_col_offset is not None else node.col_offset
667
+ return line_starts[start_line] + node.col_offset, line_starts[end_line] + end_col
668
+
669
+
670
+ def _collect_reference_sites(
671
+ source: str,
672
+ tree: ast.Module,
673
+ dotted_module: str,
674
+ module_entity_id: str,
675
+ ) -> list[ReferenceSite]:
676
+ collector = _ReferenceSiteCollector(source, tree, dotted_module, module_entity_id)
677
+ collector.visit(tree)
678
+ return collector.sites
679
+
680
+
681
+ class _ReferenceSiteCollector(ast.NodeVisitor):
682
+ def __init__(
683
+ self,
684
+ source: str,
685
+ tree: ast.Module,
686
+ dotted_module: str,
687
+ module_entity_id: str,
688
+ ) -> None:
689
+ self.source = source
690
+ self.source_lines = source.splitlines(keepends=True)
691
+ self.line_starts = _line_starts(source)
692
+ self.dotted_module = dotted_module
693
+ self.parents: list[ast.AST] = [tree]
694
+ self.owner_stack = [module_entity_id]
695
+ self.bound_stack = [_scope_local_names(tree)]
696
+ self.annotation_depth = 0
697
+ self.sites: list[ReferenceSite] = []
698
+
699
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
700
+ self._visit_function(node)
701
+
702
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
703
+ self._visit_function(node)
704
+
705
+ def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
706
+ # PEP 484 stub: signature annotations are type-checker hints, not
707
+ # references the consult-mode briefing cares about; the body is `...`.
708
+ # Skipping keeps reference-site ownership consistent with `_walk` (no
709
+ # entity for the stub → nothing to attribute references to).
710
+ if _has_overload_decorator(node):
711
+ return
712
+ function_id = self._entity_id_for_scope("function", node)
713
+ self.owner_stack.append(function_id)
714
+ self.bound_stack.append(_scope_local_names(node))
715
+ self._visit_function_signature(node)
716
+ self.parents.append(node)
717
+ for statement in node.body:
718
+ self.visit(statement)
719
+ self.parents.pop()
720
+ self.bound_stack.pop()
721
+ self.owner_stack.pop()
722
+
723
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
724
+ class_id = self._entity_id_for_scope("class", node)
725
+ self.owner_stack.append(class_id)
726
+ self.bound_stack.append(_scope_local_names(node))
727
+ self.parents.append(node)
728
+ for statement in node.body:
729
+ self.visit(statement)
730
+ self.parents.pop()
731
+ self.bound_stack.pop()
732
+ self.owner_stack.pop()
733
+
734
+ def visit_Lambda(self, node: ast.Lambda) -> None:
735
+ # Lambdas are not entities in v0.1; keep the surrounding owner and
736
+ # suppress lambda-local argument names.
737
+ self.bound_stack.append(_lambda_bound_names(node))
738
+ self.visit(node.body)
739
+ self.bound_stack.pop()
740
+
741
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
742
+ self._visit_annotation(node.annotation)
743
+ if node.value is not None:
744
+ self.visit(node.value)
745
+
746
+ def visit_arg(self, node: ast.arg) -> None:
747
+ if node.annotation is not None:
748
+ self._visit_annotation(node.annotation)
749
+
750
+ def visit_Call(self, node: ast.Call) -> None:
751
+ # `calls` owns the callee expression; references inside it are suppressed.
752
+ for arg in node.args:
753
+ self.visit(arg)
754
+ for keyword in node.keywords:
755
+ self.visit(keyword.value)
756
+
757
+ def visit_Name(self, node: ast.Name) -> None:
758
+ if isinstance(node.ctx, ast.Load) and not self._is_non_entity_local(node.id):
759
+ self.sites.append(self._site_for_name(node))
760
+
761
+ def _is_non_entity_local(self, name: str) -> bool:
762
+ return any(name in scope for scope in self.bound_stack[1:])
763
+
764
+ def _visit_function_signature(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
765
+ for arg in [
766
+ *node.args.posonlyargs,
767
+ *node.args.args,
768
+ *node.args.kwonlyargs,
769
+ ]:
770
+ self.visit(arg)
771
+ if node.args.vararg is not None:
772
+ self.visit(node.args.vararg)
773
+ if node.args.kwarg is not None:
774
+ self.visit(node.args.kwarg)
775
+ if node.returns is not None:
776
+ self._visit_annotation(node.returns)
777
+ for default in [*node.args.defaults, *(d for d in node.args.kw_defaults if d is not None)]:
778
+ self.visit(default)
779
+
780
+ def _visit_annotation(self, node: ast.expr) -> None:
781
+ self.annotation_depth += 1
782
+ self.visit(node)
783
+ self.annotation_depth -= 1
784
+
785
+ def _entity_id_for_scope(
786
+ self,
787
+ kind: Literal["class", "function"],
788
+ node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
789
+ ) -> str:
790
+ python_qualname = reconstruct_qualname(node, self.parents)
791
+ qualified_name = (
792
+ f"{self.dotted_module}.{python_qualname}" if self.dotted_module else python_qualname
793
+ )
794
+ return entity_id(_PLUGIN_ID, kind, qualified_name)
795
+
796
+ def _site_for_name(self, node: ast.Name) -> ReferenceSite:
797
+ line = node.lineno - 1
798
+ end_line = (node.end_lineno or node.lineno) - 1
799
+ end_col = node.end_col_offset or node.col_offset + len(node.id.encode("utf-8"))
800
+ source_byte_start = self.line_starts[line] + node.col_offset
801
+ source_byte_end = self.line_starts[end_line] + end_col
802
+ return ReferenceSite(
803
+ from_id=self.owner_stack[-1],
804
+ line=line,
805
+ character=_byte_col_to_lsp_character(self.source_lines[line], node.col_offset),
806
+ end_line=end_line,
807
+ end_character=_byte_col_to_lsp_character(self.source_lines[end_line], end_col),
808
+ source_byte_start=source_byte_start,
809
+ source_byte_end=source_byte_end,
810
+ kind="annotation" if self.annotation_depth else "name",
811
+ )
812
+
813
+
814
+ def _line_starts(source: str) -> tuple[int, ...]:
815
+ starts = [0]
816
+ total = 0
817
+ for line in source.splitlines(keepends=True):
818
+ total += len(line.encode("utf-8"))
819
+ starts.append(total)
820
+ return tuple(starts)
821
+
822
+
823
+ def _byte_col_to_lsp_character(line: str, byte_col: int) -> int:
824
+ prefix = line.encode("utf-8")[:byte_col].decode("utf-8")
825
+ return len(prefix.encode("utf-16-le")) // 2
826
+
827
+
828
+ def _scope_local_names(
829
+ scope: ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
830
+ ) -> set[str]:
831
+ collector = _LocalNameCollector()
832
+ if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)):
833
+ collector.names.update(_function_arg_names(scope))
834
+ for statement in scope.body:
835
+ collector.visit(statement)
836
+ return collector.names
837
+
838
+
839
+ def _lambda_bound_names(node: ast.Lambda) -> set[str]:
840
+ return set(_arguments_arg_names(node.args))
841
+
842
+
843
+ def _function_arg_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
844
+ return set(_arguments_arg_names(node.args))
845
+
846
+
847
+ def _arguments_arg_names(args: ast.arguments) -> list[str]:
848
+ names = [
849
+ *(arg.arg for arg in args.posonlyargs),
850
+ *(arg.arg for arg in args.args),
851
+ *(arg.arg for arg in args.kwonlyargs),
852
+ ]
853
+ if args.vararg is not None:
854
+ names.append(args.vararg.arg)
855
+ if args.kwarg is not None:
856
+ names.append(args.kwarg.arg)
857
+ return names
858
+
859
+
860
+ class _LocalNameCollector(ast.NodeVisitor):
861
+ def __init__(self) -> None:
862
+ self.names: set[str] = set()
863
+
864
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
865
+ _ = node
866
+
867
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
868
+ _ = node
869
+
870
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
871
+ _ = node
872
+
873
+ def visit_Name(self, node: ast.Name) -> None:
874
+ if isinstance(node.ctx, (ast.Store, ast.Del)):
875
+ self.names.add(node.id)
876
+
877
+
878
+ @dataclass
879
+ class _WalkState:
880
+ """Mutable accumulator threaded through ``_walk`` for cross-cutting bookkeeping.
881
+
882
+ ``seen_ids`` is seeded with the module-entity id by ``extract_with_stats``
883
+ so the safety net catches the (degenerate) case of a function colliding
884
+ with the module's id. ``duplicate_entities_dropped`` is bumped once per
885
+ same-id drop; the caller copies it into ``ExtractionStats``.
886
+ """
887
+
888
+ seen_ids: set[str]
889
+ file_path: str
890
+ wardline_vocabulary: WardlineVocabulary | None = None
891
+ exported_names: set[str] = field(default_factory=set)
892
+ duplicate_entities_dropped: int = 0
893
+
894
+
895
+ def _has_overload_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
896
+ """Return True if ``node`` is decorated with ``@overload`` (PEP 484 stub).
897
+
898
+ Recognises three import-name forms in source: bare ``@overload`` (from
899
+ ``from typing import overload``), ``@typing.overload``, and
900
+ ``@typing_extensions.overload``. Aliased re-imports such as
901
+ ``from typing import overload as o`` defeat this pattern-based check —
902
+ the safety-net dedup in ``_walk`` catches the resulting same-id
903
+ collision and keeps the run alive.
904
+ """
905
+ for decorator in node.decorator_list:
906
+ match decorator:
907
+ case ast.Name(id="overload"):
908
+ return True
909
+ case ast.Attribute(
910
+ value=ast.Name(id="typing" | "typing_extensions"),
911
+ attr="overload",
912
+ ):
913
+ return True
914
+ return False
915
+
916
+
917
+ def _walk( # noqa: PLR0913 - recursive walker needs both accumulators + parent context (B.3)
918
+ node: ast.AST,
919
+ parents: list[ast.AST],
920
+ dotted_module: str,
921
+ file_path: str,
922
+ parent_entity_id: str,
923
+ out_entities: list[RawEntity],
924
+ out_edges: list[RawEdge],
925
+ out_function_ids: list[str],
926
+ state: _WalkState,
927
+ ) -> None:
928
+ """Recursively walk ``node``'s AST children, emitting entities + contains edges.
929
+
930
+ ``parent_entity_id`` is the immediate-parent entity id for direct
931
+ children of ``node``. When a child entity is itself an entity-bearing
932
+ node (Class/FunctionDef), recursion drops into it with the child's
933
+ own id as the new parent — so grandchildren get the right ``from_id``
934
+ on their contains edge (B.3 Q3: emitter is exhaustive, never
935
+ transitive).
936
+
937
+ Two stub-skip rules keep the host's ``UNIQUE(entities.id)`` from
938
+ tripping. (1) ``@overload``-decorated functions are recognised
939
+ semantically: skip emission, skip recursion (PEP 484 stub bodies are
940
+ ``...``). The implementation appears last in source order and emits
941
+ normally. (2) Any other surviving same-id collision (aliased
942
+ ``overload`` imports, ``singledispatch.register`` ``def _():``
943
+ sequences, manual redefinition) is dropped first-wins with a stderr
944
+ line and a ``state.duplicate_entities_dropped`` bump. Recursion into
945
+ the dropped child is suppressed too: its nested entities would carry
946
+ a parent_id whose entity the host never sees.
947
+ """
948
+ for child in ast.iter_child_nodes(node):
949
+ new_parent_id = parent_entity_id
950
+ match child:
951
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
952
+ if _has_overload_decorator(child):
953
+ continue
954
+ entity, child_id = _build_function_entity(
955
+ child,
956
+ parents,
957
+ dotted_module,
958
+ parent_entity_id,
959
+ state,
960
+ )
961
+ if child_id in state.seen_ids:
962
+ state.duplicate_entities_dropped += 1
963
+ sys.stderr.write(
964
+ f"loomweave-plugin-python: dropping duplicate entity {child_id} "
965
+ f"in {state.file_path} at line {child.lineno} "
966
+ f"(first definition wins)\n",
967
+ )
968
+ continue
969
+ state.seen_ids.add(child_id)
970
+ out_entities.append(entity)
971
+ out_edges.append(_contains_edge(parent_entity_id, child_id))
972
+ out_function_ids.append(child_id)
973
+ new_parent_id = child_id
974
+ case ast.ClassDef():
975
+ entity, child_id = _build_class_entity(
976
+ child,
977
+ parents,
978
+ dotted_module,
979
+ parent_entity_id,
980
+ state,
981
+ )
982
+ if child_id in state.seen_ids:
983
+ state.duplicate_entities_dropped += 1
984
+ sys.stderr.write(
985
+ f"loomweave-plugin-python: dropping duplicate entity {child_id} "
986
+ f"in {state.file_path} at line {child.lineno} "
987
+ f"(first definition wins)\n",
988
+ )
989
+ continue
990
+ state.seen_ids.add(child_id)
991
+ out_entities.append(entity)
992
+ out_edges.append(_contains_edge(parent_entity_id, child_id))
993
+ new_parent_id = child_id
994
+ _walk(
995
+ child,
996
+ [*parents, child],
997
+ dotted_module,
998
+ file_path,
999
+ new_parent_id,
1000
+ out_entities,
1001
+ out_edges,
1002
+ out_function_ids,
1003
+ state,
1004
+ )
1005
+
1006
+
1007
+ def _definition_span(
1008
+ node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
1009
+ ) -> tuple[int, int, DefinitionSpan]:
1010
+ """Return ``(start_line, start_col, definition)`` for a definition node.
1011
+
1012
+ ``node.lineno`` is the ``def``/``class`` keyword line (Python 3.8+);
1013
+ decorators sit above it. When the node is decorated the returned
1014
+ ``start_line``/``start_col`` extend the entity span up to the topmost
1015
+ decorator so an ``entity_at`` query on a decorator line resolves to this
1016
+ entity (clarion-460def6a51). The ``definition`` map records the
1017
+ sub-ranges that explain why a line matched.
1018
+ """
1019
+ definition: DefinitionSpan = {"decl_line": node.lineno}
1020
+ if node.body:
1021
+ definition["body_line_start"] = node.body[0].lineno
1022
+ start_line = node.lineno
1023
+ start_col = node.col_offset
1024
+ if node.decorator_list:
1025
+ topmost = min(node.decorator_list, key=lambda d: (d.lineno, d.col_offset))
1026
+ decorator_line_start = topmost.lineno
1027
+ decorator_line_end = max(
1028
+ (d.end_lineno if d.end_lineno is not None else d.lineno) for d in node.decorator_list
1029
+ )
1030
+ definition["decorator_line_start"] = decorator_line_start
1031
+ definition["decorator_line_end"] = decorator_line_end
1032
+ start_line = decorator_line_start
1033
+ start_col = topmost.col_offset
1034
+ return start_line, start_col, definition
1035
+
1036
+
1037
+ def _contains_edge(parent_id: str, child_id: str) -> RawEdge:
1038
+ """Build a ``contains`` edge per ADR-026 decision 3 (no source range)."""
1039
+ return {
1040
+ "kind": "contains",
1041
+ "from_id": parent_id,
1042
+ "to_id": child_id,
1043
+ }
1044
+
1045
+
1046
+ _HTTP_ROUTE_DECORATOR_NAMES = {
1047
+ "get",
1048
+ "post",
1049
+ "put",
1050
+ "patch",
1051
+ "delete",
1052
+ "options",
1053
+ "head",
1054
+ "route",
1055
+ "websocket",
1056
+ }
1057
+ _CLI_DECORATOR_NAMES = {"command", "group", "callback"}
1058
+ _DATA_MODEL_BASE_NAMES = {"BaseModel", "Model", "SQLModel", "TypedDict"}
1059
+
1060
+
1061
+ def _attach_optional_entity_metadata(
1062
+ entity: RawEntity,
1063
+ *,
1064
+ docstring: str | None,
1065
+ tags: set[str] | list[str],
1066
+ ) -> None:
1067
+ if docstring:
1068
+ entity["docstring"] = docstring
1069
+ if tags:
1070
+ entity["tags"] = sorted(tags)
1071
+
1072
+
1073
+ def _attach_wardline_entity_metadata(
1074
+ entity: RawEntity,
1075
+ node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
1076
+ tags: set[str],
1077
+ vocabulary: WardlineVocabulary | None,
1078
+ ) -> None:
1079
+ if vocabulary is None:
1080
+ return
1081
+ decorators: list[WardlineDecoratorMetadata] = []
1082
+ for decorator in node.decorator_list:
1083
+ qualified_name = _expr_qualified_name(decorator)
1084
+ if qualified_name is None:
1085
+ continue
1086
+ entry = vocabulary.entry_for_decorator(qualified_name)
1087
+ if entry is None:
1088
+ continue
1089
+ decorators.append(
1090
+ {
1091
+ "canonical_name": entry.canonical_name,
1092
+ "qualified_name": qualified_name,
1093
+ "group": entry.group,
1094
+ "attrs": dict(entry.attrs),
1095
+ "line": decorator.lineno,
1096
+ },
1097
+ )
1098
+ tags.update({"wardline", f"wardline:{entry.canonical_name}"})
1099
+ if decorators:
1100
+ entity["wardline"] = {
1101
+ "descriptor_version": vocabulary.version,
1102
+ "confidence_basis": vocabulary.confidence_basis,
1103
+ "decorators": decorators,
1104
+ }
1105
+
1106
+
1107
+ def _module_export_names(tree: ast.Module) -> set[str]:
1108
+ exported: set[str] = set()
1109
+ for statement in tree.body:
1110
+ if not isinstance(statement, ast.Assign):
1111
+ continue
1112
+ if not any(
1113
+ isinstance(target, ast.Name) and target.id == "__all__" for target in statement.targets
1114
+ ):
1115
+ continue
1116
+ match statement.value:
1117
+ case ast.List(elts=elts) | ast.Tuple(elts=elts) | ast.Set(elts=elts):
1118
+ for elt in elts:
1119
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
1120
+ exported.add(elt.value)
1121
+ return exported
1122
+
1123
+
1124
+ def _expr_qualified_name(expr: ast.expr) -> str | None:
1125
+ match expr:
1126
+ case ast.Call(func=func):
1127
+ return _expr_qualified_name(func)
1128
+ case ast.Name(id=name):
1129
+ return name
1130
+ case ast.Attribute(value=value, attr=attr):
1131
+ base = _expr_qualified_name(value)
1132
+ return f"{base}.{attr}" if base else attr
1133
+ case _:
1134
+ return None
1135
+
1136
+
1137
+ def _decorator_names(
1138
+ node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
1139
+ ) -> list[str]:
1140
+ return [name for decorator in node.decorator_list if (name := _expr_qualified_name(decorator))]
1141
+
1142
+
1143
+ def _last_name(name: str) -> str:
1144
+ return name.rsplit(".", 1)[-1]
1145
+
1146
+
1147
+ def _is_module_level(parents: list[ast.AST]) -> bool:
1148
+ return len(parents) == 1
1149
+
1150
+
1151
+ def _function_tags(
1152
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
1153
+ parents: list[ast.AST],
1154
+ exported_names: set[str],
1155
+ ) -> set[str]:
1156
+ tags: set[str] = set()
1157
+ if _is_module_level(parents) and node.name == "main":
1158
+ tags.add("entry-point")
1159
+ if _is_module_level(parents) and node.name in exported_names:
1160
+ tags.add("exported-api")
1161
+ if node.name.startswith("test_") or any(
1162
+ isinstance(parent, ast.ClassDef) and parent.name.startswith("Test") for parent in parents
1163
+ ):
1164
+ tags.add("test")
1165
+ decorator_names = _decorator_names(node)
1166
+ if any(_last_name(name) in _HTTP_ROUTE_DECORATOR_NAMES for name in decorator_names):
1167
+ tags.update({"http-route", "framework-handler"})
1168
+ if any(_last_name(name) in _CLI_DECORATOR_NAMES for name in decorator_names):
1169
+ tags.update({"cli-command", "framework-handler"})
1170
+ return tags
1171
+
1172
+
1173
+ def _class_tags(node: ast.ClassDef, parents: list[ast.AST], exported_names: set[str]) -> set[str]:
1174
+ tags: set[str] = set()
1175
+ if _is_module_level(parents) and node.name in exported_names:
1176
+ tags.add("exported-api")
1177
+ if node.name.startswith("Test"):
1178
+ tags.add("test")
1179
+ decorator_names = _decorator_names(node)
1180
+ base_names = [_expr_qualified_name(base) for base in node.bases]
1181
+ if any(_last_name(name) == "dataclass" for name in decorator_names) or any(
1182
+ name is not None and _last_name(name) in _DATA_MODEL_BASE_NAMES for name in base_names
1183
+ ):
1184
+ tags.add("data-model")
1185
+ return tags
1186
+
1187
+
1188
+ def _annotation_str(node: ast.expr | None) -> str | None:
1189
+ """Unparse an annotation/expression node to its canonical source text, or
1190
+ ``None`` when absent. ``ast.unparse`` is deterministic for a given AST."""
1191
+ if node is None:
1192
+ return None
1193
+ return ast.unparse(node)
1194
+
1195
+
1196
+ def _format_param(arg: ast.arg, prefix: str = "") -> str:
1197
+ """Render one parameter as ``name`` or ``name: annotation`` (``prefix`` is
1198
+ ``*`` / ``**`` for var-positional / var-keyword params)."""
1199
+ annotation = _annotation_str(arg.annotation)
1200
+ name = f"{prefix}{arg.arg}"
1201
+ return f"{name}: {annotation}" if annotation is not None else name
1202
+
1203
+
1204
+ def _function_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> FunctionSignature:
1205
+ """SEI signature for a function (ADR-038 REQ-C-01). Near-redundant for the
1206
+ v1 deterministic move case (a byte-identical body already implies an
1207
+ identical ``def`` line), carried for spec conformance + the fuzzy future."""
1208
+ args = node.args
1209
+ params: list[str] = [_format_param(arg) for arg in (*args.posonlyargs, *args.args)]
1210
+ if args.vararg is not None:
1211
+ params.append(_format_param(args.vararg, "*"))
1212
+ params.extend(_format_param(arg) for arg in args.kwonlyargs)
1213
+ if args.kwarg is not None:
1214
+ params.append(_format_param(args.kwarg, "**"))
1215
+ return {
1216
+ "v": SIGNATURE_SCHEMA_VERSION,
1217
+ "params": params,
1218
+ "return_ann": _annotation_str(node.returns),
1219
+ }
1220
+
1221
+
1222
+ def _class_signature(node: ast.ClassDef) -> ClassSignature:
1223
+ """SEI signature for a class (ADR-038 REQ-C-01): unparsed base expressions."""
1224
+ bases = [text for text in (_annotation_str(base) for base in node.bases) if text is not None]
1225
+ return {"v": SIGNATURE_SCHEMA_VERSION, "bases": bases}
1226
+
1227
+
1228
+ def _build_function_entity(
1229
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
1230
+ parents: list[ast.AST],
1231
+ dotted_module: str,
1232
+ parent_entity_id: str,
1233
+ state: _WalkState,
1234
+ ) -> tuple[RawEntity, str]:
1235
+ python_qualname = reconstruct_qualname(node, parents)
1236
+ qualified_name = f"{dotted_module}.{python_qualname}" if dotted_module else python_qualname
1237
+ end_line = node.end_lineno if node.end_lineno is not None else node.lineno
1238
+ end_col = node.end_col_offset if node.end_col_offset is not None else node.col_offset
1239
+ start_line, start_col, definition = _definition_span(node)
1240
+ child_id = entity_id(_PLUGIN_ID, "function", qualified_name)
1241
+ entity: RawEntity = {
1242
+ "id": child_id,
1243
+ "kind": "function",
1244
+ "qualified_name": qualified_name,
1245
+ "source": {
1246
+ "file_path": state.file_path,
1247
+ "source_range": {
1248
+ "start_line": start_line,
1249
+ "start_col": start_col,
1250
+ "end_line": end_line,
1251
+ "end_col": end_col,
1252
+ },
1253
+ },
1254
+ "parent_id": parent_entity_id,
1255
+ "definition": definition,
1256
+ "signature": _function_signature(node),
1257
+ }
1258
+ tags = _function_tags(node, parents, state.exported_names)
1259
+ _attach_wardline_entity_metadata(entity, node, tags, state.wardline_vocabulary)
1260
+ _attach_optional_entity_metadata(
1261
+ entity,
1262
+ docstring=ast.get_docstring(node),
1263
+ tags=tags,
1264
+ )
1265
+ return entity, child_id
1266
+
1267
+
1268
+ def _build_class_entity(
1269
+ node: ast.ClassDef,
1270
+ parents: list[ast.AST],
1271
+ dotted_module: str,
1272
+ parent_entity_id: str,
1273
+ state: _WalkState,
1274
+ ) -> tuple[RawEntity, str]:
1275
+ """Build a class entity. Uses real ast.end_lineno/end_col_offset (not the module sentinel).
1276
+
1277
+ Class methods continue to emit as ``function`` entities (per
1278
+ detailed-design.md:67); no separate ``method`` kind. Nested classes
1279
+ nest in the qualname per ``reconstruct_qualname`` (no ``<locals>``
1280
+ between class names).
1281
+ """
1282
+ python_qualname = reconstruct_qualname(node, parents)
1283
+ qualified_name = f"{dotted_module}.{python_qualname}" if dotted_module else python_qualname
1284
+ end_line = node.end_lineno if node.end_lineno is not None else node.lineno
1285
+ end_col = node.end_col_offset if node.end_col_offset is not None else node.col_offset
1286
+ start_line, start_col, definition = _definition_span(node)
1287
+ child_id = entity_id(_PLUGIN_ID, "class", qualified_name)
1288
+ entity: RawEntity = {
1289
+ "id": child_id,
1290
+ "kind": "class",
1291
+ "qualified_name": qualified_name,
1292
+ "source": {
1293
+ "file_path": state.file_path,
1294
+ "source_range": {
1295
+ "start_line": start_line,
1296
+ "start_col": start_col,
1297
+ "end_line": end_line,
1298
+ "end_col": end_col,
1299
+ },
1300
+ },
1301
+ "parent_id": parent_entity_id,
1302
+ "definition": definition,
1303
+ "signature": _class_signature(node),
1304
+ }
1305
+ tags = _class_tags(node, parents, state.exported_names)
1306
+ _attach_wardline_entity_metadata(entity, node, tags, state.wardline_vocabulary)
1307
+ _attach_optional_entity_metadata(
1308
+ entity,
1309
+ docstring=ast.get_docstring(node),
1310
+ tags=tags,
1311
+ )
1312
+ return entity, child_id