loom-code 0.1.1__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 (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,429 @@
1
+ """Python AST visitor — extracts symbols + imports from one file.
2
+
3
+ Single-file scope: ``walk_python_file(source, path)`` returns
4
+ ``(symbols, imports, decorators)`` for the file. No cross-file
5
+ resolution happens here — that lives in :mod:`extractor`, which
6
+ owns the global symbol table and builds the import graph from
7
+ many per-file results.
8
+
9
+ Why stdlib ast (not tree-sitter): loom-code's first-party target
10
+ is Python. The stdlib parser is exact-grammar by definition and
11
+ zero-install. Tree-sitter (with grammar packages per language)
12
+ will come later for polyglot repos — :mod:`extractor` already
13
+ routes by language so a future `_treesitter_walk.py` can drop
14
+ in alongside this module without touching callers.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ from dataclasses import dataclass
21
+ from typing import Literal
22
+
23
+ # Decorators that ALWAYS indicate a landmark (entry point / tool /
24
+ # fixture / route handler). The annotator uses these to surface
25
+ # entry points without an LLM call. Keep the list short — false
26
+ # positives are cheap (one more line in the entry-points section),
27
+ # false negatives mean the annotator has to discover them with
28
+ # grep, which costs tokens.
29
+ LANDMARK_DECORATORS: frozenset[str] = frozenset(
30
+ {
31
+ # CLI frameworks
32
+ "click.command",
33
+ "click.group",
34
+ "typer.command",
35
+ # Web frameworks (FastAPI, Flask, Starlette common shapes)
36
+ "app.route",
37
+ "app.get",
38
+ "app.post",
39
+ "app.put",
40
+ "app.delete",
41
+ "router.get",
42
+ "router.post",
43
+ "router.put",
44
+ "router.delete",
45
+ # loomflow / loom-code
46
+ "tool",
47
+ "step",
48
+ # pytest / testing
49
+ "pytest.fixture",
50
+ "fixture",
51
+ }
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class _RawSymbol:
57
+ """Intermediate symbol record — what we extract from ast, before
58
+ cross-file enrichment in :mod:`extractor` (PageRank, n_callers,
59
+ test map, API-surface flag).
60
+
61
+ Frozen so accidental mutation across the extractor pipeline
62
+ surfaces as TypeError rather than a silent bug."""
63
+
64
+ name: str
65
+ qualified_name: str
66
+ kind: Literal["class", "function", "method", "constant"]
67
+ line: int
68
+ end_line: int
69
+ signature: str
70
+ docstring_first_line: str | None
71
+ decorators: tuple[str, ...]
72
+ is_public: bool # name does not start with "_"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class _RawImport:
77
+ """One import-edge candidate from one file.
78
+
79
+ ``to_module`` is the dotted module path as written in source
80
+ (``"foo.bar"`` for ``from foo.bar import x``). Relative-import
81
+ resolution (``from .. import x``) and "does this resolve to a
82
+ file in our repo" happen in :mod:`extractor` because they need
83
+ the full file set.
84
+ """
85
+
86
+ to_module: str
87
+ line: int
88
+ # The literal level for ``from . import x`` style. 0 for absolute,
89
+ # 1 for ``from .``, 2 for ``from ..``, etc. Needed downstream to
90
+ # resolve relative imports against the package layout.
91
+ level: int
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class _RawDecorator:
96
+ """A decorator on a symbol that matches :data:`LANDMARK_DECORATORS`.
97
+
98
+ ``decorator`` is normalized (no leading ``@``, no call args:
99
+ ``app.route("/x")`` is stored as ``"app.route"``). ``target_qualname``
100
+ is the qualified name of the decorated symbol (``Outer.method``).
101
+ """
102
+
103
+ decorator: str
104
+ target_qualname: str
105
+ line: int
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Public entry point
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def walk_python_file(
114
+ source: str, path: str
115
+ ) -> tuple[list[_RawSymbol], list[_RawImport], list[_RawDecorator]]:
116
+ """Parse ``source`` (the textual contents of ``path``) and return
117
+ everything the cross-file extractor needs.
118
+
119
+ ``path`` is repo-relative POSIX; we don't actually open the file
120
+ here — the caller already read it. Keeping I/O out makes this
121
+ function trivially unit-testable with inline strings.
122
+
123
+ A syntax error returns three empty lists rather than raising:
124
+ one broken file should NOT abort indexing a repo. The extractor
125
+ logs the broken paths so the user sees what was skipped.
126
+ """
127
+ try:
128
+ tree = ast.parse(source, filename=path)
129
+ except SyntaxError:
130
+ return [], [], []
131
+
132
+ source_lines = source.splitlines()
133
+ visitor = _Visitor(source_lines)
134
+ visitor.visit(tree)
135
+ return visitor.symbols, visitor.imports, visitor.decorators
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Internals
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ class _Visitor(ast.NodeVisitor):
144
+ """Walks a parsed Python file once. The traversal is class-aware
145
+ (methods get ``Class.method`` qualified names + ``kind="method"``)
146
+ but does NOT recurse into function bodies — symbols inside a
147
+ function are local and not worth indexing.
148
+
149
+ Decorators are filtered against :data:`LANDMARK_DECORATORS` here
150
+ on the per-symbol decorator list, but ALSO collected verbatim
151
+ onto every symbol (so the annotator can quote them in LOOM.md).
152
+ """
153
+
154
+ def __init__(self, source_lines: list[str]) -> None:
155
+ self._source_lines = source_lines
156
+ # Qualified-name prefix stack; pushed when entering ClassDef.
157
+ self._scope: list[str] = []
158
+ self.symbols: list[_RawSymbol] = []
159
+ self.imports: list[_RawImport] = []
160
+ self.decorators: list[_RawDecorator] = []
161
+
162
+ # ---- class / function / method ----------------------------------
163
+
164
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
165
+ qualified = self._qualify(node.name)
166
+ decorators = tuple(self._decorator_names(node.decorator_list))
167
+ self._record_landmarks(decorators, qualified, node)
168
+ self.symbols.append(
169
+ _RawSymbol(
170
+ name=node.name,
171
+ qualified_name=qualified,
172
+ kind="class",
173
+ line=node.lineno,
174
+ end_line=getattr(node, "end_lineno", node.lineno),
175
+ signature=self._signature(node),
176
+ docstring_first_line=_docstring_first_line(node),
177
+ decorators=decorators,
178
+ is_public=not node.name.startswith("_"),
179
+ )
180
+ )
181
+ # Recurse INTO the class so methods inherit Class.qualname,
182
+ # but use a fresh scope frame so we don't see siblings as
183
+ # methods.
184
+ self._scope.append(node.name)
185
+ for child in node.body:
186
+ self.visit(child)
187
+ self._scope.pop()
188
+
189
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
190
+ self._visit_callable(node)
191
+
192
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
193
+ self._visit_callable(node)
194
+
195
+ def _visit_callable(
196
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef
197
+ ) -> None:
198
+ qualified = self._qualify(node.name)
199
+ kind: Literal["function", "method"] = (
200
+ "method" if self._scope else "function"
201
+ )
202
+ decorators = tuple(self._decorator_names(node.decorator_list))
203
+ self._record_landmarks(decorators, qualified, node)
204
+ self.symbols.append(
205
+ _RawSymbol(
206
+ name=node.name,
207
+ qualified_name=qualified,
208
+ kind=kind,
209
+ line=node.lineno,
210
+ end_line=getattr(node, "end_lineno", node.lineno),
211
+ signature=self._signature(node),
212
+ docstring_first_line=_docstring_first_line(node),
213
+ decorators=decorators,
214
+ is_public=not node.name.startswith("_"),
215
+ )
216
+ )
217
+ # Do NOT descend into function bodies — nested defs aren't
218
+ # worth indexing as symbols.
219
+
220
+ # ---- module-level constants -------------------------------------
221
+
222
+ def visit_Assign(self, node: ast.Assign) -> None:
223
+ # Only catch module-level assignments (no enclosing scope).
224
+ if self._scope:
225
+ return
226
+ for target in node.targets:
227
+ if isinstance(target, ast.Name) and _is_constant_name(target.id):
228
+ self.symbols.append(
229
+ _RawSymbol(
230
+ name=target.id,
231
+ qualified_name=target.id,
232
+ kind="constant",
233
+ line=node.lineno,
234
+ end_line=getattr(node, "end_lineno", node.lineno),
235
+ signature=(
236
+ self._source_lines[node.lineno - 1].strip()
237
+ if 0 < node.lineno <= len(self._source_lines)
238
+ else f"{target.id} = ..."
239
+ ),
240
+ docstring_first_line=None,
241
+ decorators=(),
242
+ is_public=not target.id.startswith("_"),
243
+ )
244
+ )
245
+
246
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
247
+ # ``X: T = value`` at module level — same handling as Assign.
248
+ if self._scope:
249
+ return
250
+ if not isinstance(node.target, ast.Name):
251
+ return
252
+ name = node.target.id
253
+ if not _is_constant_name(name):
254
+ return
255
+ self.symbols.append(
256
+ _RawSymbol(
257
+ name=name,
258
+ qualified_name=name,
259
+ kind="constant",
260
+ line=node.lineno,
261
+ end_line=getattr(node, "end_lineno", node.lineno),
262
+ signature=(
263
+ self._source_lines[node.lineno - 1].strip()
264
+ if 0 < node.lineno <= len(self._source_lines)
265
+ else f"{name}: ..."
266
+ ),
267
+ docstring_first_line=None,
268
+ decorators=(),
269
+ is_public=not name.startswith("_"),
270
+ )
271
+ )
272
+
273
+ # ---- imports ----------------------------------------------------
274
+
275
+ def visit_Import(self, node: ast.Import) -> None:
276
+ for alias in node.names:
277
+ self.imports.append(
278
+ _RawImport(
279
+ to_module=alias.name,
280
+ line=node.lineno,
281
+ level=0,
282
+ )
283
+ )
284
+
285
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
286
+ # node.module is None for ``from . import x`` — represent as
287
+ # empty string so the resolver can still distinguish it from
288
+ # an absent module reference.
289
+ self.imports.append(
290
+ _RawImport(
291
+ to_module=node.module or "",
292
+ line=node.lineno,
293
+ level=node.level or 0,
294
+ )
295
+ )
296
+
297
+ # ---- helpers ----------------------------------------------------
298
+
299
+ def _qualify(self, name: str) -> str:
300
+ """Build the qualified name relative to the current scope.
301
+ ``["Outer", "Inner"] + "method"`` → ``"Outer.Inner.method"``."""
302
+ if not self._scope:
303
+ return name
304
+ return ".".join((*self._scope, name))
305
+
306
+ def _decorator_names(
307
+ self, decorator_list: list[ast.expr]
308
+ ) -> list[str]:
309
+ """Convert decorator AST nodes to display strings.
310
+
311
+ ``@foo`` → ``"foo"``
312
+ ``@foo.bar`` → ``"foo.bar"``
313
+ ``@foo(...)`` → ``"foo"`` (call args dropped)
314
+ ``@foo.bar(x)`` → ``"foo.bar"``
315
+ Anything else → ``ast.unparse`` (rare; lambdas, etc.).
316
+ """
317
+ out: list[str] = []
318
+ for dec in decorator_list:
319
+ out.append(_decorator_to_name(dec))
320
+ return out
321
+
322
+ def _record_landmarks(
323
+ self,
324
+ decorators: tuple[str, ...],
325
+ target_qualname: str,
326
+ node: ast.AST,
327
+ ) -> None:
328
+ """Match decorators against :data:`LANDMARK_DECORATORS` and
329
+ record any hits. Match is exact (dotted form) — we don't try
330
+ to handle aliased imports (``from click import command as
331
+ cmd``); that's an explicit choice to keep the matcher cheap.
332
+ """
333
+ for dec in decorators:
334
+ if dec in LANDMARK_DECORATORS:
335
+ self.decorators.append(
336
+ _RawDecorator(
337
+ decorator=dec,
338
+ target_qualname=target_qualname,
339
+ line=node.lineno,
340
+ )
341
+ )
342
+
343
+ def _signature(
344
+ self, node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef
345
+ ) -> str:
346
+ """The verbatim source for the ``def …`` / ``class …`` line.
347
+
348
+ Multi-line signatures (``def f(\\n a,\\n) -> int:``) are
349
+ collapsed onto one line by joining + whitespace-squashing,
350
+ because the index is text — a single-line ground-truth string
351
+ is more useful to the annotator than preserved formatting."""
352
+ start = node.lineno - 1 # 1-indexed to 0-indexed
353
+ # ``body`` is always non-empty for ClassDef / FunctionDef; ast
354
+ # guarantees at least one statement (often a Pass or Expr).
355
+ if not node.body:
356
+ return self._source_lines[start].strip()
357
+ body_start = node.body[0].lineno - 1
358
+ sig_lines = self._source_lines[start:body_start]
359
+ if not sig_lines:
360
+ return self._source_lines[start].strip()
361
+ joined = " ".join(line.strip() for line in sig_lines if line.strip())
362
+ # Trim trailing ``:`` whitespace — looks nicer in LOOM.md.
363
+ return joined.rstrip()
364
+
365
+
366
+ def _decorator_to_name(node: ast.expr) -> str:
367
+ """Stringify a decorator node to ``foo.bar`` form.
368
+
369
+ Handles three common cases explicitly + falls back to
370
+ ``ast.unparse`` for anything exotic (lambdas, walrus, etc.).
371
+ """
372
+ if isinstance(node, ast.Call):
373
+ return _decorator_to_name(node.func)
374
+ if isinstance(node, ast.Name):
375
+ return node.id
376
+ if isinstance(node, ast.Attribute):
377
+ # Walk the attribute chain right-to-left.
378
+ parts: list[str] = []
379
+ cur: ast.expr = node
380
+ while isinstance(cur, ast.Attribute):
381
+ parts.append(cur.attr)
382
+ cur = cur.value
383
+ if isinstance(cur, ast.Name):
384
+ parts.append(cur.id)
385
+ return ".".join(reversed(parts))
386
+ # Attribute on something complex (function call result, etc.)
387
+ # — fall through to unparse.
388
+ return ast.unparse(node)
389
+
390
+
391
+ def _is_constant_name(name: str) -> bool:
392
+ """A module-level assignment counts as a "constant" symbol if
393
+ it's NAMED_LIKE_THIS (uppercase + underscores) or is a
394
+ type-alias-style name (PascalCase).
395
+
396
+ Filter design: most module-level ``x = ...`` lines are private
397
+ plumbing the agent shouldn't care about. CONSTANT_CASE and
398
+ TypeAlias-style names are the ones with documentation value.
399
+ Dunders (``__all__``, ``__version__``) are caught here too —
400
+ they're useful index entries because they signal API surface.
401
+ """
402
+ if not name:
403
+ return False
404
+ if name.startswith("__") and name.endswith("__"):
405
+ return True
406
+ if name.isupper() or "_" in name and name.replace("_", "").isupper():
407
+ return True
408
+ # PascalCase (type aliases, sentinel singletons): first char upper,
409
+ # contains a lowercase letter somewhere (rule out ALL_CAPS twice).
410
+ if name[0].isupper() and any(c.islower() for c in name):
411
+ return True
412
+ return False
413
+
414
+
415
+ def _docstring_first_line(
416
+ node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
417
+ ) -> str | None:
418
+ """Return the first non-empty line of the symbol's docstring, or
419
+ ``None`` if absent. The annotator quotes this verbatim so the
420
+ LOOM.md entry stays grounded in what the code actually says
421
+ about itself."""
422
+ raw = ast.get_docstring(node)
423
+ if not raw:
424
+ return None
425
+ for line in raw.splitlines():
426
+ stripped = line.strip()
427
+ if stripped:
428
+ return stripped
429
+ return None