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/__init__.py
ADDED
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)
|
uncoded/namespace_map.py
ADDED
|
@@ -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
|