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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|