warrant-cli 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.
- warrant/__init__.py +8 -0
- warrant/__main__.py +3 -0
- warrant/checks.py +120 -0
- warrant/cli.py +321 -0
- warrant/config.py +192 -0
- warrant/errors.py +2 -0
- warrant/gate.py +151 -0
- warrant/hashing.py +85 -0
- warrant/intent.py +104 -0
- warrant/ledger.py +106 -0
- warrant/project.py +81 -0
- warrant/report.py +261 -0
- warrant/scaffold.py +112 -0
- warrant/verify.py +120 -0
- warrant_cli-0.1.0.dist-info/METADATA +189 -0
- warrant_cli-0.1.0.dist-info/RECORD +20 -0
- warrant_cli-0.1.0.dist-info/WHEEL +5 -0
- warrant_cli-0.1.0.dist-info/entry_points.txt +2 -0
- warrant_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- warrant_cli-0.1.0.dist-info/top_level.txt +1 -0
warrant/__init__.py
ADDED
warrant/__main__.py
ADDED
warrant/checks.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Checks: the machine-decidable form of the intent, approved by a principal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from warrant.config import STRENGTHS, Config, load_toml
|
|
11
|
+
from warrant.errors import WarrantError
|
|
12
|
+
from warrant.hashing import digest_json, tree_digest
|
|
13
|
+
from warrant.intent import Intent
|
|
14
|
+
|
|
15
|
+
MANIFEST = "checks.toml"
|
|
16
|
+
_FIELDS = {"id", "covers", "statement", "run", "strength", "timeout", "tags", "regression_for"}
|
|
17
|
+
_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Check:
|
|
22
|
+
id: str
|
|
23
|
+
visibility: str # "public" (the builder may see it) or "holdout" (the builder must not)
|
|
24
|
+
covers: list[str]
|
|
25
|
+
statement: str
|
|
26
|
+
run: list[str]
|
|
27
|
+
strength: str
|
|
28
|
+
timeout: int
|
|
29
|
+
tags: list[str]
|
|
30
|
+
regression_for: list[str]
|
|
31
|
+
directory: Path
|
|
32
|
+
digest: str # covers the definition and every file in the check's directory
|
|
33
|
+
|
|
34
|
+
def matches(self, selector: str) -> bool:
|
|
35
|
+
if selector == "all":
|
|
36
|
+
return True
|
|
37
|
+
if selector in ("public", "holdout"):
|
|
38
|
+
return self.visibility == selector
|
|
39
|
+
kind, _, value = selector.partition(":")
|
|
40
|
+
return (kind == "tag" and value in self.tags) or (kind == "id" and value == self.id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_checks(config: Config, intent: Intent) -> list[Check]:
|
|
44
|
+
checks: list[Check] = []
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
for visibility, directory in (("public", config.public_dir), ("holdout", config.holdout_dir)):
|
|
47
|
+
if directory is None:
|
|
48
|
+
continue
|
|
49
|
+
manifest = directory / MANIFEST
|
|
50
|
+
if not manifest.is_file():
|
|
51
|
+
raise WarrantError(f"missing {config.display(manifest)}")
|
|
52
|
+
entries = load_toml(manifest).get("check", [])
|
|
53
|
+
if not isinstance(entries, list):
|
|
54
|
+
raise WarrantError(f"{config.display(manifest)}: write checks as [[check]] tables")
|
|
55
|
+
files = tree_digest(directory)
|
|
56
|
+
for number, entry in enumerate(entries, start=1):
|
|
57
|
+
check = _parse(entry, visibility, directory, files, intent, f"{config.display(manifest)}, check {number}")
|
|
58
|
+
if check.id in seen:
|
|
59
|
+
raise WarrantError(f"check {check.id} is defined more than once")
|
|
60
|
+
seen.add(check.id)
|
|
61
|
+
checks.append(check)
|
|
62
|
+
return checks
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse(entry, visibility: str, directory: Path, files: str, intent: Intent, where: str) -> Check:
|
|
66
|
+
if not isinstance(entry, dict):
|
|
67
|
+
raise WarrantError(f"{where}: each [[check]] must be a table")
|
|
68
|
+
unknown = sorted(set(entry) - _FIELDS)
|
|
69
|
+
if unknown:
|
|
70
|
+
raise WarrantError(f"{where}: unknown field(s) {', '.join(unknown)}")
|
|
71
|
+
check_id = entry.get("id")
|
|
72
|
+
if not isinstance(check_id, str) or not _ID.match(check_id):
|
|
73
|
+
raise WarrantError(f"{where}: id must be a short identifier such as P1 or H3")
|
|
74
|
+
where = f"check {check_id}"
|
|
75
|
+
|
|
76
|
+
covers = entry.get("covers")
|
|
77
|
+
if not isinstance(covers, list) or not covers or not all(isinstance(c, str) for c in covers):
|
|
78
|
+
raise WarrantError(f'{where}: covers must list the promises it checks, e.g. covers = ["G1"]')
|
|
79
|
+
missing = [clause for clause in covers if intent.clause(clause) is None]
|
|
80
|
+
if missing:
|
|
81
|
+
raise WarrantError(f"{where} covers {', '.join(missing)}, which {intent.path.name} does not define")
|
|
82
|
+
|
|
83
|
+
statement = entry.get("statement")
|
|
84
|
+
if not isinstance(statement, str) or not statement.strip():
|
|
85
|
+
raise WarrantError(f"{where}: statement must say in plain words what the check shows")
|
|
86
|
+
|
|
87
|
+
run = entry.get("run")
|
|
88
|
+
if isinstance(run, str):
|
|
89
|
+
run = shlex.split(run)
|
|
90
|
+
if not isinstance(run, list) or not run or not all(isinstance(part, str) for part in run):
|
|
91
|
+
raise WarrantError(f"{where}: run must be a command, as a string or a list of strings")
|
|
92
|
+
|
|
93
|
+
strength = entry.get("strength")
|
|
94
|
+
if strength not in STRENGTHS:
|
|
95
|
+
raise WarrantError(f"{where}: strength must be one of {', '.join(STRENGTHS)}")
|
|
96
|
+
|
|
97
|
+
timeout = entry.get("timeout", 120)
|
|
98
|
+
if type(timeout) is not int or timeout <= 0:
|
|
99
|
+
raise WarrantError(f"{where}: timeout must be a whole number of seconds")
|
|
100
|
+
|
|
101
|
+
definition = {
|
|
102
|
+
"id": check_id,
|
|
103
|
+
"visibility": visibility,
|
|
104
|
+
"covers": covers,
|
|
105
|
+
"statement": " ".join(statement.split()),
|
|
106
|
+
"run": run,
|
|
107
|
+
"strength": strength,
|
|
108
|
+
"timeout": timeout,
|
|
109
|
+
"tags": _strings(entry, "tags", where),
|
|
110
|
+
"regression_for": _strings(entry, "regression_for", where),
|
|
111
|
+
}
|
|
112
|
+
digest = digest_json({"definition": definition, "files": files})
|
|
113
|
+
return Check(**definition, directory=directory, digest=digest)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _strings(entry: dict, key: str, where: str) -> list[str]:
|
|
117
|
+
value = entry.get(key, [])
|
|
118
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
|
119
|
+
raise WarrantError(f"{where}: {key} must be a list of strings")
|
|
120
|
+
return value
|
warrant/cli.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""The warrant command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from warrant import __version__, gate, report, scaffold
|
|
12
|
+
from warrant.config import load_config, validate_selector
|
|
13
|
+
from warrant.errors import WarrantError
|
|
14
|
+
from warrant.hashing import short, tree_digest
|
|
15
|
+
from warrant.ledger import Ledger
|
|
16
|
+
from warrant.project import Project, find_root, load_project
|
|
17
|
+
from warrant.verify import run_checks
|
|
18
|
+
|
|
19
|
+
_INCIDENT_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
parser = _parser()
|
|
24
|
+
args = parser.parse_args(argv)
|
|
25
|
+
if not hasattr(args, "handler"):
|
|
26
|
+
parser.print_help()
|
|
27
|
+
return 2
|
|
28
|
+
try:
|
|
29
|
+
code = args.handler(args)
|
|
30
|
+
sys.stdout.flush()
|
|
31
|
+
return code
|
|
32
|
+
except WarrantError as error:
|
|
33
|
+
print(f"warrant: {error}", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
except BrokenPipeError:
|
|
36
|
+
# The reader went away (for example `warrant log | head`); that's not an error.
|
|
37
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(
|
|
43
|
+
prog="warrant",
|
|
44
|
+
description="Humans state intent. Agents write the code. Evidence decides what ships.",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--version", action="version", version=f"warrant {__version__}")
|
|
47
|
+
parser.add_argument("-C", dest="directory", default=".", metavar="DIR", help="run as if started in DIR")
|
|
48
|
+
commands = parser.add_subparsers(metavar="COMMAND")
|
|
49
|
+
|
|
50
|
+
command = commands.add_parser("init", help="create a new project")
|
|
51
|
+
command.add_argument("path", nargs="?", default=".", help="where to create it (default: here)")
|
|
52
|
+
command.add_argument("--name", help="project name (default: the directory's name)")
|
|
53
|
+
command.set_defaults(handler=_init)
|
|
54
|
+
|
|
55
|
+
command = commands.add_parser("check", help="validate the intent, checks and policy, and show coverage")
|
|
56
|
+
command.add_argument("--workspace", metavar="DIR", help="check separation against this workspace")
|
|
57
|
+
command.set_defaults(handler=_check)
|
|
58
|
+
|
|
59
|
+
command = commands.add_parser("approve", help="approve the current intent, checks and policy (principals only)")
|
|
60
|
+
command.add_argument("--by", required=True, metavar="NAME", help="who is approving")
|
|
61
|
+
command.add_argument("--note", default="", help="why, or what changed")
|
|
62
|
+
command.set_defaults(handler=_approve)
|
|
63
|
+
|
|
64
|
+
command = commands.add_parser("brief", help="print the builder's brief, which never includes hidden checks")
|
|
65
|
+
command.add_argument("--out", metavar="FILE", help="write it to FILE")
|
|
66
|
+
command.set_defaults(handler=_brief)
|
|
67
|
+
|
|
68
|
+
command = commands.add_parser("verify", help="run checks on a snapshot of the workspace and record evidence")
|
|
69
|
+
command.add_argument("--workspace", metavar="DIR")
|
|
70
|
+
command.add_argument(
|
|
71
|
+
"--audience", choices=("principal", "builder"), default="principal",
|
|
72
|
+
help="builder output reveals hidden checks only as the promises they found broken",
|
|
73
|
+
)
|
|
74
|
+
command.add_argument(
|
|
75
|
+
"--select", action="append", metavar="SELECTOR",
|
|
76
|
+
help="run only matching checks: all, public, holdout, tag:NAME or id:ID (repeatable)",
|
|
77
|
+
)
|
|
78
|
+
command.set_defaults(handler=_verify)
|
|
79
|
+
|
|
80
|
+
command = commands.add_parser("gate", help="issue a warrant or a denial for an action such as merge")
|
|
81
|
+
command.add_argument("action", help="a gate defined in warrant.toml")
|
|
82
|
+
command.add_argument("--workspace", metavar="DIR")
|
|
83
|
+
command.set_defaults(handler=_gate)
|
|
84
|
+
|
|
85
|
+
command = commands.add_parser("report", help="show which promises are kept and broken")
|
|
86
|
+
command.add_argument("--workspace", metavar="DIR")
|
|
87
|
+
command.add_argument("--audience", choices=("principal", "builder"), default="principal")
|
|
88
|
+
command.add_argument("--out", metavar="FILE", help="write it to FILE")
|
|
89
|
+
command.set_defaults(handler=_report)
|
|
90
|
+
|
|
91
|
+
command = commands.add_parser("incident", help="record or list problems found in operation")
|
|
92
|
+
actions = command.add_subparsers(dest="incident_action", metavar="ACTION")
|
|
93
|
+
add = actions.add_parser("add", help="record an incident")
|
|
94
|
+
add.add_argument("id", help="an ID such as INC-1")
|
|
95
|
+
add.add_argument("description", help="what happened, in plain words")
|
|
96
|
+
add.add_argument("--clause", action="append", default=[], metavar="ID", help="a promise involved (repeatable)")
|
|
97
|
+
add.add_argument("--by", required=True, metavar="NAME", help="who is recording it")
|
|
98
|
+
actions.add_parser("list", help="list incidents and their regression checks")
|
|
99
|
+
command.set_defaults(handler=_incident, incident_parser=command)
|
|
100
|
+
|
|
101
|
+
command = commands.add_parser("log", help="show the ledger")
|
|
102
|
+
command.add_argument("--verify", action="store_true", help="check that the ledger and evidence are intact")
|
|
103
|
+
command.set_defaults(handler=_log)
|
|
104
|
+
return parser
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _base(args) -> Path:
|
|
108
|
+
base = Path(args.directory).expanduser().resolve()
|
|
109
|
+
if not base.is_dir():
|
|
110
|
+
raise WarrantError(f"-C {args.directory}: not a directory")
|
|
111
|
+
return base
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _project(args) -> Project:
|
|
115
|
+
return load_project(find_root(_base(args)))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _workspace(args, project: Project) -> tuple[Path, str]:
|
|
119
|
+
if args.workspace:
|
|
120
|
+
path = Path(args.workspace).expanduser()
|
|
121
|
+
path = path if path.is_absolute() else _base(args) / path
|
|
122
|
+
elif project.config.workspace is not None:
|
|
123
|
+
path = project.config.workspace
|
|
124
|
+
else:
|
|
125
|
+
raise WarrantError("no workspace given: pass --workspace DIR or set workspace in warrant.toml")
|
|
126
|
+
if not path.is_dir():
|
|
127
|
+
raise WarrantError(f"workspace {path} is not a directory")
|
|
128
|
+
return path.resolve(), project.config.display(path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _emit(text: str, out: str | None, args) -> None:
|
|
132
|
+
if out is None:
|
|
133
|
+
sys.stdout.write(text)
|
|
134
|
+
return
|
|
135
|
+
path = Path(out).expanduser()
|
|
136
|
+
path = path if path.is_absolute() else _base(args) / path
|
|
137
|
+
path.write_text(text, encoding="utf-8")
|
|
138
|
+
print(f"Wrote {path}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _init(args) -> int:
|
|
142
|
+
target = Path(args.path).expanduser()
|
|
143
|
+
target = (target if target.is_absolute() else _base(args) / target).resolve()
|
|
144
|
+
name = args.name or target.name
|
|
145
|
+
created = scaffold.create(target, name)
|
|
146
|
+
print(f"Created a Warrant project in {target}:")
|
|
147
|
+
for path in created:
|
|
148
|
+
print(f" {path.relative_to(target)}")
|
|
149
|
+
print(" workspace/")
|
|
150
|
+
print()
|
|
151
|
+
print("Next:")
|
|
152
|
+
print(" 1. Write your promises in intent.md.")
|
|
153
|
+
print(" 2. Have an examiner draft checks for them in checks/, or write them yourself.")
|
|
154
|
+
print(" 3. Review the checks, then approve: warrant approve --by YOUR-NAME")
|
|
155
|
+
print(" 4. Hand the builder its brief: warrant brief")
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _check(args) -> int:
|
|
160
|
+
project = _project(args)
|
|
161
|
+
config, intent = project.config, project.intent
|
|
162
|
+
ok = True
|
|
163
|
+
print(config.name)
|
|
164
|
+
print(f" intent {config.display(intent.path)}: {len(intent.clauses)} promises ({report.section_summary(intent)})")
|
|
165
|
+
visible = sum(check.visibility == "public" for check in project.checks)
|
|
166
|
+
print(f" checks {visible} visible, {len(project.checks) - visible} hidden")
|
|
167
|
+
|
|
168
|
+
coverage = project.coverage()
|
|
169
|
+
uncovered = [clause for clause, checks in coverage.items() if not checks]
|
|
170
|
+
visible_only = [
|
|
171
|
+
clause for clause, checks in coverage.items()
|
|
172
|
+
if checks and all(check.visibility == "public" for check in checks)
|
|
173
|
+
]
|
|
174
|
+
if uncovered:
|
|
175
|
+
ok = False
|
|
176
|
+
print(f" coverage NOT COVERED: {', '.join(uncovered)}")
|
|
177
|
+
else:
|
|
178
|
+
note = f"; only visible checks cover {', '.join(visible_only)}" if visible_only else ""
|
|
179
|
+
print(f" coverage every promise has a check{note}")
|
|
180
|
+
|
|
181
|
+
workspace = None
|
|
182
|
+
if args.workspace:
|
|
183
|
+
workspace = Path(args.workspace).expanduser()
|
|
184
|
+
workspace = workspace if workspace.is_absolute() else _base(args) / workspace
|
|
185
|
+
elif config.workspace is not None:
|
|
186
|
+
workspace = config.workspace
|
|
187
|
+
if workspace is None:
|
|
188
|
+
print(" separation no workspace configured")
|
|
189
|
+
else:
|
|
190
|
+
problems = config.separation_problems(workspace)
|
|
191
|
+
for problem in problems:
|
|
192
|
+
print(f" separation PROBLEM: {problem}")
|
|
193
|
+
if problems:
|
|
194
|
+
ok = False
|
|
195
|
+
else:
|
|
196
|
+
print(f" separation ok: hidden checks and the ledger are outside {config.display(workspace)}")
|
|
197
|
+
|
|
198
|
+
print(f" approval {report.approval_line(project)}")
|
|
199
|
+
print(f" gates {', '.join(config.gates)}")
|
|
200
|
+
return 0 if ok else 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _approve(args) -> int:
|
|
204
|
+
project = _project(args)
|
|
205
|
+
if project.config.workspace is not None:
|
|
206
|
+
problems = project.config.separation_problems(project.config.workspace)
|
|
207
|
+
if problems:
|
|
208
|
+
raise WarrantError("refusing to approve: " + "; ".join(problems))
|
|
209
|
+
state, previous = project.approval_state()
|
|
210
|
+
if state == "current":
|
|
211
|
+
by, at = previous["data"]["by"], report.when(previous["time"])
|
|
212
|
+
print(f"Nothing to approve: {by} approved this exact intent, checks and policy on {at}.")
|
|
213
|
+
return 0
|
|
214
|
+
entry = project.ledger.append("approve", f"principal:{args.by}", {
|
|
215
|
+
"by": args.by,
|
|
216
|
+
"note": args.note,
|
|
217
|
+
"baseline": project.baseline_digest(),
|
|
218
|
+
**project.baseline(),
|
|
219
|
+
})
|
|
220
|
+
print(
|
|
221
|
+
f"Approved {short(entry['data']['baseline'])}: {len(project.intent.clauses)} promises, "
|
|
222
|
+
f"{len(project.checks)} checks and {len(project.config.gates)} gates."
|
|
223
|
+
)
|
|
224
|
+
if state == "stale":
|
|
225
|
+
print(" Changes since the last approval: " + "; ".join(project.changes_since(previous)))
|
|
226
|
+
uncovered = [clause for clause, checks in project.coverage().items() if not checks]
|
|
227
|
+
if uncovered:
|
|
228
|
+
print(f" Warning: no check covers {', '.join(uncovered)}.")
|
|
229
|
+
print(f"Recorded in ledger entry #{entry['seq']}.")
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _brief(args) -> int:
|
|
234
|
+
_emit(report.render_brief(_project(args)), args.out, args)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _verify(args) -> int:
|
|
239
|
+
project = _project(args)
|
|
240
|
+
workspace, label = _workspace(args, project)
|
|
241
|
+
selectors = args.select or ["all"]
|
|
242
|
+
for selector in selectors:
|
|
243
|
+
validate_selector(selector, "--select")
|
|
244
|
+
run = run_checks(project, workspace, label, selectors)
|
|
245
|
+
print(report.render_run(project, run, args.audience))
|
|
246
|
+
if args.audience == "principal" and project.approval_state()[0] != "current":
|
|
247
|
+
print("Note: these checks aren't approved in their current form, so gates won't count this evidence yet.")
|
|
248
|
+
return 0 if all(outcome.result == "pass" for outcome in run.outcomes) else 1
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _gate(args) -> int:
|
|
252
|
+
project = _project(args)
|
|
253
|
+
workspace, label = _workspace(args, project)
|
|
254
|
+
decision = gate.evaluate(project, args.action, tree_digest(workspace))
|
|
255
|
+
entry = gate.record(project, decision, label)
|
|
256
|
+
print(report.render_decision(decision, label, entry))
|
|
257
|
+
return 0 if decision.granted else 1
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _report(args) -> int:
|
|
261
|
+
project = _project(args)
|
|
262
|
+
workspace, label = _workspace(args, project)
|
|
263
|
+
_emit(report.render_report(project, tree_digest(workspace), label, args.audience), args.out, args)
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _incident(args) -> int:
|
|
268
|
+
if args.incident_action is None:
|
|
269
|
+
args.incident_parser.print_help()
|
|
270
|
+
return 2
|
|
271
|
+
project = _project(args)
|
|
272
|
+
incidents = project.incidents()
|
|
273
|
+
if args.incident_action == "list":
|
|
274
|
+
if not incidents:
|
|
275
|
+
print("No incidents recorded.")
|
|
276
|
+
for incident in incidents:
|
|
277
|
+
regressions = [check.id for check in project.checks if incident["id"] in check.regression_for]
|
|
278
|
+
print(f"{incident['id']} {report.when(incident['time'])} {incident['description']}")
|
|
279
|
+
print(f" promises: {', '.join(incident['clauses']) or 'none named'}; "
|
|
280
|
+
f"regression checks: {', '.join(regressions) or 'none yet'}")
|
|
281
|
+
return 0
|
|
282
|
+
if not _INCIDENT_ID.match(args.id):
|
|
283
|
+
raise WarrantError(f"{args.id!r} is not a usable incident ID; try something like INC-1")
|
|
284
|
+
if any(incident["id"] == args.id for incident in incidents):
|
|
285
|
+
raise WarrantError(f"incident {args.id} is already recorded")
|
|
286
|
+
unknown = [clause for clause in args.clause if project.intent.clause(clause) is None]
|
|
287
|
+
if unknown:
|
|
288
|
+
raise WarrantError(f"{project.intent.path.name} does not define {', '.join(unknown)}")
|
|
289
|
+
entry = project.ledger.append("incident", f"principal:{args.by}", {
|
|
290
|
+
"id": args.id,
|
|
291
|
+
"description": args.description,
|
|
292
|
+
"clauses": args.clause,
|
|
293
|
+
"by": args.by,
|
|
294
|
+
})
|
|
295
|
+
print(f"Recorded incident {args.id} in ledger entry #{entry['seq']}.")
|
|
296
|
+
stopped = [name for name, g in project.config.gates.items() if g.require_regressions]
|
|
297
|
+
if stopped:
|
|
298
|
+
print(f" {', '.join(stopped)} will be denied until an approved check with "
|
|
299
|
+
f'regression_for = ["{args.id}"] passes.')
|
|
300
|
+
if not args.clause:
|
|
301
|
+
print(" It names no promise. If none of the intent covers what went wrong, the intent is missing one.")
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _log(args) -> int:
|
|
306
|
+
ledger = Ledger(load_config(find_root(_base(args))).ledger_dir)
|
|
307
|
+
entries = ledger.entries()
|
|
308
|
+
if args.verify:
|
|
309
|
+
problems = ledger.problems()
|
|
310
|
+
if problems:
|
|
311
|
+
print("The ledger has been altered:")
|
|
312
|
+
print("\n".join(f" - {problem}" for problem in problems))
|
|
313
|
+
return 1
|
|
314
|
+
print(f"Ledger intact: {len(entries)} entries, head {short(ledger.head())}.")
|
|
315
|
+
print("Keep a copy of the head digest where builders can't write, to detect a wholesale rewrite.")
|
|
316
|
+
return 0
|
|
317
|
+
if not entries:
|
|
318
|
+
print("The ledger is empty.")
|
|
319
|
+
for entry in entries:
|
|
320
|
+
print(report.render_entry(entry))
|
|
321
|
+
return 0
|
warrant/config.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""warrant.toml: where things live, and which evidence authorizes which action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from warrant.errors import WarrantError
|
|
11
|
+
from warrant.hashing import digest_json
|
|
12
|
+
|
|
13
|
+
CONFIG_NAME = "warrant.toml"
|
|
14
|
+
STRENGTHS = ("judged", "tested", "proved")
|
|
15
|
+
|
|
16
|
+
_TOP_LEVEL = {"project", "checks", "gates"}
|
|
17
|
+
_PROJECT_FIELDS = {"name", "intent", "workspace", "ledger"}
|
|
18
|
+
_CHECKS_FIELDS = {"public", "holdout"}
|
|
19
|
+
_GATE_FIELDS = {"checks", "min_strength", "after", "cover_all_intent", "require_regressions"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Gate:
|
|
24
|
+
name: str
|
|
25
|
+
checks: list[str]
|
|
26
|
+
min_strength: str
|
|
27
|
+
after: list[str]
|
|
28
|
+
cover_all_intent: bool
|
|
29
|
+
require_regressions: bool
|
|
30
|
+
|
|
31
|
+
def policy(self) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"checks": self.checks,
|
|
34
|
+
"min_strength": self.min_strength,
|
|
35
|
+
"after": self.after,
|
|
36
|
+
"cover_all_intent": self.cover_all_intent,
|
|
37
|
+
"require_regressions": self.require_regressions,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Config:
|
|
43
|
+
root: Path
|
|
44
|
+
name: str
|
|
45
|
+
intent_path: Path
|
|
46
|
+
workspace: Path | None
|
|
47
|
+
ledger_dir: Path
|
|
48
|
+
public_dir: Path | None
|
|
49
|
+
holdout_dir: Path | None
|
|
50
|
+
gates: dict[str, Gate]
|
|
51
|
+
|
|
52
|
+
def policy_digest(self) -> str:
|
|
53
|
+
return digest_json({name: gate.policy() for name, gate in sorted(self.gates.items())})
|
|
54
|
+
|
|
55
|
+
def display(self, path: Path) -> str:
|
|
56
|
+
"""A path as people should see it: relative to the project when possible."""
|
|
57
|
+
return os.path.relpath(path.resolve(), self.root)
|
|
58
|
+
|
|
59
|
+
def separation_problems(self, workspace: Path) -> list[str]:
|
|
60
|
+
"""Ways the builder working in this workspace could read what it must not."""
|
|
61
|
+
workspace = workspace.resolve()
|
|
62
|
+
problems = []
|
|
63
|
+
for label, path in (("the hidden checks", self.holdout_dir), ("the ledger", self.ledger_dir)):
|
|
64
|
+
if path is not None and path.resolve().is_relative_to(workspace):
|
|
65
|
+
problems.append(
|
|
66
|
+
f"{label} ({self.display(path)}) would be inside the builder's workspace "
|
|
67
|
+
f"({self.display(workspace)}), where the builder could read them"
|
|
68
|
+
)
|
|
69
|
+
return problems
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_toml(path: Path) -> dict:
|
|
73
|
+
try:
|
|
74
|
+
with path.open("rb") as handle:
|
|
75
|
+
return tomllib.load(handle)
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
raise WarrantError(f"file not found: {path}") from None
|
|
78
|
+
except tomllib.TOMLDecodeError as error:
|
|
79
|
+
raise WarrantError(f"{path}: {error}") from None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(root: Path) -> Config:
|
|
83
|
+
root = root.resolve()
|
|
84
|
+
data = load_toml(root / CONFIG_NAME)
|
|
85
|
+
_known(data, _TOP_LEVEL, CONFIG_NAME)
|
|
86
|
+
project = _table(data, "project", CONFIG_NAME)
|
|
87
|
+
checks = _table(data, "checks", CONFIG_NAME)
|
|
88
|
+
gate_tables = _table(data, "gates", CONFIG_NAME)
|
|
89
|
+
_known(project, _PROJECT_FIELDS, "[project]")
|
|
90
|
+
_known(checks, _CHECKS_FIELDS, "[checks]")
|
|
91
|
+
if not gate_tables:
|
|
92
|
+
raise WarrantError(f"{CONFIG_NAME} defines no gates; add one such as [gates.merge]")
|
|
93
|
+
gates = {name: _gate(name, table) for name, table in gate_tables.items()}
|
|
94
|
+
_check_order(gates)
|
|
95
|
+
public, holdout = _path(root, checks, "public", "[checks]"), _path(root, checks, "holdout", "[checks]")
|
|
96
|
+
if public is None and holdout is None:
|
|
97
|
+
raise WarrantError("[checks] needs a public or a holdout directory")
|
|
98
|
+
return Config(
|
|
99
|
+
root=root,
|
|
100
|
+
name=_string(project, "name", "[project]", default=root.name),
|
|
101
|
+
intent_path=_path(root, project, "intent", "[project]", default="intent.md"),
|
|
102
|
+
workspace=_path(root, project, "workspace", "[project]"),
|
|
103
|
+
ledger_dir=_path(root, project, "ledger", "[project]", default="ledger"),
|
|
104
|
+
public_dir=public,
|
|
105
|
+
holdout_dir=holdout,
|
|
106
|
+
gates=gates,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_selector(selector: str, where: str) -> None:
|
|
111
|
+
if selector in ("all", "public", "holdout"):
|
|
112
|
+
return
|
|
113
|
+
kind, separator, value = selector.partition(":")
|
|
114
|
+
if separator and kind in ("tag", "id") and value:
|
|
115
|
+
return
|
|
116
|
+
raise WarrantError(f"{where}: unknown check selector {selector!r}; use all, public, holdout, tag:NAME or id:ID")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _gate(name: str, table) -> Gate:
|
|
120
|
+
where = f"[gates.{name}]"
|
|
121
|
+
if not isinstance(table, dict):
|
|
122
|
+
raise WarrantError(f"{where} must be a table")
|
|
123
|
+
_known(table, _GATE_FIELDS, where)
|
|
124
|
+
selectors = _strings(table, "checks", where, default=["all"])
|
|
125
|
+
for selector in selectors:
|
|
126
|
+
validate_selector(selector, where)
|
|
127
|
+
strength = table.get("min_strength", "tested")
|
|
128
|
+
if strength not in STRENGTHS:
|
|
129
|
+
raise WarrantError(f"{where}: min_strength must be one of {', '.join(STRENGTHS)}")
|
|
130
|
+
return Gate(
|
|
131
|
+
name=name,
|
|
132
|
+
checks=selectors,
|
|
133
|
+
min_strength=strength,
|
|
134
|
+
after=_strings(table, "after", where, default=[]),
|
|
135
|
+
cover_all_intent=_bool(table, "cover_all_intent", where),
|
|
136
|
+
require_regressions=_bool(table, "require_regressions", where),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_order(gates: dict[str, Gate]) -> None:
|
|
141
|
+
for gate in gates.values():
|
|
142
|
+
for prerequisite in gate.after:
|
|
143
|
+
if prerequisite not in gates:
|
|
144
|
+
raise WarrantError(f"[gates.{gate.name}] comes after {prerequisite!r}, which is not a gate")
|
|
145
|
+
|
|
146
|
+
def visit(name: str, path: tuple[str, ...]) -> None:
|
|
147
|
+
if name in path:
|
|
148
|
+
raise WarrantError("gates depend on each other in a loop: " + " -> ".join((*path, name)))
|
|
149
|
+
for prerequisite in gates[name].after:
|
|
150
|
+
visit(prerequisite, (*path, name))
|
|
151
|
+
|
|
152
|
+
for name in gates:
|
|
153
|
+
visit(name, ())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _known(table: dict, allowed: set[str], where: str) -> None:
|
|
157
|
+
unknown = sorted(set(table) - allowed)
|
|
158
|
+
if unknown:
|
|
159
|
+
raise WarrantError(f"{where}: unknown setting(s) {', '.join(unknown)}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _table(data: dict, key: str, where: str) -> dict:
|
|
163
|
+
value = data.get(key, {})
|
|
164
|
+
if not isinstance(value, dict):
|
|
165
|
+
raise WarrantError(f"{where}: [{key}] must be a table")
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _string(table: dict, key: str, where: str, default: str | None = None) -> str | None:
|
|
170
|
+
value = table.get(key, default)
|
|
171
|
+
if value is not None and (not isinstance(value, str) or not value.strip()):
|
|
172
|
+
raise WarrantError(f"{where}: {key} must be a non-empty string")
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _strings(table: dict, key: str, where: str, default: list[str]) -> list[str]:
|
|
177
|
+
value = table.get(key, default)
|
|
178
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
|
179
|
+
raise WarrantError(f"{where}: {key} must be a list of strings")
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _bool(table: dict, key: str, where: str) -> bool:
|
|
184
|
+
value = table.get(key, False)
|
|
185
|
+
if not isinstance(value, bool):
|
|
186
|
+
raise WarrantError(f"{where}: {key} must be true or false")
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _path(root: Path, table: dict, key: str, where: str, default: str | None = None) -> Path | None:
|
|
191
|
+
value = _string(table, key, where, default)
|
|
192
|
+
return None if value is None else (root / value).resolve()
|
warrant/errors.py
ADDED