exacly 0.2.1__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.
- exacly/__init__.py +5 -0
- exacly/__main__.py +5 -0
- exacly/cli.py +509 -0
- exacly/core.py +707 -0
- exacly/mcp.py +162 -0
- exacly/py.typed +0 -0
- exacly/query.py +358 -0
- exacly/refresh.py +256 -0
- exacly/schemas/0.1/2026-10-23.json +918 -0
- exacly/schemas/0.2/config/2026-10-23.json +791 -0
- exacly/schemas/0.2/state/2026-10-23.json +452 -0
- exacly/views.py +339 -0
- exacly-0.2.1.dist-info/METADATA +177 -0
- exacly-0.2.1.dist-info/RECORD +17 -0
- exacly-0.2.1.dist-info/WHEEL +4 -0
- exacly-0.2.1.dist-info/entry_points.txt +4 -0
- exacly-0.2.1.dist-info/licenses/LICENSE +202 -0
exacly/__init__.py
ADDED
exacly/__main__.py
ADDED
exacly/cli.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Command lines.
|
|
2
|
+
|
|
3
|
+
exacly validate PATH... [--revision REV] [--date YYYY-MM-DD] [--json]
|
|
4
|
+
exacly refresh PATH... --context CONTEXT --state FILE [--junit FILE...] [--distribution] [run options]
|
|
5
|
+
exacly show|trace|impact|gaps [-m PATH]... [--json] ...; exacly mcp [-m PATH]...
|
|
6
|
+
exacly view plan [--base REV] | site [--out DIR]
|
|
7
|
+
exacly-validate and exacly-refresh are the first two under their names before 0.2.0, kept for one release
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import glob
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from collections.abc import Callable, Iterable
|
|
21
|
+
from fnmatch import fnmatchcase
|
|
22
|
+
from importlib import metadata
|
|
23
|
+
from pathlib import Path, PurePosixPath
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
from exacly import mcp, query, views
|
|
29
|
+
from exacly.core import is_state, validate_documents
|
|
30
|
+
from exacly.refresh import RefreshError, Run, read_junit, refresh
|
|
31
|
+
|
|
32
|
+
SUFFIXES = {".yaml", ".yml", ".json"}
|
|
33
|
+
DEFAULT_MODEL = Path("spec/exacly")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def wanted(file: Path, folder: Path) -> bool:
|
|
37
|
+
"""Whether a file under a folder given is a model document: YAML or JSON, not in a hidden folder."""
|
|
38
|
+
return file.suffix in SUFFIXES and not any(p.startswith(".") for p in file.relative_to(folder).parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_pattern(path: Path) -> bool:
|
|
42
|
+
return any(c in str(path) for c in "*?[")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def static_part(path: Path) -> Path:
|
|
46
|
+
"""The folder a pattern such as spec/*/model.yaml starts in: its parts before the first one with a wildcard."""
|
|
47
|
+
parts = path.parts
|
|
48
|
+
first = next(i for i, part in enumerate(parts) if is_pattern(Path(part)))
|
|
49
|
+
return Path(*parts[:first]) if first else Path(".")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def matches(name: str, pattern: str) -> bool:
|
|
53
|
+
"""Whether a path matches a pattern part by part, as `glob` does: * and ? don't cross a /, and a hidden
|
|
54
|
+
name, starting with a dot, matches only a part that starts with a dot too."""
|
|
55
|
+
names, patterns = PurePosixPath(name).parts, PurePosixPath(pattern).parts
|
|
56
|
+
return len(names) == len(patterns) and all(
|
|
57
|
+
fnmatchcase(n, p) and (p.startswith(".") or not n.startswith(".")) for n, p in zip(names, patterns, strict=True)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load(paths: list[Path]) -> dict[str, Any]:
|
|
62
|
+
"""Each file given, every file a pattern such as spec/exacly/*.yaml matches, and every YAML or JSON file under
|
|
63
|
+
each folder given, by path."""
|
|
64
|
+
files: list[Path] = []
|
|
65
|
+
for path in paths:
|
|
66
|
+
if is_pattern(path):
|
|
67
|
+
found = sorted(Path(f) for f in glob.glob(str(path)) if Path(f).is_file())
|
|
68
|
+
if not found:
|
|
69
|
+
raise query.QueryError(f"{path} matches no files")
|
|
70
|
+
files += found
|
|
71
|
+
elif path.is_dir():
|
|
72
|
+
files += sorted(f for f in path.rglob("*") if f.is_file() and wanted(f, path))
|
|
73
|
+
else:
|
|
74
|
+
files.append(path)
|
|
75
|
+
return {str(f): read(f) for f in files}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read(file: Path) -> Any:
|
|
79
|
+
"""One document, parsed. Raises QueryError, saying which file and where, when it can't be read or parsed."""
|
|
80
|
+
try:
|
|
81
|
+
text = file.read_text(encoding="utf-8")
|
|
82
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
83
|
+
raise query.QueryError(f"{file} can't be read: {error}") from error
|
|
84
|
+
return parse(text, str(file))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Loader(yaml.SafeLoader):
|
|
88
|
+
"""PyYAML's safe loader, but a scalar that matches a type without being one, such as the unquoted date
|
|
89
|
+
2026-13-24, is a ConstructorError that says where, not a bare ValueError."""
|
|
90
|
+
|
|
91
|
+
def construct_object(self, node: yaml.Node, deep: bool = False) -> Any:
|
|
92
|
+
try:
|
|
93
|
+
return super().construct_object(node, deep)
|
|
94
|
+
except ValueError as error:
|
|
95
|
+
kind = node.tag.rsplit(":", 1)[-1]
|
|
96
|
+
problem = f"{node.value!r} is not a valid {kind}: {error}"
|
|
97
|
+
raise yaml.constructor.ConstructorError(None, None, problem, node.start_mark) from error
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse(text: str, name: str) -> Any:
|
|
101
|
+
try:
|
|
102
|
+
return yaml.load(text, Loader)
|
|
103
|
+
except yaml.YAMLError as error:
|
|
104
|
+
where = getattr(error, "problem_mark", None)
|
|
105
|
+
at = f" at line {where.line + 1}, column {where.column + 1}" if where else ""
|
|
106
|
+
raise query.QueryError(f"{name} is not valid YAML{at}: {getattr(error, 'problem', None) or error}") from error
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_at(revision: str, paths: list[Path]) -> dict[str, Any]:
|
|
110
|
+
"""As `load`, but the files as they were at a Git revision. Paths may be relative or absolute, inside the
|
|
111
|
+
repository that holds them; documents are named by their path in that repository. A pattern is matched
|
|
112
|
+
against the files at that revision, so a file the change deleted is still compared."""
|
|
113
|
+
|
|
114
|
+
def git(where: Path, *args: str) -> str:
|
|
115
|
+
return subprocess.run(
|
|
116
|
+
["git", "-C", str(where), *args], check=True, capture_output=True, encoding="utf-8"
|
|
117
|
+
).stdout
|
|
118
|
+
|
|
119
|
+
documents = {}
|
|
120
|
+
for given in paths:
|
|
121
|
+
pattern = is_pattern(given)
|
|
122
|
+
if pattern:
|
|
123
|
+
# Resolve the folder the pattern starts in, so that it compares with the repository's real path through
|
|
124
|
+
# symbolic links, such as macOS's /var for /private/var.
|
|
125
|
+
path = Path(os.path.normpath(Path.cwd() / given))
|
|
126
|
+
folder = static_part(path)
|
|
127
|
+
path = folder.resolve() / path.relative_to(folder)
|
|
128
|
+
else:
|
|
129
|
+
path = given.resolve()
|
|
130
|
+
start = static_part(path) if pattern else path if path.is_dir() else path.parent
|
|
131
|
+
while not start.is_dir(): # A folder the change deleted, which held files at the revision.
|
|
132
|
+
start = start.parent
|
|
133
|
+
top = Path(git(start, "rev-parse", "--show-toplevel").strip()).resolve()
|
|
134
|
+
try:
|
|
135
|
+
inside = path.relative_to(top)
|
|
136
|
+
except ValueError as error:
|
|
137
|
+
raise query.QueryError(f"{path} is not in the Git repository at {top}") from error
|
|
138
|
+
listed = static_part(inside) if pattern else inside
|
|
139
|
+
# NUL-separated, so Git doesn't quote names with non-ASCII or special characters.
|
|
140
|
+
for name in git(top, "ls-tree", "-r", "-z", "--name-only", revision, "--", str(listed)).split("\0"):
|
|
141
|
+
if not name:
|
|
142
|
+
continue
|
|
143
|
+
file = Path(name)
|
|
144
|
+
if matches(name, inside.as_posix()) if pattern else file == inside or wanted(file, inside):
|
|
145
|
+
documents[name] = parse(git(top, "show", f"{revision}:{name}"), f"{name} at {revision}")
|
|
146
|
+
return documents
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: list[str] | None = None) -> int:
|
|
150
|
+
parser = argparse.ArgumentParser(
|
|
151
|
+
prog="exacly validate",
|
|
152
|
+
description="Validate an Exacly model and print the section 11 validation report. "
|
|
153
|
+
"Exits 1 when the model does not conform.",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"paths",
|
|
157
|
+
nargs="+",
|
|
158
|
+
type=Path,
|
|
159
|
+
metavar="PATH",
|
|
160
|
+
help="a 0.1 product map; or 0.2 config and state documents, or folders holding them, such as spec/exacly",
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"--revision", help="config revision being validated, such as a Git commit; state from another one is reported"
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--date", type=dt.date.fromisoformat, default=dt.date.today(), help="report date (default: today)"
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
169
|
+
args = parser.parse_args(argv)
|
|
170
|
+
try:
|
|
171
|
+
documents = load(args.paths)
|
|
172
|
+
except query.QueryError as error:
|
|
173
|
+
print(f"exacly validate: {error}", file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
if not documents:
|
|
176
|
+
parser.error("no YAML or JSON documents found")
|
|
177
|
+
report = validate_documents(documents, args.date, args.revision)
|
|
178
|
+
print(json.dumps(report.as_dict(), indent=2) if args.json else report.as_text())
|
|
179
|
+
return 1 if report.errors else 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def github_defaults() -> dict[str, str | None]:
|
|
183
|
+
"""Run details GitHub Actions provides in its environment, when running there."""
|
|
184
|
+
env = os.environ
|
|
185
|
+
server, repo, run_id, sha = (
|
|
186
|
+
env.get(k) for k in ("GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_RUN_ID", "GITHUB_SHA")
|
|
187
|
+
)
|
|
188
|
+
workflow_ref = env.get("GITHUB_WORKFLOW_REF") # owner/repo/.github/workflows/spec.yml@refs/heads/main
|
|
189
|
+
return {
|
|
190
|
+
"revision": sha,
|
|
191
|
+
"workflow": workflow_ref.split("@")[0].removeprefix(f"{repo}/") if workflow_ref and repo else None,
|
|
192
|
+
"job": env.get("GITHUB_JOB"),
|
|
193
|
+
"run_url": f"{server}/{repo}/actions/runs/{run_id}" if server and repo and run_id else None,
|
|
194
|
+
"source_url": f"{server}/{repo}/blob/{sha}" if server and repo and sha else None,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def refresh_main(argv: list[str] | None = None) -> int:
|
|
199
|
+
defaults = github_defaults()
|
|
200
|
+
parser = argparse.ArgumentParser(
|
|
201
|
+
prog="exacly refresh",
|
|
202
|
+
description="Observe what a model's bindings name and write the state document for one context. "
|
|
203
|
+
"In GitHub Actions, the run options default to the current run.",
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument("paths", nargs="+", type=Path, metavar="PATH", help="config documents, or folders holding them")
|
|
206
|
+
parser.add_argument("--context", required=True, help="the context observed, such as main or production")
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
"--state", required=True, type=Path, help="state document to write; an earlier one is read first"
|
|
209
|
+
)
|
|
210
|
+
parser.add_argument("--junit", nargs="*", default=[], type=Path, help="JUnit XML test reports from the run")
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--distribution",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="the context is how the product is distributed: an implementer present at the revision counts as released",
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument(
|
|
217
|
+
"--revision", default=defaults["revision"], help="config revision observed, such as a Git commit"
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument("--workflow", default=defaults["workflow"], help="workflow path of the run, for bindings")
|
|
220
|
+
parser.add_argument("--job", default=defaults["job"], help="job of the run, for bindings")
|
|
221
|
+
parser.add_argument("--job-status", help="the job's status so far, for bindings without cases (success, failure)")
|
|
222
|
+
parser.add_argument("--run-url", default=defaults["run_url"], help="link to the run, cited as evidence")
|
|
223
|
+
parser.add_argument("--source-url", default=defaults["source_url"], help="base link to files at the revision")
|
|
224
|
+
parser.add_argument("--root", type=Path, default=Path(), help="repository root that references resolve against")
|
|
225
|
+
args = parser.parse_args(argv)
|
|
226
|
+
if not args.revision:
|
|
227
|
+
parser.error("--revision is required outside GitHub Actions")
|
|
228
|
+
try:
|
|
229
|
+
documents = {name: doc for name, doc in load(args.paths).items() if not is_state(doc)}
|
|
230
|
+
except query.QueryError as error:
|
|
231
|
+
print(f"exacly refresh: {error}", file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
previous = json.loads(args.state.read_text()) if args.state.exists() else None
|
|
234
|
+
run = Run(
|
|
235
|
+
revision=args.revision,
|
|
236
|
+
context=args.context,
|
|
237
|
+
observed_at=dt.datetime.now(dt.UTC).replace(microsecond=0),
|
|
238
|
+
workflow=args.workflow,
|
|
239
|
+
job=args.job,
|
|
240
|
+
job_status=args.job_status,
|
|
241
|
+
url=args.run_url,
|
|
242
|
+
source_url=args.source_url,
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
state = refresh(documents, read_junit(args.junit), run, previous, args.distribution, args.root)
|
|
246
|
+
except RefreshError as error:
|
|
247
|
+
print(f"exacly refresh: {error}", file=sys.stderr)
|
|
248
|
+
return 1
|
|
249
|
+
args.state.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
args.state.write_text(json.dumps(state, indent=2) + "\n")
|
|
251
|
+
counts = Counter(f"{a['dimension']} {a['state']}" for r in state["records"].values() for a in r["assessments"])
|
|
252
|
+
summary = ", ".join(f"{n} {k}" for k, n in sorted(counts.items())) or "no assessments"
|
|
253
|
+
print(f"Wrote {args.state}: {args.context} serial {state['serial']} at {args.revision}: {summary}")
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def label(node: dict[str, Any]) -> str:
|
|
258
|
+
"""One line naming a record, with its status in each context."""
|
|
259
|
+
text = f"{node['id']} {node['type']}: {node['name']}"
|
|
260
|
+
if node["lifecycle"] != "active":
|
|
261
|
+
text += f" ({node['lifecycle']})"
|
|
262
|
+
status = node.get("status") or {}
|
|
263
|
+
if status:
|
|
264
|
+
text += (
|
|
265
|
+
" ["
|
|
266
|
+
+ "; ".join(
|
|
267
|
+
f"{context}: " + ", ".join(f"{d} {s}" for d, s in sorted(dims.items()))
|
|
268
|
+
for context, dims in sorted(status.items())
|
|
269
|
+
)
|
|
270
|
+
+ "]"
|
|
271
|
+
)
|
|
272
|
+
return text
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def tree_lines(node: dict[str, Any], depth: int = 0) -> list[str]:
|
|
276
|
+
via = node.get("via")
|
|
277
|
+
via = f"{via['type']} {'->' if via['direction'] == 'out' else '<-'} " if via else ""
|
|
278
|
+
line = " " * depth + via + label(node) + (" (above)" if node.get("repeated") else "")
|
|
279
|
+
return [line, *(x for child in node.get("children", []) for x in tree_lines(child, depth + 1))]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def finding_line(f: dict[str, Any]) -> str:
|
|
283
|
+
where = f" ({f['document']})" if f.get("document") else ""
|
|
284
|
+
return f" {f['severity']} {f['rule']} {f.get('record') or ''}: {f['message']}{where}".replace(" :", ":")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def show_text(answer: dict[str, Any]) -> str:
|
|
288
|
+
r = answer["record"]
|
|
289
|
+
lines = [label({**r, "status": None}), r["description"]]
|
|
290
|
+
lines.append(f"Owner {r['owner']}, reviewed {r['last_reviewed_at'] or 'never'}, in {answer['document']}")
|
|
291
|
+
details = {
|
|
292
|
+
k: v
|
|
293
|
+
for k, v in r.items()
|
|
294
|
+
if k not in {"id", "type", "name", "lifecycle", "description", "owner", "last_reviewed_at", "evidence"}
|
|
295
|
+
}
|
|
296
|
+
if details:
|
|
297
|
+
lines += ["", yaml.safe_dump(details, sort_keys=False, allow_unicode=True, width=100).rstrip()]
|
|
298
|
+
lines += ["", "Relationships"]
|
|
299
|
+
for rel in answer["relationships"]:
|
|
300
|
+
arrow = "->" if rel["direction"] == "out" else "<-"
|
|
301
|
+
role = f" ({rel['role']})" if rel.get("role") else ""
|
|
302
|
+
lines.append(f" {rel['type']}{role} {arrow} {label(rel['record'])}")
|
|
303
|
+
if not answer["relationships"]:
|
|
304
|
+
lines.append(" none")
|
|
305
|
+
lines += ["", "State"]
|
|
306
|
+
for context, observed in sorted(answer["state"].items()):
|
|
307
|
+
lines.append(f" {context}, observed against {observed['config_revision'][:12]}:")
|
|
308
|
+
for a in observed.get("assessments", []):
|
|
309
|
+
reason = f": {a['reason']}" if a.get("reason") else ""
|
|
310
|
+
cited = "; ".join(f"{e['claim']} ({e['locator']})" for e in a["evidence"])
|
|
311
|
+
lines.append(f" {a['dimension']} {a['state']}{reason}" + (f" - {cited}" if cited else ""))
|
|
312
|
+
for work in observed.get("pending", []):
|
|
313
|
+
lines.append(f" pending {work['task']} since {work['opened_at']}")
|
|
314
|
+
if not answer["state"]:
|
|
315
|
+
lines.append(" not observed")
|
|
316
|
+
lines += [
|
|
317
|
+
"",
|
|
318
|
+
"Findings",
|
|
319
|
+
*(finding_line(f) for f in answer["findings"]),
|
|
320
|
+
*([] if answer["findings"] else [" none"]),
|
|
321
|
+
]
|
|
322
|
+
return "\n".join(lines)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def trace_text(answer: dict[str, Any]) -> str:
|
|
326
|
+
return "\n".join(["Why", *tree_lines(answer["why"]), "", "How", *tree_lines(answer["how"])])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def impact_text(answer: dict[str, Any]) -> str:
|
|
330
|
+
lines = []
|
|
331
|
+
if "records" in answer:
|
|
332
|
+
for kind, sign in (("added", "+"), ("changed", "~"), ("removed", "-")):
|
|
333
|
+
for r in answer["records"][kind]:
|
|
334
|
+
fields = f" ({', '.join(r['fields'])})" if r.get("fields") else ""
|
|
335
|
+
lines.append(f"{sign} {r['id']} {r['type']}: {r['name']}{fields}")
|
|
336
|
+
lines += [f"+ {rel}" for rel in answer["relationships"]["added"]]
|
|
337
|
+
lines += [f"~ {r['relationship']} ({', '.join(r['fields'])})" for r in answer["relationships"]["changed"]]
|
|
338
|
+
lines += [f"- {rel}" for rel in answer["relationships"]["removed"]]
|
|
339
|
+
if not lines:
|
|
340
|
+
return "No changes to the model."
|
|
341
|
+
lines.append("")
|
|
342
|
+
answer = answer["impact"]
|
|
343
|
+
else:
|
|
344
|
+
lines += ["Changed", *(f" {label(r)}" for r in answer["changed"]), ""]
|
|
345
|
+
lines.append("Affected")
|
|
346
|
+
lines += [f" {label(r)}\n {views.via(r['path'], ' -> ')}" for r in answer["affected"]] or [" nothing else"]
|
|
347
|
+
lines += ["", "Outcomes: " + (", ".join(answer["outcomes"]) or "none reached"), "", "Check again"]
|
|
348
|
+
for r in answer["recheck"]:
|
|
349
|
+
tests = r["observe"].get("tests")
|
|
350
|
+
how = (
|
|
351
|
+
f"{tests['workflow']}" + (f" cases {', '.join(tests['cases'])}" if tests.get("cases") else "")
|
|
352
|
+
if tests
|
|
353
|
+
else "no tests binding"
|
|
354
|
+
)
|
|
355
|
+
lines.append(f" {r['id']} {r['name']}: {how}")
|
|
356
|
+
if not answer["recheck"]:
|
|
357
|
+
lines.append(" no Requirement")
|
|
358
|
+
return "\n".join(lines)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def gaps_text(answer: dict[str, Any]) -> str:
|
|
362
|
+
contexts = ", ".join(
|
|
363
|
+
f"{c} (serial {s['serial']}, config {s['config_revision'][:12]})" for c, s in sorted(answer["contexts"].items())
|
|
364
|
+
)
|
|
365
|
+
lines = [f"Result: {answer['result']}", f"State: {contexts or 'none'}", ""]
|
|
366
|
+
for kind in ("errors", "warnings", "waived"):
|
|
367
|
+
lines += [f"{kind.capitalize()} ({len(answer[kind])})", *(finding_line(f) for f in answer[kind]), ""]
|
|
368
|
+
titles = {
|
|
369
|
+
"unverified": "Requirements not verified in any context",
|
|
370
|
+
"unreleased": "Requirements not released in any context",
|
|
371
|
+
"unobserved": "Requirements with no observation binding",
|
|
372
|
+
"proposed_measures": "Measures still proposed",
|
|
373
|
+
}
|
|
374
|
+
for kind, title in titles.items():
|
|
375
|
+
items = answer["coverage"][kind]
|
|
376
|
+
lines.append(f"{title} ({len(items)})")
|
|
377
|
+
lines += [f" {label(r)}" for r in items]
|
|
378
|
+
lines.append("")
|
|
379
|
+
lines.append(f"Pending work ({len(answer['pending'])})")
|
|
380
|
+
lines += [f" {w['context']}: {w['record']} {w['task']} since {w['opened_at']}" for w in answer["pending"]]
|
|
381
|
+
return "\n".join(lines).rstrip()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def load_base(revision: str, paths: list[Path], model: query.Model) -> query.Model:
|
|
385
|
+
"""The model at `paths` as it was at a Git revision; empty when it didn't exist yet."""
|
|
386
|
+
try:
|
|
387
|
+
documents = load_at(revision, paths)
|
|
388
|
+
except subprocess.CalledProcessError as error:
|
|
389
|
+
raise query.QueryError(f"git failed: {error.stderr.strip()}") from error
|
|
390
|
+
return query.load(documents) if documents else query.empty(model.graph.namespace)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def ask(
|
|
394
|
+
command: str,
|
|
395
|
+
paths: list[Path],
|
|
396
|
+
record: str | None = None,
|
|
397
|
+
records: Iterable[str] = (),
|
|
398
|
+
base: str | None = None,
|
|
399
|
+
) -> tuple[dict[str, Any], str]:
|
|
400
|
+
"""Answer one question about the model at `paths` (show, trace, impact or gaps), as data and as text.
|
|
401
|
+
Impact takes `records`, or a Git revision as `base`. Raises QueryError when it can't answer."""
|
|
402
|
+
model = query.load(load(paths))
|
|
403
|
+
if command in ("show", "trace"):
|
|
404
|
+
if not record:
|
|
405
|
+
raise query.QueryError(f"{command} needs a record ID")
|
|
406
|
+
answer = query.show(model, record) if command == "show" else query.trace(model, record)
|
|
407
|
+
return answer, (show_text if command == "show" else trace_text)(answer)
|
|
408
|
+
if command == "impact":
|
|
409
|
+
records = list(records)
|
|
410
|
+
if bool(base) == bool(records):
|
|
411
|
+
raise query.QueryError("impact takes record IDs or a base revision, not both")
|
|
412
|
+
answer = query.change_impact(load_base(base, paths, model), model) if base else query.impact(model, records)
|
|
413
|
+
return answer, impact_text(answer)
|
|
414
|
+
if command == "gaps":
|
|
415
|
+
answer = query.gaps(model)
|
|
416
|
+
return answer, gaps_text(answer)
|
|
417
|
+
raise query.QueryError(f"unknown question {command}")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def view_main(args: argparse.Namespace) -> int:
|
|
421
|
+
paths = args.model or [DEFAULT_MODEL]
|
|
422
|
+
model = query.load(load(paths))
|
|
423
|
+
if args.kind == "plan":
|
|
424
|
+
base = args.base or "main"
|
|
425
|
+
print(views.plan(query.change_impact(load_base(base, paths, model), model), query.gaps(model), base), end="")
|
|
426
|
+
return 0
|
|
427
|
+
out = args.out or Path("reports/site")
|
|
428
|
+
written = views.site(model, out)
|
|
429
|
+
print(f"Wrote {len(written)} pages to {out}; open {out / 'index.html'}")
|
|
430
|
+
return 0
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def exacly_main(argv: list[str] | None = None) -> int:
|
|
434
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
435
|
+
if argv and argv[0] in ("validate", "refresh"):
|
|
436
|
+
return main(argv[1:]) if argv[0] == "validate" else refresh_main(argv[1:])
|
|
437
|
+
parser = argparse.ArgumentParser(
|
|
438
|
+
prog="exacly",
|
|
439
|
+
description="Validate an Exacly model, write its state, and ask it about its records.",
|
|
440
|
+
)
|
|
441
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
442
|
+
common.add_argument(
|
|
443
|
+
"-m",
|
|
444
|
+
"--model",
|
|
445
|
+
action="append",
|
|
446
|
+
type=Path,
|
|
447
|
+
metavar="PATH",
|
|
448
|
+
help="config and state documents, or folders holding them (default: spec/exacly)",
|
|
449
|
+
)
|
|
450
|
+
common.add_argument("--json", action="store_true", help="print the answer as JSON")
|
|
451
|
+
commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
452
|
+
commands.add_parser("validate", help="validate the model and print the section 11 report")
|
|
453
|
+
commands.add_parser("refresh", help="write state for one context from what the bindings observe")
|
|
454
|
+
show = commands.add_parser("show", parents=[common], help="one record: relationships, state and findings")
|
|
455
|
+
show.add_argument("record")
|
|
456
|
+
trace = commands.add_parser("trace", parents=[common], help="why a record exists, and how it is realized")
|
|
457
|
+
trace.add_argument("record")
|
|
458
|
+
impact = commands.add_parser("impact", parents=[common], help="what a change to records affects")
|
|
459
|
+
impact.add_argument("records", nargs="*", metavar="RECORD")
|
|
460
|
+
impact.add_argument("--base", metavar="REV", help="compare with the model at this Git revision instead")
|
|
461
|
+
commands.add_parser("gaps", parents=[common], help="findings, and what is not yet verified, released or observed")
|
|
462
|
+
commands.add_parser(
|
|
463
|
+
"mcp", parents=[common], help="answer these questions for coding agents, as an MCP server on stdio"
|
|
464
|
+
)
|
|
465
|
+
view = commands.add_parser("view", parents=[common], help="section 10 views: a pull request plan, or a site")
|
|
466
|
+
view.add_argument("kind", choices=["plan", "site"], help="plan: Markdown for a pull request; site: HTML pages")
|
|
467
|
+
view.add_argument("--base", metavar="REV", help="plan: the Git revision the change is against (default: main)")
|
|
468
|
+
view.add_argument("--out", type=Path, metavar="DIR", help="site: the folder to write (default: reports/site)")
|
|
469
|
+
args = parser.parse_args(argv)
|
|
470
|
+
if args.command == "view":
|
|
471
|
+
try:
|
|
472
|
+
return view_main(args)
|
|
473
|
+
except query.QueryError as error:
|
|
474
|
+
print(f"exacly: {error}", file=sys.stderr)
|
|
475
|
+
return 1
|
|
476
|
+
if args.command == "mcp":
|
|
477
|
+
mcp.serve(mcp.Server(args.model or [DEFAULT_MODEL], ask, metadata.version("exacly")))
|
|
478
|
+
return 0
|
|
479
|
+
try:
|
|
480
|
+
answer, text = ask(
|
|
481
|
+
args.command,
|
|
482
|
+
args.model or [DEFAULT_MODEL],
|
|
483
|
+
record=getattr(args, "record", None),
|
|
484
|
+
records=getattr(args, "records", ()),
|
|
485
|
+
base=getattr(args, "base", None),
|
|
486
|
+
)
|
|
487
|
+
except query.QueryError as error:
|
|
488
|
+
print(f"exacly: {error}", file=sys.stderr)
|
|
489
|
+
return 1
|
|
490
|
+
print(json.dumps(answer, indent=2, default=str) if args.json else text)
|
|
491
|
+
return 0
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def renamed(command: str, run: Callable[[list[str] | None], int]) -> Callable[[], int]:
|
|
495
|
+
"""The command under its name before 0.2.0, which says what to run instead."""
|
|
496
|
+
|
|
497
|
+
def run_renamed() -> int:
|
|
498
|
+
print(f"exacly-{command} is now exacly {command}; the old name goes in the next release.", file=sys.stderr)
|
|
499
|
+
return run(None)
|
|
500
|
+
|
|
501
|
+
return run_renamed
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
validate_renamed = renamed("validate", main)
|
|
505
|
+
refresh_renamed = renamed("refresh", refresh_main)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
sys.exit(exacly_main())
|