uncoded 0.5.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.
- uncoded/__init__.py +5 -0
- uncoded/cli.py +103 -0
- uncoded/config.py +62 -0
- uncoded/extract.py +137 -0
- uncoded/instruction_files.py +133 -0
- uncoded/namespace_map.py +84 -0
- uncoded/serena_setup.py +203 -0
- uncoded/skill.py +388 -0
- uncoded/stubs.py +389 -0
- uncoded/sync.py +50 -0
- uncoded-0.5.0.dist-info/METADATA +226 -0
- uncoded-0.5.0.dist-info/RECORD +15 -0
- uncoded-0.5.0.dist-info/WHEEL +4 -0
- uncoded-0.5.0.dist-info/entry_points.txt +2 -0
- uncoded-0.5.0.dist-info/licenses/LICENSE +21 -0
uncoded/stubs.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Generate .pyi stub files for agent navigation."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from uncoded.extract import _property_kind, iter_source_files
|
|
9
|
+
from uncoded.sync import remove_file, sync_file
|
|
10
|
+
|
|
11
|
+
# Width cap for inlining the right-hand side of an assignment. If the unparsed
|
|
12
|
+
# RHS exceeds this, it is elided to "..." so stubs stay compact and stable.
|
|
13
|
+
VALUE_WIDTH_CAP = 80
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class StubParam:
|
|
18
|
+
"""A function parameter with name and optional type annotation."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
annotation: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class StubFunction:
|
|
26
|
+
"""A function or method with its signature."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
params: list[StubParam] = field(default_factory=list)
|
|
30
|
+
return_annotation: str | None = None
|
|
31
|
+
docstring_excerpt: str | None = None
|
|
32
|
+
is_async: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class StubAssignment:
|
|
37
|
+
"""A module-level or class-level assignment.
|
|
38
|
+
|
|
39
|
+
``value_source`` is the rendered RHS if it fits within ``VALUE_WIDTH_CAP``,
|
|
40
|
+
or the literal string ``"..."`` if the RHS was elided. ``None`` means there
|
|
41
|
+
is no RHS at all (e.g. a bare annotation ``X: int``).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
annotation: str | None = None
|
|
46
|
+
value_source: str | None = None
|
|
47
|
+
is_type_alias: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class StubClass:
|
|
52
|
+
"""A class with its members."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
bases: list[str] = field(default_factory=list)
|
|
56
|
+
docstring_excerpt: str | None = None
|
|
57
|
+
attributes: list[StubAssignment] = field(default_factory=list)
|
|
58
|
+
methods: list[StubFunction] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class StubModule:
|
|
63
|
+
"""All symbols extracted from a single Python module."""
|
|
64
|
+
|
|
65
|
+
rel_path: str
|
|
66
|
+
imports: list[str] = field(default_factory=list)
|
|
67
|
+
constants: list[StubAssignment] = field(default_factory=list)
|
|
68
|
+
classes: list[StubClass] = field(default_factory=list)
|
|
69
|
+
functions: list[StubFunction] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _first_sentence(
|
|
73
|
+
node: ast.AsyncFunctionDef | ast.FunctionDef | ast.ClassDef | ast.Module,
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""Return the first sentence of a node's docstring, or None."""
|
|
76
|
+
docstring = ast.get_docstring(node)
|
|
77
|
+
if not docstring:
|
|
78
|
+
return None
|
|
79
|
+
text = docstring.strip()
|
|
80
|
+
match = re.match(r"(.+?\.)\s", text + " ")
|
|
81
|
+
if match:
|
|
82
|
+
return match.group(1)
|
|
83
|
+
return text.split("\n")[0].strip()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_params(args: ast.arguments) -> list[StubParam]:
|
|
87
|
+
"""Extract parameters from a function argument node, without defaults."""
|
|
88
|
+
params: list[StubParam] = []
|
|
89
|
+
|
|
90
|
+
for arg in args.posonlyargs:
|
|
91
|
+
annotation = ast.unparse(arg.annotation) if arg.annotation else None
|
|
92
|
+
params.append(StubParam(name=arg.arg, annotation=annotation))
|
|
93
|
+
if args.posonlyargs:
|
|
94
|
+
params.append(StubParam(name="/"))
|
|
95
|
+
|
|
96
|
+
for arg in args.args:
|
|
97
|
+
annotation = ast.unparse(arg.annotation) if arg.annotation else None
|
|
98
|
+
params.append(StubParam(name=arg.arg, annotation=annotation))
|
|
99
|
+
|
|
100
|
+
if args.vararg:
|
|
101
|
+
annotation = (
|
|
102
|
+
ast.unparse(args.vararg.annotation) if args.vararg.annotation else None
|
|
103
|
+
)
|
|
104
|
+
params.append(StubParam(name=f"*{args.vararg.arg}", annotation=annotation))
|
|
105
|
+
elif args.kwonlyargs:
|
|
106
|
+
params.append(StubParam(name="*"))
|
|
107
|
+
|
|
108
|
+
for arg in args.kwonlyargs:
|
|
109
|
+
annotation = ast.unparse(arg.annotation) if arg.annotation else None
|
|
110
|
+
params.append(StubParam(name=arg.arg, annotation=annotation))
|
|
111
|
+
|
|
112
|
+
if args.kwarg:
|
|
113
|
+
annotation = (
|
|
114
|
+
ast.unparse(args.kwarg.annotation) if args.kwarg.annotation else None
|
|
115
|
+
)
|
|
116
|
+
params.append(StubParam(name=f"**{args.kwarg.arg}", annotation=annotation))
|
|
117
|
+
|
|
118
|
+
return params
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render_value(value: ast.expr) -> str:
|
|
122
|
+
"""Render an expression as source, eliding to '...' if too long or multi-line."""
|
|
123
|
+
source = ast.unparse(value)
|
|
124
|
+
if len(source) > VALUE_WIDTH_CAP or "\n" in source:
|
|
125
|
+
return "..."
|
|
126
|
+
return source
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_assignment(
|
|
130
|
+
node: ast.Assign | ast.AnnAssign | ast.TypeAlias,
|
|
131
|
+
) -> StubAssignment | None:
|
|
132
|
+
"""Build a StubAssignment from an assignment-style AST node.
|
|
133
|
+
|
|
134
|
+
Returns None if the node's target is not a single simple name (e.g. tuple
|
|
135
|
+
unpacking or attribute assignment), which we can't represent cleanly.
|
|
136
|
+
"""
|
|
137
|
+
if isinstance(node, ast.TypeAlias):
|
|
138
|
+
if not isinstance(node.name, ast.Name):
|
|
139
|
+
return None
|
|
140
|
+
return StubAssignment(
|
|
141
|
+
name=node.name.id,
|
|
142
|
+
value_source=_render_value(node.value),
|
|
143
|
+
is_type_alias=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if isinstance(node, ast.AnnAssign):
|
|
147
|
+
if not isinstance(node.target, ast.Name):
|
|
148
|
+
return None
|
|
149
|
+
annotation = ast.unparse(node.annotation)
|
|
150
|
+
value_source = _render_value(node.value) if node.value is not None else None
|
|
151
|
+
return StubAssignment(
|
|
152
|
+
name=node.target.id,
|
|
153
|
+
annotation=annotation,
|
|
154
|
+
value_source=value_source,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# ast.Assign
|
|
158
|
+
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
|
|
159
|
+
return None
|
|
160
|
+
return StubAssignment(
|
|
161
|
+
name=node.targets[0].id,
|
|
162
|
+
value_source=_render_value(node.value),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_function(
|
|
167
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
168
|
+
) -> StubFunction:
|
|
169
|
+
"""Build a StubFunction from a function or method AST node."""
|
|
170
|
+
return StubFunction(
|
|
171
|
+
name=node.name,
|
|
172
|
+
params=_extract_params(node.args),
|
|
173
|
+
return_annotation=ast.unparse(node.returns) if node.returns else None,
|
|
174
|
+
docstring_excerpt=_first_sentence(node),
|
|
175
|
+
is_async=isinstance(node, ast.AsyncFunctionDef),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _property_attribute(
|
|
180
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
181
|
+
) -> StubAssignment:
|
|
182
|
+
"""Build a StubAssignment representing a @property as a class attribute."""
|
|
183
|
+
return StubAssignment(
|
|
184
|
+
name=node.name,
|
|
185
|
+
annotation=ast.unparse(node.returns) if node.returns else None,
|
|
186
|
+
value_source=None,
|
|
187
|
+
is_type_alias=False,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _extract_class(node: ast.ClassDef) -> StubClass:
|
|
192
|
+
"""Build a StubClass from a class AST node."""
|
|
193
|
+
bases = [ast.unparse(b) for b in node.bases]
|
|
194
|
+
|
|
195
|
+
attributes: list[StubAssignment] = []
|
|
196
|
+
methods: list[StubFunction] = []
|
|
197
|
+
|
|
198
|
+
for child in ast.iter_child_nodes(node):
|
|
199
|
+
if isinstance(child, (ast.AnnAssign, ast.Assign)):
|
|
200
|
+
assignment = _extract_assignment(child)
|
|
201
|
+
if assignment is not None:
|
|
202
|
+
attributes.append(assignment)
|
|
203
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
204
|
+
kind = _property_kind(child)
|
|
205
|
+
if kind == "setter" or kind == "deleter":
|
|
206
|
+
continue
|
|
207
|
+
if kind == "property":
|
|
208
|
+
attributes.append(_property_attribute(child))
|
|
209
|
+
else:
|
|
210
|
+
methods.append(_extract_function(child))
|
|
211
|
+
|
|
212
|
+
return StubClass(
|
|
213
|
+
name=node.name,
|
|
214
|
+
bases=bases,
|
|
215
|
+
docstring_excerpt=_first_sentence(node),
|
|
216
|
+
attributes=attributes,
|
|
217
|
+
methods=methods,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def extract_stub(source: str, rel_path: str) -> StubModule:
|
|
222
|
+
"""Parse Python source and extract imports, constants, classes, and functions."""
|
|
223
|
+
tree = ast.parse(source)
|
|
224
|
+
imports: list[str] = []
|
|
225
|
+
constants: list[StubAssignment] = []
|
|
226
|
+
classes: list[StubClass] = []
|
|
227
|
+
functions: list[StubFunction] = []
|
|
228
|
+
|
|
229
|
+
for node in ast.iter_child_nodes(tree):
|
|
230
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
231
|
+
imports.append(ast.unparse(node))
|
|
232
|
+
elif isinstance(node, ast.ClassDef):
|
|
233
|
+
classes.append(_extract_class(node))
|
|
234
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
235
|
+
functions.append(_extract_function(node))
|
|
236
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.TypeAlias)):
|
|
237
|
+
assignment = _extract_assignment(node)
|
|
238
|
+
if assignment is not None:
|
|
239
|
+
constants.append(assignment)
|
|
240
|
+
|
|
241
|
+
return StubModule(
|
|
242
|
+
rel_path=rel_path,
|
|
243
|
+
imports=imports,
|
|
244
|
+
constants=constants,
|
|
245
|
+
classes=classes,
|
|
246
|
+
functions=functions,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _render_param(p: StubParam) -> str:
|
|
251
|
+
"""Render a single parameter as a string for a function signature."""
|
|
252
|
+
if p.name in ("/", "*"):
|
|
253
|
+
return p.name
|
|
254
|
+
if p.annotation:
|
|
255
|
+
return f"{p.name}: {p.annotation}"
|
|
256
|
+
return p.name
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _render_function(func: StubFunction, indent: str = "") -> list[str]:
|
|
260
|
+
"""Render a function or method as stub lines, indented for methods."""
|
|
261
|
+
params_str = ", ".join(_render_param(p) for p in func.params)
|
|
262
|
+
ret = f" -> {func.return_annotation}" if func.return_annotation else ""
|
|
263
|
+
prefix = "async def" if func.is_async else "def"
|
|
264
|
+
lines = [f"{indent}{prefix} {func.name}({params_str}){ret}:"]
|
|
265
|
+
if func.docstring_excerpt:
|
|
266
|
+
lines.append(f'{indent} """{func.docstring_excerpt}"""')
|
|
267
|
+
lines.append(f"{indent} ...")
|
|
268
|
+
return lines
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _format_assignment_body(a: StubAssignment) -> str:
|
|
272
|
+
"""Render the 'name [: type] [= value]' portion of an assignment."""
|
|
273
|
+
if a.is_type_alias:
|
|
274
|
+
return f"type {a.name} = {a.value_source}"
|
|
275
|
+
head = f"{a.name}: {a.annotation}" if a.annotation else a.name
|
|
276
|
+
if a.value_source is None:
|
|
277
|
+
return head
|
|
278
|
+
return f"{head} = {a.value_source}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _render_assignment(a: StubAssignment, indent: str = "") -> str:
|
|
282
|
+
"""Render a module-level assignment as a stub line."""
|
|
283
|
+
body = _format_assignment_body(a)
|
|
284
|
+
return f"{indent}{body}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def render_stub(module: StubModule) -> str:
|
|
288
|
+
"""Render a StubModule as a .pyi file string."""
|
|
289
|
+
lines: list[str] = [f"# {module.rel_path}", ""]
|
|
290
|
+
|
|
291
|
+
if module.imports:
|
|
292
|
+
lines.extend(module.imports)
|
|
293
|
+
lines.append("")
|
|
294
|
+
|
|
295
|
+
for const in module.constants:
|
|
296
|
+
lines.append(_render_assignment(const))
|
|
297
|
+
if module.constants:
|
|
298
|
+
lines.append("")
|
|
299
|
+
|
|
300
|
+
for func in module.functions:
|
|
301
|
+
lines.extend(_render_function(func))
|
|
302
|
+
lines.append("")
|
|
303
|
+
|
|
304
|
+
for cls in module.classes:
|
|
305
|
+
bases_str = f"({', '.join(cls.bases)})" if cls.bases else ""
|
|
306
|
+
lines.append(f"class {cls.name}{bases_str}:")
|
|
307
|
+
if cls.docstring_excerpt:
|
|
308
|
+
lines.append(f' """{cls.docstring_excerpt}"""')
|
|
309
|
+
lines.append("")
|
|
310
|
+
|
|
311
|
+
for attr in cls.attributes:
|
|
312
|
+
lines.append(_render_assignment(attr, indent=" "))
|
|
313
|
+
if cls.attributes:
|
|
314
|
+
lines.append("")
|
|
315
|
+
|
|
316
|
+
for method in cls.methods:
|
|
317
|
+
lines.extend(_render_function(method, indent=" "))
|
|
318
|
+
lines.append("")
|
|
319
|
+
|
|
320
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _generate_stubs(source_root: Path) -> dict[Path, str]:
|
|
324
|
+
"""Return a mapping from stub relative paths to rendered stub content."""
|
|
325
|
+
result: dict[Path, str] = {}
|
|
326
|
+
for source, rel_path in iter_source_files(source_root):
|
|
327
|
+
try:
|
|
328
|
+
module = extract_stub(source, rel_path)
|
|
329
|
+
except SyntaxError:
|
|
330
|
+
continue
|
|
331
|
+
if not module.classes and not module.functions and not module.constants:
|
|
332
|
+
continue
|
|
333
|
+
result[Path(rel_path).with_suffix(".pyi")] = render_stub(module)
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
DEFAULT_STUBS_OUTPUT = Path(".uncoded/stubs")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def build_stubs(
|
|
341
|
+
source_root: Path,
|
|
342
|
+
output_dir: Path = DEFAULT_STUBS_OUTPUT,
|
|
343
|
+
*,
|
|
344
|
+
check: bool = False,
|
|
345
|
+
) -> int:
|
|
346
|
+
"""Sync stub files for all symbols under source_root, removing any orphans.
|
|
347
|
+
|
|
348
|
+
Writes only files whose content has changed. After reconciling the current
|
|
349
|
+
set of stubs, any pre-existing ``.pyi`` files in the corresponding subtree
|
|
350
|
+
of ``output_dir`` whose source has been removed or renamed are deleted,
|
|
351
|
+
and any directories left empty by the deletion are pruned. Only the
|
|
352
|
+
subtree corresponding to ``source_root`` is touched, so other source
|
|
353
|
+
roots' stubs are not affected.
|
|
354
|
+
|
|
355
|
+
When ``check=True``, the on-disk tree is not mutated; instead, prospective
|
|
356
|
+
writes and removals are reported and counted. Returns the number of
|
|
357
|
+
changes (or prospective changes).
|
|
358
|
+
"""
|
|
359
|
+
changes = 0
|
|
360
|
+
expected: set[Path] = set()
|
|
361
|
+
for rel_stub_path, content in _generate_stubs(source_root).items():
|
|
362
|
+
stub_path = output_dir / rel_stub_path
|
|
363
|
+
if sync_file(stub_path, content, check=check):
|
|
364
|
+
changes += 1
|
|
365
|
+
expected.add(stub_path.resolve())
|
|
366
|
+
|
|
367
|
+
base = Path.cwd().resolve()
|
|
368
|
+
try:
|
|
369
|
+
source_rel = source_root.resolve().relative_to(base)
|
|
370
|
+
except ValueError:
|
|
371
|
+
# source_root is outside cwd; we have no safe subtree to clean.
|
|
372
|
+
return changes
|
|
373
|
+
stubs_root = output_dir / source_rel
|
|
374
|
+
if not stubs_root.exists():
|
|
375
|
+
return changes
|
|
376
|
+
|
|
377
|
+
for existing in stubs_root.rglob("*.pyi"):
|
|
378
|
+
if existing.resolve() not in expected and remove_file(existing, check=check):
|
|
379
|
+
changes += 1
|
|
380
|
+
|
|
381
|
+
if check:
|
|
382
|
+
return changes
|
|
383
|
+
|
|
384
|
+
# Prune now-empty directories, deepest-first, but keep stubs_root itself.
|
|
385
|
+
for d in sorted(stubs_root.rglob("*"), key=lambda p: len(p.parts), reverse=True):
|
|
386
|
+
if d.is_dir() and not any(d.iterdir()):
|
|
387
|
+
d.rmdir()
|
|
388
|
+
|
|
389
|
+
return changes
|
uncoded/sync.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Content-aware file writes with an optional check-only mode.
|
|
2
|
+
|
|
3
|
+
Every place that writes an artifact (namespace map, stubs, instruction-file
|
|
4
|
+
sections) routes through :func:`sync_file` / :func:`remove_file` so that two
|
|
5
|
+
concerns live in one place: only write when content actually changes, and
|
|
6
|
+
when ``check=True`` report the prospective action without touching disk.
|
|
7
|
+
|
|
8
|
+
``check=True`` is what powers ``uncoded --check`` — the same pipeline runs,
|
|
9
|
+
but the writers never mutate the tree, and the CLI exits non-zero if any
|
|
10
|
+
helper reports a change. That gives CI a zero-mutation freshness gate.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sync_file(path: Path, content: str, *, check: bool = False) -> bool:
|
|
17
|
+
"""Write ``content`` to ``path`` if it differs from what's on disk.
|
|
18
|
+
|
|
19
|
+
Prints ``Wrote``/``Updated`` in apply mode, ``Would write``/``Would
|
|
20
|
+
update`` in check mode. Returns ``True`` if a write was (or would be)
|
|
21
|
+
performed, ``False`` if the file was already up to date. Parent
|
|
22
|
+
directories are created as needed.
|
|
23
|
+
"""
|
|
24
|
+
if not path.exists():
|
|
25
|
+
if not check:
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
path.write_text(content)
|
|
28
|
+
print(f"{'Would write' if check else 'Wrote'} {path}")
|
|
29
|
+
return True
|
|
30
|
+
if path.read_text() == content:
|
|
31
|
+
return False
|
|
32
|
+
if not check:
|
|
33
|
+
path.write_text(content)
|
|
34
|
+
print(f"{'Would update' if check else 'Updated'} {path}")
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def remove_file(path: Path, *, check: bool = False) -> bool:
|
|
39
|
+
"""Remove ``path`` if it exists.
|
|
40
|
+
|
|
41
|
+
Prints ``Removed`` in apply mode, ``Would remove`` in check mode.
|
|
42
|
+
Returns ``True`` if a removal was (or would be) performed, ``False``
|
|
43
|
+
if the file was already absent.
|
|
44
|
+
"""
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return False
|
|
47
|
+
if not check:
|
|
48
|
+
path.unlink()
|
|
49
|
+
print(f"{'Would remove' if check else 'Removed'} {path}")
|
|
50
|
+
return True
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uncoded
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Static symbol indexes for AI-assisted code navigation
|
|
5
|
+
Project-URL: Homepage, https://github.com/alimanfoo/uncoded
|
|
6
|
+
Project-URL: Repository, https://github.com/alimanfoo/uncoded
|
|
7
|
+
Author-email: Alistair Miles <alimanfoo@googlemail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: pyyaml>=6.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pre-commit>=3.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# uncoded
|
|
21
|
+
|
|
22
|
+
AI coding agents navigate codebases poorly. They grep for guessed keywords,
|
|
23
|
+
skim the first few lines of files, and fill gaps from pretraining rather than
|
|
24
|
+
reading the actual code. The result is plausible-looking output built on a
|
|
25
|
+
hallucinated understanding of code that's sitting right there, unread.
|
|
26
|
+
|
|
27
|
+
**uncoded** builds a static navigation index so agents can orient themselves
|
|
28
|
+
at the start of a task and navigate deterministically to what they need —
|
|
29
|
+
no guessing, no grep.
|
|
30
|
+
|
|
31
|
+
Additionally, **uncoded** provides some convenience for setting up your coding agent with a language server, so they can reliably find symbol usages and rename, edit, or delete symbols safely.
|
|
32
|
+
|
|
33
|
+
## What it generates
|
|
34
|
+
|
|
35
|
+
Running `uncoded sync` produces:
|
|
36
|
+
|
|
37
|
+
**`.uncoded/namespace.yaml`** — a hierarchical YAML file listing every symbol:
|
|
38
|
+
directories, files, classes (with attributes and methods), functions. Covers
|
|
39
|
+
all configured source roots. An agent can load this at the start of a task
|
|
40
|
+
and immediately know the full vocabulary of the codebase.
|
|
41
|
+
|
|
42
|
+
**`.uncoded/stubs/`** — one `.pyi` stub per source file, with imports, full
|
|
43
|
+
signatures (parameter names, types, return types), first-sentence docstrings,
|
|
44
|
+
module constants, and class attributes.
|
|
45
|
+
|
|
46
|
+
**`.claude/skills/coherence-review/SKILL.md`** and
|
|
47
|
+
**`.agents/skills/coherence-review/SKILL.md`** — a coherence review skill,
|
|
48
|
+
written to both Claude Code and Codex skill directories (see
|
|
49
|
+
[Coherence review](#coherence-review) below).
|
|
50
|
+
|
|
51
|
+
`uncoded` also injects a navigation protocol into `CLAUDE.md`/`AGENTS.md`, so agents
|
|
52
|
+
working in the repo pick up the instructions automatically.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
pip install uncoded
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or with uv:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
uv add uncoded
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configure
|
|
67
|
+
|
|
68
|
+
Add a `[tool.uncoded]` section to your `pyproject.toml`:
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[tool.uncoded]
|
|
72
|
+
source-roots = ["src", "tests"]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Use
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
uncoded sync
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run it from the repo root. It reads `pyproject.toml` to find your source
|
|
82
|
+
roots, builds the index, and updates `CLAUDE.md`/`AGENTS.md`.
|
|
83
|
+
|
|
84
|
+
Commit the generated `.uncoded/` directory so agents working
|
|
85
|
+
in the repo always have a current index.
|
|
86
|
+
|
|
87
|
+
## Keep it current with pre-commit
|
|
88
|
+
|
|
89
|
+
Add `uncoded sync` as a pre-commit hook so the index stays in sync
|
|
90
|
+
automatically:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
- repo: local
|
|
94
|
+
hooks:
|
|
95
|
+
- id: uncoded
|
|
96
|
+
name: uncoded
|
|
97
|
+
entry: uncoded sync
|
|
98
|
+
language: system
|
|
99
|
+
pass_filenames: false
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Like `ruff format`: if `uncoded sync` modifies any files, the commit
|
|
103
|
+
fails and you stage the updated index before committing again.
|
|
104
|
+
|
|
105
|
+
You can also set up your CI to run `pre-commit run --all-files` to verify the index is up to date.
|
|
106
|
+
|
|
107
|
+
## Verify the index is fresh
|
|
108
|
+
|
|
109
|
+
For CI or scripted checks that must not modify the working tree, use
|
|
110
|
+
the `check` subcommand:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
uncoded check
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
It runs the same pipeline but writes nothing. Exits 0 if every generated
|
|
117
|
+
file is byte-identical to what a rebuild would produce, and 1 otherwise
|
|
118
|
+
— printing which files would change. A stale index is a silent failure
|
|
119
|
+
mode (agents read misleading names and signatures), so gating on this in
|
|
120
|
+
CI is worthwhile even alongside a pre-commit hook.
|
|
121
|
+
|
|
122
|
+
## How agents use it
|
|
123
|
+
|
|
124
|
+
When `uncoded` is set up, a navigation section is automatically maintained in
|
|
125
|
+
the configured instruction files (by default, `CLAUDE.md` and `AGENTS.md`).
|
|
126
|
+
Agents following that protocol:
|
|
127
|
+
|
|
128
|
+
1. Read `.uncoded/namespace.yaml` to orient — every symbol, at a glance.
|
|
129
|
+
2. Read the relevant `.pyi` stubs to understand imports, signatures, constants, class members, and docstring summaries.
|
|
130
|
+
3. Use Serena's `find_symbol(..., include_body=True)` when they need implementation detail for a specific symbol.
|
|
131
|
+
|
|
132
|
+
The split is deliberate: `uncoded` provides a stable map and semantic summary;
|
|
133
|
+
Serena resolves the current source body. No grep, no stale line-number
|
|
134
|
+
coordinates, no offset arithmetic.
|
|
135
|
+
|
|
136
|
+
## Coherence review
|
|
137
|
+
|
|
138
|
+
AI coding agents tend to leave codebases in an incoherent state: names that
|
|
139
|
+
no longer match behaviour, docstrings that describe stale signatures, dead
|
|
140
|
+
symbols, pattern changes applied in some places but not others. `uncoded sync`
|
|
141
|
+
installs a `/coherence-review` skill that runs a structured diagnostic sweep to
|
|
142
|
+
find these problems.
|
|
143
|
+
|
|
144
|
+
Invoke it in Claude Code:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
/coherence-review
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The review works in four sweeps:
|
|
151
|
+
|
|
152
|
+
1. **Orient** — loads `namespace.yaml` and forms a vocabulary map.
|
|
153
|
+
2. **Lexical** — scans the namespace for naming inconsistency: concept
|
|
154
|
+
duplication, qualifier accretion (`_v2`, `_legacy`, `_final`), vocabulary
|
|
155
|
+
islands, name collision with drift.
|
|
156
|
+
3. **Promissory** — reads stubs, checking each symbol's name / signature /
|
|
157
|
+
docstring triple for internal disagreement.
|
|
158
|
+
4. **Structural** — checks for boundary violations (private symbols imported
|
|
159
|
+
across modules), overgrown public surfaces, cross-domain imports, and
|
|
160
|
+
zero-caller public symbols.
|
|
161
|
+
|
|
162
|
+
Output is a timestamped Markdown report saved to `.uncoded/reviews/`, with
|
|
163
|
+
verbatim evidence and a confidence level (high / medium / low) for each
|
|
164
|
+
finding. The review only reports — it proposes no fixes. The human decides
|
|
165
|
+
what to follow up.
|
|
166
|
+
|
|
167
|
+
## Using uncoded with a language server
|
|
168
|
+
|
|
169
|
+
Symbol-level operations — finding callers, reading or editing a single
|
|
170
|
+
symbol, renaming, safe deletion — are better served by a language server
|
|
171
|
+
than by grep and freeform text edits. Uncoded's map supplies the
|
|
172
|
+
`name_path` and `relative_path` these tools take as input.
|
|
173
|
+
|
|
174
|
+
The recommended setup is [oraios/serena][serena] as the MCP bridge with
|
|
175
|
+
[astral-sh/ty][ty] as the Python language-server backend. Serena launches
|
|
176
|
+
via `uvx`, so there's nothing to install globally; ty is downloaded by
|
|
177
|
+
Serena on first use.
|
|
178
|
+
|
|
179
|
+
### Setup
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
uv run uncoded setup-serena
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Generates three files, tailored for Claude Code:
|
|
186
|
+
|
|
187
|
+
- **`.mcp.json`** — registers Serena as an MCP server, launched via `uvx`.
|
|
188
|
+
- **`.serena/project.yml`** — picks ty as the backend, ignores `.uncoded/`,
|
|
189
|
+
drops the redundant `execute_shell_command`.
|
|
190
|
+
- **`.claude/settings.json`** — enables the Serena server and allowlists
|
|
191
|
+
its navigation, edit, and memory tools.
|
|
192
|
+
|
|
193
|
+
Safe to re-run: JSON files merge into existing content (so pre-existing
|
|
194
|
+
MCP servers and permissions are preserved), and the Serena project YAML
|
|
195
|
+
is left alone once present. Restart your agent afterwards so the new
|
|
196
|
+
MCP server is picked up.
|
|
197
|
+
|
|
198
|
+
If you're not using Claude Code, the generated `.serena/project.yml` is
|
|
199
|
+
MCP-client-agnostic, and `.mcp.json` can serve as a starting point —
|
|
200
|
+
replace `claude-code` with your client's context name.
|
|
201
|
+
|
|
202
|
+
[serena]: https://github.com/oraios/serena
|
|
203
|
+
[ty]: https://github.com/astral-sh/ty
|
|
204
|
+
|
|
205
|
+
## Dev setup
|
|
206
|
+
|
|
207
|
+
Clone, install dependencies, and wire up the pre-commit hooks:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
git clone https://github.com/alimanfoo/uncoded
|
|
211
|
+
cd uncoded
|
|
212
|
+
uv sync --extra dev
|
|
213
|
+
uv run pre-commit install
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Run the tests:
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
uv run pytest
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Run all checks (the same suite CI runs):
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
uv run pre-commit run --all-files
|
|
226
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
uncoded/__init__.py,sha256=MjXnTazgjAK1flha1TC6c-Mz42ovqHnwtMt1fwXif2g,89
|
|
2
|
+
uncoded/cli.py,sha256=TflX5JSgfv9W4RekYmYEhXou1-_D3z-1CjGo7drcNzc,3310
|
|
3
|
+
uncoded/config.py,sha256=7GBz4IQ_i-o4cXpKjTfQHTC7HDOH4tFN_M5jUppaNj8,1832
|
|
4
|
+
uncoded/extract.py,sha256=9FwDgSawQd6IthL3Qy4q1kGpY19dnRK0OTU8zF1mZg8,4562
|
|
5
|
+
uncoded/instruction_files.py,sha256=scKbl3Y2c_80i5PCWfX_-7JIX7noveE10Bi-k9o8kzc,5955
|
|
6
|
+
uncoded/namespace_map.py,sha256=s_gsKxDd18UtFxu4RtIRbrGDQl1pq2Pu3urI4l1kMaA,2523
|
|
7
|
+
uncoded/serena_setup.py,sha256=DowjJOje2bg29lkVVqnSxtCf2yMBnnX3pflxsYUQdgE,6719
|
|
8
|
+
uncoded/skill.py,sha256=4SZ0zCCrLBcO5d84D7_X5b8IHO0HH-AaZNSEPIXHx08,16641
|
|
9
|
+
uncoded/stubs.py,sha256=ns9e7lSz3FqHOuXitLHCrGzJi_tmuYDxgI4y8LFGoLs,12967
|
|
10
|
+
uncoded/sync.py,sha256=CkAcliTeoFTJeCmcdZGxlcFq0YR4gHYzH-dxCD609R8,1892
|
|
11
|
+
uncoded-0.5.0.dist-info/METADATA,sha256=mJUY5SWEoD9V3xiRs3Xd9vzovZpY8dd3QWgfxOyDsLk,7525
|
|
12
|
+
uncoded-0.5.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
uncoded-0.5.0.dist-info/entry_points.txt,sha256=C6dE7We5ZSE3z2KTWBzPceaRCS8XbgZ1WG8DV4S06H0,45
|
|
14
|
+
uncoded-0.5.0.dist-info/licenses/LICENSE,sha256=gDwBEl-d7vujEM9osJvZhkrb3GTU8VwDfYC4ekNcT1s,1071
|
|
15
|
+
uncoded-0.5.0.dist-info/RECORD,,
|