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 ADDED
@@ -0,0 +1,3 @@
1
+ """repo-context-optimizer: deterministic contract Trees for coding agents."""
2
+
3
+ __version__ = "0.1.0"
rco/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """python -m rco"""
2
+ import sys
3
+
4
+ from rco.cli import main
5
+
6
+ sys.exit(main())
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)