henxels 0.3.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.
Files changed (51) hide show
  1. henxels/__init__.py +17 -0
  2. henxels/__main__.py +8 -0
  3. henxels/bless.py +68 -0
  4. henxels/casing.py +30 -0
  5. henxels/catalogue.py +128 -0
  6. henxels/cli.py +366 -0
  7. henxels/commands.py +30 -0
  8. henxels/contract.py +152 -0
  9. henxels/diffinfo.py +53 -0
  10. henxels/digest.py +85 -0
  11. henxels/doctor.py +58 -0
  12. henxels/engine/__init__.py +1 -0
  13. henxels/engine/discover.py +74 -0
  14. henxels/engine/gitinfo.py +95 -0
  15. henxels/engine/report.py +79 -0
  16. henxels/explain.py +53 -0
  17. henxels/filesize.py +98 -0
  18. henxels/findings.py +32 -0
  19. henxels/guard.py +80 -0
  20. henxels/hookrun.py +90 -0
  21. henxels/hooks.py +82 -0
  22. henxels/invocation.py +26 -0
  23. henxels/locations.py +96 -0
  24. henxels/runner.py +102 -0
  25. henxels/scaffold.py +130 -0
  26. henxels/schema/__init__.py +9 -0
  27. henxels/schema/henxels.schema.json +131 -0
  28. henxels/settings.py +61 -0
  29. henxels/similarity.py +112 -0
  30. henxels/statements/__init__.py +25 -0
  31. henxels/statements/builtins/__init__.py +33 -0
  32. henxels/statements/builtins/_helpers.py +44 -0
  33. henxels/statements/builtins/commands.py +18 -0
  34. henxels/statements/builtins/content.py +167 -0
  35. henxels/statements/builtins/history.py +52 -0
  36. henxels/statements/builtins/links.py +100 -0
  37. henxels/statements/builtins/meta.py +50 -0
  38. henxels/statements/builtins/naming.py +35 -0
  39. henxels/statements/builtins/security.py +39 -0
  40. henxels/statements/builtins/size.py +17 -0
  41. henxels/statements/builtins/structure.py +92 -0
  42. henxels/statements/registry.py +95 -0
  43. henxels/statements/scope.py +85 -0
  44. henxels/util/__init__.py +1 -0
  45. henxels/util/glob.py +55 -0
  46. henxels-0.3.0.dist-info/METADATA +259 -0
  47. henxels-0.3.0.dist-info/RECORD +51 -0
  48. henxels-0.3.0.dist-info/WHEEL +5 -0
  49. henxels-0.3.0.dist-info/entry_points.txt +2 -0
  50. henxels-0.3.0.dist-info/licenses/LICENSE +21 -0
  51. henxels-0.3.0.dist-info/top_level.txt +1 -0
