repo-context-optimizer 0.1.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.
- rco/__init__.py +3 -0
- rco/__main__.py +6 -0
- rco/build.py +88 -0
- rco/canon.py +127 -0
- rco/check.py +229 -0
- rco/cli.py +202 -0
- rco/diff.py +266 -0
- rco/extract/__init__.py +38 -0
- rco/extract/cli.py +896 -0
- rco/extract/python.py +160 -0
- rco/extract/routes.py +297 -0
- rco/extract/sql.py +755 -0
- rco/fixture.py +87 -0
- rco/forest.py +208 -0
- rco/forest_model.py +238 -0
- rco/gitread.py +97 -0
- rco/imports.py +103 -0
- rco/model.py +115 -0
- rco/modules.py +19 -0
- rco/output.py +103 -0
- rco/pack.py +266 -0
- rco/pin.py +171 -0
- rco/prose/__init__.py +60 -0
- rco/prose/check.py +54 -0
- rco/prose/llm.py +36 -0
- rco/prose/mine.py +75 -0
- rco/redact.py +198 -0
- rco/render.py +48 -0
- rco/store/__init__.py +151 -0
- rco/store/sqlite.py +201 -0
- rco/triage.py +158 -0
- rco/update.py +164 -0
- repo_context_optimizer-0.1.0.dist-info/METADATA +443 -0
- repo_context_optimizer-0.1.0.dist-info/RECORD +38 -0
- repo_context_optimizer-0.1.0.dist-info/WHEEL +5 -0
- repo_context_optimizer-0.1.0.dist-info/entry_points.txt +2 -0
- repo_context_optimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
- repo_context_optimizer-0.1.0.dist-info/top_level.txt +1 -0
rco/__init__.py
ADDED
rco/__main__.py
ADDED
rco/build.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Build Trees and Maps from a commit (tree-contract section 6)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from rco import canon, extract, gitread, modules, redact
|
|
9
|
+
from rco.model import Kind, Map, Module, Node, SourceFile, Tree, node_sort_key
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _sorted(n: Node) -> Node:
|
|
13
|
+
return replace(
|
|
14
|
+
n,
|
|
15
|
+
attrs=tuple(sorted(n.attrs)),
|
|
16
|
+
children=tuple(sorted((_sorted(c) for c in n.children), key=node_sort_key)),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def file_node(path: str, attrs: tuple[tuple[str, str], ...] = ()) -> Node:
|
|
21
|
+
return Node(Kind.FILE, PurePosixPath(path).name, path, attrs=attrs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def nodes_for(src: SourceFile, link: bool = False) -> list[Node]:
|
|
25
|
+
"""Every extractor that claims the file; FILE outline when none does."""
|
|
26
|
+
if link:
|
|
27
|
+
return [file_node(src.path, (("link", "1"),))]
|
|
28
|
+
if src.data.startswith(gitread.LFS_MAGIC):
|
|
29
|
+
return [file_node(src.path, (("lfs", "pointer"),))]
|
|
30
|
+
out: list[Node] = []
|
|
31
|
+
claimed = False
|
|
32
|
+
for ex in extract.EXTRACTORS:
|
|
33
|
+
if ex.claims(src.path):
|
|
34
|
+
claimed = True
|
|
35
|
+
out.extend(ex.extract(src))
|
|
36
|
+
if not claimed:
|
|
37
|
+
return [file_node(src.path)]
|
|
38
|
+
# duplicate FILE error nodes from several extractors collapse into one
|
|
39
|
+
seen: set[tuple] = set()
|
|
40
|
+
uniq = []
|
|
41
|
+
for n in out:
|
|
42
|
+
if n.kind is Kind.FILE:
|
|
43
|
+
k = (n.name, n.path, n.attrs)
|
|
44
|
+
if k in seen:
|
|
45
|
+
continue
|
|
46
|
+
seen.add(k)
|
|
47
|
+
uniq.append(n)
|
|
48
|
+
return uniq
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_tree(module: Module, files: Sequence[SourceFile],
|
|
52
|
+
links: frozenset[str] = frozenset()) -> Tree:
|
|
53
|
+
"""Tree of one module. files must cover module.files; links are symlink paths."""
|
|
54
|
+
by_path = {f.path: f for f in files}
|
|
55
|
+
nodes: list[Node] = []
|
|
56
|
+
for p in module.files:
|
|
57
|
+
nodes.extend(nodes_for(by_path[p], p in links))
|
|
58
|
+
nodes = [_sorted(redact.redact_node(n)) for n in nodes]
|
|
59
|
+
vers = extract.versions()
|
|
60
|
+
return Tree(
|
|
61
|
+
module=module.id,
|
|
62
|
+
module_hash=canon.module_hash(module, {f.path: f.blob for f in files}, vers),
|
|
63
|
+
extractors=vers,
|
|
64
|
+
nodes=tuple(sorted(nodes, key=node_sort_key)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_map(repo: Path, sha: str, only: Sequence[str] | None = None) -> Map:
|
|
69
|
+
"""Map of every module at sha, or of the modules that own the paths in only."""
|
|
70
|
+
notes: list[str] = []
|
|
71
|
+
entries: list[gitread.Entry] = []
|
|
72
|
+
for e in gitread.ls_tree(repo, sha):
|
|
73
|
+
if e.mode == gitread.MODE_SUBMODULE:
|
|
74
|
+
notes.append(f"skipped submodule {e.path}")
|
|
75
|
+
else:
|
|
76
|
+
entries.append(e)
|
|
77
|
+
mods = modules.group(e.path for e in entries)
|
|
78
|
+
if only is not None:
|
|
79
|
+
want = {modules.module_of(p.strip("/")) for p in only}
|
|
80
|
+
mods = tuple(m for m in mods if m.id in want)
|
|
81
|
+
keep = {p for m in mods for p in m.files}
|
|
82
|
+
entries = [e for e in entries if e.path in keep]
|
|
83
|
+
links = frozenset(e.path for e in entries if e.mode == gitread.MODE_SYMLINK)
|
|
84
|
+
data = gitread.read_blobs(repo, [e.blob for e in entries if e.path not in links])
|
|
85
|
+
srcs = {e.path: SourceFile(e.path, e.blob, b"" if e.path in links else data[e.blob])
|
|
86
|
+
for e in entries}
|
|
87
|
+
trees = tuple(build_tree(m, [srcs[p] for p in m.files], links) for m in mods)
|
|
88
|
+
return Map(sha=sha, trees=trees, notes=tuple(sorted(notes)))
|
rco/canon.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Canonical JSON and hashes (docs/design/tree-contract.md, section 3)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from rco.model import Kind, Map, Module, Node, Tree
|
|
8
|
+
|
|
9
|
+
SCHEMA_TREE = "rco.tree/1"
|
|
10
|
+
SCHEMA_MAP = "rco.map/1"
|
|
11
|
+
SCHEMA_MODULEHASH = "rco.modulehash/1"
|
|
12
|
+
|
|
13
|
+
_NODE_KEYS = frozenset({"kind", "name", "path", "sig", "doc", "attrs", "children"})
|
|
14
|
+
_TREE_KEYS = frozenset({"schema", "module", "module_hash", "extractors", "nodes", "tree_hash"})
|
|
15
|
+
_MAP_KEYS = frozenset({"schema", "sha", "notes", "modules"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical_json(obj) -> bytes:
|
|
19
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
|
|
20
|
+
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sha256_tag(b: bytes) -> str:
|
|
24
|
+
return "sha256:" + hashlib.sha256(b).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def node_to_obj(n: Node) -> dict:
|
|
28
|
+
o: dict = {"kind": n.kind.value, "name": n.name}
|
|
29
|
+
if n.path:
|
|
30
|
+
o["path"] = n.path
|
|
31
|
+
if n.sig:
|
|
32
|
+
o["sig"] = n.sig
|
|
33
|
+
if n.doc:
|
|
34
|
+
o["doc"] = n.doc
|
|
35
|
+
if n.attrs:
|
|
36
|
+
o["attrs"] = dict(n.attrs)
|
|
37
|
+
if n.children:
|
|
38
|
+
o["children"] = [node_to_obj(c) for c in n.children]
|
|
39
|
+
return o
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tree_to_obj(t: Tree) -> dict:
|
|
43
|
+
return {
|
|
44
|
+
"schema": SCHEMA_TREE,
|
|
45
|
+
"module": t.module,
|
|
46
|
+
"module_hash": t.module_hash,
|
|
47
|
+
"extractors": [list(e) for e in t.extractors],
|
|
48
|
+
"nodes": [node_to_obj(n) for n in t.nodes],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tree_hash(t: Tree) -> str:
|
|
53
|
+
"""sha256 of the Tree's contract: everything but module_hash.
|
|
54
|
+
|
|
55
|
+
module_hash moves on any file edit; TreeHash must move only when the
|
|
56
|
+
contract does (README incremental update: "TreeHash changed? no,
|
|
57
|
+
internal change").
|
|
58
|
+
"""
|
|
59
|
+
obj = tree_to_obj(t)
|
|
60
|
+
del obj["module_hash"]
|
|
61
|
+
return sha256_tag(canonical_json(obj))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def map_to_obj(m: Map) -> dict:
|
|
65
|
+
mods = []
|
|
66
|
+
for t in m.trees:
|
|
67
|
+
o = tree_to_obj(t)
|
|
68
|
+
o["tree_hash"] = tree_hash(t)
|
|
69
|
+
mods.append(o)
|
|
70
|
+
return {"schema": SCHEMA_MAP, "sha": m.sha, "notes": list(m.notes), "modules": mods}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def module_hash(module: Module, blobs: dict[str, str],
|
|
74
|
+
extractors: tuple[tuple[str, str], ...]) -> str:
|
|
75
|
+
return sha256_tag(canonical_json({
|
|
76
|
+
"schema": SCHEMA_MODULEHASH,
|
|
77
|
+
"module": module.id,
|
|
78
|
+
"files": [[p, blobs[p]] for p in module.files],
|
|
79
|
+
"extractors": [list(e) for e in extractors],
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _strict(o: dict, allowed: frozenset[str], what: str) -> None:
|
|
84
|
+
if not isinstance(o, dict):
|
|
85
|
+
raise ValueError(f"{what}: expected an object")
|
|
86
|
+
extra = set(o) - allowed
|
|
87
|
+
if extra:
|
|
88
|
+
raise ValueError(f"{what}: unknown keys {sorted(extra)}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def node_from_obj(o: dict) -> Node:
|
|
92
|
+
_strict(o, _NODE_KEYS, "node")
|
|
93
|
+
attrs = o.get("attrs", {})
|
|
94
|
+
if not isinstance(attrs, dict):
|
|
95
|
+
raise ValueError("node: attrs must be an object")
|
|
96
|
+
return Node(
|
|
97
|
+
kind=Kind(o["kind"]),
|
|
98
|
+
name=str(o["name"]),
|
|
99
|
+
path=str(o.get("path", "")),
|
|
100
|
+
sig=str(o.get("sig", "")),
|
|
101
|
+
doc=str(o.get("doc", "")),
|
|
102
|
+
attrs=tuple(sorted((str(k), str(v)) for k, v in attrs.items())),
|
|
103
|
+
children=tuple(node_from_obj(c) for c in o.get("children", [])),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def tree_from_obj(o: dict) -> Tree:
|
|
108
|
+
_strict(o, _TREE_KEYS, "tree")
|
|
109
|
+
if o.get("schema") != SCHEMA_TREE:
|
|
110
|
+
raise ValueError(f"tree: schema must be {SCHEMA_TREE}")
|
|
111
|
+
return Tree(
|
|
112
|
+
module=str(o["module"]),
|
|
113
|
+
module_hash=str(o["module_hash"]),
|
|
114
|
+
extractors=tuple((str(a), str(b)) for a, b in o["extractors"]),
|
|
115
|
+
nodes=tuple(node_from_obj(n) for n in o["nodes"]),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def map_from_obj(o: dict) -> Map:
|
|
120
|
+
_strict(o, _MAP_KEYS, "map")
|
|
121
|
+
if o.get("schema") != SCHEMA_MAP:
|
|
122
|
+
raise ValueError(f"map: schema must be {SCHEMA_MAP}")
|
|
123
|
+
return Map(
|
|
124
|
+
sha=str(o["sha"]),
|
|
125
|
+
trees=tuple(tree_from_obj(t) for t in o["modules"]),
|
|
126
|
+
notes=tuple(str(n) for n in o.get("notes", [])),
|
|
127
|
+
)
|
rco/check.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Map checker (README Verification layer 2; tree-contract rco.check/1).
|
|
2
|
+
|
|
3
|
+
Re-extracts at SHA and compares with a given map. Every finding has a
|
|
4
|
+
path; findings are sorted, so output is byte-identical for the same input.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from rco import build, canon
|
|
16
|
+
from rco.model import Kind, Map, Node, Tree, walk
|
|
17
|
+
from rco.output import Exit
|
|
18
|
+
|
|
19
|
+
SCHEMA_CHECK = "rco.check/1"
|
|
20
|
+
SCHEMA_SELFTEST = "rco.selftest/2"
|
|
21
|
+
|
|
22
|
+
Status = Literal["missing", "extra", "changed", "stale", "edge"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Finding:
|
|
27
|
+
status: Status
|
|
28
|
+
key: str
|
|
29
|
+
path: str
|
|
30
|
+
detail: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _shapes(t: Tree) -> dict[str, list[tuple[str, tuple]]]:
|
|
34
|
+
out: dict[str, list[tuple[str, tuple]]] = defaultdict(list)
|
|
35
|
+
for key, n in walk(t):
|
|
36
|
+
out[key].append(n.shape())
|
|
37
|
+
return {k: sorted(v) for k, v in out.items()}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _path_of(key: str) -> str:
|
|
41
|
+
return key.split(":", 2)[1]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fmt(shapes: list[tuple[str, tuple]]) -> str:
|
|
45
|
+
return "; ".join(f"sig={s!r}" + (f" attrs={dict(a)!r}" if a else "") for s, a in shapes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compare(claimed: Map, actual: Map) -> tuple[Finding, ...]:
|
|
49
|
+
"""Every mismatch between a claimed map and a fresh one."""
|
|
50
|
+
found: list[Finding] = []
|
|
51
|
+
if claimed.sha != actual.sha:
|
|
52
|
+
found.append(Finding("stale", "map:sha", "", f"map sha {claimed.sha} != {actual.sha}"))
|
|
53
|
+
cm = {t.module: t for t in claimed.trees}
|
|
54
|
+
am = {t.module: t for t in actual.trees}
|
|
55
|
+
for mod in sorted(set(cm) | set(am)):
|
|
56
|
+
c, a = cm.get(mod), am.get(mod)
|
|
57
|
+
mkey = f"module:{mod}"
|
|
58
|
+
if c is None:
|
|
59
|
+
found.append(Finding("missing", mkey, mod, "module not in map"))
|
|
60
|
+
elif a is None:
|
|
61
|
+
found.append(Finding("extra", mkey, mod, "module not in code"))
|
|
62
|
+
elif c.module_hash != a.module_hash:
|
|
63
|
+
found.append(Finding("stale", mkey, mod, "module_hash"))
|
|
64
|
+
cs = _shapes(c) if c else {}
|
|
65
|
+
as_ = _shapes(a) if a else {}
|
|
66
|
+
for key in sorted(set(cs) | set(as_)):
|
|
67
|
+
if key not in as_:
|
|
68
|
+
found.append(Finding("extra", key, _path_of(key), "claimed, not in code"))
|
|
69
|
+
elif key not in cs:
|
|
70
|
+
found.append(Finding("missing", key, _path_of(key), "in code, not in map"))
|
|
71
|
+
elif cs[key] != as_[key]:
|
|
72
|
+
found.append(Finding("changed", key, _path_of(key),
|
|
73
|
+
f"{_fmt(cs[key])} -> {_fmt(as_[key])}"))
|
|
74
|
+
return tuple(sorted(found, key=lambda f: (f.path, f.key, f.status)))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class CheckReport:
|
|
79
|
+
sha: str
|
|
80
|
+
map_sha: str
|
|
81
|
+
findings: tuple[Finding, ...]
|
|
82
|
+
|
|
83
|
+
def to_obj(self) -> dict:
|
|
84
|
+
counts = {s: 0 for s in ("changed", "extra", "missing", "stale", "edge")}
|
|
85
|
+
for f in self.findings:
|
|
86
|
+
counts[f.status] += 1
|
|
87
|
+
return {
|
|
88
|
+
"schema": SCHEMA_CHECK, "sha": self.sha, "map_sha": self.map_sha,
|
|
89
|
+
"counts": counts,
|
|
90
|
+
"findings": [{"status": f.status, "key": f.key, "path": f.path, "detail": f.detail}
|
|
91
|
+
for f in self.findings],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def to_markdown(self) -> str:
|
|
95
|
+
lines = [f"# rco check (SHA={self.sha[:12]}, map={self.map_sha[:12]})", ""]
|
|
96
|
+
if not self.findings:
|
|
97
|
+
lines.append("map matches the code: no findings")
|
|
98
|
+
for f in self.findings:
|
|
99
|
+
lines.append(f"- {f.status}: `{f.key}` {f.detail}")
|
|
100
|
+
return "\n".join(lines) + "\n"
|
|
101
|
+
|
|
102
|
+
def exit_code(self) -> Exit:
|
|
103
|
+
return Exit.FINDINGS if self.findings else Exit.OK
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def prose_name_findings(store, actual: Map) -> tuple[Finding, ...]:
|
|
107
|
+
"""Names cited in stored prose that are not in the module's Tree."""
|
|
108
|
+
from rco.prose import flags as prose_flags
|
|
109
|
+
found: list[Finding] = []
|
|
110
|
+
for t in actual.trees:
|
|
111
|
+
for name in prose_flags(store.get_prose(t.module), t):
|
|
112
|
+
found.append(Finding("extra", f"prose:{t.module}:{name}", t.module,
|
|
113
|
+
"cited in prose, not in the Tree"))
|
|
114
|
+
return tuple(sorted(found, key=lambda f: (f.path, f.key, f.status)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check(repo: Path, sha: str, claimed: Map | None = None, store=None,
|
|
118
|
+
claimed_forest=None) -> CheckReport:
|
|
119
|
+
t0 = time.perf_counter()
|
|
120
|
+
findings: tuple[Finding, ...] = ()
|
|
121
|
+
map_sha = ""
|
|
122
|
+
if claimed is not None:
|
|
123
|
+
actual = build.build_map(repo, sha)
|
|
124
|
+
findings = compare(claimed, actual)
|
|
125
|
+
map_sha = claimed.sha
|
|
126
|
+
if store is not None:
|
|
127
|
+
findings = findings + prose_name_findings(store, actual)
|
|
128
|
+
if claimed_forest is not None:
|
|
129
|
+
from rco.forest import build_forest, compare_edges
|
|
130
|
+
findings = findings + compare_edges(claimed_forest, build_forest(repo, sha))
|
|
131
|
+
findings = tuple(sorted(findings, key=lambda f: (f.path, f.key, f.status)))
|
|
132
|
+
if not map_sha:
|
|
133
|
+
map_sha = claimed_forest.sha
|
|
134
|
+
rep = CheckReport(sha=sha, map_sha=map_sha, findings=findings)
|
|
135
|
+
print(f"rco check: {len(rep.findings)} findings in {time.perf_counter() - t0:.3f}s",
|
|
136
|
+
file=sys.stderr)
|
|
137
|
+
return rep
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------ planted drift
|
|
141
|
+
|
|
142
|
+
def _edit_first(nodes: tuple[Node, ...], pred, fn) -> tuple[tuple[Node, ...], bool]:
|
|
143
|
+
"""Apply fn to the first node (depth-first) matching pred; fn may return None to drop."""
|
|
144
|
+
out = []
|
|
145
|
+
done = False
|
|
146
|
+
for n in nodes:
|
|
147
|
+
if not done and pred(n):
|
|
148
|
+
r = fn(n)
|
|
149
|
+
done = True
|
|
150
|
+
if r is not None:
|
|
151
|
+
out.append(r)
|
|
152
|
+
continue
|
|
153
|
+
if not done and n.children:
|
|
154
|
+
kids, done = _edit_first(n.children, pred, fn)
|
|
155
|
+
n = replace(n, children=kids)
|
|
156
|
+
out.append(n)
|
|
157
|
+
return tuple(out), done
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _edit_map(m: Map, pred, fn) -> Map:
|
|
161
|
+
trees = []
|
|
162
|
+
done = False
|
|
163
|
+
for t in m.trees:
|
|
164
|
+
if not done:
|
|
165
|
+
nodes, done = _edit_first(t.nodes, pred, fn)
|
|
166
|
+
t = replace(t, nodes=nodes)
|
|
167
|
+
trees.append(t)
|
|
168
|
+
if not done:
|
|
169
|
+
raise AssertionError("planted drift found no target")
|
|
170
|
+
return replace(m, trees=tuple(trees))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def plants(m: Map) -> tuple[tuple[str, Status, Map], ...]:
|
|
174
|
+
"""The six planted drifts: (name, status the checker must report, map)."""
|
|
175
|
+
k = lambda kind: (lambda n: n.kind is kind) # noqa: E731
|
|
176
|
+
first_mod = m.trees[0]
|
|
177
|
+
added = Node(Kind.FUNCTION, "planted_extra", "", "() -> None")
|
|
178
|
+
return (
|
|
179
|
+
("remove a flag", "missing", _edit_map(m, k(Kind.FLAG), lambda n: None)),
|
|
180
|
+
("rename a column", "extra",
|
|
181
|
+
_edit_map(m, k(Kind.COLUMN), lambda n: replace(n, name=n.name + "_renamed"))),
|
|
182
|
+
("change a sig", "changed",
|
|
183
|
+
_edit_map(m, k(Kind.FUNCTION), lambda n: replace(n, sig=n.sig + " # planted"))),
|
|
184
|
+
("add a function", "extra",
|
|
185
|
+
_edit_map(m, k(Kind.PYMODULE),
|
|
186
|
+
lambda n: replace(n, children=n.children + (added,)))),
|
|
187
|
+
("drop a module", "missing", replace(m, trees=m.trees[1:])),
|
|
188
|
+
("alter a module_hash", "stale",
|
|
189
|
+
replace(m, trees=(replace(first_mod, module_hash="sha256:" + "0" * 64),) + m.trees[1:])),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class SelftestReport:
|
|
195
|
+
rows: tuple[tuple[str, str, int, bool], ...] # name, expected status, findings, caught
|
|
196
|
+
|
|
197
|
+
def to_obj(self) -> dict:
|
|
198
|
+
return {"schema": SCHEMA_SELFTEST,
|
|
199
|
+
"plants": [{"name": n, "expect": s, "findings": c, "caught": ok}
|
|
200
|
+
for n, s, c, ok in self.rows]}
|
|
201
|
+
|
|
202
|
+
def to_markdown(self) -> str:
|
|
203
|
+
lines = ["# rco check --selftest", ""]
|
|
204
|
+
for n, s, c, ok in self.rows:
|
|
205
|
+
lines.append(f"- {'caught' if ok else 'MISSED'}: {n} (expect {s}, {c} findings)")
|
|
206
|
+
return "\n".join(lines) + "\n"
|
|
207
|
+
|
|
208
|
+
def exit_code(self) -> Exit:
|
|
209
|
+
return Exit.OK if self.rows and all(r[3] for r in self.rows) else Exit.FINDINGS
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def selftest_on(m: Map) -> SelftestReport:
|
|
213
|
+
if compare(m, m):
|
|
214
|
+
raise AssertionError("an unchanged map must have no findings")
|
|
215
|
+
rows = []
|
|
216
|
+
for name, status, planted in plants(m):
|
|
217
|
+
found = compare(planted, m)
|
|
218
|
+
rows.append((name, status, len(found), any(f.status == status for f in found)))
|
|
219
|
+
return SelftestReport(tuple(rows))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def selftest() -> SelftestReport:
|
|
223
|
+
"""Build the drift fixture's map in a scratch repo and plant six errors."""
|
|
224
|
+
from rco import fixture
|
|
225
|
+
t0 = time.perf_counter()
|
|
226
|
+
with fixture.drift_repo() as (repo, sha):
|
|
227
|
+
rep = selftest_on(build.build_map(repo, sha))
|
|
228
|
+
print(f"rco check --selftest: {time.perf_counter() - t0:.3f}s", file=sys.stderr)
|
|
229
|
+
return rep
|
rco/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""rco command line (tree-contract section 8)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Sequence
|
|
11
|
+
|
|
12
|
+
from rco import __version__, build, canon, check, diff, gitread
|
|
13
|
+
from rco.forest_model import Forest
|
|
14
|
+
from rco.model import Map
|
|
15
|
+
from rco.output import Exit, UsageError, emit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Parser(argparse.ArgumentParser):
|
|
19
|
+
def error(self, message: str): # argparse would exit 2 itself; keep it one path
|
|
20
|
+
raise UsageError(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _common(p: argparse.ArgumentParser) -> None:
|
|
24
|
+
p.add_argument("--repo", default=".", help="git repository (default: .)")
|
|
25
|
+
p.add_argument("--format", choices=("markdown", "json"), default="markdown",
|
|
26
|
+
help="output format (default: markdown)")
|
|
27
|
+
p.add_argument("--out", type=Path, default=None, help="write the report to FILE")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_parser() -> argparse.ArgumentParser:
|
|
31
|
+
p = _Parser(prog="rco", description="Deterministic repo context for coding agents.")
|
|
32
|
+
p.add_argument("--version", action="version", version=f"rco {__version__}")
|
|
33
|
+
sub = p.add_subparsers(dest="cmd", required=True, parser_class=_Parser)
|
|
34
|
+
|
|
35
|
+
t = sub.add_parser("tree", help="print the Map (Trees) of a commit")
|
|
36
|
+
t.add_argument("sha", nargs="?", default="HEAD")
|
|
37
|
+
t.add_argument("--for", dest="for_paths", nargs="+", default=None, metavar="PATH",
|
|
38
|
+
help="only the modules that own these paths")
|
|
39
|
+
_common(t)
|
|
40
|
+
|
|
41
|
+
c = sub.add_parser("check", help="check a map or forest against the code at a commit")
|
|
42
|
+
c.add_argument("map", nargs="?", default=None,
|
|
43
|
+
help="map JSON (rco tree --format json) or forest JSON (rco forest --format json)")
|
|
44
|
+
c.add_argument("sha", nargs="?", default=None, help="commit (default: the map's sha)")
|
|
45
|
+
c.add_argument("--selftest", action="store_true", help="run the planted-drift self-test")
|
|
46
|
+
_common(c)
|
|
47
|
+
|
|
48
|
+
d = sub.add_parser("diff", help="contract changes between two commits")
|
|
49
|
+
d.add_argument("base", nargs="?", default=None)
|
|
50
|
+
d.add_argument("head", nargs="?", default=None)
|
|
51
|
+
_common(d)
|
|
52
|
+
|
|
53
|
+
u = sub.add_parser("update", help="publish a commit to the store (.rco/rco.db)")
|
|
54
|
+
u.add_argument("new", nargs="?", default="HEAD")
|
|
55
|
+
u.add_argument("--name", default="main", help="pointer name (default: main)")
|
|
56
|
+
_common(u)
|
|
57
|
+
|
|
58
|
+
k = sub.add_parser("pack", help="Forest plus Trees for an agent, within a token budget")
|
|
59
|
+
k.add_argument("sha", nargs="?", default="HEAD")
|
|
60
|
+
k.add_argument("--for", dest="for_paths", nargs="+", default=None, metavar="FILE")
|
|
61
|
+
k.add_argument("--budget", type=int, default=8000, help="estimated tokens (default: 8000)")
|
|
62
|
+
_common(k)
|
|
63
|
+
|
|
64
|
+
r = sub.add_parser("triage", help="hint per branch: clean or a structural brief (never rebases)")
|
|
65
|
+
r.add_argument("branches", nargs="*", metavar="BRANCH")
|
|
66
|
+
r.add_argument("--onto", default=None, help="target ref (default: the default branch)")
|
|
67
|
+
_common(r)
|
|
68
|
+
|
|
69
|
+
n = sub.add_parser("notice", help="cross-PR contract notice: other branches using this PR's changes")
|
|
70
|
+
n.add_argument("others", nargs="*", metavar="BRANCH")
|
|
71
|
+
n.add_argument("--base", default=None, help="this PR's base (default: merge-base)")
|
|
72
|
+
n.add_argument("--head", default=None, help="this PR's head (default: HEAD)")
|
|
73
|
+
_common(n)
|
|
74
|
+
|
|
75
|
+
f = sub.add_parser("forest", help="print the computed Forest of a commit")
|
|
76
|
+
f.add_argument("sha", nargs="?", default="HEAD")
|
|
77
|
+
_common(f)
|
|
78
|
+
|
|
79
|
+
pn = sub.add_parser("pin", help="pin the contract of files (commit it as the intended contract)")
|
|
80
|
+
pn.add_argument("sha", nargs="?", default="HEAD")
|
|
81
|
+
pn.add_argument("--for", dest="for_paths", nargs="+", required=True, metavar="FILE")
|
|
82
|
+
_common(pn)
|
|
83
|
+
|
|
84
|
+
dr = sub.add_parser("drift", help="contract drift from committed pins (a report, not a block)")
|
|
85
|
+
dr.add_argument("pins", nargs="+", type=Path, metavar="PIN", help="pin file or directory of *.json pins")
|
|
86
|
+
dr.add_argument("--at", default="HEAD", help="commit to check (default: HEAD)")
|
|
87
|
+
_common(dr)
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class MapReport:
|
|
93
|
+
map: Map
|
|
94
|
+
|
|
95
|
+
def to_obj(self) -> dict:
|
|
96
|
+
return canon.map_to_obj(self.map)
|
|
97
|
+
|
|
98
|
+
def to_markdown(self) -> str:
|
|
99
|
+
from rco.render import map_markdown
|
|
100
|
+
return map_markdown(self.map)
|
|
101
|
+
|
|
102
|
+
def exit_code(self) -> Exit:
|
|
103
|
+
return Exit.OK
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_claim(path: str) -> Map | Forest:
|
|
107
|
+
"""A claimed rco.map/1 or rco.forest/1 file."""
|
|
108
|
+
try:
|
|
109
|
+
obj = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
110
|
+
except (OSError, ValueError) as e:
|
|
111
|
+
raise UsageError(f"unreadable map {path}: {e}") from None
|
|
112
|
+
schema = obj.get("schema") if isinstance(obj, dict) else None
|
|
113
|
+
try:
|
|
114
|
+
if schema == "rco.map/1":
|
|
115
|
+
return canon.map_from_obj(obj)
|
|
116
|
+
if schema == "rco.forest/1":
|
|
117
|
+
from rco.forest_model import forest_from_obj
|
|
118
|
+
return forest_from_obj(obj)
|
|
119
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
120
|
+
kind = "map" if schema == "rco.map/1" else "forest"
|
|
121
|
+
raise UsageError(f"unreadable {kind} {path}: {e}") from None
|
|
122
|
+
raise UsageError(f"unreadable map {path}: schema must be rco.map/1 or rco.forest/1")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run(args: argparse.Namespace) -> Exit:
|
|
126
|
+
repo = Path(args.repo)
|
|
127
|
+
if args.cmd == "tree":
|
|
128
|
+
sha = gitread.resolve(repo, args.sha)
|
|
129
|
+
return emit(MapReport(build.build_map(repo, sha, args.for_paths)), args.format, args.out)
|
|
130
|
+
if args.cmd == "check":
|
|
131
|
+
if args.selftest:
|
|
132
|
+
return emit(check.selftest(), args.format, args.out)
|
|
133
|
+
if args.map is None:
|
|
134
|
+
raise UsageError("check needs MAP or FOREST or --selftest")
|
|
135
|
+
claim = _read_claim(args.map)
|
|
136
|
+
if isinstance(claim, Forest):
|
|
137
|
+
sha = gitread.resolve(repo, args.sha or claim.sha)
|
|
138
|
+
return emit(check.check(repo, sha, claimed_forest=claim), args.format, args.out)
|
|
139
|
+
m = claim
|
|
140
|
+
sha = gitread.resolve(repo, args.sha or m.sha)
|
|
141
|
+
store = None
|
|
142
|
+
db = repo / ".rco" / "rco.db"
|
|
143
|
+
if db.exists(): # read only: check never creates a store
|
|
144
|
+
from rco.store.sqlite import SqliteStore
|
|
145
|
+
store = SqliteStore(db)
|
|
146
|
+
return emit(check.check(repo, sha, m, store), args.format, args.out)
|
|
147
|
+
if args.cmd == "diff":
|
|
148
|
+
return emit(diff.diff(repo, args.base, args.head), args.format, args.out)
|
|
149
|
+
if args.cmd == "update":
|
|
150
|
+
from rco import update
|
|
151
|
+
from rco.prose import drain_queue
|
|
152
|
+
from rco.store.sqlite import open_repo_store
|
|
153
|
+
store = open_repo_store(repo)
|
|
154
|
+
res = update.update(repo, store, args.new, args.name)
|
|
155
|
+
drain_queue(repo, store, args.name) # mined prose: queue is not a separate CLI step
|
|
156
|
+
return emit(update.UpdateReport(res), args.format, args.out)
|
|
157
|
+
if args.cmd == "triage":
|
|
158
|
+
from rco import triage
|
|
159
|
+
return emit(triage.triage(repo, args.branches, args.onto), args.format, args.out)
|
|
160
|
+
if args.cmd == "notice":
|
|
161
|
+
from rco import triage
|
|
162
|
+
return emit(triage.notice(repo, args.base, args.head, args.others), args.format, args.out)
|
|
163
|
+
if args.cmd == "forest":
|
|
164
|
+
from rco import forest
|
|
165
|
+
sha = gitread.resolve(repo, args.sha)
|
|
166
|
+
return emit(forest.ForestReport(forest.build_forest(repo, sha)), args.format, args.out)
|
|
167
|
+
if args.cmd == "pack":
|
|
168
|
+
from rco import pack
|
|
169
|
+
sha = gitread.resolve(repo, args.sha)
|
|
170
|
+
store = None
|
|
171
|
+
db = repo / ".rco" / "rco.db"
|
|
172
|
+
if db.exists(): # read only: pack never creates a store
|
|
173
|
+
from rco.store.sqlite import SqliteStore
|
|
174
|
+
store = SqliteStore(db)
|
|
175
|
+
p = pack.pack(repo, sha, args.for_paths, args.budget, store)
|
|
176
|
+
return emit(pack.PackReport(p, " ".join(args.for_paths or ())), args.format, args.out)
|
|
177
|
+
if args.cmd == "pin":
|
|
178
|
+
from rco import pin
|
|
179
|
+
sha = gitread.resolve(repo, args.sha)
|
|
180
|
+
return emit(pin.make_pin(repo, sha, args.for_paths), args.format, args.out)
|
|
181
|
+
if args.cmd == "drift":
|
|
182
|
+
from rco import pin
|
|
183
|
+
files = pin.pin_files(args.pins)
|
|
184
|
+
sha = gitread.resolve(repo, args.at)
|
|
185
|
+
return emit(pin.drift(repo, files, sha), args.format, args.out)
|
|
186
|
+
raise UsageError(f"unknown command {args.cmd}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
190
|
+
try:
|
|
191
|
+
args = make_parser().parse_args(argv)
|
|
192
|
+
return int(run(args))
|
|
193
|
+
except UsageError as e:
|
|
194
|
+
print(f"rco: {e}", file=sys.stderr)
|
|
195
|
+
return int(Exit.USAGE)
|
|
196
|
+
except SystemExit as e: # --help / --version
|
|
197
|
+
return int(e.code or 0)
|
|
198
|
+
except Exception as e: # noqa: BLE001 - the exit-3 contract
|
|
199
|
+
if os.environ.get("RCO_DEBUG") == "1":
|
|
200
|
+
raise
|
|
201
|
+
print(f"rco: internal error: {type(e).__name__}: {e}", file=sys.stderr)
|
|
202
|
+
return int(Exit.INTERNAL)
|