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.
@@ -0,0 +1,3 @@
1
+ """Hamilton -- specification-gated development with enforced verification."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from hamilton_core.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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