henxels/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """henxels — external structure that gives shape to your project.
2
+
3
+ Custom checks register with the ``@statement`` decorator:
4
+
5
+ from henxels import statement
6
+
7
+ @statement("max_lines")
8
+ def max_lines(limit, scope):
9
+ return [f"{f} is too long" for f in scope.files
10
+ if scope.line_count(f) > limit]
11
+ """
12
+
13
+ from henxels.statements import Scope, as_list, statement
14
+
15
+ __version__ = "0.2.0"
16
+
17
+ __all__ = ["statement", "Scope", "as_list", "__version__"]
henxels/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m henxels`` (used as a hook fallback when the script isn't on PATH)."""
2
+
3
+ import sys
4
+
5
+ from henxels.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
henxels/bless.py ADDED
@@ -0,0 +1,68 @@
1
+ """Conscious-override tokens — the elegant escape hatch for guards.
2
+
3
+ A guard blocks a destructive reflex (push, deleting lines/files). To proceed you run
4
+ a deliberate, non-standard verb — ``henxels bless push`` — which mints a one-time
5
+ token. The git hook then *consumes* it. A reflexive retry can't reuse the token
6
+ because it's bound to a fingerprint (the exact SHA being pushed, or the exact set of
7
+ deletions) and expires quickly.
8
+
9
+ Tokens live under ``.git/henxels/`` so they're repo-local and never committed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import time
16
+ from pathlib import Path
17
+
18
+ DEFAULT_TTL = 600 # seconds — a bless is a *recent* conscious act, not a standing grant
19
+
20
+
21
+ def _store_dir(root: Path | str) -> Path:
22
+ return Path(root) / ".git" / "henxels"
23
+
24
+
25
+ def _token_path(root: Path | str, action: str) -> Path:
26
+ return _store_dir(root) / f"bless-{action}.json"
27
+
28
+
29
+ def bless(root: Path | str, action: str, fingerprint: str, ttl: int = DEFAULT_TTL, now: float | None = None) -> Path:
30
+ """Mint a one-time token for ``action`` bound to ``fingerprint``."""
31
+ now = time.time() if now is None else now
32
+ store = _store_dir(root)
33
+ store.mkdir(parents=True, exist_ok=True)
34
+ path = _token_path(root, action)
35
+ path.write_text(
36
+ json.dumps({"fingerprint": fingerprint, "expires_at": now + ttl}),
37
+ encoding="utf-8",
38
+ )
39
+ return path
40
+
41
+
42
+ def is_blessed(root: Path | str, action: str, fingerprint: str, now: float | None = None) -> bool:
43
+ """True if a matching, unexpired token exists (without consuming it)."""
44
+ now = time.time() if now is None else now
45
+ path = _token_path(root, action)
46
+ if not path.is_file():
47
+ return False
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ except (ValueError, OSError):
51
+ return False
52
+ if data.get("fingerprint") != fingerprint:
53
+ return False
54
+ if float(data.get("expires_at", 0)) < now:
55
+ return False
56
+ return True
57
+
58
+
59
+ def consume(root: Path | str, action: str, fingerprint: str, now: float | None = None) -> bool:
60
+ """Verify a matching token and delete it. Returns True if it was valid."""
61
+ ok = is_blessed(root, action, fingerprint, now=now)
62
+ # Always clear the token file: a used token is spent, a stale/mismatched one is junk.
63
+ _token_path(root, action).unlink(missing_ok=True)
64
+ return ok
65
+
66
+
67
+ def clear(root: Path | str, action: str) -> None:
68
+ _token_path(root, action).unlink(missing_ok=True)
henxels/casing.py ADDED
@@ -0,0 +1,30 @@
1
+ """Naming conventions — the closed set used by the `casing` statement and Scope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # Keys are the values accepted by the `casing:` statement (and the schema enum).
8
+ # snake_case / SCREAMING_SNAKE_CASE permit leading underscores — `_private.py` and
9
+ # `_helpers.py` are idiomatic Python, not naming violations. (Dunder files like
10
+ # __init__.py are exempted separately, in is_dunder.)
11
+ NAMING_CONVENTIONS: dict[str, str] = {
12
+ "snake_case": r"^_*[a-z0-9]+(_[a-z0-9]+)*$",
13
+ "kebab-case": r"^[a-z0-9]+(-[a-z0-9]+)*$",
14
+ "camelCase": r"^[a-z][a-zA-Z0-9]*$",
15
+ "PascalCase": r"^[A-Z][a-zA-Z0-9]*$",
16
+ "SCREAMING_SNAKE_CASE": r"^_*[A-Z0-9]+(_[A-Z0-9]+)*$",
17
+ "any": r".*",
18
+ }
19
+
20
+
21
+ def is_dunder(base: str) -> bool:
22
+ """Dunder files (__init__, __main__) are language-mandated, not style choices."""
23
+ return len(base) > 4 and base.startswith("__") and base.endswith("__")
24
+
25
+
26
+ def matches(base: str, convention: str) -> bool:
27
+ pattern = NAMING_CONVENTIONS.get(convention)
28
+ if pattern is None:
29
+ return True
30
+ return not base or is_dunder(base) or bool(re.match(pattern, base))
henxels/catalogue.py ADDED
@@ -0,0 +1,128 @@
1
+ """Discoverability + contribution: browse statements, scaffold new ones, upstream them.
2
+
3
+ The framework grows by contribution, so reuse must be easy (`catalogue`), authoring
4
+ must be boilerplate-free (`create-new-statement`), and contributing a reusable one
5
+ must be a single nudge (`contribute`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from henxels.statements.registry import all_statements
13
+
14
+ LOCAL_CHECK_FILE = "henxels_checks.py"
15
+
16
+
17
+ def render_catalogue() -> str:
18
+ stmts = all_statements()
19
+ builtin = sorted((d for d in stmts.values() if d.builtin), key=lambda d: d.name)
20
+ custom = sorted((d for d in stmts.values() if not d.builtin), key=lambda d: d.name)
21
+
22
+ lines = ["henxels catalogue — statements you can use inside a henxel", ""]
23
+ lines.append("Built-in (the standard library):")
24
+ for d in builtin:
25
+ lines.append(f" {d.name:<20} {d.help}{_flags(d)}")
26
+ if custom:
27
+ lines += ["", "Custom (loaded from this repo):"]
28
+ for d in custom:
29
+ lines.append(f" {d.name:<20} {d.help or '(no description)'}{_flags(d)}")
30
+ from henxels.invocation import henxels_cmd
31
+
32
+ hx = henxels_cmd()
33
+ lines += [
34
+ "",
35
+ f"Reuse before reinventing. Missing one? {hx} create-new-statement <name>",
36
+ f"Reusable beyond this repo? {hx} contribute (send a ready-to-merge PR)",
37
+ ]
38
+ return "\n".join(lines)
39
+
40
+
41
+ def _flags(d) -> str:
42
+ bits = []
43
+ if d.per_file:
44
+ bits.append("per-file")
45
+ if d.stage:
46
+ bits.append(d.stage)
47
+ return f" [{', '.join(bits)}]" if bits else ""
48
+
49
+
50
+ def create_statement_scaffold(name: str, root: Path | str) -> tuple[Path, str]:
51
+ """Append a template statement to henxels_checks.py. Returns (path, 'created'|'updated')."""
52
+ root = Path(root)
53
+ func = _identifier(name)
54
+ path = root / LOCAL_CHECK_FILE
55
+ existed = path.is_file()
56
+ body = "" if existed else "from henxels import statement\n"
57
+ body += _TEMPLATE.format(name=name, func=func)
58
+ with path.open("a", encoding="utf-8") as fh:
59
+ fh.write(body)
60
+ return path, ("updated" if existed else "created")
61
+
62
+
63
+ def contribute_guide(name: str | None = None) -> str:
64
+ lines = [
65
+ "Contributing a statement upstream (the project thrives on this):",
66
+ "",
67
+ "Is it REUSABLE — useful in other repos, not tied to this one's names/paths?",
68
+ " • Yes → upstream it as a built-in. Send a ready-to-merge PR (not an issue):",
69
+ " 1. add the function to henxels/statements/builtins.py with builtin=True",
70
+ " and a clear help= string;",
71
+ " 2. add a test in tests/test_statements.py;",
72
+ " 3. run the gates locally (lint + tests must pass — PRs arrive merge-ready);",
73
+ " 4. open the PR at https://github.com/benquemax/henxels",
74
+ " • No (ad-hoc to this repo) → keep it local in henxels_checks.py.",
75
+ ]
76
+ if name:
77
+ stmt = all_statements().get(name)
78
+ if stmt and not stmt.builtin:
79
+ lines += ["", f"`{name}` is a custom statement — a good contribution candidate."]
80
+ elif stmt and stmt.builtin:
81
+ lines += ["", f"`{name}` is already built-in."]
82
+ return "\n".join(lines)
83
+
84
+
85
+ def contribute_snippet(name: str) -> tuple[str, str] | None:
86
+ """For a local custom statement, return (builtin-ready source, test stub)."""
87
+ import inspect
88
+
89
+ sdef = all_statements().get(name)
90
+ if not sdef or sdef.builtin:
91
+ return None
92
+ try:
93
+ source = inspect.getsource(sdef.fn).rstrip()
94
+ except (OSError, TypeError):
95
+ source = f"# (could not read the source of {name})"
96
+ func = _identifier(name)
97
+ test_stub = (
98
+ f"def test_{func}(tmp_path):\n"
99
+ f" # TODO: build a Scope and assert `{name}` returns the right instruction(s)\n"
100
+ f" ..."
101
+ )
102
+ return source, test_stub
103
+
104
+
105
+ def _identifier(name: str) -> str:
106
+ out = "".join(c if c.isalnum() else "_" for c in name).strip("_")
107
+ return out or "my_check"
108
+
109
+
110
+ _TEMPLATE = '''
111
+
112
+ @statement("{name}", help="TODO: one-line description")
113
+ def {func}(param, file, scope):
114
+ """TODO: explain what this checks.
115
+
116
+ Args are injected by name — keep only what you need:
117
+ param the value from the contract (e.g. 500 for `{name}: 500`)
118
+ file asking for `file` makes this PER-FILE (henxels loops for you)
119
+ scope scope.files, scope.read_text(f), scope.line_count(f), scope.exists(p)
120
+
121
+ Return None/True to pass, or a STRING INSTRUCTION (shown to the agent) to fail.
122
+
123
+ Reusable beyond this repo? `henxels contribute {name}`.
124
+ """
125
+ # if <something is wrong with `file`>:
126
+ # return "do X instead"
127
+ return None
128
+ '''
henxels/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ """henxels command-line interface (v2).
2
+
3
+ henxels init scaffold contract + hooks + AGENTS.md digest
4
+ henxels check [--all|--staged] […] run the contract (every henxel is a test)
5
+ henxels explain <path> what governs this location
6
+ henxels catalogue browse the statements you can use
7
+ henxels create-new-statement <n> scaffold a local custom statement
8
+ henxels contribute [name] how to upstream a reusable statement
9
+ henxels bless <push|delete> consciously override a protection
10
+ henxels sync refresh the AGENTS.md digest
11
+ henxels doctor check the setup
12
+
13
+ Exit codes: 0 = clean, 1 = held by a henxel, 2 = usage/contract problem.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from henxels import settings
23
+ from henxels.contract import (
24
+ Contract,
25
+ ContractError,
26
+ apply_imports,
27
+ find_contract,
28
+ load_contract,
29
+ )
30
+ from henxels.diffinfo import staged_diff
31
+ from henxels.engine import gitinfo
32
+ from henxels.engine.discover import discover
33
+ from henxels.engine.report import is_fancy, render, render_summary, summarize
34
+ from henxels.explain import explain_path
35
+ from henxels.findings import Finding
36
+ from henxels.runner import run_contract
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ parser = argparse.ArgumentParser(
41
+ prog="henxels",
42
+ description="Suspenders for your repo. Keep your ADHD agent in henxels.",
43
+ )
44
+ sub = parser.add_subparsers(dest="command")
45
+
46
+ pi = sub.add_parser("init", help="scaffold the contract, hooks, and AGENTS.md digest")
47
+ pi.add_argument("--no-hooks", action="store_true")
48
+ pi.add_argument("--no-digest", action="store_true")
49
+ pi.add_argument("--force", action="store_true")
50
+ pi.set_defaults(func=cmd_init)
51
+
52
+ pc = sub.add_parser("check", help="run the contract")
53
+ pc.add_argument("paths", nargs="*")
54
+ pc.add_argument("--all", action="store_true")
55
+ pc.add_argument("--staged", action="store_true")
56
+ pc.add_argument("--config", default=None)
57
+ pc.add_argument("--plain", action="store_true")
58
+ pc.set_defaults(func=cmd_check)
59
+
60
+ pe = sub.add_parser("explain", help="show the henxels governing a path")
61
+ pe.add_argument("path")
62
+ pe.add_argument("--config", default=None)
63
+ pe.add_argument("--json", action="store_true", help="machine-readable output for agent tooling")
64
+ pe.set_defaults(func=cmd_explain)
65
+
66
+ pcat = sub.add_parser("catalogue", help="browse the statements you can use")
67
+ pcat.set_defaults(func=cmd_catalogue)
68
+
69
+ pn = sub.add_parser("create-new-statement", help="scaffold a custom statement")
70
+ pn.add_argument("name")
71
+ pn.set_defaults(func=cmd_create_statement)
72
+
73
+ pcon = sub.add_parser("contribute", help="how to upstream a reusable statement")
74
+ pcon.add_argument("name", nargs="?", default=None)
75
+ pcon.set_defaults(func=cmd_contribute)
76
+
77
+ pb = sub.add_parser("bless", help="consciously override a protection")
78
+ pb.add_argument("action", choices=["push", "delete"])
79
+ pb.set_defaults(func=cmd_bless)
80
+
81
+ ps = sub.add_parser("sync", help="refresh the AGENTS.md digest")
82
+ ps.add_argument("--config", default=None)
83
+ ps.add_argument("--target", default="AGENTS.md")
84
+ ps.set_defaults(func=cmd_sync)
85
+
86
+ pd = sub.add_parser("doctor", help="check that henxels is correctly set up")
87
+ pd.set_defaults(func=cmd_doctor)
88
+
89
+ # Git passes arguments to its hooks (pre-push gets "<remote> <url>"); swallow them.
90
+ p_pc = sub.add_parser("_precommit")
91
+ p_pc.add_argument("hook_args", nargs="*")
92
+ p_pc.set_defaults(func=cmd_precommit)
93
+ p_pp = sub.add_parser("_prepush")
94
+ p_pp.add_argument("hook_args", nargs="*")
95
+ p_pp.set_defaults(func=cmd_prepush)
96
+
97
+ args = parser.parse_args(argv)
98
+ if not args.command:
99
+ parser.print_help()
100
+ return 0
101
+ return args.func(args)
102
+
103
+
104
+ # --- helpers -------------------------------------------------------------
105
+
106
+ def _load(root: Path, config_path: str | None = None) -> Contract:
107
+ path = Path(config_path) if config_path else find_contract(root)
108
+ if path is None:
109
+ raise ContractError(
110
+ "No contract found. Looked for henxels.yaml at the repo root.\n"
111
+ " Run `henxels init` to create one."
112
+ )
113
+ contract = load_contract(path)
114
+ apply_imports(contract, root=root)
115
+ return contract
116
+
117
+
118
+ def _emit(findings: list[Finding], plain: bool = False) -> int:
119
+ fancy = is_fancy() and not plain
120
+ text = render(findings, fancy=fancy)
121
+ if text:
122
+ print(text)
123
+ print()
124
+ print(render_summary(findings, fancy=fancy))
125
+ blocks, _ = summarize(findings)
126
+ return 1 if blocks else 0
127
+
128
+
129
+ # --- commands ------------------------------------------------------------
130
+
131
+ def cmd_check(args) -> int:
132
+ root = Path.cwd()
133
+ try:
134
+ contract = _load(root, args.config)
135
+ except ContractError as exc:
136
+ print(exc, file=sys.stderr)
137
+ return 2
138
+
139
+ staged_mode = False
140
+ if args.paths:
141
+ files = [_rel(p, root) for p in args.paths]
142
+ elif args.staged:
143
+ files, staged_mode = gitinfo.staged_files(root), True
144
+ elif args.all:
145
+ files = discover(root)
146
+ elif gitinfo.is_git_repo(root):
147
+ files, staged_mode = gitinfo.staged_files(root), True
148
+ else:
149
+ files = discover(root)
150
+
151
+ if (args.paths or args.staged) and not files:
152
+ print("Nothing to check.")
153
+ return 0
154
+
155
+ # Diff-aware statements (append_only, immutable, bump_updated_on_change) only make
156
+ # sense against staged change — outside that, diff is None and they pass.
157
+ diff = staged_diff(root) if staged_mode else None
158
+ findings = run_contract(contract, root, files, diff=diff)
159
+ sim = settings.similarity(contract)
160
+ if sim:
161
+ from henxels.similarity import warn_similar
162
+
163
+ findings.extend(warn_similar(sim, root, files))
164
+
165
+ large = settings.large_files(contract)
166
+ if large:
167
+ from henxels.filesize import warn_large_files
168
+
169
+ findings.extend(warn_large_files(large, root, files))
170
+ return _emit(findings, plain=args.plain)
171
+
172
+
173
+ def cmd_explain(args) -> int:
174
+ root = Path.cwd()
175
+ try:
176
+ contract = _load(root, args.config)
177
+ except ContractError as exc:
178
+ print(exc, file=sys.stderr)
179
+ return 2
180
+ if args.json:
181
+ import json
182
+
183
+ from henxels.explain import explain_data
184
+
185
+ print(json.dumps(explain_data(contract, args.path), indent=2))
186
+ else:
187
+ print(explain_path(contract, args.path))
188
+ return 0
189
+
190
+
191
+ def cmd_catalogue(args) -> int:
192
+ from henxels.catalogue import render_catalogue
193
+
194
+ root = Path.cwd()
195
+ try:
196
+ _load(root) # load + import so custom statements show up
197
+ except ContractError:
198
+ pass
199
+ print(render_catalogue())
200
+ return 0
201
+
202
+
203
+ def cmd_create_statement(args) -> int:
204
+ from henxels.catalogue import create_statement_scaffold
205
+ from henxels.invocation import henxels_cmd
206
+
207
+ path, action = create_statement_scaffold(args.name, Path.cwd())
208
+ print(f"✓ {path.name} {action} — added a template statement '{args.name}'.")
209
+ print("Next:")
210
+ print(" • Fill in the function (it's auto-loaded — no imports: needed).")
211
+ print(f" • Use it in a henxel: {args.name}: <param>")
212
+ print(f" • Reusable beyond this repo? {henxels_cmd()} contribute {args.name}")
213
+ return 0
214
+
215
+
216
+ def cmd_contribute(args) -> int:
217
+ from henxels.catalogue import contribute_guide, contribute_snippet
218
+
219
+ root = Path.cwd()
220
+ try:
221
+ _load(root)
222
+ except ContractError:
223
+ pass
224
+ print(contribute_guide(args.name))
225
+ if args.name:
226
+ snippet = contribute_snippet(args.name)
227
+ if snippet:
228
+ source, test_stub = snippet
229
+ print("\n--- ready-to-paste built-in (add builtin=True + a help= string) ---\n")
230
+ print(source)
231
+ print("\n--- test stub for tests/test_statements.py ---\n")
232
+ print(test_stub)
233
+ return 0
234
+
235
+
236
+ def cmd_init(args) -> int:
237
+ from henxels.engine.report import BANNER
238
+ from henxels.scaffold import init
239
+
240
+ root = Path.cwd()
241
+ if is_fancy():
242
+ print(BANNER)
243
+ print()
244
+
245
+ report = init(root, install_git_hooks=not args.no_hooks, write_digest=not args.no_digest, force=args.force)
246
+
247
+ state, info = report["contract"]
248
+ if state == "created":
249
+ print(f"✓ henxels.yaml created for a {info} project")
250
+ else:
251
+ print("• henxels.yaml already exists — left as-is (use --force to replace)")
252
+ hooks = report.get("hooks")
253
+ if hooks is None:
254
+ print("• git hooks: skipped (not a git repo, or --no-hooks)")
255
+ else:
256
+ for hook, outcome in hooks.items():
257
+ mark = "✓" if outcome in ("installed", "updated") else "•"
258
+ print(f"{mark} git hook {hook}: {outcome}")
259
+ if report.get("digest"):
260
+ print(f"✓ AGENTS.md {report['digest']} — agents now see the contract")
261
+ if report.get("schema"):
262
+ print(f"✓ {report['schema']} — editor autocomplete for henxels.yaml")
263
+ print(" (install a YAML language-server extension, e.g. Red Hat YAML, to see it)")
264
+
265
+ from henxels.invocation import henxels_cmd
266
+
267
+ hx = henxels_cmd()
268
+ print()
269
+ print("Next:")
270
+ print(f" • Tailor the rules: edit henxels.yaml (browse `{hx} catalogue` for statements).")
271
+ print(f" • Ask what governs a spot: {hx} explain <path>")
272
+ print(f" • Validate everything: {hx} check --all")
273
+ print(" • To disobey a rule, change henxels.yaml — that's the whole idea.")
274
+ if hx != "henxels":
275
+ print(f" • Tip: `uv tool install henxels` (or pipx) puts `henxels` on your PATH so you can drop `{hx.rsplit(' ', 1)[0]} `.")
276
+ return 0
277
+
278
+
279
+ def cmd_doctor(args) -> int:
280
+ from henxels.doctor import diagnose
281
+
282
+ fancy = is_fancy()
283
+ all_ok = True
284
+ for c in diagnose(Path.cwd()):
285
+ mark = "✓" if c.ok else "✗"
286
+ all_ok = all_ok and c.ok
287
+ tail = f" — {c.detail}" if c.detail else ""
288
+ if fancy:
289
+ print(f" \033[{'32' if c.ok else '31'}m{mark}\033[0m {c.label}{tail}")
290
+ else:
291
+ print(f" {mark} {c.label}{tail}")
292
+ print()
293
+ print("henxels is ready." if all_ok else "Some checks need attention (see above).")
294
+ return 0 if all_ok else 1
295
+
296
+
297
+ def cmd_sync(args) -> int:
298
+ from henxels.digest import sync_file
299
+
300
+ root = Path.cwd()
301
+ try:
302
+ contract = _load(root, args.config)
303
+ except ContractError as exc:
304
+ print(exc, file=sys.stderr)
305
+ return 2
306
+ action = sync_file(root / args.target, contract)
307
+ print(f"✓ {args.target} {action} — contract digest is in sync.")
308
+ return 0
309
+
310
+
311
+ def cmd_bless(args) -> int:
312
+ from henxels import bless as bless_mod
313
+ from henxels.guard import collect_deletions
314
+
315
+ root = Path.cwd()
316
+ if not gitinfo.is_git_repo(root):
317
+ print("Not a git repo — nothing to bless here.", file=sys.stderr)
318
+ return 2
319
+
320
+ if args.action == "push":
321
+ bless_mod.bless(root, "push", gitinfo.head_sha(root) or "no-head")
322
+ print("✓ push blessed. Your next `git push` will go through (once).")
323
+ return 0
324
+
325
+ contract = None
326
+ try:
327
+ contract = _load(root)
328
+ except ContractError:
329
+ pass
330
+ over = (settings.delete_protection(contract) or {"over_lines": 5})["over_lines"] if contract else 5
331
+ deletions = collect_deletions(root, over)
332
+ if deletions.empty:
333
+ print("Nothing staged that needs a delete-bless.")
334
+ return 0
335
+ bless_mod.bless(root, "delete", deletions.fingerprint())
336
+ lost = deletions.files + [p for p, _ in deletions.lines]
337
+ print(f"✓ deletion blessed for: {', '.join(lost)}")
338
+ return 0
339
+
340
+
341
+ def cmd_precommit(args) -> int:
342
+ from henxels.hookrun import run_precommit
343
+
344
+ code, findings = run_precommit(Path.cwd())
345
+ _emit(findings)
346
+ return code
347
+
348
+
349
+ def cmd_prepush(args) -> int:
350
+ from henxels.hookrun import run_prepush
351
+
352
+ code, findings = run_prepush(Path.cwd())
353
+ _emit(findings)
354
+ return code
355
+
356
+
357
+ def _rel(p: str, root: Path) -> str:
358
+ path = Path(p)
359
+ try:
360
+ return path.resolve().relative_to(root.resolve()).as_posix()
361
+ except ValueError:
362
+ return path.as_posix()
363
+
364
+
365
+ if __name__ == "__main__": # pragma: no cover
366
+ sys.exit(main())
henxels/commands.py ADDED
@@ -0,0 +1,30 @@
1
+ """Run command-gate statements (run_before_commit / run_before_push).
2
+
3
+ The command's own output streams to the terminal; a non-zero exit becomes a blocking
4
+ finding. This keeps the test/lint gate in the contract — the single source of truth.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ from henxels.findings import BLOCK, Finding
13
+
14
+
15
+ def run_commands(commands: list[str], stage: str, root: Path | str) -> list[Finding]:
16
+ findings: list[Finding] = []
17
+ for command in commands:
18
+ result = subprocess.run(command, shell=True, cwd=str(root))
19
+ if result.returncode != 0:
20
+ findings.append(
21
+ Finding(
22
+ level=BLOCK,
23
+ henxel=f"`{command}` must pass before {stage.replace('_', ' ')}",
24
+ path="",
25
+ message="",
26
+ details=[f"command failed (exit {result.returncode}) — fix the failure above"],
27
+ steer=f"or change the run_before_{stage.split('_')[-1]} henxel in henxels.yaml",
28
+ )
29
+ )
30
+ return findings