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 ADDED
@@ -0,0 +1,8 @@
1
+ """Warrant: humans state intent, agents write the code, evidence decides what ships."""
2
+
3
+ import sys
4
+
5
+ if sys.version_info < (3, 11):
6
+ raise ImportError("Warrant needs Python 3.11 or newer")
7
+
8
+ __version__ = "0.1.0"
warrant/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from warrant.cli import main
2
+
3
+ raise SystemExit(main())
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
@@ -0,0 +1,2 @@
1
+ class WarrantError(Exception):
2
+ """A problem the user can fix, such as a bad setting or a malformed file."""