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 ADDED
@@ -0,0 +1,5 @@
1
+ """Uncoded."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("uncoded")
uncoded/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ """CLI entry point for uncoded."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from uncoded.config import read_instruction_files, read_source_roots
8
+ from uncoded.extract import walk_source
9
+ from uncoded.instruction_files import sync_instruction_file
10
+ from uncoded.namespace_map import build_map, render_map
11
+ from uncoded.serena_setup import setup_serena
12
+ from uncoded.skill import sync_skill
13
+ from uncoded.stubs import build_stubs
14
+ from uncoded.sync import sync_file
15
+
16
+ DEFAULT_MAP_OUTPUT = Path(".uncoded/namespace.yaml")
17
+
18
+
19
+ def _sync(*, check: bool = False) -> int:
20
+ """Sync (or verify) the namespace map, stub files, and instruction-file sections.
21
+
22
+ When ``check=True``, the on-disk tree is not mutated: each step reports
23
+ whether it would write. Returns 1 if any step reports a prospective
24
+ change (so CI can gate on a stale index), 0 if the tree is already in
25
+ sync. In apply mode, returns 0 on success or 1 on configuration error.
26
+ """
27
+ try:
28
+ source_roots = [r.resolve() for r in read_source_roots()]
29
+ except (FileNotFoundError, KeyError) as e:
30
+ print(f"Error: {e}", file=sys.stderr)
31
+ return 1
32
+
33
+ for root in source_roots:
34
+ if not root.is_dir():
35
+ print(f"Error: {root} is not a directory", file=sys.stderr)
36
+ return 1
37
+
38
+ changes = 0
39
+
40
+ modules = [m for root in source_roots for m in walk_source(root)]
41
+ map_content = render_map(build_map(modules))
42
+ if sync_file(DEFAULT_MAP_OUTPUT, map_content, check=check):
43
+ changes += 1
44
+
45
+ for root in source_roots:
46
+ changes += build_stubs(root, check=check)
47
+
48
+ for path in read_instruction_files():
49
+ if sync_instruction_file(path, check=check):
50
+ changes += 1
51
+
52
+ if sync_skill(check=check):
53
+ changes += 1
54
+
55
+ if check:
56
+ if changes:
57
+ print(f"Index out of date: {changes} file(s) would change.")
58
+ return 1
59
+ print("Index is up to date.")
60
+ return 0
61
+ return 0
62
+
63
+
64
+ def main() -> int:
65
+ """Dispatch the uncoded CLI.
66
+
67
+ Three subcommands: ``sync`` builds or refreshes the navigation index;
68
+ ``check`` verifies the index matches what a rebuild would produce
69
+ (exits non-zero on drift, useful in CI); ``setup-serena`` generates
70
+ MCP and Claude Code config for the recommended Serena + ty LSP
71
+ integration.
72
+ """
73
+ parser = argparse.ArgumentParser(
74
+ prog="uncoded",
75
+ description="Build a navigation index for AI coding agents.",
76
+ )
77
+ subparsers = parser.add_subparsers(dest="command", required=True)
78
+ subparsers.add_parser(
79
+ "sync",
80
+ help=(
81
+ "Build or refresh the namespace map, stub files, and "
82
+ "instruction-file sections."
83
+ ),
84
+ )
85
+ subparsers.add_parser(
86
+ "check",
87
+ help=(
88
+ "Verify the index is up to date without writing. Exits non-zero "
89
+ "if any file would change. Useful in CI."
90
+ ),
91
+ )
92
+ subparsers.add_parser(
93
+ "setup-serena",
94
+ help=(
95
+ "Write .mcp.json, .serena/project.yml, and .claude/settings.json "
96
+ "for the recommended Serena + ty LSP integration."
97
+ ),
98
+ )
99
+ args = parser.parse_args()
100
+
101
+ if args.command == "setup-serena":
102
+ return setup_serena()
103
+ return _sync(check=args.command == "check")
uncoded/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Read uncoded configuration from pyproject.toml."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from uncoded.instruction_files import DEFAULT_INSTRUCTION_FILES
7
+
8
+
9
+ def find_pyproject_toml() -> Path | None:
10
+ """Search for pyproject.toml starting from cwd, walking up."""
11
+ current = Path.cwd()
12
+ while True:
13
+ candidate = current / "pyproject.toml"
14
+ if candidate.exists():
15
+ return candidate
16
+ parent = current.parent
17
+ if parent == current:
18
+ return None
19
+ current = parent
20
+
21
+
22
+ def read_source_roots() -> list[Path]:
23
+ """Read source roots from [tool.uncoded] source-roots in pyproject.toml."""
24
+ toml_path = find_pyproject_toml()
25
+ if toml_path is None:
26
+ raise FileNotFoundError(
27
+ "No pyproject.toml found. Add [tool.uncoded] source-roots to configure."
28
+ )
29
+
30
+ with toml_path.open("rb") as f:
31
+ data = tomllib.load(f)
32
+
33
+ try:
34
+ roots = data["tool"]["uncoded"]["source-roots"]
35
+ except KeyError:
36
+ raise KeyError(
37
+ "No [tool.uncoded] source-roots found in pyproject.toml."
38
+ ) from None
39
+
40
+ return [Path(r) for r in roots]
41
+
42
+
43
+ def read_instruction_files() -> list[Path]:
44
+ """Read instruction files from [tool.uncoded] instruction-files in pyproject.toml.
45
+
46
+ Falls back to ``DEFAULT_INSTRUCTION_FILES`` if the key is absent or no
47
+ ``pyproject.toml`` is found, so that ``uncoded`` works on a fresh repo
48
+ without explicit configuration.
49
+ """
50
+ toml_path = find_pyproject_toml()
51
+ if toml_path is None:
52
+ return list(DEFAULT_INSTRUCTION_FILES)
53
+
54
+ with toml_path.open("rb") as f:
55
+ data = tomllib.load(f)
56
+
57
+ try:
58
+ files = data["tool"]["uncoded"]["instruction-files"]
59
+ except KeyError:
60
+ return list(DEFAULT_INSTRUCTION_FILES)
61
+
62
+ return [Path(f) for f in files]
uncoded/extract.py ADDED
@@ -0,0 +1,137 @@
1
+ """Extract symbols from Python source files using the AST."""
2
+
3
+ import ast
4
+ from collections.abc import Iterator
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class ClassInfo:
11
+ """A class with its attributes and methods."""
12
+
13
+ name: str
14
+ attributes: list[str] = field(default_factory=list)
15
+ methods: list[str] = field(default_factory=list)
16
+
17
+
18
+ @dataclass
19
+ class ModuleInfo:
20
+ """Symbols found in a single Python module."""
21
+
22
+ rel_path: str
23
+ constants: list[str] = field(default_factory=list)
24
+ classes: list[ClassInfo] = field(default_factory=list)
25
+ functions: list[str] = field(default_factory=list)
26
+
27
+
28
+ def _property_kind(
29
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
30
+ ) -> str | None:
31
+ """Classify a method by its property-related decorators.
32
+
33
+ Returns "property" for @property, "setter" for @<name>.setter,
34
+ "deleter" for @<name>.deleter, or None for a plain method.
35
+ """
36
+ for d in node.decorator_list:
37
+ if isinstance(d, ast.Name) and d.id == "property":
38
+ return "property"
39
+ if isinstance(d, ast.Attribute) and d.attr in ("setter", "deleter"):
40
+ return d.attr
41
+ return None
42
+
43
+
44
+ def _assign_target_name(node: ast.Assign | ast.AnnAssign) -> str | None:
45
+ """Return the single-name target of an assignment, or None if not a simple name."""
46
+ if isinstance(node, ast.AnnAssign):
47
+ target = node.target
48
+ return target.id if isinstance(target, ast.Name) else None
49
+ if len(node.targets) != 1:
50
+ return None
51
+ target = node.targets[0]
52
+ return target.id if isinstance(target, ast.Name) else None
53
+
54
+
55
+ def extract_module(source: str, rel_path: str) -> ModuleInfo:
56
+ """Parse Python source and extract classes, functions, and constants."""
57
+ tree = ast.parse(source)
58
+
59
+ constants: list[str] = []
60
+ classes: list[ClassInfo] = []
61
+ functions: list[str] = []
62
+
63
+ for node in ast.iter_child_nodes(tree):
64
+ if isinstance(node, ast.ClassDef):
65
+ attributes: list[str] = []
66
+ methods: list[str] = []
67
+ for n in ast.iter_child_nodes(node):
68
+ if isinstance(n, (ast.AnnAssign, ast.Assign)):
69
+ name = _assign_target_name(n)
70
+ if name:
71
+ attributes.append(name)
72
+ elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
73
+ kind = _property_kind(n)
74
+ if kind == "setter" or kind == "deleter":
75
+ continue
76
+ if kind == "property":
77
+ attributes.append(n.name)
78
+ else:
79
+ methods.append(n.name)
80
+ classes.append(
81
+ ClassInfo(name=node.name, attributes=attributes, methods=methods)
82
+ )
83
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
84
+ functions.append(node.name)
85
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
86
+ name = _assign_target_name(node)
87
+ if name:
88
+ constants.append(name)
89
+ elif isinstance(node, ast.TypeAlias) and isinstance(node.name, ast.Name):
90
+ constants.append(node.name.id)
91
+
92
+ return ModuleInfo(
93
+ rel_path=rel_path,
94
+ constants=constants,
95
+ classes=classes,
96
+ functions=functions,
97
+ )
98
+
99
+
100
+ def iter_source_files(
101
+ source_root: Path, base: Path | None = None
102
+ ) -> Iterator[tuple[str, str]]:
103
+ """Yield (source_text, rel_path) for every Python file under *source_root*.
104
+
105
+ Paths are relative to *base* (defaults to cwd).
106
+ """
107
+ if base is None:
108
+ base = Path.cwd()
109
+
110
+ source_root = source_root.resolve()
111
+ base = base.resolve()
112
+
113
+ for py_file in sorted(source_root.rglob("*.py")):
114
+ rel_path = str(py_file.relative_to(base))
115
+ yield py_file.read_text(), rel_path
116
+
117
+
118
+ def walk_source(source_root: Path, base: Path | None = None) -> list[ModuleInfo]:
119
+ """Walk a source root and extract symbols from all Python files.
120
+
121
+ Paths in the returned ModuleInfo are relative to *base* (defaults to
122
+ cwd), so they can be used directly to open files from the repo root.
123
+
124
+ Skips files with no symbols and files with syntax errors.
125
+ """
126
+ modules: list[ModuleInfo] = []
127
+
128
+ for source, rel_path in iter_source_files(source_root, base):
129
+ try:
130
+ module = extract_module(source, rel_path)
131
+ except SyntaxError:
132
+ continue
133
+
134
+ if module.classes or module.functions or module.constants:
135
+ modules.append(module)
136
+
137
+ return modules
@@ -0,0 +1,133 @@
1
+ """Maintain the uncoded navigation section in agent instruction files.
2
+
3
+ Different coding agents read different instruction files from a repo's root.
4
+ Claude Code reads ``CLAUDE.md``; an emerging cross-agent convention uses
5
+ ``AGENTS.md``. Until the ecosystem converges, a project that wants to support
6
+ both populations needs both files, with the same navigation guidance in each.
7
+ This module owns a delimited section in any such file and keeps it in sync.
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+ from uncoded.sync import sync_file
13
+
14
+ MARKER_START = "<!-- uncoded:start -->"
15
+ MARKER_END = "<!-- uncoded:end -->"
16
+
17
+ DEFAULT_INSTRUCTION_FILES = [Path("CLAUDE.md"), Path("AGENTS.md")]
18
+
19
+ _SECTION_BODY = """\
20
+ ## How to read and edit code in this codebase
21
+
22
+ This repo uses [uncoded](https://github.com/alimanfoo/uncoded) to maintain
23
+ a symbol index over its source code, designed for AI agents to navigate
24
+ deterministically rather than by grep-and-skim. For source navigation, use
25
+ the index — grep-and-skim produces a noisier, slower version of what the
26
+ index already lists in full. (For free-text search elsewhere —
27
+ Markdown, configs, commit messages — grep remains the right tool. The index
28
+ is about source.)
29
+
30
+ **Step 1 — Orient. Do this now, before anything else.** Your first action
31
+ in this session — before answering the user, before any other tool call —
32
+ is to read the namespace map in full:
33
+
34
+ ```
35
+ Read .uncoded/namespace.yaml
36
+ ```
37
+
38
+ Do this once, immediately, at session start — not "eventually" or "when
39
+ a code question comes up." Without the map loaded, design and navigation
40
+ answers will come from pretrained guesses rather than the code actually
41
+ here. The map lists every symbol in the codebase — directories, files,
42
+ classes, methods, functions — in source order.
43
+
44
+ **Step 2 — Understand.** Before reading any `.py` source file in this repo,
45
+ read its `.pyi` stub first. Stub paths mirror source paths under
46
+ `.uncoded/stubs/`:
47
+
48
+ ```
49
+ src/foo/bar.py → .uncoded/stubs/src/foo/bar.pyi
50
+ tests/test_foo.py → .uncoded/stubs/tests/test_foo.pyi
51
+ ```
52
+
53
+ This applies to every file you intend to touch or reference — including
54
+ tests. The stub is sufficient for most navigation: it contains imports,
55
+ every signature with types, module-level assignments, class attributes,
56
+ and first-sentence docstrings. Skipping to source means reading many
57
+ lines to learn what the stub would have told you in one. If no stub
58
+ exists at the expected path, the file has no symbols indexed — in that
59
+ narrow case, read source directly.
60
+
61
+ **Step 3 — Read source through Serena.** When you need source beyond
62
+ what the stub shows, use Serena to read exactly the symbol body. The
63
+ namespace map and stub give you the exact `relative_path` and `name_path`
64
+ Serena needs — e.g. `ClassName/method` for a method,
65
+ `function_name` for a top-level function.
66
+
67
+ Stubs intentionally do not carry source line ranges: line numbers churn
68
+ when nearby code moves, creating noisy generated diffs and teaching
69
+ agents to trust stale coordinates. Let uncoded provide the stable map
70
+ and signatures; let Serena resolve the current source body.
71
+
72
+ **For symbol-level operations — use Serena.** Where Serena's MCP tools
73
+ are available (`mcp__serena__*` in the tool list), prefer them over
74
+ Read / Edit / grep for anything that operates on a symbol as a unit.
75
+
76
+ - **Read one symbol body.** `find_symbol` with `include_body=True`
77
+ returns exactly the symbol — no offset arithmetic, no risk of reading
78
+ too much. Stay on stubs for a wider sweep; use Serena when you need
79
+ implementation detail.
80
+ - **Find callers, or check whether a symbol is dead.**
81
+ `find_referencing_symbols` returns every reference resolved by the
82
+ language server. Do not grep for the name — grep hits comments,
83
+ strings, attribute lookups on unrelated types, and re-exports.
84
+ - **Rename.** `rename_symbol` updates every reference across the
85
+ codebase in one call. Multi-file find-and-replace misses imports
86
+ and re-exports and racks up false positives.
87
+ - **Edit a whole symbol.** `replace_symbol_body`,
88
+ `insert_before_symbol`, and `insert_after_symbol` operate on the
89
+ symbol as a unit. Immune to the Edit tool's "string not unique"
90
+ failure mode, never accidentally modify a similarly-named
91
+ neighbour, and keep surrounding indentation consistent.
92
+ - **Delete a symbol.** `safe_delete_symbol` checks for live
93
+ references before removing — dead code goes cleanly, live code
94
+ stays put.
95
+
96
+ Reach for Read + Edit when Serena does not fit: free-text files
97
+ (Markdown, YAML, configs), partial-line edits inside a function body
98
+ after you have retrieved it, environments where Serena is unavailable,
99
+ or the rare stub-less Python file that needs exploratory reading."""
100
+
101
+ SECTION = f"{MARKER_START}\n{_SECTION_BODY}\n{MARKER_END}\n"
102
+
103
+
104
+ def generate_section() -> str:
105
+ """Return the full delimited uncoded section for an instruction file."""
106
+ return SECTION
107
+
108
+
109
+ def _replace_or_append(existing: str, section: str) -> str:
110
+ """Replace the delimited section in existing text, or append it if absent."""
111
+ start = existing.find(MARKER_START)
112
+ end = existing.find(MARKER_END)
113
+ if start != -1 and end != -1 and start < end:
114
+ before = existing[:start]
115
+ after = existing[end + len(MARKER_END) :].lstrip("\n")
116
+ return before + section + after
117
+ stripped = existing.rstrip("\n")
118
+ prefix = stripped + "\n\n" if stripped else ""
119
+ return prefix + section
120
+
121
+
122
+ def sync_instruction_file(path: Path, *, check: bool = False) -> bool:
123
+ """Write or update the uncoded navigation section in an instruction file.
124
+
125
+ When ``check=True``, reports a prospective change without touching disk.
126
+ Returns ``True`` if a write was (or would be) performed.
127
+ """
128
+ section = generate_section()
129
+ if not path.exists():
130
+ return sync_file(path, section, check=check)
131
+ existing = path.read_text()
132
+ updated = _replace_or_append(existing, section)
133
+ return sync_file(path, updated, check=check)
@@ -0,0 +1,84 @@
1
+ """Generate a YAML namespace map from extracted symbols."""
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ from uncoded.extract import ModuleInfo
8
+
9
+ HEADER = """\
10
+ # Symbol index of this codebase, for agent navigation.
11
+ # Generated by uncoded — do not edit; regeneration overwrites.
12
+ #
13
+ # Pure key hierarchy (no lists, no values); indent to zoom in.
14
+ # Directory keys end with "/". Entries within a file or class appear
15
+ # in source order.
16
+ #
17
+ # For signatures and types, see stubs:
18
+ # src/foo/bar.py → .uncoded/stubs/src/foo/bar.pyi
19
+ """
20
+
21
+
22
+ class _CleanDumper(yaml.SafeDumper):
23
+ """YAML dumper that indents list items and suppresses 'null' values."""
24
+
25
+ def increase_indent(self, flow=False, indentless=False):
26
+ """Force list items to be indented relative to their parent key."""
27
+ return super().increase_indent(flow, False)
28
+
29
+
30
+ # Render None as empty rather than "null" so leaf symbols appear as just "name:"
31
+ _CleanDumper.add_representer(
32
+ type(None),
33
+ lambda dumper, _: dumper.represent_scalar("tag:yaml.org,2002:null", ""),
34
+ )
35
+
36
+
37
+ def build_map(modules: list[ModuleInfo]) -> dict:
38
+ """Build a nested dict representing the namespace.
39
+
40
+ Keys are repo-relative paths. Directory keys have a trailing slash.
41
+ Symbols sit directly under their file key.
42
+ """
43
+ root: dict = {}
44
+
45
+ for module in modules:
46
+ parts = Path(module.rel_path).parts
47
+ current = root
48
+
49
+ # Create intermediate directory entries
50
+ for dir_part in parts[:-1]:
51
+ key = dir_part + "/"
52
+ if key not in current:
53
+ current[key] = {}
54
+ current = current[key]
55
+
56
+ # Build the file entry — symbols directly under the file key.
57
+ # Classes with methods map to their method list.
58
+ # Classes without methods, bare functions, and constants map to None.
59
+ file_entry: dict = {}
60
+
61
+ for const in module.constants:
62
+ file_entry[const] = None
63
+
64
+ for cls in module.classes:
65
+ members = cls.attributes + cls.methods
66
+ file_entry[cls.name] = {m: None for m in members} if members else None
67
+
68
+ for func in module.functions:
69
+ file_entry[func] = None
70
+
71
+ current[parts[-1]] = file_entry
72
+
73
+ return root
74
+
75
+
76
+ def render_map(namespace: dict) -> str:
77
+ """Render a namespace map dict as a YAML string with an explanatory header."""
78
+ body = yaml.dump(
79
+ namespace,
80
+ Dumper=_CleanDumper,
81
+ default_flow_style=False,
82
+ sort_keys=False,
83
+ )
84
+ return HEADER + "\n" + body