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.
- henxels/__init__.py +17 -0
- henxels/__main__.py +8 -0
- henxels/bless.py +68 -0
- henxels/casing.py +30 -0
- henxels/catalogue.py +128 -0
- henxels/cli.py +366 -0
- henxels/commands.py +30 -0
- henxels/contract.py +152 -0
- henxels/diffinfo.py +53 -0
- henxels/digest.py +85 -0
- henxels/doctor.py +58 -0
- henxels/engine/__init__.py +1 -0
- henxels/engine/discover.py +74 -0
- henxels/engine/gitinfo.py +95 -0
- henxels/engine/report.py +79 -0
- henxels/explain.py +53 -0
- henxels/filesize.py +98 -0
- henxels/findings.py +32 -0
- henxels/guard.py +80 -0
- henxels/hookrun.py +90 -0
- henxels/hooks.py +82 -0
- henxels/invocation.py +26 -0
- henxels/locations.py +96 -0
- henxels/runner.py +102 -0
- henxels/scaffold.py +130 -0
- henxels/schema/__init__.py +9 -0
- henxels/schema/henxels.schema.json +131 -0
- henxels/settings.py +61 -0
- henxels/similarity.py +112 -0
- henxels/statements/__init__.py +25 -0
- henxels/statements/builtins/__init__.py +33 -0
- henxels/statements/builtins/_helpers.py +44 -0
- henxels/statements/builtins/commands.py +18 -0
- henxels/statements/builtins/content.py +167 -0
- henxels/statements/builtins/history.py +52 -0
- henxels/statements/builtins/links.py +100 -0
- henxels/statements/builtins/meta.py +50 -0
- henxels/statements/builtins/naming.py +35 -0
- henxels/statements/builtins/security.py +39 -0
- henxels/statements/builtins/size.py +17 -0
- henxels/statements/builtins/structure.py +92 -0
- henxels/statements/registry.py +95 -0
- henxels/statements/scope.py +85 -0
- henxels/util/__init__.py +1 -0
- henxels/util/glob.py +55 -0
- henxels-0.3.0.dist-info/METADATA +259 -0
- henxels-0.3.0.dist-info/RECORD +51 -0
- henxels-0.3.0.dist-info/WHEEL +5 -0
- henxels-0.3.0.dist-info/entry_points.txt +2 -0
- henxels-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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
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
|