alter-runtime 0.3.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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,544 @@
1
+ """alter_runtime.weave.resolver — Semantic-unit resolver for the Weave coordination plane.
2
+
3
+ D-WEAVE-VC-2 §2(a), §3, §7 precondition 1 (RATIFIED 2026-05-21).
4
+
5
+ This module resolves a file + line-range to the enclosing code symbol (a function,
6
+ method, class, or export). It is the load-bearing primitive consumed by:
7
+
8
+ - S2 ``weave_intent_writer`` (enriches intent records with semantic-unit context)
9
+ - S4 ``weave-validate.py`` (affected-set computation for incremental local CI)
10
+
11
+ Implementation decisions (from the Phase-0 plan):
12
+
13
+ - **Embedded tree-sitter, not the serena MCP.** Serena runs as a cold ``uvx``
14
+ subprocess; the weave hot path cannot afford the spawn latency. tree-sitter
15
+ parses in-process, sub-millisecond per file, and is robust to partially-written
16
+ / syntactically-broken files — tree-sitter's error-recovery is the reason it is
17
+ chosen over ``ast``.
18
+
19
+ - **Pure observation.** No blocking, no locks, no winner-picking. Resolution
20
+ failure degrades to ``None`` (file-level intent) — never raises, never blocks.
21
+
22
+ - **Python only (Phase 0).** The plan targets ``backend/app/`` and
23
+ ``alter_runtime/`` in Phase 0. Extending to TypeScript / Rust is a Phase-1
24
+ concern.
25
+
26
+ Public interface is a FROZEN CONTRACT (S4 codes against it verbatim):
27
+
28
+ resolve(file_path, line_range) -> SemanticUnit | None
29
+ import_graph(roots) -> dict[str, set[str]]
30
+ reverse_dependents(changed, roots) -> set[str]
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import os
37
+ import sys
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import TYPE_CHECKING, Optional
41
+
42
+ if TYPE_CHECKING: # pragma: no cover — type-only import; never executed at runtime
43
+ import tree_sitter
44
+
45
+ log = logging.getLogger(__name__)
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # tree-sitter language singleton (initialised once, never re-created)
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _PARSER: Optional["tree_sitter.Parser"] = None
52
+ _LANGUAGE: Optional["tree_sitter.Language"] = None
53
+
54
+
55
+ def _get_parser() -> Optional["tree_sitter.Parser"]:
56
+ """Return (or lazily initialise) the shared tree-sitter Python parser.
57
+
58
+ Returns ``None`` if tree-sitter is not installed — callers degrade
59
+ gracefully rather than raising ImportError at module import time.
60
+ """
61
+ global _PARSER, _LANGUAGE
62
+ if _PARSER is not None:
63
+ return _PARSER
64
+ try:
65
+ import tree_sitter_python as tspython
66
+ from tree_sitter import Language, Parser
67
+
68
+ _LANGUAGE = Language(tspython.language())
69
+ _PARSER = Parser(_LANGUAGE)
70
+ return _PARSER
71
+ except Exception as exc: # pragma: no cover — only hit if dep is absent
72
+ log.warning("tree-sitter unavailable; resolver will return None for all calls: %s", exc)
73
+ return None
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Public data class
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class SemanticUnit:
83
+ """A resolved code symbol — function, method, class, module, or export.
84
+
85
+ Attributes
86
+ ----------
87
+ symbol:
88
+ Short name of the symbol, e.g. ``"login"``.
89
+ kind:
90
+ One of ``"function"`` | ``"method"`` | ``"class"`` | ``"module"`` |
91
+ ``"export"``. Python ``@decorated`` symbols carry the kind of the
92
+ underlying definition, not ``"decorated"``.
93
+ qualified_name:
94
+ Dot-separated fully-qualified name derived from the file path and
95
+ enclosing symbol hierarchy, e.g.
96
+ ``"backend.app.api.auth.login"``.
97
+ file_path:
98
+ Absolute (or as-supplied) path to the source file.
99
+ start_line:
100
+ 1-based first line of the symbol body.
101
+ end_line:
102
+ 1-based last line of the symbol body (inclusive).
103
+ """
104
+
105
+ symbol: str
106
+ kind: str
107
+ qualified_name: str
108
+ file_path: str
109
+ start_line: int
110
+ end_line: int
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Internal helpers
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def _file_to_module(file_path: str) -> str:
119
+ """Derive a dotted module name from a file path.
120
+
121
+ Strategy: walk ``sys.path`` entries looking for the longest prefix that
122
+ contains the file; use the relative sub-path as the dotted name. Falls
123
+ back to a sanitised version of the absolute path when nothing in
124
+ ``sys.path`` matches.
125
+
126
+ Examples
127
+ --------
128
+ ``/repo/backend/app/api/auth.py`` with ``/repo`` in ``sys.path``
129
+ → ``"backend.app.api.auth"``.
130
+ """
131
+ p = Path(file_path).resolve()
132
+
133
+ # Strip .py suffix
134
+ if p.suffix == ".py":
135
+ p = p.with_suffix("")
136
+ # __init__ → package name (strip the trailing __init__ segment)
137
+ if p.name == "__init__":
138
+ p = p.parent
139
+
140
+ # Try each sys.path entry from longest to shortest so the most-specific
141
+ # match wins.
142
+ candidates = sorted(
143
+ [Path(s).resolve() for s in sys.path if s],
144
+ key=lambda x: len(str(x)),
145
+ reverse=True,
146
+ )
147
+ for base in candidates:
148
+ try:
149
+ rel = p.relative_to(base)
150
+ return str(rel).replace(os.sep, ".")
151
+ except ValueError:
152
+ continue
153
+
154
+ # Fallback: sanitise the absolute path
155
+ return str(p).lstrip("/").replace(os.sep, ".").replace("-", "_")
156
+
157
+
158
+ def _build_scope_stack(
159
+ root_node: "tree_sitter.Node",
160
+ target_line: int,
161
+ ) -> list[tuple[str, str]]:
162
+ """Return the ordered list of (name, kind) enclosing symbols for ``target_line``.
163
+
164
+ ``target_line`` is 0-based (tree-sitter convention).
165
+ Traverses the AST depth-first; pushes (name, kind) pairs for
166
+ ``function_definition``, ``class_definition``, and ``decorated_definition``
167
+ nodes whose span contains ``target_line``.
168
+ """
169
+ stack: list[tuple[str, str]] = []
170
+
171
+ def _kind_for(node_type: str, parent_kind: str | None) -> str:
172
+ if node_type == "class_definition":
173
+ return "class"
174
+ if node_type == "function_definition":
175
+ return "method" if parent_kind == "class" else "function"
176
+ return "function"
177
+
178
+ def _walk(node: "tree_sitter.Node", current_class: str | None = None) -> None:
179
+ for child in node.children:
180
+ start = child.start_point[0]
181
+ end = child.end_point[0]
182
+
183
+ # Only descend into nodes whose span contains the target line
184
+ if not (start <= target_line <= end):
185
+ continue
186
+
187
+ effective_child = child
188
+ # Unwrap decorated_definition → look at the inner function/class
189
+ if child.type == "decorated_definition":
190
+ # The actual definition is the last child of a decorated_definition
191
+ inner = child.child_by_field_name("definition")
192
+ if inner is None:
193
+ # Fallback: last non-decorator child
194
+ for c in reversed(child.children):
195
+ if c.type in ("function_definition", "class_definition"):
196
+ inner = c
197
+ break
198
+ if inner is not None:
199
+ effective_child = inner
200
+
201
+ if effective_child.type in ("function_definition", "class_definition"):
202
+ name_node = effective_child.child_by_field_name("name")
203
+ if name_node:
204
+ name = name_node.text.decode("utf-8", errors="replace")
205
+ kind = _kind_for(
206
+ effective_child.type,
207
+ stack[-1][1] if stack else None,
208
+ )
209
+ stack.append((name, kind))
210
+ _walk(
211
+ effective_child,
212
+ current_class=name
213
+ if effective_child.type == "class_definition"
214
+ else current_class,
215
+ )
216
+ return # Found a containing symbol — stop scanning siblings
217
+
218
+ # Recurse into non-symbol-boundary nodes (e.g. block, if_statement)
219
+ _walk(child, current_class=current_class)
220
+
221
+ _walk(root_node)
222
+ return stack
223
+
224
+
225
+ def _parse_bytes(source: bytes) -> Optional["tree_sitter.Tree"]:
226
+ """Parse ``source`` bytes with the shared parser. Returns ``None`` on failure."""
227
+ parser = _get_parser()
228
+ if parser is None:
229
+ return None
230
+ try:
231
+ return parser.parse(source)
232
+ except Exception as exc:
233
+ log.debug("tree-sitter parse error: %s", exc)
234
+ return None
235
+
236
+
237
+ def _node_span(
238
+ root_node: "tree_sitter.Node",
239
+ symbol_stack: list[tuple[str, str]],
240
+ ) -> tuple[int, int]:
241
+ """Locate the innermost symbol in ``symbol_stack`` and return its 1-based line span."""
242
+ if not symbol_stack:
243
+ return (1, root_node.end_point[0] + 1)
244
+
245
+ def _unwrap_decorated(
246
+ node: "tree_sitter.Node",
247
+ ) -> "tree_sitter.Node":
248
+ """Unwrap a decorated_definition to its inner function/class."""
249
+ if node.type == "decorated_definition":
250
+ inner = node.child_by_field_name("definition")
251
+ if inner is not None:
252
+ return inner
253
+ for c in reversed(node.children):
254
+ if c.type in ("function_definition", "class_definition"):
255
+ return c
256
+ return node
257
+
258
+ def _find_named_descendant(
259
+ node: "tree_sitter.Node",
260
+ target_name: str,
261
+ ) -> Optional["tree_sitter.Node"]:
262
+ """DFS search for a function/class node with ``target_name`` anywhere
263
+ in the subtree rooted at ``node``. Returns the first match."""
264
+ for child in node.children:
265
+ effective = _unwrap_decorated(child)
266
+ if effective.type in ("function_definition", "class_definition"):
267
+ name_node = effective.child_by_field_name("name")
268
+ if name_node and name_node.text.decode("utf-8", errors="replace") == target_name:
269
+ return effective
270
+ # Recurse into all children (including block, suite, etc.)
271
+ found = _find_named_descendant(child, target_name)
272
+ if found is not None:
273
+ return found
274
+ return None
275
+
276
+ # Walk the symbol stack from outermost to innermost, narrowing the search
277
+ # scope at each level.
278
+ current_scope: "tree_sitter.Node" = root_node
279
+ for name, _ in symbol_stack:
280
+ found = _find_named_descendant(current_scope, name)
281
+ if found is None:
282
+ # Could not locate symbol — fall back to module span
283
+ return (1, root_node.end_point[0] + 1)
284
+ current_scope = found
285
+
286
+ return (current_scope.start_point[0] + 1, current_scope.end_point[0] + 1)
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Public API
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def resolve(file_path: str, line_range: tuple[int, int]) -> SemanticUnit | None:
295
+ """Resolve a file + line range to the enclosing semantic unit.
296
+
297
+ Parameters
298
+ ----------
299
+ file_path:
300
+ Path to the Python source file. May be absolute or relative; may
301
+ not exist on disk (edit-in-flight case — then ``None`` is returned).
302
+ line_range:
303
+ ``(start_line, end_line)`` — 1-based, inclusive.
304
+
305
+ Returns
306
+ -------
307
+ SemanticUnit
308
+ The innermost function / method / class whose span contains the
309
+ given line range. When the range falls outside all symbol
310
+ boundaries the module itself is returned as a ``"module"`` unit.
311
+ None
312
+ Returned on any unrecoverable error (missing file, unreadable bytes,
313
+ irrecoverable parse failure) so callers can degrade gracefully to
314
+ file-level intent.
315
+
316
+ Notes
317
+ -----
318
+ - tree-sitter's error-recovery is leveraged for partially-written files:
319
+ even if the file contains syntax errors, tree-sitter produces a partial
320
+ AST and this function returns the best enclosing symbol it can find.
321
+ - This function is pure observation — it never writes to disk, never
322
+ blocks on I/O beyond reading the source file, and never raises.
323
+ """
324
+ try:
325
+ p = Path(file_path)
326
+ if not p.exists():
327
+ return None
328
+
329
+ source = p.read_bytes()
330
+ tree = _parse_bytes(source)
331
+ if tree is None:
332
+ return None
333
+
334
+ root = tree.root_node
335
+
336
+ # Use the midpoint of the range as the query line (0-based)
337
+ start_1, end_1 = line_range
338
+ mid_line_0 = ((start_1 - 1) + (end_1 - 1)) // 2
339
+
340
+ # Check if file is completely unparseable (root is ERROR with no useful children)
341
+ if root.has_error and all(c.type == "ERROR" for c in root.children if not c.is_extra):
342
+ # File is garbage — return module-level unit
343
+ module_name = _file_to_module(file_path)
344
+ short = module_name.split(".")[-1] if module_name else Path(file_path).stem
345
+ return SemanticUnit(
346
+ symbol=short,
347
+ kind="module",
348
+ qualified_name=module_name or short,
349
+ file_path=file_path,
350
+ start_line=1,
351
+ end_line=root.end_point[0] + 1,
352
+ )
353
+
354
+ symbol_stack = _build_scope_stack(root, mid_line_0)
355
+ module_qname = _file_to_module(file_path)
356
+
357
+ if not symbol_stack:
358
+ # Line falls in module scope — return module unit
359
+ short = module_qname.split(".")[-1] if module_qname else Path(file_path).stem
360
+ return SemanticUnit(
361
+ symbol=short,
362
+ kind="module",
363
+ qualified_name=module_qname or short,
364
+ file_path=file_path,
365
+ start_line=1,
366
+ end_line=root.end_point[0] + 1,
367
+ )
368
+
369
+ innermost_name, innermost_kind = symbol_stack[-1]
370
+ qualified_name = ".".join([module_qname] + [name for name, _ in symbol_stack])
371
+ start_l, end_l = _node_span(root, symbol_stack)
372
+
373
+ return SemanticUnit(
374
+ symbol=innermost_name,
375
+ kind=innermost_kind,
376
+ qualified_name=qualified_name,
377
+ file_path=file_path,
378
+ start_line=start_l,
379
+ end_line=end_l,
380
+ )
381
+
382
+ except Exception as exc:
383
+ log.debug("resolve(%r, %r) failed: %s", file_path, line_range, exc)
384
+ return None
385
+
386
+
387
+ def import_graph(roots: list[str]) -> dict[str, set[str]]:
388
+ """Build an import dependency graph for all Python files under ``roots``.
389
+
390
+ Parameters
391
+ ----------
392
+ roots:
393
+ List of root directories (or individual ``.py`` files) to scan.
394
+
395
+ Returns
396
+ -------
397
+ dict mapping each module's qualified name to the set of qualified names
398
+ it imports. Only modules discovered within ``roots`` appear as keys;
399
+ the imported-name values may reference stdlib or third-party modules.
400
+
401
+ Notes
402
+ -----
403
+ - Parsed with tree-sitter for robustness on in-flight / broken files.
404
+ - Falls back to an empty import set for any file that cannot be parsed.
405
+ - Relative imports (``from . import x``) are resolved against the
406
+ containing package where possible; unresolvable ones are stored as
407
+ the dotted relative form (``".x"``).
408
+ - This function is pure observation — no blocking, no side-effects.
409
+ """
410
+ graph: dict[str, set[str]] = {}
411
+
412
+ def _collect_files(root: str) -> list[Path]:
413
+ p = Path(root)
414
+ if p.is_file() and p.suffix == ".py":
415
+ return [p]
416
+ if p.is_dir():
417
+ return list(p.rglob("*.py"))
418
+ return []
419
+
420
+ all_files: list[Path] = []
421
+ for r in roots:
422
+ all_files.extend(_collect_files(r))
423
+
424
+ for file_path in all_files:
425
+ module_qname = _file_to_module(str(file_path))
426
+ imports = _extract_imports(file_path, module_qname)
427
+ graph[module_qname] = imports
428
+
429
+ return graph
430
+
431
+
432
+ def _extract_imports(file_path: Path, module_qname: str) -> set[str]:
433
+ """Return the set of module names imported by ``file_path``."""
434
+ try:
435
+ source = file_path.read_bytes()
436
+ except OSError:
437
+ return set()
438
+
439
+ tree = _parse_bytes(source)
440
+ if tree is None:
441
+ return set()
442
+
443
+ imports: set[str] = set()
444
+ root = tree.root_node
445
+
446
+ def _walk(node: "tree_sitter.Node") -> None:
447
+ if node.type == "import_statement":
448
+ # import a.b.c (possibly comma-separated)
449
+ for child in node.children:
450
+ if child.type == "dotted_name":
451
+ imports.add(child.text.decode("utf-8", errors="replace"))
452
+ elif child.type == "aliased_import":
453
+ # import a.b as x — the dotted_name is the first child
454
+ dn = child.child_by_field_name("name")
455
+ if dn:
456
+ imports.add(dn.text.decode("utf-8", errors="replace"))
457
+
458
+ elif node.type == "import_from_statement":
459
+ # from <module> import <names>
460
+ # The module part may be a dotted_name or a relative_import
461
+ module_part = ""
462
+ relative_dots = 0
463
+
464
+ for child in node.children:
465
+ if child.type == "relative_import":
466
+ # Count dots for relative resolution
467
+ relative_dots = child.text.decode("utf-8", errors="replace").count(".")
468
+ elif child.type == "dotted_name" and not module_part:
469
+ module_part = child.text.decode("utf-8", errors="replace")
470
+
471
+ if relative_dots > 0:
472
+ # Resolve relative to the containing package
473
+ pkg_parts = module_qname.split(".")
474
+ # Go up <relative_dots> levels
475
+ base_parts = pkg_parts[: max(0, len(pkg_parts) - relative_dots)]
476
+ if module_part:
477
+ resolved = ".".join(base_parts + [module_part])
478
+ else:
479
+ resolved = ".".join(base_parts) if base_parts else "."
480
+ imports.add(resolved)
481
+ elif module_part:
482
+ imports.add(module_part)
483
+
484
+ for child in node.children:
485
+ _walk(child)
486
+
487
+ _walk(root)
488
+ return imports
489
+
490
+
491
+ def reverse_dependents(changed_modules: set[str], roots: list[str]) -> set[str]:
492
+ """Return all modules that transitively import any module in ``changed_modules``.
493
+
494
+ Parameters
495
+ ----------
496
+ changed_modules:
497
+ Qualified names of the modules that have changed.
498
+ roots:
499
+ Root directories to build the import graph from (same as
500
+ ``import_graph``).
501
+
502
+ Returns
503
+ -------
504
+ Set of qualified module names that (directly or transitively) depend on
505
+ any changed module. The changed modules themselves are NOT included
506
+ unless they transitively import each other.
507
+
508
+ Notes
509
+ -----
510
+ - This is the **affected-set** computation that S4 (``weave-validate.py``)
511
+ uses to select which tests to run.
512
+ - Pure observation — no side effects.
513
+ - Under-approximation is possible (dynamic imports, ``importlib`` calls)
514
+ and is by design: the §5 agreement-rate metric measures the gap; the
515
+ GHA periodic audit remains the backstop.
516
+ """
517
+ graph = import_graph(roots)
518
+
519
+ # Build reverse map: module -> set of modules that import it
520
+ reverse: dict[str, set[str]] = {m: set() for m in graph}
521
+ for importer, imported_set in graph.items():
522
+ for imported in imported_set:
523
+ if imported not in reverse:
524
+ reverse[imported] = set()
525
+ reverse[imported].add(importer)
526
+
527
+ # BFS / iterative expansion from changed_modules
528
+ visited: set[str] = set()
529
+ frontier = set(changed_modules)
530
+ while frontier:
531
+ next_frontier: set[str] = set()
532
+ for mod in frontier:
533
+ if mod in visited:
534
+ continue
535
+ visited.add(mod)
536
+ dependents = reverse.get(mod, set())
537
+ for dep in dependents:
538
+ if dep not in visited:
539
+ next_frontier.add(dep)
540
+ frontier = next_frontier
541
+
542
+ # Return dependents only (exclude the changed modules themselves unless
543
+ # they form a cycle)
544
+ return visited - changed_modules