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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|