adrinaline 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.
- adrinaline/__init__.py +5 -0
- adrinaline/__main__.py +3 -0
- adrinaline/check.py +155 -0
- adrinaline/cli.py +504 -0
- adrinaline/data/0001.md +27 -0
- adrinaline/data/SKILL.md +55 -0
- adrinaline/data/template.md +18 -0
- adrinaline/frontmatter.py +52 -0
- adrinaline/git.py +132 -0
- adrinaline/graph.py +256 -0
- adrinaline/model.py +147 -0
- adrinaline/output.py +78 -0
- adrinaline/prime.py +103 -0
- adrinaline/repo.py +360 -0
- adrinaline-0.1.0.dist-info/METADATA +100 -0
- adrinaline-0.1.0.dist-info/RECORD +19 -0
- adrinaline-0.1.0.dist-info/WHEEL +4 -0
- adrinaline-0.1.0.dist-info/entry_points.txt +3 -0
- adrinaline-0.1.0.dist-info/licenses/LICENSE +21 -0
adrinaline/__init__.py
ADDED
adrinaline/__main__.py
ADDED
adrinaline/check.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Validation rules for `adr check`. Each rule returns Finding objects."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
from adrinaline.graph import ACYCLIC, AdrGraph
|
|
7
|
+
from adrinaline.model import STORED_RELATIONS, Finding, Status
|
|
8
|
+
from adrinaline.repo import AdrSet
|
|
9
|
+
|
|
10
|
+
SNAKE_KEYS = {"superseded_by", "depends_on", "relates_to", "amended_by", "dependency_of"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _duplicates(s: AdrSet) -> list[Finding]:
|
|
14
|
+
groups = defaultdict(list)
|
|
15
|
+
for adr in s.adrs:
|
|
16
|
+
groups[adr.number].append(adr)
|
|
17
|
+
return [
|
|
18
|
+
Finding(
|
|
19
|
+
"duplicate-number",
|
|
20
|
+
"error",
|
|
21
|
+
str(adr.path),
|
|
22
|
+
f"{adr.id} is used by {', '.join(a.path.name for a in group)}; fix with `adr renumber`",
|
|
23
|
+
)
|
|
24
|
+
for group in groups.values()
|
|
25
|
+
if len(group) > 1
|
|
26
|
+
for adr in group
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _gaps(s: AdrSet, taken_elsewhere: set[int]) -> list[Finding]:
|
|
31
|
+
return [
|
|
32
|
+
Finding("number-gap", "warning", "", f"{n:04d} is missing")
|
|
33
|
+
for n in range(1, max(s.numbers, default=0))
|
|
34
|
+
if n not in s.numbers and n not in taken_elsewhere
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _relations(s: AdrSet, graph: AdrGraph) -> list[Finding]:
|
|
39
|
+
found: list[Finding] = []
|
|
40
|
+
for adr in s.adrs:
|
|
41
|
+
path = str(adr.path)
|
|
42
|
+
for name in STORED_RELATIONS:
|
|
43
|
+
for n in adr.relations[name]:
|
|
44
|
+
if n == adr.number:
|
|
45
|
+
found.append(Finding("self-relation", "error", path, f"{adr.id} {name} itself"))
|
|
46
|
+
elif n not in graph.nodes:
|
|
47
|
+
found.append(
|
|
48
|
+
Finding(
|
|
49
|
+
"dangling-relation",
|
|
50
|
+
"error",
|
|
51
|
+
path,
|
|
52
|
+
f"{adr.id} {name} {n:04d}, which does not exist",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
for name, inverse in (("supersedes", "superseded-by"), ("superseded-by", "supersedes")):
|
|
56
|
+
for n in adr.relations[name]:
|
|
57
|
+
other = graph.nodes.get(n)
|
|
58
|
+
if (
|
|
59
|
+
other
|
|
60
|
+
and other.number != adr.number
|
|
61
|
+
and adr.number not in other.relations[inverse]
|
|
62
|
+
):
|
|
63
|
+
found.append(
|
|
64
|
+
Finding(
|
|
65
|
+
"asymmetric-relation",
|
|
66
|
+
"error",
|
|
67
|
+
path,
|
|
68
|
+
f"{adr.id} {name} {other.id}, but {other.id} has no "
|
|
69
|
+
f"{inverse}: [{adr.number}]",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if bool(adr.relations["superseded-by"]) != (adr.status is Status.SUPERSEDED):
|
|
73
|
+
found.append(
|
|
74
|
+
Finding(
|
|
75
|
+
"status-mismatch",
|
|
76
|
+
"error",
|
|
77
|
+
path,
|
|
78
|
+
f"{adr.id}: status {adr.status} does not match superseded-by "
|
|
79
|
+
f"{adr.relations['superseded-by']}",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
for relation in ACYCLIC:
|
|
83
|
+
if cycle := graph.cycle(relation):
|
|
84
|
+
chain = " -> ".join(f"{n:04d}" for n in cycle)
|
|
85
|
+
found.append(Finding("cycle", "error", "", f"{relation} cycle: {chain}"))
|
|
86
|
+
return found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _unknown_keys(s: AdrSet) -> list[Finding]:
|
|
90
|
+
return [
|
|
91
|
+
Finding(
|
|
92
|
+
"unknown-relation-key",
|
|
93
|
+
"warning",
|
|
94
|
+
str(adr.path),
|
|
95
|
+
f"`{key}` is ignored; use `{key.replace('_', '-')}`",
|
|
96
|
+
)
|
|
97
|
+
for adr in s.adrs
|
|
98
|
+
for key in adr.meta
|
|
99
|
+
if key in SNAKE_KEYS
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _scopes(
|
|
104
|
+
s: AdrSet, graph: AdrGraph, tracked: list[str] | None, changed: list[str] | None
|
|
105
|
+
) -> list[Finding]:
|
|
106
|
+
found: list[Finding] = []
|
|
107
|
+
if tracked is not None:
|
|
108
|
+
paths = [PurePosixPath(f) for f in tracked]
|
|
109
|
+
for adr in s.adrs:
|
|
110
|
+
for glob in adr.scope:
|
|
111
|
+
if not any(p.full_match(glob) for p in paths):
|
|
112
|
+
found.append(
|
|
113
|
+
Finding(
|
|
114
|
+
"scope-matches-nothing",
|
|
115
|
+
"warning",
|
|
116
|
+
str(adr.path),
|
|
117
|
+
f"{adr.id}: scope {glob!r} matches no file",
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
for path in changed or []:
|
|
121
|
+
if hits := graph.governing(path):
|
|
122
|
+
names = ", ".join(f"{a.id} {a.title}" for a in hits)
|
|
123
|
+
found.append(
|
|
124
|
+
Finding(
|
|
125
|
+
"scoped-change",
|
|
126
|
+
"warning",
|
|
127
|
+
path,
|
|
128
|
+
f"{path} is governed by {names}; review before merging",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return found
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_checks(
|
|
135
|
+
s: AdrSet,
|
|
136
|
+
*,
|
|
137
|
+
template_exists: bool,
|
|
138
|
+
tracked: list[str] | None = None,
|
|
139
|
+
changed: list[str] | None = None,
|
|
140
|
+
taken_elsewhere: set[int] | None = None,
|
|
141
|
+
) -> list[Finding]:
|
|
142
|
+
graph = AdrGraph(s.adrs)
|
|
143
|
+
findings = [
|
|
144
|
+
*s.problems,
|
|
145
|
+
*_duplicates(s),
|
|
146
|
+
*_gaps(s, taken_elsewhere or set()),
|
|
147
|
+
*_relations(s, graph),
|
|
148
|
+
*_unknown_keys(s),
|
|
149
|
+
*_scopes(s, graph, tracked, changed),
|
|
150
|
+
]
|
|
151
|
+
if not template_exists:
|
|
152
|
+
findings.append(
|
|
153
|
+
Finding("no-template", "warning", "", "template is missing; `adr init` recreates it")
|
|
154
|
+
)
|
|
155
|
+
return sorted(findings, key=lambda f: (f.path, f.code))
|
adrinaline/cli.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Command-line interface: argparse wiring and one cmd_<name> function per command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from adrinaline import __version__, git, prime, repo
|
|
14
|
+
from adrinaline.check import run_checks
|
|
15
|
+
from adrinaline.frontmatter import render, split
|
|
16
|
+
from adrinaline.graph import (
|
|
17
|
+
FORWARD,
|
|
18
|
+
IMPACT,
|
|
19
|
+
AdrGraph,
|
|
20
|
+
all_nodes,
|
|
21
|
+
graph_data,
|
|
22
|
+
render_dot,
|
|
23
|
+
render_mermaid,
|
|
24
|
+
render_text,
|
|
25
|
+
)
|
|
26
|
+
from adrinaline.model import LINKABLE, Adr, AdrError, Status
|
|
27
|
+
from adrinaline.output import EXIT_FINDINGS, EXIT_OK, Out, adr_dict, error
|
|
28
|
+
|
|
29
|
+
AGENT_HINT = "Agents: run `adr prime` first. Every command accepts --json."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Ctx:
|
|
34
|
+
args: argparse.Namespace
|
|
35
|
+
loc: repo.Location
|
|
36
|
+
out: Out
|
|
37
|
+
cwd: Path
|
|
38
|
+
|
|
39
|
+
def load(self) -> repo.AdrSet:
|
|
40
|
+
return repo.load(self.loc, getattr(self.args, "at", None))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def today() -> dt.date:
|
|
44
|
+
return dt.date.today()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_line(adr: Adr) -> str:
|
|
48
|
+
return f"{adr.id} {adr.status:<10} {adr.date} {adr.title}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def show_text(
|
|
52
|
+
adr: Adr, relations: dict[str, list[int]], by_number: dict[int, Adr], out: Out
|
|
53
|
+
) -> str:
|
|
54
|
+
lines = [f"{adr.id} {adr.title}", f"status: {adr.status} date: {adr.date}"]
|
|
55
|
+
if adr.tags:
|
|
56
|
+
lines.append("tags: " + ", ".join(adr.tags))
|
|
57
|
+
if adr.scope:
|
|
58
|
+
lines.append("scope: " + ", ".join(adr.scope))
|
|
59
|
+
for name, numbers in relations.items():
|
|
60
|
+
labels = [
|
|
61
|
+
f"{n:04d} {by_number[n].title}" if n in by_number else f"{n:04d} (missing)"
|
|
62
|
+
for n in numbers
|
|
63
|
+
]
|
|
64
|
+
lines.append(f"{name}: " + "; ".join(labels))
|
|
65
|
+
lines += [f"path: {out.rel(adr.path)}", "", adr.body.strip()]
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_init(ctx: Ctx) -> None:
|
|
70
|
+
loc, force = ctx.loc, ctx.args.force
|
|
71
|
+
loc.dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
written: list[Path] = []
|
|
73
|
+
if force or not loc.template.exists():
|
|
74
|
+
loc.template.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
repo.atomic_write(loc.template, repo.data_text("template.md"))
|
|
76
|
+
written.append(loc.template)
|
|
77
|
+
first = next(loc.dir.glob("0001-*.md"), None)
|
|
78
|
+
if force or first is None:
|
|
79
|
+
target = first or loc.dir / "0001-record-architecture-decisions.md"
|
|
80
|
+
meta, body = split(repo.data_text("0001.md"))
|
|
81
|
+
meta["date"] = today()
|
|
82
|
+
if name := git.user_name(ctx.cwd):
|
|
83
|
+
meta["decision-makers"] = repo.flow([name])
|
|
84
|
+
repo.atomic_write(target, render(meta, body))
|
|
85
|
+
written.append(target)
|
|
86
|
+
paths = [ctx.out.rel(p) for p in written]
|
|
87
|
+
text = "\n".join(f"wrote {p}" for p in paths) or "already initialised"
|
|
88
|
+
ctx.out.result({"dir": ctx.out.rel(loc.dir), "written": paths}, text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _edit(path: Path) -> None:
|
|
92
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
|
|
93
|
+
try:
|
|
94
|
+
argv = shlex.split(editor)
|
|
95
|
+
if not argv:
|
|
96
|
+
raise ValueError("empty editor command")
|
|
97
|
+
subprocess.run([*argv, str(path)], check=False)
|
|
98
|
+
except (OSError, ValueError) as exc:
|
|
99
|
+
raise AdrError("io", f"cannot run editor {editor!r}: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def non_negative(value: str) -> int:
|
|
103
|
+
n = int(value)
|
|
104
|
+
if n < 0:
|
|
105
|
+
raise argparse.ArgumentTypeError("must be >= 0")
|
|
106
|
+
return n
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_new(ctx: Ctx) -> None:
|
|
110
|
+
args, loc = ctx.args, ctx.loc
|
|
111
|
+
if not loc.dir.is_dir():
|
|
112
|
+
raise repo.no_dir(loc)
|
|
113
|
+
wanted = {r: getattr(args, r.replace("-", "_")) for r in ("supersedes", *LINKABLE)}
|
|
114
|
+
targets: dict[str, list[Adr]] = {}
|
|
115
|
+
if any(wanted.values()):
|
|
116
|
+
adrs = ctx.load().adrs
|
|
117
|
+
targets = {
|
|
118
|
+
r: list({a.number: a for a in (repo.resolve(adrs, ref) for ref in refs)}.values())
|
|
119
|
+
for r, refs in wanted.items()
|
|
120
|
+
}
|
|
121
|
+
for old in targets["supersedes"]:
|
|
122
|
+
if old.status is not Status.ACCEPTED and not args.force:
|
|
123
|
+
raise AdrError(
|
|
124
|
+
"invalid-transition",
|
|
125
|
+
f"{old.id} is {old.status}; only accepted ADRs can be superseded",
|
|
126
|
+
current=old.status.value,
|
|
127
|
+
requested=Status.SUPERSEDED.value,
|
|
128
|
+
)
|
|
129
|
+
body = None
|
|
130
|
+
if args.body_file == "-":
|
|
131
|
+
body = sys.stdin.read()
|
|
132
|
+
elif args.body_file:
|
|
133
|
+
try:
|
|
134
|
+
body = Path(args.body_file).read_text(encoding="utf-8")
|
|
135
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
136
|
+
raise AdrError("io", f"cannot read {args.body_file}: {exc}") from exc
|
|
137
|
+
status = Status.ACCEPTED if args.accepted else Status.PROPOSED
|
|
138
|
+
text = repo.render_new(repo.template_text(loc), args.title, status, today(), body, args.tag)
|
|
139
|
+
number, skipped = repo.next_number(loc)
|
|
140
|
+
path = repo.create_file(loc.dir, number, repo.slugify(args.title), text)
|
|
141
|
+
for n, ref in sorted(skipped.items()):
|
|
142
|
+
ctx.out.note(f"skipped {n:04d}, taken on {ref}")
|
|
143
|
+
if targets:
|
|
144
|
+
adrs = ctx.load().adrs
|
|
145
|
+
by_path = {a.path: a for a in adrs}
|
|
146
|
+
graph = AdrGraph(adrs)
|
|
147
|
+
new = by_path[path]
|
|
148
|
+
for old in targets["supersedes"]:
|
|
149
|
+
repo.supersede(graph, by_path[old.path], new, force=args.force, today=today())
|
|
150
|
+
for relation in LINKABLE:
|
|
151
|
+
for dst in targets[relation]:
|
|
152
|
+
repo.link(graph, new, relation, by_path[dst.path])
|
|
153
|
+
if args.edit:
|
|
154
|
+
_edit(path)
|
|
155
|
+
adr = repo.parse_adr(path, path.read_text(encoding="utf-8"))
|
|
156
|
+
skipped_json = {f"{n:04d}": ref for n, ref in sorted(skipped.items())}
|
|
157
|
+
ctx.out.result(adr_dict(adr, ctx.out, skipped=skipped_json), ctx.out.rel(path))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_show(ctx: Ctx) -> None:
|
|
161
|
+
adrs = ctx.load().adrs
|
|
162
|
+
adr = repo.resolve(adrs, ctx.args.ref)
|
|
163
|
+
if ctx.args.raw:
|
|
164
|
+
ctx.out.result({"path": ctx.out.rel(adr.path), "text": adr.text}, adr.text)
|
|
165
|
+
return
|
|
166
|
+
graph = AdrGraph(adrs)
|
|
167
|
+
relations = graph.related(adr.number)
|
|
168
|
+
extra = {}
|
|
169
|
+
if relations.get("superseded-by"):
|
|
170
|
+
extra["successor"] = graph.successor(adr.number)
|
|
171
|
+
ctx.out.result(
|
|
172
|
+
adr_dict(adr, ctx.out, relations=relations, body=True, **extra),
|
|
173
|
+
show_text(adr, relations, graph.nodes, ctx.out),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_supersede(ctx: Ctx) -> None:
|
|
178
|
+
adrs = ctx.load().adrs
|
|
179
|
+
old, new = repo.resolve(adrs, ctx.args.old), repo.resolve(adrs, ctx.args.by)
|
|
180
|
+
repo.supersede(AdrGraph(adrs), old, new, force=ctx.args.force, today=today())
|
|
181
|
+
ctx.out.result(
|
|
182
|
+
{"superseded": adr_dict(old, ctx.out), "by": adr_dict(new, ctx.out)},
|
|
183
|
+
f"{old.id} {old.title}: superseded by {new.id} {new.title}",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_link(ctx: Ctx) -> None:
|
|
188
|
+
adrs = ctx.load().adrs
|
|
189
|
+
src, dst = repo.resolve(adrs, ctx.args.src), repo.resolve(adrs, ctx.args.dst)
|
|
190
|
+
changed = repo.link(AdrGraph(adrs), src, ctx.args.relation, dst)
|
|
191
|
+
verb = "linked" if changed else "already linked"
|
|
192
|
+
ctx.out.result(
|
|
193
|
+
{"changed": [ctx.out.rel(src.path)] if changed else []},
|
|
194
|
+
f"{verb}: {src.id} {ctx.args.relation} {dst.id}",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_unlink(ctx: Ctx) -> None:
|
|
199
|
+
adrs = ctx.load().adrs
|
|
200
|
+
src, dst = repo.resolve(adrs, ctx.args.src), repo.resolve(adrs, ctx.args.dst)
|
|
201
|
+
changed = repo.unlink(src, ctx.args.relation, dst)
|
|
202
|
+
text = "\n".join(f"updated {ctx.out.rel(a.path)}" for a in changed) or "no such link"
|
|
203
|
+
ctx.out.result({"changed": [ctx.out.rel(a.path) for a in changed]}, text)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_log(ctx: Ctx) -> None:
|
|
207
|
+
adr = repo.resolve(ctx.load().adrs, ctx.args.ref)
|
|
208
|
+
rows = git.log(ctx.loc.root, adr.path)
|
|
209
|
+
text = "\n".join(f"{r['date']} {r['commit'][:8]} {r['author']} {r['subject']}" for r in rows)
|
|
210
|
+
ctx.out.result(rows, text or "no commits yet")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_graph(ctx: Ctx) -> None:
|
|
214
|
+
args = ctx.args
|
|
215
|
+
graph = AdrGraph(ctx.load().adrs)
|
|
216
|
+
relations = tuple(args.relation) if args.relation else (IMPACT if args.reverse else FORWARD)
|
|
217
|
+
sub = None
|
|
218
|
+
if args.ref is not None:
|
|
219
|
+
start = repo.resolve(graph.adrs, args.ref).number
|
|
220
|
+
sub = graph.reachable(start, relations, reverse=args.reverse, depth=args.depth)
|
|
221
|
+
nodes, edges = sub.nodes, sub.edges
|
|
222
|
+
else:
|
|
223
|
+
edges = [e for e in graph.edges if e.relation in relations]
|
|
224
|
+
nodes = all_nodes(graph)
|
|
225
|
+
fmt = "json" if args.json else args.format
|
|
226
|
+
if args.rank:
|
|
227
|
+
ranks = graph.rank()
|
|
228
|
+
nodes = sorted(nodes, key=lambda n: (-ranks.get(n, 0.0), n))
|
|
229
|
+
if fmt == "json":
|
|
230
|
+
ctx.out.json_mode = True
|
|
231
|
+
ctx.out.result(graph_data(graph, nodes, edges, ranks), "")
|
|
232
|
+
else:
|
|
233
|
+
lines = [
|
|
234
|
+
f"{ranks.get(n, 0.0):.3f} {graph.label(n)} [{graph.status(n)}]" for n in nodes
|
|
235
|
+
]
|
|
236
|
+
ctx.out.result(None, "\n".join(lines))
|
|
237
|
+
return
|
|
238
|
+
if fmt == "json":
|
|
239
|
+
ctx.out.json_mode = True
|
|
240
|
+
ctx.out.result(graph_data(graph, nodes, edges), "")
|
|
241
|
+
elif fmt == "mermaid":
|
|
242
|
+
ctx.out.result(None, render_mermaid(graph, nodes, edges))
|
|
243
|
+
elif fmt == "dot":
|
|
244
|
+
ctx.out.result(None, render_dot(graph, nodes, edges))
|
|
245
|
+
else:
|
|
246
|
+
ctx.out.result(None, render_text(graph, sub, edges))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_list(ctx: Ctx) -> None:
|
|
250
|
+
args = ctx.args
|
|
251
|
+
adrs = [
|
|
252
|
+
adr
|
|
253
|
+
for adr in ctx.load().adrs
|
|
254
|
+
if (not args.status or adr.status in args.status)
|
|
255
|
+
and (not args.tag or set(args.tag) & set(adr.tags))
|
|
256
|
+
]
|
|
257
|
+
ctx.out.result([adr_dict(a, ctx.out) for a in adrs], "\n".join(list_line(a) for a in adrs))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_index(ctx: Ctx) -> None:
|
|
261
|
+
adrs = ctx.load().adrs
|
|
262
|
+
rows = ["| ADR | Title | Status | Date |", "|---|---|---|---|"]
|
|
263
|
+
rows += [
|
|
264
|
+
f"| [{a.id}]({a.path.name}) | {a.title.replace('|', '\\|')} | {a.status} | {a.date} |"
|
|
265
|
+
for a in adrs
|
|
266
|
+
]
|
|
267
|
+
ctx.out.result([adr_dict(a, ctx.out) for a in adrs], "\n".join(rows))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _set_status(to: Status) -> Callable[[Ctx], None]:
|
|
271
|
+
def cmd(ctx: Ctx) -> None:
|
|
272
|
+
adr = repo.resolve(ctx.load().adrs, ctx.args.ref)
|
|
273
|
+
before = adr.status
|
|
274
|
+
repo.transition(adr, to, force=ctx.args.force, today=today())
|
|
275
|
+
repo.save(adr)
|
|
276
|
+
ctx.out.result(adr_dict(adr, ctx.out), f"{adr.id} {adr.title}: {before} -> {to}")
|
|
277
|
+
|
|
278
|
+
return cmd
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_check(ctx: Ctx) -> int:
|
|
282
|
+
root = ctx.loc.root
|
|
283
|
+
if ctx.args.diff and root is None:
|
|
284
|
+
raise AdrError("no-git", "--diff needs a git repository")
|
|
285
|
+
at = getattr(ctx.args, "at", None)
|
|
286
|
+
findings = run_checks(
|
|
287
|
+
ctx.load(),
|
|
288
|
+
template_exists=ctx.loc.template.is_file(),
|
|
289
|
+
tracked=(git.files_at(root, at) if at else git.tracked_files(root)) if root else None,
|
|
290
|
+
changed=git.changed_files(root, ctx.args.diff) if root and ctx.args.diff else None,
|
|
291
|
+
taken_elsewhere=set(git.numbers_on_refs(root, ctx.loc.dir)) if root and not at else None,
|
|
292
|
+
)
|
|
293
|
+
failed = any(f.severity == "error" for f in findings) or (ctx.args.strict and bool(findings))
|
|
294
|
+
rows = [
|
|
295
|
+
asdict(f) | {"path": ctx.out.rel(Path(f.path)) if Path(f.path).is_absolute() else f.path}
|
|
296
|
+
for f in findings
|
|
297
|
+
]
|
|
298
|
+
text = "\n".join(f"{r['severity']}: {r['code']}: {r['message']}" for r in rows) or "ok"
|
|
299
|
+
ctx.out.result({"ok": not failed, "findings": rows}, text)
|
|
300
|
+
return EXIT_FINDINGS if failed else EXIT_OK
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _repo_path(raw: str, cwd: Path, base: Path) -> str:
|
|
304
|
+
try:
|
|
305
|
+
return (cwd / raw).resolve().relative_to(base).as_posix()
|
|
306
|
+
except ValueError:
|
|
307
|
+
return Path(raw).as_posix()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def cmd_for(ctx: Ctx) -> None:
|
|
311
|
+
graph = AdrGraph(ctx.load().adrs)
|
|
312
|
+
base = ctx.loc.root or ctx.cwd
|
|
313
|
+
data: dict[str, list[dict]] = {}
|
|
314
|
+
lines: list[str] = []
|
|
315
|
+
for raw in ctx.args.paths:
|
|
316
|
+
path = _repo_path(raw, ctx.cwd, base)
|
|
317
|
+
hits = graph.governing(path, include_proposed=ctx.args.all)
|
|
318
|
+
data[path] = [adr_dict(a, ctx.out) for a in hits]
|
|
319
|
+
lines.append(f"{path}:" if hits else f"{path}: no governing ADRs")
|
|
320
|
+
lines += [f" {a.id} {a.title} [{a.status}]" for a in hits]
|
|
321
|
+
ctx.out.result(data, "\n".join(lines))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def cmd_prime(ctx: Ctx) -> None:
|
|
325
|
+
graph = AdrGraph(ctx.load().adrs)
|
|
326
|
+
root = ctx.loc.root
|
|
327
|
+
at = getattr(ctx.args, "at", None)
|
|
328
|
+
changed = git.working_changes(root) if root and at is None else []
|
|
329
|
+
b = prime.briefing(graph, ctx.out.rel(ctx.loc.dir), changed, ctx.args.budget)
|
|
330
|
+
|
|
331
|
+
def rows(adrs: list[Adr]) -> list[dict]:
|
|
332
|
+
return [adr_dict(a, ctx.out, relations=graph.related(a.number)) for a in adrs]
|
|
333
|
+
|
|
334
|
+
data = {
|
|
335
|
+
"dir": ctx.out.rel(ctx.loc.dir),
|
|
336
|
+
"counts": b.counts,
|
|
337
|
+
"in_force": rows(b.shown),
|
|
338
|
+
"omitted": b.omitted,
|
|
339
|
+
"proposed": rows(b.proposed),
|
|
340
|
+
"rules": prime.RULES,
|
|
341
|
+
}
|
|
342
|
+
ctx.out.result(data, b.markdown)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def cmd_renumber(ctx: Ctx) -> None:
|
|
346
|
+
s = ctx.load()
|
|
347
|
+
adr = repo.resolve(s.adrs, ctx.args.ref)
|
|
348
|
+
old = ctx.out.rel(adr.path)
|
|
349
|
+
new_path, changed = repo.renumber(s, adr, ctx.args.to)
|
|
350
|
+
updated = [ctx.out.rel(a.path) for a in changed]
|
|
351
|
+
lines = [f"renamed {old} -> {ctx.out.rel(new_path)}", *(f"updated {p}" for p in updated)]
|
|
352
|
+
ctx.out.result({"from": old, "to": ctx.out.rel(new_path), "updated": updated}, "\n".join(lines))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_skill(ctx: Ctx) -> None:
|
|
356
|
+
text = repo.data_text("SKILL.md")
|
|
357
|
+
ctx.out.result({"skill": text}, text)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
361
|
+
# Global flags work before and after the subcommand: the subparser copies use
|
|
362
|
+
# SUPPRESS so they never overwrite a value given at the top level.
|
|
363
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
364
|
+
parser = argparse.ArgumentParser(
|
|
365
|
+
prog="adr", description="Git-native Architecture Decision Records.", epilog=AGENT_HINT
|
|
366
|
+
)
|
|
367
|
+
S = argparse.SUPPRESS
|
|
368
|
+
for target, hidden in ((common, True), (parser, False)):
|
|
369
|
+
target.add_argument(
|
|
370
|
+
"--json",
|
|
371
|
+
action="store_true",
|
|
372
|
+
default=S if hidden else False,
|
|
373
|
+
help="print JSON on stdout",
|
|
374
|
+
)
|
|
375
|
+
target.add_argument(
|
|
376
|
+
"--dir",
|
|
377
|
+
type=Path,
|
|
378
|
+
default=S if hidden else None,
|
|
379
|
+
help="ADR directory (overrides discovery)",
|
|
380
|
+
)
|
|
381
|
+
target.add_argument(
|
|
382
|
+
"-q", "--quiet", action="store_true", default=S if hidden else False, help="no output"
|
|
383
|
+
)
|
|
384
|
+
parser.add_argument("--version", action="version", version=f"adr {__version__}")
|
|
385
|
+
reads = argparse.ArgumentParser(add_help=False)
|
|
386
|
+
for target, hidden in ((reads, True), (parser, False)):
|
|
387
|
+
target.add_argument(
|
|
388
|
+
"--at",
|
|
389
|
+
metavar="REF",
|
|
390
|
+
default=S if hidden else None,
|
|
391
|
+
help="read ADRs as of a git revision (read commands only)",
|
|
392
|
+
)
|
|
393
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
394
|
+
|
|
395
|
+
def add(
|
|
396
|
+
name: str, func: Callable[[Ctx], int | None], help_: str, *, reads_: bool = False
|
|
397
|
+
) -> argparse.ArgumentParser:
|
|
398
|
+
p = sub.add_parser(
|
|
399
|
+
name, parents=[common, reads] if reads_ else [common], help=help_, description=help_
|
|
400
|
+
)
|
|
401
|
+
p.set_defaults(func=func, reads=reads_)
|
|
402
|
+
return p
|
|
403
|
+
|
|
404
|
+
p = add("init", cmd_init, "create the ADR directory, template and ADR 0001")
|
|
405
|
+
p.add_argument("--force", action="store_true", help="overwrite the template and ADR 0001")
|
|
406
|
+
|
|
407
|
+
p = add("new", cmd_new, "create a new ADR")
|
|
408
|
+
p.add_argument("title")
|
|
409
|
+
p.add_argument("--accepted", action="store_true", help="create as accepted")
|
|
410
|
+
p.add_argument("--tag", action="append", default=[])
|
|
411
|
+
p.add_argument("--body-file", metavar="PATH", help="body below the title ('-' = stdin)")
|
|
412
|
+
p.add_argument("--edit", action="store_true", help="open $VISUAL/$EDITOR afterwards")
|
|
413
|
+
for relation in ("supersedes", *LINKABLE):
|
|
414
|
+
p.add_argument(f"--{relation}", action="append", default=[], metavar="REF")
|
|
415
|
+
p.add_argument("--force", action="store_true", help="supersede ADRs that are not accepted")
|
|
416
|
+
|
|
417
|
+
p = add("show", cmd_show, "show one ADR", reads_=True)
|
|
418
|
+
p.add_argument("ref")
|
|
419
|
+
p.add_argument("--raw", action="store_true", help="print the file verbatim")
|
|
420
|
+
|
|
421
|
+
p = add("list", cmd_list, "list ADRs", reads_=True)
|
|
422
|
+
p.add_argument("--status", action="append", choices=[s.value for s in Status])
|
|
423
|
+
p.add_argument("--tag", action="append")
|
|
424
|
+
|
|
425
|
+
add("index", cmd_index, "print a Markdown index table", reads_=True)
|
|
426
|
+
|
|
427
|
+
for name, to in (
|
|
428
|
+
("accept", Status.ACCEPTED),
|
|
429
|
+
("reject", Status.REJECTED),
|
|
430
|
+
("deprecate", Status.DEPRECATED),
|
|
431
|
+
):
|
|
432
|
+
p = add(name, _set_status(to), f"mark an ADR as {to}")
|
|
433
|
+
p.add_argument("ref")
|
|
434
|
+
p.add_argument("--force", action="store_true", help="allow any transition (repairs)")
|
|
435
|
+
|
|
436
|
+
p = add("check", cmd_check, "validate all ADRs", reads_=True)
|
|
437
|
+
p.add_argument("--strict", action="store_true", help="fail on warnings too")
|
|
438
|
+
p.add_argument("--diff", metavar="REF", help="also report changed files governed by ADRs")
|
|
439
|
+
|
|
440
|
+
p = add("supersede", cmd_supersede, "mark OLD as superseded by NEW")
|
|
441
|
+
p.add_argument("old", metavar="OLD")
|
|
442
|
+
p.add_argument("--by", required=True, metavar="NEW")
|
|
443
|
+
p.add_argument("--force", action="store_true", help="supersede even if OLD is not accepted")
|
|
444
|
+
|
|
445
|
+
for name, func, help_ in (
|
|
446
|
+
("link", cmd_link, "add a relation"),
|
|
447
|
+
("unlink", cmd_unlink, "remove a relation"),
|
|
448
|
+
):
|
|
449
|
+
p = add(name, func, help_)
|
|
450
|
+
p.add_argument("src", metavar="REF")
|
|
451
|
+
p.add_argument("relation", choices=LINKABLE)
|
|
452
|
+
p.add_argument("dst", metavar="REF")
|
|
453
|
+
|
|
454
|
+
p = add("graph", cmd_graph, "render the ADR graph", reads_=True)
|
|
455
|
+
p.add_argument("ref", nargs="?")
|
|
456
|
+
p.add_argument("--relation", action="append", choices=FORWARD)
|
|
457
|
+
p.add_argument("--depth", type=non_negative)
|
|
458
|
+
p.add_argument("--reverse", action="store_true", help="follow inverse edges (impact)")
|
|
459
|
+
p.add_argument("--format", choices=("text", "mermaid", "dot", "json"), default="text")
|
|
460
|
+
p.add_argument("--rank", action="store_true", help="order ADRs by importance (PageRank)")
|
|
461
|
+
|
|
462
|
+
p = add("renumber", cmd_renumber, "give an ADR a new number and update references")
|
|
463
|
+
p.add_argument("ref")
|
|
464
|
+
p.add_argument("--to", type=int, required=True, metavar="N")
|
|
465
|
+
|
|
466
|
+
p = add("log", cmd_log, "show the git history of an ADR")
|
|
467
|
+
p.add_argument("ref")
|
|
468
|
+
|
|
469
|
+
p = add("for", cmd_for, "list the ADRs in force that govern each path", reads_=True)
|
|
470
|
+
p.add_argument("paths", nargs="+", metavar="PATH")
|
|
471
|
+
p.add_argument("--all", action="store_true", help="include proposed ADRs")
|
|
472
|
+
|
|
473
|
+
p = add("prime", cmd_prime, "print the agent briefing", reads_=True)
|
|
474
|
+
p.add_argument(
|
|
475
|
+
"--budget", type=non_negative, metavar="N", help="limit decisions in force to about N words"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
add("skill", cmd_skill, "print the bundled agent skill (SKILL.md)")
|
|
479
|
+
return parser
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def main(
|
|
483
|
+
argv: list[str] | None = None,
|
|
484
|
+
cwd: Path | None = None,
|
|
485
|
+
env: Mapping[str, str] | None = None,
|
|
486
|
+
) -> int:
|
|
487
|
+
parser = build_parser()
|
|
488
|
+
args = parser.parse_args(argv)
|
|
489
|
+
if args.at and not args.reads:
|
|
490
|
+
parser.error("--at is only valid for read commands (show, list, index, graph, check, for)")
|
|
491
|
+
if args.func is cmd_graph and args.rank and not args.json and args.format in ("mermaid", "dot"):
|
|
492
|
+
parser.error("--rank supports only text and json output")
|
|
493
|
+
cwd = (cwd or Path.cwd()).resolve()
|
|
494
|
+
env = os.environ if env is None else env
|
|
495
|
+
try:
|
|
496
|
+
loc = repo.locate(cwd, env, args.dir)
|
|
497
|
+
out = Out(json_mode=args.json, quiet=args.quiet, base=loc.root or cwd)
|
|
498
|
+
return args.func(Ctx(args, loc, out, cwd)) or EXIT_OK
|
|
499
|
+
except AdrError as exc:
|
|
500
|
+
return error(exc, args.json)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def run() -> None:
|
|
504
|
+
sys.exit(main())
|