hamilton-core 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.
- hamilton_core/__init__.py +3 -0
- hamilton_core/__main__.py +6 -0
- hamilton_core/check.py +613 -0
- hamilton_core/cli.py +75 -0
- hamilton_core/guard.py +76 -0
- hamilton_core/init.py +83 -0
- hamilton_core/launch.py +198 -0
- hamilton_core/model.py +202 -0
- hamilton_core/show.py +138 -0
- hamilton_core/status.py +141 -0
- hamilton_core/templates/AGENTS.md +24 -0
- hamilton_core/templates/CLAUDE.md +2 -0
- hamilton_core/templates/claude/settings.json +16 -0
- hamilton_core/templates/hamilton/config +26 -0
- hamilton_core/templates/hamilton/phase +1 -0
- hamilton_core/templates/prompts/hamilton.md +559 -0
- hamilton_core/templates/spec/actors.md +11 -0
- hamilton_core/templates/spec/requirements.md +45 -0
- hamilton_core/templates/spec/vision.md +21 -0
- hamilton_core/tree.py +70 -0
- hamilton_core/upgrade.py +114 -0
- hamilton_core-0.1.0.dist-info/METADATA +402 -0
- hamilton_core-0.1.0.dist-info/RECORD +27 -0
- hamilton_core-0.1.0.dist-info/WHEEL +5 -0
- hamilton_core-0.1.0.dist-info/entry_points.txt +2 -0
- hamilton_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- hamilton_core-0.1.0.dist-info/top_level.txt +1 -0
hamilton_core/check.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""`hamilton check` -- the verification gate.
|
|
2
|
+
|
|
3
|
+
Reads `spec/requirements.md`, `spec/actors.md` and `.hamilton/config`, runs the
|
|
4
|
+
project's test command, scans the configured test paths for `@covers
|
|
5
|
+
R-nnnn/ACn` tags, and compares every acceptance criterion against
|
|
6
|
+
`.hamilton/verified` (the hashes recorded the last time `check` passed).
|
|
7
|
+
|
|
8
|
+
The model is one tree (D-014): `spec/requirements.md`, whose interior nodes are
|
|
9
|
+
the architecture and carry an `Interface:`. `spec/actors.md` is a flat list.
|
|
10
|
+
There is no components/modules model. Rules:
|
|
11
|
+
|
|
12
|
+
no-test-command .hamilton/config has no (or a blank) test_command
|
|
13
|
+
tests-failed test_command ran and did not exit 0
|
|
14
|
+
uncovered an AC has no @covers tag in a file under test_paths
|
|
15
|
+
orphan-tag a tag names a requirement or AC that does not exist
|
|
16
|
+
orphan-requirement a requirement with no Parent and no Actor (a root must
|
|
17
|
+
name the actor whose goal it is)
|
|
18
|
+
dangling-ref a Parent or Actor value names no such entity
|
|
19
|
+
cyclic-parent a requirement's Parent chain loops
|
|
20
|
+
stale an AC's text changed since check last passed
|
|
21
|
+
malformed a requirement has no ACs, no Statement, a repeated id,
|
|
22
|
+
or an unparseable line
|
|
23
|
+
|
|
24
|
+
Every problem in a run is reported, not just the first. On a fully clean run
|
|
25
|
+
with at least one requirement, `check` rewrites `.hamilton/verified` and exits
|
|
26
|
+
0. Exit 1 on any finding; exit 2 when it cannot run at all (`spec/requirements.md`
|
|
27
|
+
or `.hamilton/config` missing). `--json` emits
|
|
28
|
+
{"ok": bool, "findings": [...], "warnings": [...], "notices": [...],
|
|
29
|
+
"requirements": int, "acceptance_criteria": int} or {"error": "..."}.
|
|
30
|
+
`notices` flag config that is set but does nothing (e.g. `mutation_command`,
|
|
31
|
+
which is reserved and unimplemented); they never change the exit code.
|
|
32
|
+
|
|
33
|
+
Advisory **warnings** never change the exit code and never fail an existing
|
|
34
|
+
project:
|
|
35
|
+
|
|
36
|
+
long-statement a Statement over 20 words -- it is several requirements
|
|
37
|
+
welded together; split it and push detail into ACs
|
|
38
|
+
long-description an Actor Description that is more than one sentence
|
|
39
|
+
no-interface an interior requirement (has children) with no `Interface:`
|
|
40
|
+
line -- expected while a subsystem is still being decomposed
|
|
41
|
+
|
|
42
|
+
The finding messages are the tool's real interface: the primary reader is an
|
|
43
|
+
agent repairing a mistake it just made, so each one states where, which rule
|
|
44
|
+
fired, what was expected, what was found, and the concrete next action.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import hashlib
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
import re
|
|
53
|
+
import subprocess
|
|
54
|
+
import sys
|
|
55
|
+
import unicodedata
|
|
56
|
+
|
|
57
|
+
REQ_REL = "spec/requirements.md"
|
|
58
|
+
CONFIG_REL = ".hamilton/config"
|
|
59
|
+
VERIFIED_REL = ".hamilton/verified"
|
|
60
|
+
KNOWN_FIELDS = {"Parent", "Actor", "Statement", "Criteria", "Interface"}
|
|
61
|
+
# Fields a past model used; recognised and ignored so an older `requirements.md`
|
|
62
|
+
# still parses (D-014). Not stored, not flagged.
|
|
63
|
+
RETIRED_FIELDS = {"Component"}
|
|
64
|
+
TAG_RE = re.compile(r"@covers\s+(R-\d{4})/(AC\d+)\b")
|
|
65
|
+
|
|
66
|
+
STATEMENT_WORD_LIMIT = 20
|
|
67
|
+
# a sentence terminator with real text on both sides -> a second sentence;
|
|
68
|
+
# `\w{2,}` before the dot skips abbreviations like "e.g." / "U.S."
|
|
69
|
+
_SECOND_SENTENCE_RE = re.compile(r"\w{2,}[.!?]['\")\]]?\s+[A-Z(\[]")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UsageError(Exception):
|
|
73
|
+
"""Missing spec or config file -> exit 2."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize(text: str) -> str:
|
|
77
|
+
"""Canonical form of an AC string, used for hashing. It defines when an AC
|
|
78
|
+
counts as changed: an edit that survives normalisation changes the hash;
|
|
79
|
+
one that does not is cosmetic.
|
|
80
|
+
|
|
81
|
+
1. Unicode NFC, so canonically-equivalent forms hash identically.
|
|
82
|
+
2. Every run of whitespace -- ASCII or any Unicode whitespace --
|
|
83
|
+
collapses to a single ASCII space.
|
|
84
|
+
3. Leading and trailing whitespace is removed.
|
|
85
|
+
4. Case is preserved: a case-only edit is a real edit.
|
|
86
|
+
5. Punctuation is preserved: a trailing "." can be a real edit.
|
|
87
|
+
"""
|
|
88
|
+
return re.sub(r"\s+", " ", unicodedata.normalize("NFC", text)).strip()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sha(text: str) -> str:
|
|
92
|
+
""""sha256:" + the SHA-256 of ``normalize(text)``, UTF-8 encoded."""
|
|
93
|
+
return "sha256:" + hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_config(root: str) -> dict:
|
|
97
|
+
"""Parse `.hamilton/config` into {key: (value, lineno)}.
|
|
98
|
+
|
|
99
|
+
`key=value` per line; a line whose first non-blank char is `#` is a
|
|
100
|
+
comment; the value is the literal remainder of the line after the first
|
|
101
|
+
`=`. Raises UsageError (exit 2) if the file is absent.
|
|
102
|
+
"""
|
|
103
|
+
path = os.path.join(root, CONFIG_REL)
|
|
104
|
+
if not os.path.isfile(path):
|
|
105
|
+
raise UsageError(f"{CONFIG_REL}: not found (run `hamilton init`, or "
|
|
106
|
+
f"create it with test_command and test_paths lines)")
|
|
107
|
+
cfg = {}
|
|
108
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
109
|
+
for n, line in enumerate(fh, 1):
|
|
110
|
+
if line.lstrip().startswith("#") or "=" not in line:
|
|
111
|
+
continue
|
|
112
|
+
key, _, value = line.partition("=")
|
|
113
|
+
cfg[key.strip()] = (value.rstrip("\n"), n)
|
|
114
|
+
return cfg
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_tests(root: str, cfg: dict):
|
|
118
|
+
"""Run test_command in ``root``. Returns (ok, detail, lineno). The command's
|
|
119
|
+
own output is redirected to stderr so `--json` stdout stays clean."""
|
|
120
|
+
entry = cfg.get("test_command")
|
|
121
|
+
if entry is None or not entry[0].strip():
|
|
122
|
+
return False, "test_command is not set in .hamilton/config", (entry[1] if entry else 1)
|
|
123
|
+
cmd, lineno = entry[0], entry[1]
|
|
124
|
+
print(f"hamilton check: running test_command: {cmd.strip()}", file=sys.stderr)
|
|
125
|
+
try:
|
|
126
|
+
p = subprocess.run(cmd, shell=True, cwd=root, stdout=sys.stderr, timeout=1800)
|
|
127
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
128
|
+
return False, f"test_command could not be run ({exc})", lineno
|
|
129
|
+
if p.returncode == 0:
|
|
130
|
+
return True, "", lineno
|
|
131
|
+
return False, f"test_command {cmd.strip()!r} exited {p.returncode}", lineno
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract(path: str):
|
|
135
|
+
"""Tolerant line matcher (not a parser). Returns (reqs, duplicates, malformed).
|
|
136
|
+
|
|
137
|
+
reqs -- {R-id: {title, statement, statement_line, parent, actor,
|
|
138
|
+
interface, open_line, acs:{AC-id:{text,line}}}}
|
|
139
|
+
title/parent/actor/interface are None when absent. A
|
|
140
|
+
`Component:` line (a retired field) is recognised and ignored.
|
|
141
|
+
duplicates -- [(R-id, line, first_line)] -- the same `## R-nnnn` twice
|
|
142
|
+
malformed -- [(R-id, line, reason)] -- reason is a full agent-facing sentence
|
|
143
|
+
|
|
144
|
+
Lines inside a fenced code block (``` or ~~~) are ignored, so the commented
|
|
145
|
+
example that `hamilton init` writes is not parsed as a real requirement.
|
|
146
|
+
"""
|
|
147
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
148
|
+
lines = fh.read().splitlines()
|
|
149
|
+
reqs, duplicates, malformed, cur, in_fence = {}, [], [], None, False
|
|
150
|
+
fields = ", ".join(sorted(KNOWN_FIELDS))
|
|
151
|
+
|
|
152
|
+
for n, raw in enumerate(lines, 1):
|
|
153
|
+
stripped = raw.strip()
|
|
154
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
155
|
+
in_fence = not in_fence
|
|
156
|
+
continue
|
|
157
|
+
if in_fence or not stripped:
|
|
158
|
+
continue
|
|
159
|
+
head = re.match(r"(#{1,6})\s+(.*)$", stripped)
|
|
160
|
+
if head:
|
|
161
|
+
rid = re.match(r"(R-\d{4})\b", head.group(2).strip())
|
|
162
|
+
if rid and head.group(1) == "##":
|
|
163
|
+
cur = rid.group(1)
|
|
164
|
+
if cur in reqs:
|
|
165
|
+
duplicates.append((cur, n, reqs[cur]["open_line"]))
|
|
166
|
+
else:
|
|
167
|
+
title = head.group(2).strip()[len(cur):].strip().strip('"').strip()
|
|
168
|
+
reqs[cur] = {"title": title or None, "statement": None,
|
|
169
|
+
"statement_line": None, "parent": None,
|
|
170
|
+
"actor": None, "interface": None,
|
|
171
|
+
"open_line": n, "acs": {}}
|
|
172
|
+
elif cur is not None:
|
|
173
|
+
malformed.append((cur, n,
|
|
174
|
+
f"unexpected heading {stripped!r} while inside {cur}. "
|
|
175
|
+
f"Expected: the only headings in {REQ_REL} are '## R-nnnn' "
|
|
176
|
+
f"requirement openers. Found: a heading at another level, "
|
|
177
|
+
f"or '## ' not followed by an R-nnnn id. Fix: if it opens "
|
|
178
|
+
f"a new requirement write it as '## R-nnnn'; otherwise drop "
|
|
179
|
+
f"the leading '#'(s) or delete the line."))
|
|
180
|
+
continue
|
|
181
|
+
if cur is None:
|
|
182
|
+
continue # prose before the first requirement is ignored
|
|
183
|
+
ac = re.match(r"-\s+(AC\d+):\s?(.*)$", stripped)
|
|
184
|
+
if ac:
|
|
185
|
+
acid = ac.group(1)
|
|
186
|
+
if acid in reqs[cur]["acs"]:
|
|
187
|
+
first = reqs[cur]["acs"][acid]["line"]
|
|
188
|
+
malformed.append((cur, n,
|
|
189
|
+
f"{cur} defines {acid} twice. Expected: each AC id appears "
|
|
190
|
+
f"once within its requirement. Found: {acid} first defined "
|
|
191
|
+
f"at {REQ_REL}:{first}, redefined here -- the later text "
|
|
192
|
+
f"would silently win. Fix: renumber this criterion, or "
|
|
193
|
+
f"merge it into the first {acid}."))
|
|
194
|
+
else:
|
|
195
|
+
reqs[cur]["acs"][acid] = {"text": ac.group(2).strip(), "line": n}
|
|
196
|
+
continue
|
|
197
|
+
field = re.match(r"([A-Za-z][\w -]*?):\s?(.*)$", stripped)
|
|
198
|
+
if field and field.group(1) in RETIRED_FIELDS:
|
|
199
|
+
continue # recognised, ignored (D-014)
|
|
200
|
+
if field and field.group(1) in KNOWN_FIELDS:
|
|
201
|
+
key, val = field.group(1), field.group(2).strip()
|
|
202
|
+
if key == "Statement":
|
|
203
|
+
reqs[cur]["statement"] = val
|
|
204
|
+
reqs[cur]["statement_line"] = n
|
|
205
|
+
elif key == "Parent":
|
|
206
|
+
m = re.match(r"(R-\d{4})", val)
|
|
207
|
+
reqs[cur]["parent"] = m.group(1) if m else (val or None)
|
|
208
|
+
elif key == "Actor":
|
|
209
|
+
m = re.match(r"(A-\d{4})", val)
|
|
210
|
+
reqs[cur]["actor"] = m.group(1) if m else (val or None)
|
|
211
|
+
elif key == "Interface":
|
|
212
|
+
reqs[cur]["interface"] = val or None
|
|
213
|
+
continue
|
|
214
|
+
if stripped.startswith("- "):
|
|
215
|
+
malformed.append((cur, n,
|
|
216
|
+
f"malformed acceptance criterion inside {cur}: a '- ' bullet "
|
|
217
|
+
f"that is not '- AC<n>: <condition> -> <outcome>'. Expected: "
|
|
218
|
+
f"the label 'AC' in capitals, one or more digits, ': ', then "
|
|
219
|
+
f"the criterion text. Found: a bullet that does not match. "
|
|
220
|
+
f"Fix: rewrite it as '- AC<n>: ... -> ...', or remove the "
|
|
221
|
+
f"leading '- ' if it is not a criterion."))
|
|
222
|
+
else:
|
|
223
|
+
malformed.append((cur, n,
|
|
224
|
+
f"unparseable line inside {cur}. Expected: a '## R-nnnn' "
|
|
225
|
+
f"heading, a 'Key: value' field ({fields}), or a "
|
|
226
|
+
f"'- AC<n>: <condition> -> <outcome>' criterion. Found: a "
|
|
227
|
+
f"non-blank line matching none of these. Fix: reword it to a "
|
|
228
|
+
f"recognised field or criterion, fold it into the Statement, "
|
|
229
|
+
f"or delete it. Note: each field must be a single line -- a "
|
|
230
|
+
f"wrapped continuation lands here."))
|
|
231
|
+
return reqs, duplicates, malformed
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _iter_files(root: str):
|
|
235
|
+
"""Yield repo-relative paths, honouring .gitignore when inside a git repo."""
|
|
236
|
+
try:
|
|
237
|
+
r = subprocess.run(
|
|
238
|
+
["git", "-C", root, "ls-files", "--others", "--cached",
|
|
239
|
+
"--exclude-standard", "-z"],
|
|
240
|
+
capture_output=True, timeout=15)
|
|
241
|
+
if r.returncode == 0:
|
|
242
|
+
yield from (p for p in r.stdout.decode("utf-8", "replace").split("\0") if p)
|
|
243
|
+
return
|
|
244
|
+
except (OSError, subprocess.SubprocessError):
|
|
245
|
+
pass
|
|
246
|
+
for dpath, dnames, fnames in os.walk(root):
|
|
247
|
+
dnames[:] = [d for d in dnames if d != ".git"]
|
|
248
|
+
for fn in fnames:
|
|
249
|
+
yield os.path.relpath(os.path.join(dpath, fn), root)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def scan(root: str, test_paths: str):
|
|
253
|
+
"""Return [(R-id, AC-id, relpath, line)] for `@covers R-nnnn/ACn` tags found
|
|
254
|
+
in files under one of the ``test_paths`` prefixes (space-separated, relative
|
|
255
|
+
to root). A tag anywhere else -- README, the implementation, a notes file --
|
|
256
|
+
does not count. Also skips .git/, spec/, .hamilton/, files over 2 MB, and
|
|
257
|
+
git-ignored paths. The tag is matched as raw text, so any comment syntax in
|
|
258
|
+
any language works.
|
|
259
|
+
"""
|
|
260
|
+
prefixes = [p.replace("\\", "/").strip("/") for p in test_paths.split() if p.strip()]
|
|
261
|
+
hits = []
|
|
262
|
+
if not prefixes:
|
|
263
|
+
return hits
|
|
264
|
+
for rel in _iter_files(root):
|
|
265
|
+
r = rel.replace("\\", "/")
|
|
266
|
+
if r.split("/", 1)[0] in ("spec", ".hamilton", ".git"):
|
|
267
|
+
continue
|
|
268
|
+
if not any(r == p or r.startswith(p + "/") for p in prefixes):
|
|
269
|
+
continue
|
|
270
|
+
full = os.path.join(root, rel)
|
|
271
|
+
try:
|
|
272
|
+
if os.path.getsize(full) > 2_000_000:
|
|
273
|
+
continue
|
|
274
|
+
with open(full, "r", encoding="utf-8") as fh:
|
|
275
|
+
for i, line in enumerate(fh, 1):
|
|
276
|
+
for m in TAG_RE.finditer(line):
|
|
277
|
+
hits.append((m.group(1), m.group(2), rel, i))
|
|
278
|
+
except (OSError, UnicodeDecodeError):
|
|
279
|
+
continue
|
|
280
|
+
return hits
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def read_verified(root: str) -> dict:
|
|
284
|
+
"""{"R-nnnn/ACn": "sha256:..."} from `.hamilton/verified`, one `id hash`
|
|
285
|
+
per line. This is the state left by the last passing `check`; a missing or
|
|
286
|
+
empty file means no run has passed yet, so nothing is stale."""
|
|
287
|
+
path = os.path.join(root, VERIFIED_REL)
|
|
288
|
+
out = {}
|
|
289
|
+
if not os.path.isfile(path):
|
|
290
|
+
return out
|
|
291
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
292
|
+
for line in fh:
|
|
293
|
+
parts = line.split()
|
|
294
|
+
if len(parts) == 2:
|
|
295
|
+
out[parts[0]] = parts[1]
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def write_verified(root: str, reqs: dict) -> None:
|
|
300
|
+
"""Record the current hash of every AC, sorted by id for a stable diff.
|
|
301
|
+
Called only on a fully clean run. Commit this file so staleness is
|
|
302
|
+
meaningful on other machines and in CI."""
|
|
303
|
+
lines = sorted(f"{rid}/{acid} {sha(ac['text'])}"
|
|
304
|
+
for rid, r in reqs.items() for acid, ac in r["acs"].items())
|
|
305
|
+
with open(os.path.join(root, VERIFIED_REL), "w", encoding="utf-8") as fh:
|
|
306
|
+
fh.write("\n".join(lines) + ("\n" if lines else ""))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _sample(ids, limit=8):
|
|
310
|
+
"""A bounded, sorted preview of an id collection for a finding message."""
|
|
311
|
+
ids = sorted(ids)
|
|
312
|
+
if not ids:
|
|
313
|
+
return "none"
|
|
314
|
+
if len(ids) <= limit:
|
|
315
|
+
return ", ".join(ids)
|
|
316
|
+
return ", ".join(ids[:limit]) + f", ... ({len(ids)} total)"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _finding(rule, detail, file, line, req=None, ac=None):
|
|
320
|
+
message = f"{file}:{line}: {rule}: {detail}"
|
|
321
|
+
return {"rule": rule, "file": file, "line": line, "req": req, "ac": ac,
|
|
322
|
+
"message": message}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _warning(rule, detail, file, line):
|
|
326
|
+
"""Advisory only: never counted as a finding, never changes the exit code.
|
|
327
|
+
The `warning:` word in the message keeps it distinct from a finding."""
|
|
328
|
+
return {"rule": rule, "file": file, "line": line,
|
|
329
|
+
"message": f"{file}:{line}: warning: {rule}: {detail}"}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _one_sentence(text: str) -> bool:
|
|
333
|
+
collapsed = " ".join(text.split())
|
|
334
|
+
return not _SECOND_SENTENCE_RE.search(collapsed)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _config_notices(cfg: dict) -> list:
|
|
338
|
+
"""Config that is set but does nothing yet. A line that looks active and is
|
|
339
|
+
silently ignored is the failure the falsification ledger had, so say so
|
|
340
|
+
every run."""
|
|
341
|
+
out = []
|
|
342
|
+
mc = cfg.get("mutation_command")
|
|
343
|
+
if mc is not None and mc[0].strip():
|
|
344
|
+
out.append(
|
|
345
|
+
f"{CONFIG_REL}:{mc[1]}: mutation_command is set "
|
|
346
|
+
f"({mc[0].strip()!r}) but mutation testing is not implemented -- it "
|
|
347
|
+
f"is NOT being run and the gate does not depend on it. Unset it "
|
|
348
|
+
f"until Hamilton wires it up.")
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def collect_warnings(root: str, reqs: dict, actors=None):
|
|
353
|
+
"""The advisory checks (see module docstring). Kept apart from `run` so a
|
|
354
|
+
caller can ask for them without the gate; `run` passes the actors it has
|
|
355
|
+
already parsed so `spec/actors.md` is not re-read. Returns [warning...]."""
|
|
356
|
+
from hamilton_core import model # local: model imports this module
|
|
357
|
+
actors = model.parse_actors(root) if actors is None else actors
|
|
358
|
+
has_child = {r["parent"] for r in reqs.values() if r["parent"] in reqs}
|
|
359
|
+
|
|
360
|
+
out = []
|
|
361
|
+
for rid, r in reqs.items():
|
|
362
|
+
stmt = r["statement"]
|
|
363
|
+
if stmt:
|
|
364
|
+
words = len(stmt.split())
|
|
365
|
+
if words > STATEMENT_WORD_LIMIT:
|
|
366
|
+
out.append(_warning("long-statement",
|
|
367
|
+
f"{rid}'s Statement is {words} words. A Statement is one "
|
|
368
|
+
f"sentence, under {STATEMENT_WORD_LIMIT} words, describing "
|
|
369
|
+
f"one behaviour. At this length it is several requirements "
|
|
370
|
+
f"welded together, which makes the spec unreviewable. Fix: "
|
|
371
|
+
f"split it into separate '## R-nnnn' requirements and move "
|
|
372
|
+
f"the detail down into acceptance criteria, where the gate "
|
|
373
|
+
f"can act on it (SKILL.md, Specify).",
|
|
374
|
+
REQ_REL, r["statement_line"] or r["open_line"]))
|
|
375
|
+
if rid in has_child and not r["interface"]:
|
|
376
|
+
out.append(_warning("no-interface",
|
|
377
|
+
f"{rid} has child requirements, so it is a subsystem boundary, "
|
|
378
|
+
f"but no 'Interface:' line. Expected: an 'Interface:' naming "
|
|
379
|
+
f"what crosses the boundary this requirement owns -- that is "
|
|
380
|
+
f"the integration-test surface. Found: none. "
|
|
381
|
+
f"Fix: add an 'Interface:' line once the boundary is settled; "
|
|
382
|
+
f"before then this is expected.",
|
|
383
|
+
REQ_REL, r["open_line"]))
|
|
384
|
+
|
|
385
|
+
for aid, a in actors.items():
|
|
386
|
+
desc = a.get("description")
|
|
387
|
+
if desc and not _one_sentence(desc):
|
|
388
|
+
out.append(_warning("long-description",
|
|
389
|
+
f"the Description of actor {aid} is more than one sentence. An "
|
|
390
|
+
f"Actor Description is a single sentence: the external role and "
|
|
391
|
+
f"what it needs from the system. Fix: cut it to one sentence.",
|
|
392
|
+
model.ACTORS_REL, a["line"]))
|
|
393
|
+
|
|
394
|
+
out.sort(key=lambda w: (w["file"], w["line"], w["rule"]))
|
|
395
|
+
return out
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def run(root: str):
|
|
399
|
+
"""Returns (findings, warnings, notices, requirement_count, ac_count).
|
|
400
|
+
`warnings` are advisory (module docstring); `notices` flag configuration
|
|
401
|
+
that is set but does nothing. Neither changes the exit code."""
|
|
402
|
+
if not os.path.isfile(os.path.join(root, REQ_REL)):
|
|
403
|
+
raise UsageError(f"{REQ_REL}: not found (run hamilton check from the "
|
|
404
|
+
f"project root, the directory that holds spec/)")
|
|
405
|
+
cfg = read_config(root)
|
|
406
|
+
notices = _config_notices(cfg)
|
|
407
|
+
|
|
408
|
+
reqs, duplicates, malformed = extract(os.path.join(root, REQ_REL))
|
|
409
|
+
n_reqs = len(reqs)
|
|
410
|
+
n_acs = sum(len(r["acs"]) for r in reqs.values())
|
|
411
|
+
|
|
412
|
+
if not reqs:
|
|
413
|
+
return ([_finding("malformed",
|
|
414
|
+
"spec/requirements.md defines no requirements. Expected: at least "
|
|
415
|
+
"one '## R-nnnn' block -- with a Statement and acceptance criteria "
|
|
416
|
+
"-- outside any fenced code block. Found: only the fenced example, "
|
|
417
|
+
"or an empty file. Fix: write a real requirement below the "
|
|
418
|
+
"example, then re-run hamilton check.",
|
|
419
|
+
REQ_REL, 1)], [], notices, n_reqs, n_acs)
|
|
420
|
+
|
|
421
|
+
out = []
|
|
422
|
+
from hamilton_core import model as _model # local: model imports this module
|
|
423
|
+
actors = _model.parse_actors(root)
|
|
424
|
+
warnings = collect_warnings(root, reqs, actors)
|
|
425
|
+
|
|
426
|
+
tc = cfg.get("test_command")
|
|
427
|
+
if tc is None or not tc[0].strip():
|
|
428
|
+
out.append(_finding("no-test-command",
|
|
429
|
+
f"{CONFIG_REL} has {'no' if tc is None else 'a blank'} "
|
|
430
|
+
f"test_command, so the gate has no suite to run and cannot certify "
|
|
431
|
+
f"anything. Expected: a 'test_command=<command>' line naming what "
|
|
432
|
+
f"runs the project's tests (exit 0 on success). Found: "
|
|
433
|
+
f"{'the key is absent' if tc is None else 'the value is empty'}. "
|
|
434
|
+
f"Fix: set test_command in {CONFIG_REL}, e.g. "
|
|
435
|
+
f"'test_command=python -m pytest -q'.",
|
|
436
|
+
CONFIG_REL, tc[1] if tc else 1))
|
|
437
|
+
else:
|
|
438
|
+
ok, detail, cfg_line = run_tests(root, cfg)
|
|
439
|
+
if not ok:
|
|
440
|
+
cmd = tc[0].strip()
|
|
441
|
+
out.append(_finding("tests-failed",
|
|
442
|
+
f"{detail}. Expected: the project's own test suite to pass "
|
|
443
|
+
f"before the gate certifies anything. Found: it did not. Fix: "
|
|
444
|
+
f"run '{cmd}' yourself from the project root to see why it "
|
|
445
|
+
f"fails and repair the implementation or the test; or "
|
|
446
|
+
f"set/correct test_command in {CONFIG_REL}.",
|
|
447
|
+
CONFIG_REL, cfg_line))
|
|
448
|
+
|
|
449
|
+
for rid, line, first in duplicates:
|
|
450
|
+
out.append(_finding("malformed",
|
|
451
|
+
f"{rid} is declared a second time at this line. Expected: each "
|
|
452
|
+
f"'## R-nnnn' id appears once in {REQ_REL}; ids are allocated once "
|
|
453
|
+
f"and never reused. Found: {rid} was first declared at "
|
|
454
|
+
f"{REQ_REL}:{first}. Fix: give one of the two a fresh unused id "
|
|
455
|
+
f"and repoint its @covers tags.",
|
|
456
|
+
REQ_REL, line, req=rid))
|
|
457
|
+
|
|
458
|
+
for rid, line, reason in malformed:
|
|
459
|
+
out.append(_finding("malformed", reason, REQ_REL, line, req=rid))
|
|
460
|
+
|
|
461
|
+
for rid, r in reqs.items():
|
|
462
|
+
if r["statement"] is None:
|
|
463
|
+
out.append(_finding("malformed",
|
|
464
|
+
f"{rid} has no Statement. Expected: a 'Statement: <what shall "
|
|
465
|
+
f"be true>' line between '## {rid}' and its criteria -- the "
|
|
466
|
+
f"requirement has to say what it requires (concept 4.1). "
|
|
467
|
+
f"Found: none. Fix: add a Statement line.",
|
|
468
|
+
REQ_REL, r["open_line"], req=rid))
|
|
469
|
+
if not r["acs"]:
|
|
470
|
+
out.append(_finding("malformed",
|
|
471
|
+
f"{rid} has no acceptance criteria. Expected: at least one "
|
|
472
|
+
f"'- AC<n>: <observable condition> -> <expected outcome>' line "
|
|
473
|
+
f"before the next '##' heading or end of file. Found: none. "
|
|
474
|
+
f"Fix: add one or more '- AC1: ... -> ...' lines under {rid}.",
|
|
475
|
+
REQ_REL, r["open_line"], req=rid))
|
|
476
|
+
|
|
477
|
+
# --- requirement tree (D-014) ---
|
|
478
|
+
# actors were parsed once near the top of run().
|
|
479
|
+
for rid, r in reqs.items():
|
|
480
|
+
par, act = r["parent"], r.get("actor")
|
|
481
|
+
|
|
482
|
+
if par is not None and par not in reqs:
|
|
483
|
+
out.append(_finding("dangling-ref",
|
|
484
|
+
f"{rid} has 'Parent: {par}', which is not a declared "
|
|
485
|
+
f"requirement. Expected: Parent names a '## R-nnnn' heading in "
|
|
486
|
+
f"{REQ_REL}. Found: {REQ_REL} declares {_sample(reqs)}. Fix: "
|
|
487
|
+
f"correct the Parent, or add '## {par}'.",
|
|
488
|
+
REQ_REL, r["open_line"], req=rid))
|
|
489
|
+
|
|
490
|
+
if act is not None and re.fullmatch(r"A-\d{4}", act) and act not in actors:
|
|
491
|
+
have = _sample(actors) if actors else f"{_model.ACTORS_REL} declares none"
|
|
492
|
+
out.append(_finding("dangling-ref",
|
|
493
|
+
f"{rid} has 'Actor: {act}', which is not declared. Expected: "
|
|
494
|
+
f"Actor names a '## A-nnnn' block in {_model.ACTORS_REL}. Found: "
|
|
495
|
+
f"{have}. Fix: correct the Actor, or add '## {act}'.",
|
|
496
|
+
REQ_REL, r["open_line"], req=rid))
|
|
497
|
+
|
|
498
|
+
if par is None and not act:
|
|
499
|
+
out.append(_finding("orphan-requirement",
|
|
500
|
+
f"{rid} has no Parent, so it is a system goal -- and a system "
|
|
501
|
+
f"goal must name the actor whose goal it is. Expected: an "
|
|
502
|
+
f"'Actor: A-nnnn' line, or a 'Parent:' line making {rid} a "
|
|
503
|
+
f"child of another requirement. Found: neither. Fix: add the "
|
|
504
|
+
f"actor (spec/actors.md), or give it a parent (D-014).",
|
|
505
|
+
REQ_REL, r["open_line"], req=rid))
|
|
506
|
+
|
|
507
|
+
for rid in reqs:
|
|
508
|
+
chain, node = [], rid
|
|
509
|
+
while node in reqs and node not in chain:
|
|
510
|
+
chain.append(node)
|
|
511
|
+
node = reqs[node]["parent"]
|
|
512
|
+
if node in chain:
|
|
513
|
+
loop = chain[chain.index(node):] + [node]
|
|
514
|
+
out.append(_finding("cyclic-parent",
|
|
515
|
+
f"the Parent chain of {rid} forms a cycle: "
|
|
516
|
+
f"{' -> '.join(loop)}. Expected: following Parent links always "
|
|
517
|
+
f"reaches a root. Found: a loop. Fix: re-point one Parent so "
|
|
518
|
+
f"the chain terminates.",
|
|
519
|
+
REQ_REL, reqs[rid]["open_line"], req=rid))
|
|
520
|
+
break
|
|
521
|
+
|
|
522
|
+
test_paths = cfg.get("test_paths", ("", 0))[0]
|
|
523
|
+
covered = set()
|
|
524
|
+
for req, ac, file, line in scan(root, test_paths):
|
|
525
|
+
if req not in reqs:
|
|
526
|
+
out.append(_finding("orphan-tag",
|
|
527
|
+
f"the tag '@covers {req}/{ac}' names requirement {req}, which "
|
|
528
|
+
f"does not exist. Expected: every tag references a '## R-nnnn' "
|
|
529
|
+
f"heading in {REQ_REL}. Found: {REQ_REL} declares "
|
|
530
|
+
f"{_sample(reqs)}. Fix: correct the tag to an existing "
|
|
531
|
+
f"requirement id, or add '## {req}' in spec phase.",
|
|
532
|
+
file, line, req=req, ac=ac))
|
|
533
|
+
elif ac not in reqs[req]["acs"]:
|
|
534
|
+
out.append(_finding("orphan-tag",
|
|
535
|
+
f"the tag '@covers {req}/{ac}' names criterion {ac}, which "
|
|
536
|
+
f"{req} does not define. Expected: {ac} listed as a "
|
|
537
|
+
f"'- {ac}: ...' line under '## {req}'. Found: {req} defines "
|
|
538
|
+
f"{_sample(reqs[req]['acs'])}. Fix: point the tag at one of "
|
|
539
|
+
f"those, or add '- {ac}: <condition> -> <outcome>' under {req}.",
|
|
540
|
+
file, line, req=req, ac=ac))
|
|
541
|
+
else:
|
|
542
|
+
covered.add((req, ac))
|
|
543
|
+
|
|
544
|
+
verified = read_verified(root)
|
|
545
|
+
has_paths = bool(test_paths.split())
|
|
546
|
+
for rid, r in reqs.items():
|
|
547
|
+
for acid, ac in sorted(r["acs"].items()):
|
|
548
|
+
qual = f"{rid}/{acid}"
|
|
549
|
+
if (rid, acid) not in covered:
|
|
550
|
+
where = (f"no file under test_paths ({test_paths}) contains it"
|
|
551
|
+
if has_paths else
|
|
552
|
+
f"test_paths is not set in {CONFIG_REL}, so no tag "
|
|
553
|
+
f"can count")
|
|
554
|
+
out.append(_finding("uncovered",
|
|
555
|
+
f"{qual} has an acceptance criterion with no test claiming "
|
|
556
|
+
f"it. Expected: a comment '@covers {qual}' in a file under "
|
|
557
|
+
f"test_paths (any language -- the tag text is matched, not "
|
|
558
|
+
f"the comment syntax). Found: {where}. Fix: add "
|
|
559
|
+
f"'@covers {qual}' to the test that exercises this "
|
|
560
|
+
f"criterion.",
|
|
561
|
+
REQ_REL, ac["line"], req=rid, ac=acid))
|
|
562
|
+
want, seen = sha(ac["text"]), verified.get(qual)
|
|
563
|
+
if seen is not None and seen != want:
|
|
564
|
+
out.append(_finding("stale",
|
|
565
|
+
f"{qual} was reworded since hamilton check last passed. "
|
|
566
|
+
f"Expected: the AC text to still hash to {seen} (recorded "
|
|
567
|
+
f"in {VERIFIED_REL} at the last green run). Found: it now "
|
|
568
|
+
f"hashes to {want}. Its test and implementation may no "
|
|
569
|
+
f"longer match what it says. Fix: re-check the "
|
|
570
|
+
f"implementation and the '@covers {qual}' test against the "
|
|
571
|
+
f"new wording; a clean hamilton check records the new hash.",
|
|
572
|
+
REQ_REL, ac["line"], req=rid, ac=acid))
|
|
573
|
+
|
|
574
|
+
out.sort(key=lambda f: (f["file"] or "", f["line"] or 0, f["rule"]))
|
|
575
|
+
# Re-record the AC hashes whenever nothing but `stale` is outstanding: the
|
|
576
|
+
# tests pass, every AC is covered, the spec parses. `stale` is then a
|
|
577
|
+
# single red run after an AC edit -- it forces one more `hamilton check`
|
|
578
|
+
# (which re-runs the suite against the new wording) and then clears.
|
|
579
|
+
if not [f for f in out if f["rule"] != "stale"]:
|
|
580
|
+
write_verified(root, reqs)
|
|
581
|
+
return out, warnings, notices, n_reqs, n_acs
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def main(as_json: bool = False) -> int:
|
|
585
|
+
try:
|
|
586
|
+
findings, warnings, notices, n_reqs, n_acs = run(os.getcwd())
|
|
587
|
+
except UsageError as exc:
|
|
588
|
+
if as_json:
|
|
589
|
+
print(json.dumps({"error": str(exc)}))
|
|
590
|
+
else:
|
|
591
|
+
print(f"hamilton check: {exc}", file=sys.stderr)
|
|
592
|
+
return 2
|
|
593
|
+
if as_json:
|
|
594
|
+
print(json.dumps({"ok": not findings, "findings": findings,
|
|
595
|
+
"warnings": warnings, "notices": notices,
|
|
596
|
+
"requirements": n_reqs,
|
|
597
|
+
"acceptance_criteria": n_acs}))
|
|
598
|
+
else:
|
|
599
|
+
for f in findings:
|
|
600
|
+
print(f["message"])
|
|
601
|
+
for w in warnings:
|
|
602
|
+
print(w["message"], file=sys.stderr)
|
|
603
|
+
for n in notices:
|
|
604
|
+
print(f"hamilton check: notice: {n}", file=sys.stderr)
|
|
605
|
+
noun = "criterion" if n_acs == 1 else "criteria"
|
|
606
|
+
print(f"hamilton check: {n_reqs} requirement(s), {n_acs} acceptance {noun}",
|
|
607
|
+
file=sys.stderr)
|
|
608
|
+
if warnings:
|
|
609
|
+
print(f"hamilton check: {len(warnings)} warning(s) — advisory, "
|
|
610
|
+
f"not failures", file=sys.stderr)
|
|
611
|
+
print(f"hamilton check: {'ok' if not findings else str(len(findings)) + ' problem(s)'}",
|
|
612
|
+
file=sys.stderr)
|
|
613
|
+
return 1 if findings else 0
|