maat16-agentlint 0.1.1.dev3__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.
- agentlint/__init__.py +13 -0
- agentlint/__main__.py +3 -0
- agentlint/cli.py +111 -0
- agentlint/core.py +373 -0
- agentlint/packs/__init__.py +4 -0
- agentlint/packs/_shared.py +329 -0
- agentlint/packs/agents.py +264 -0
- agentlint/packs/claudemd.py +191 -0
- agentlint/packs/data/claude-code-settings.schema.json +4260 -0
- agentlint/packs/mcp.py +171 -0
- agentlint/packs/settings.py +172 -0
- agentlint/packs/skills.py +315 -0
- agentlint/report.py +209 -0
- agentlint/report_html.py +259 -0
- maat16_agentlint-0.1.1.dev3.dist-info/METADATA +169 -0
- maat16_agentlint-0.1.1.dev3.dist-info/RECORD +19 -0
- maat16_agentlint-0.1.1.dev3.dist-info/WHEEL +5 -0
- maat16_agentlint-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- maat16_agentlint-0.1.1.dev3.dist-info/top_level.txt +1 -0
agentlint/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""agentlint: design-rule governance for agent projects.
|
|
2
|
+
|
|
3
|
+
Rules are ``DR-nnn`` ids in packs (built-in: spec, claudemd, mcp; per project: rules.py).
|
|
4
|
+
A project's policy sets levels and exemptions; reports come as text, json, GitHub
|
|
5
|
+
annotations or html. See README.md.
|
|
6
|
+
"""
|
|
7
|
+
from .core import Context, Finding, Policy, PolicyError, Skill, rule # noqa: F401
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
11
|
+
__version__ = _version("maat16-agentlint") # set by setuptools-scm from git at build time
|
|
12
|
+
except PackageNotFoundError: # a bare checkout on PYTHONPATH, not installed
|
|
13
|
+
__version__ = "0+unknown"
|
agentlint/__main__.py
ADDED
agentlint/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""agentlint command line.
|
|
2
|
+
|
|
3
|
+
agentlint <policy.toml | project-name> [--strict] [--format text|json|github|html] [--out FILE] [--open]
|
|
4
|
+
agentlint <policy> --list-rules [--format text|json|md] [--out FILE]
|
|
5
|
+
agentlint --list-rules --format md --split --out docs # the built-in families, one file each
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 pass · 1 findings at a failing level (errors, or warnings with --strict) ·
|
|
8
|
+
2 the policy or a rule pack is broken (nothing was validated).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
import webbrowser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .core import RANGES, REGISTRY, PolicyError, builtin_policy, family_of, load_packs, load_policy, run
|
|
18
|
+
from .report import (render_github, render_json, render_rules_index, render_rules_json,
|
|
19
|
+
render_rules_md, render_rules_text, render_text, summary)
|
|
20
|
+
from .report_html import render_html
|
|
21
|
+
|
|
22
|
+
PROJECTS = Path(__file__).resolve().parent.parent / "projects"
|
|
23
|
+
RENDER = {"text": render_text, "json": render_json, "github": render_github, "html": render_html}
|
|
24
|
+
RULES = {"text": render_rules_text, "json": render_rules_json, "md": render_rules_md}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_policy(arg: str) -> Path:
|
|
28
|
+
p = Path(arg)
|
|
29
|
+
if p.is_file():
|
|
30
|
+
return p.resolve()
|
|
31
|
+
if p.is_dir() and (p / "policy.toml").is_file():
|
|
32
|
+
return (p / "policy.toml").resolve()
|
|
33
|
+
cand = PROJECTS / arg / "policy.toml"
|
|
34
|
+
if cand.is_file():
|
|
35
|
+
return cand
|
|
36
|
+
raise PolicyError(f"no policy at {arg!r} and no project {arg!r} under {PROJECTS}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def emit(text: str, out: Path | None) -> None:
|
|
40
|
+
if out is None:
|
|
41
|
+
print(text)
|
|
42
|
+
return
|
|
43
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
out.write_text(text, encoding="utf-8", newline="\n") # LF on every OS: generated files are committed
|
|
45
|
+
print(f"agentlint: wrote {out}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write_split(policy, out: Path) -> None:
|
|
49
|
+
"""One Markdown document per family plus an index, into a directory."""
|
|
50
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
families = sorted({family_of(r.number) for r in REGISTRY.values()},
|
|
52
|
+
key=lambda f: next(i for i, (_r, n, _d) in enumerate(RANGES) if n == f))
|
|
53
|
+
for fam in families:
|
|
54
|
+
(out / f"{fam}.md").write_text(render_rules_md(policy, fam), encoding="utf-8", newline="\n")
|
|
55
|
+
(out / "README.md").write_text(render_rules_index(policy), encoding="utf-8", newline="\n")
|
|
56
|
+
print(f"agentlint: wrote {out}/README.md and {', '.join(f + '.md' for f in families)}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> int:
|
|
60
|
+
for stream in (sys.stdout, sys.stderr):
|
|
61
|
+
if hasattr(stream, "reconfigure"):
|
|
62
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
63
|
+
ap = argparse.ArgumentParser(prog="agentlint", description=(
|
|
64
|
+
"Design-rule governance for agent projects: skills, subagents, CLAUDE.md, MCP and settings, "
|
|
65
|
+
"plus each project's own rules, with levels, exemptions and reports for CI."))
|
|
66
|
+
ap.add_argument("policy", nargs="?", help="a policy.toml (or its directory), or a project name under "
|
|
67
|
+
"projects/; omit it with --list-rules to see the built-in packs")
|
|
68
|
+
ap.add_argument("--root", type=Path, help="override the project root the policy names")
|
|
69
|
+
ap.add_argument("--strict", action="store_true", help="warnings fail too (the CI setting)")
|
|
70
|
+
ap.add_argument("--format", choices=(*RENDER, "md"), default="text",
|
|
71
|
+
help="report format (default text); md is for --list-rules only")
|
|
72
|
+
ap.add_argument("--out", type=Path, help="write the report to this file; the text report still prints")
|
|
73
|
+
ap.add_argument("--open", action="store_true", help="open the file written with --out in the default browser")
|
|
74
|
+
ap.add_argument("--list-rules", action="store_true", help="print the rules register and exit")
|
|
75
|
+
ap.add_argument("--split", action="store_true",
|
|
76
|
+
help="with --list-rules --format md --out DIR: one document per family plus README.md")
|
|
77
|
+
args = ap.parse_args(argv)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
if args.policy is None:
|
|
81
|
+
if not args.list_rules:
|
|
82
|
+
raise PolicyError("name a policy or a project, or use --list-rules for the built-in register")
|
|
83
|
+
policy = builtin_policy(Path.cwd())
|
|
84
|
+
else:
|
|
85
|
+
policy = load_policy(resolve_policy(args.policy), args.root)
|
|
86
|
+
load_packs(policy)
|
|
87
|
+
if args.list_rules:
|
|
88
|
+
if args.split:
|
|
89
|
+
if args.format != "md" or args.out is None:
|
|
90
|
+
raise PolicyError("--split needs --format md and --out DIR")
|
|
91
|
+
write_split(policy, args.out)
|
|
92
|
+
return 0
|
|
93
|
+
emit(RULES.get(args.format, render_rules_text)(policy), args.out)
|
|
94
|
+
return 0
|
|
95
|
+
if args.format == "md":
|
|
96
|
+
raise PolicyError("--format md is for --list-rules; reports come as text, json, github or html")
|
|
97
|
+
findings, ctx = run(policy)
|
|
98
|
+
except PolicyError as e:
|
|
99
|
+
print(f"agentlint: {e}", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
|
|
102
|
+
emit(RENDER[args.format](policy, ctx, findings, args.strict), args.out)
|
|
103
|
+
if args.out is not None:
|
|
104
|
+
print(render_text(policy, ctx, findings, args.strict))
|
|
105
|
+
if args.open:
|
|
106
|
+
webbrowser.open(args.out.resolve().as_uri())
|
|
107
|
+
return 1 if summary(findings, args.strict)["failed"] else 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__": # pragma: no cover
|
|
111
|
+
raise SystemExit(main())
|
agentlint/core.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""agentlint core: the rule registry, discovery, the policy, exemptions and the run.
|
|
2
|
+
|
|
3
|
+
A rule is a generator decorated with ``@rule("DR-012", "error", "summary", source=...)``.
|
|
4
|
+
It receives a ``Context`` and yields ``(path, message)`` pairs - ``path`` relative to the
|
|
5
|
+
project root - or ``ctx.skip(reason)`` when it cannot run (a table it needs will not
|
|
6
|
+
import). A skip is reported, never counted as a pass.
|
|
7
|
+
|
|
8
|
+
Rules live in packs: built-in ones under ``agentlint/packs/`` (``spec``, ``claudemd``,
|
|
9
|
+
``mcp``) and a project's own ``rules.py`` beside its policy. Ids are ``DR-nnn`` and unique
|
|
10
|
+
across every pack a run loads; each family owns a number range (``RANGES``).
|
|
11
|
+
|
|
12
|
+
The policy (TOML) sets the project root, the skill globs, the packs, the effective level of
|
|
13
|
+
any rule (``error`` / ``warn`` / ``info`` / ``off``) and the exemptions. An exemption names
|
|
14
|
+
a rule, a path glob, a reason, an owner and an expiry date; findings it covers are reported
|
|
15
|
+
as exempt (still visible), an expired one stops applying and is itself reported (DR-001),
|
|
16
|
+
an unused one is reported (DR-002).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import datetime as dt
|
|
21
|
+
import fnmatch
|
|
22
|
+
import importlib
|
|
23
|
+
import importlib.util
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
import tomllib
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import yaml
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
sys.exit("agentlint: pyyaml is required (pip install pyyaml)")
|
|
34
|
+
|
|
35
|
+
LEVELS = ("error", "warn", "info", "off")
|
|
36
|
+
RUN_LEVELS = LEVELS[:3]
|
|
37
|
+
RULE_ID = re.compile(r"^DR-\d{3}$")
|
|
38
|
+
|
|
39
|
+
#: Number ranges by family. A pack keeps to its range; project packs use 900 upward and
|
|
40
|
+
#: only one project pack loads per run, so their numbers never meet.
|
|
41
|
+
RANGES = (
|
|
42
|
+
((0, 9), "governance", "the policy itself, raised by the engine"),
|
|
43
|
+
((10, 99), "skills", "SKILL.md: the Agent Skills specification and Claude Code's extensions"),
|
|
44
|
+
((100, 199), "claudemd", "CLAUDE.md, AGENTS.md, CLAUDE.local.md and .claude/rules"),
|
|
45
|
+
((200, 299), "mcp", ".mcp.json servers and the settings that approve them"),
|
|
46
|
+
((300, 399), "agents", "subagent definitions under .claude/agents"),
|
|
47
|
+
((400, 499), "settings", ".claude/settings.json: permissions, hooks, keys"),
|
|
48
|
+
((500, 899), "reserved", "future families"),
|
|
49
|
+
((900, 999), "project", "the project's own pack"),
|
|
50
|
+
)
|
|
51
|
+
#: The packs every policy gets unless it names its own list.
|
|
52
|
+
BUILTIN_PACKS = ("skills", "claudemd", "mcp", "agents", "settings")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PolicyError(Exception):
|
|
56
|
+
"""A problem in the policy or a rule pack: exit 2, nothing validated."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------ rules
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Rule:
|
|
63
|
+
id: str
|
|
64
|
+
level: str
|
|
65
|
+
summary: str
|
|
66
|
+
category: str
|
|
67
|
+
source: str
|
|
68
|
+
pack: str
|
|
69
|
+
doc: str
|
|
70
|
+
fn: object
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def number(self) -> int:
|
|
74
|
+
return int(self.id[3:])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
REGISTRY: dict[str, Rule] = {}
|
|
78
|
+
_PACK: list[str] = ["engine"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def family_of(number: int) -> str:
|
|
82
|
+
for (lo, hi), name, _ in RANGES:
|
|
83
|
+
if lo <= number <= hi:
|
|
84
|
+
return name
|
|
85
|
+
return "?"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rule(id: str, level: str, summary: str, *, source: str = "", category: str | None = None):
|
|
89
|
+
"""Register a rule under a ``DR-nnn`` id with its default level, a one-line summary
|
|
90
|
+
and the source it enforces (a spec section, a project file). The docstring is the
|
|
91
|
+
rule's explanation in the register."""
|
|
92
|
+
if not RULE_ID.match(id):
|
|
93
|
+
raise PolicyError(f"rule id {id!r} must look like DR-012")
|
|
94
|
+
if level not in RUN_LEVELS:
|
|
95
|
+
raise PolicyError(f"rule {id}: level must be one of {RUN_LEVELS}, not {level!r}")
|
|
96
|
+
|
|
97
|
+
def deco(fn):
|
|
98
|
+
if id in REGISTRY:
|
|
99
|
+
raise PolicyError(f"rule {id} is defined twice ({REGISTRY[id].pack} and {_PACK[-1]})")
|
|
100
|
+
REGISTRY[id] = Rule(id, level, summary, category or family_of(int(id[3:])), source,
|
|
101
|
+
_PACK[-1], " ".join((fn.__doc__ or "").split()), fn)
|
|
102
|
+
return fn
|
|
103
|
+
return deco
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Skip:
|
|
107
|
+
"""Yielded by a rule that cannot run; reported, never a pass."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, reason: str):
|
|
110
|
+
self.reason = reason
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------ skills
|
|
114
|
+
|
|
115
|
+
FRONT = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)(.*)\Z", re.S)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def split_frontmatter(text: str) -> tuple[dict, str]:
|
|
119
|
+
m = FRONT.match(text)
|
|
120
|
+
if not m:
|
|
121
|
+
raise ValueError("no YAML frontmatter opened and closed by ---")
|
|
122
|
+
try:
|
|
123
|
+
meta = yaml.safe_load(m.group(1))
|
|
124
|
+
except yaml.YAMLError as e:
|
|
125
|
+
raise ValueError(f"frontmatter is not valid YAML: {e}") from None
|
|
126
|
+
if not isinstance(meta, dict):
|
|
127
|
+
raise ValueError("frontmatter is not a mapping")
|
|
128
|
+
return meta, m.group(2)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Skill:
|
|
133
|
+
dir: Path
|
|
134
|
+
rel: str # SKILL.md relative to the root (posix); the dir when missing
|
|
135
|
+
text: str = ""
|
|
136
|
+
meta: dict | None = None # None when the file is missing or does not parse
|
|
137
|
+
body: str = ""
|
|
138
|
+
error: str | None = None
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def name(self) -> str:
|
|
142
|
+
return self.dir.name
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
PRUNE = {".git", "node_modules", ".venv", "venv", "__pycache__", "build", "dist", ".tox"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _pruned(p: Path) -> bool:
|
|
149
|
+
return any(part in PRUNE or part.endswith(".egg-info") for part in p.parts)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def discover(root: Path, globs: list[str]) -> list[Skill]:
|
|
153
|
+
seen: set[Path] = set()
|
|
154
|
+
out: list[Skill] = []
|
|
155
|
+
for g in globs:
|
|
156
|
+
for d in sorted(root.glob(g)):
|
|
157
|
+
if not d.is_dir() or d in seen or _pruned(d.relative_to(root)):
|
|
158
|
+
continue
|
|
159
|
+
seen.add(d)
|
|
160
|
+
md = next((d / n for n in ("SKILL.md", "skill.md") if (d / n).exists()), None)
|
|
161
|
+
s = Skill(d, (md or d).relative_to(root).as_posix())
|
|
162
|
+
if md is None:
|
|
163
|
+
s.error = "SKILL.md missing"
|
|
164
|
+
else:
|
|
165
|
+
try:
|
|
166
|
+
s.text = md.read_text(encoding="utf-8")
|
|
167
|
+
s.meta, s.body = split_frontmatter(s.text)
|
|
168
|
+
except (OSError, UnicodeDecodeError, ValueError) as e:
|
|
169
|
+
s.error = str(e)
|
|
170
|
+
out.append(s)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class Context:
|
|
176
|
+
root: Path
|
|
177
|
+
policy: "Policy"
|
|
178
|
+
skills: list[Skill]
|
|
179
|
+
cache: dict = field(default_factory=dict) # for a pack to share what it parsed
|
|
180
|
+
inventory: dict = field(default_factory=dict) # what was discovered, by kind, for the report
|
|
181
|
+
|
|
182
|
+
def rel(self, p: Path) -> str:
|
|
183
|
+
try:
|
|
184
|
+
return Path(p).resolve().relative_to(self.root).as_posix()
|
|
185
|
+
except ValueError:
|
|
186
|
+
return Path(p).as_posix()
|
|
187
|
+
|
|
188
|
+
def files(self, *globs: str) -> list[Path]:
|
|
189
|
+
"""Files under the root matching any glob, pruned of vendored and build trees."""
|
|
190
|
+
found: set[Path] = set()
|
|
191
|
+
for g in globs:
|
|
192
|
+
for p in self.root.glob(g):
|
|
193
|
+
if p.is_file() and not _pruned(p.relative_to(self.root)):
|
|
194
|
+
found.add(p)
|
|
195
|
+
return sorted(found)
|
|
196
|
+
|
|
197
|
+
def skip(self, reason: str) -> Skip:
|
|
198
|
+
return Skip(reason)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------ policy
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class Exemption:
|
|
205
|
+
index: int
|
|
206
|
+
rule: str
|
|
207
|
+
path: str
|
|
208
|
+
reason: str
|
|
209
|
+
by: str
|
|
210
|
+
until: dt.date | None
|
|
211
|
+
matched: int = 0
|
|
212
|
+
|
|
213
|
+
def covers(self, rule_id: str, path: str) -> bool:
|
|
214
|
+
return rule_id == self.rule and fnmatch.fnmatchcase(path, self.path)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class Policy:
|
|
219
|
+
file: Path
|
|
220
|
+
name: str
|
|
221
|
+
root: Path
|
|
222
|
+
skills: list[str]
|
|
223
|
+
packs: list[str]
|
|
224
|
+
levels: dict[str, str]
|
|
225
|
+
exemptions: list[Exemption]
|
|
226
|
+
|
|
227
|
+
def level(self, rule_id: str) -> str:
|
|
228
|
+
return self.levels.get(rule_id, REGISTRY[rule_id].level)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
DEFAULT_SKILLS = ["**/.claude/skills/*", "**/.agents/skills/*", "skills/*"]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_policy(path: Path, root_override: Path | None = None) -> Policy:
|
|
235
|
+
try:
|
|
236
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
237
|
+
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
238
|
+
raise PolicyError(f"{path}: {e}") from None
|
|
239
|
+
proj = data.get("project") or {}
|
|
240
|
+
root = (root_override or (path.parent / proj.get("root", "."))).resolve()
|
|
241
|
+
if not root.is_dir():
|
|
242
|
+
raise PolicyError(f"{path}: project root {root} is not a directory")
|
|
243
|
+
rules = data.get("rules") or {}
|
|
244
|
+
levels = dict(rules.get("levels") or {})
|
|
245
|
+
for k, v in levels.items():
|
|
246
|
+
if v not in LEVELS:
|
|
247
|
+
raise PolicyError(f"{path}: [rules.levels] {k} = {v!r}; use one of {LEVELS}")
|
|
248
|
+
exemptions = []
|
|
249
|
+
for i, e in enumerate(data.get("exemptions") or [], 1):
|
|
250
|
+
if not isinstance(e, dict) or not e.get("rule") or not e.get("reason"):
|
|
251
|
+
raise PolicyError(f"{path}: exemption #{i} needs at least 'rule' and 'reason'")
|
|
252
|
+
until = e.get("until")
|
|
253
|
+
if until is not None and not isinstance(until, dt.date):
|
|
254
|
+
raise PolicyError(f"{path}: exemption #{i} 'until' must be a bare date, e.g. until = 2026-10-09")
|
|
255
|
+
exemptions.append(Exemption(i, str(e["rule"]), str(e.get("path", "*")), str(e["reason"]),
|
|
256
|
+
str(e.get("by", "")), until))
|
|
257
|
+
return Policy(path, str(proj.get("name") or path.parent.name), root,
|
|
258
|
+
list(proj.get("skills") or DEFAULT_SKILLS), list(rules.get("packs") or BUILTIN_PACKS),
|
|
259
|
+
levels, exemptions)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def builtin_policy(root: Path) -> Policy:
|
|
263
|
+
"""A policy with every built-in pack and no overrides: what ``--list-rules`` uses when
|
|
264
|
+
no project is named, and a sensible first run on any directory."""
|
|
265
|
+
return Policy(root / "policy.toml", root.name, root, list(DEFAULT_SKILLS), list(BUILTIN_PACKS), {}, [])
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def load_packs(policy: Policy) -> None:
|
|
269
|
+
"""Import every pack the policy names: a bare name is built in (``agentlint.packs.<name>``),
|
|
270
|
+
anything with a slash or ``.py`` is a file relative to the policy."""
|
|
271
|
+
for p in policy.packs:
|
|
272
|
+
_PACK.append(p)
|
|
273
|
+
try:
|
|
274
|
+
if "/" in p or p.endswith(".py"):
|
|
275
|
+
f = (policy.file.parent / p).resolve()
|
|
276
|
+
if not f.is_file():
|
|
277
|
+
raise PolicyError(f"{policy.file}: rule pack not found: {f}")
|
|
278
|
+
spec = importlib.util.spec_from_file_location(f"agentlint_pack_{f.stem}_{abs(hash(str(f)))}", f)
|
|
279
|
+
mod = importlib.util.module_from_spec(spec)
|
|
280
|
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
281
|
+
else:
|
|
282
|
+
try:
|
|
283
|
+
importlib.import_module(f"agentlint.packs.{p}")
|
|
284
|
+
except ModuleNotFoundError as e:
|
|
285
|
+
if e.name == f"agentlint.packs.{p}":
|
|
286
|
+
raise PolicyError(f"{policy.file}: no built-in pack named {p!r}") from None
|
|
287
|
+
raise
|
|
288
|
+
finally:
|
|
289
|
+
_PACK.pop()
|
|
290
|
+
unknown = sorted({k for k in policy.levels if k not in REGISTRY}
|
|
291
|
+
| {e.rule for e in policy.exemptions if e.rule not in REGISTRY})
|
|
292
|
+
if unknown:
|
|
293
|
+
raise PolicyError(f"{policy.file}: names rules no loaded pack defines: {', '.join(unknown)}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------ the run
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class Finding:
|
|
300
|
+
rule: str
|
|
301
|
+
level: str # effective level, or "skip"
|
|
302
|
+
path: str
|
|
303
|
+
message: str
|
|
304
|
+
status: str = "open" # open | exempt | skip
|
|
305
|
+
exemption: Exemption | None = None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# governance rules the engine raises itself
|
|
309
|
+
|
|
310
|
+
@rule("DR-001", "warn", "an exemption has passed its 'until' date", source="policy [[exemptions]]")
|
|
311
|
+
def _expired(ctx):
|
|
312
|
+
"""An expired exemption no longer applies: the findings it covered reopen at their own
|
|
313
|
+
level, and the stale entry is reported until it is renewed or removed."""
|
|
314
|
+
return ()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@rule("DR-002", "info", "an exemption matched no finding", source="policy [[exemptions]]")
|
|
318
|
+
def _unused(ctx):
|
|
319
|
+
"""An exemption that covers nothing is dead policy; remove it so the file stays a true
|
|
320
|
+
record of what is tolerated."""
|
|
321
|
+
return ()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@rule("DR-003", "error", "a rule crashed while running", source="the rule pack")
|
|
325
|
+
def _crashed(ctx):
|
|
326
|
+
"""A rule that raises is a bug in the pack, reported as an error so it is fixed rather
|
|
327
|
+
than silently skipped."""
|
|
328
|
+
return ()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def run(policy: Policy, today: dt.date | None = None) -> tuple[list[Finding], Context]:
|
|
332
|
+
today = today or dt.date.today()
|
|
333
|
+
ctx = Context(policy.root, policy, discover(policy.root, policy.skills))
|
|
334
|
+
ctx.inventory["skills"] = len(ctx.skills)
|
|
335
|
+
findings: list[Finding] = []
|
|
336
|
+
active = [e for e in policy.exemptions if e.until is None or e.until >= today]
|
|
337
|
+
for r in sorted(REGISTRY.values(), key=lambda r: r.number):
|
|
338
|
+
level = policy.level(r.id)
|
|
339
|
+
if level == "off" or r.id in ("DR-001", "DR-002", "DR-003"):
|
|
340
|
+
continue
|
|
341
|
+
try:
|
|
342
|
+
items = list(r.fn(ctx) or ())
|
|
343
|
+
except Exception as e: # noqa: BLE001 - a crashing rule must not stop the run
|
|
344
|
+
if policy.level("DR-003") != "off":
|
|
345
|
+
findings.append(Finding("DR-003", policy.level("DR-003"), "",
|
|
346
|
+
f"{r.id} crashed: {type(e).__name__}: {e}"))
|
|
347
|
+
continue
|
|
348
|
+
for it in items:
|
|
349
|
+
if isinstance(it, Skip):
|
|
350
|
+
findings.append(Finding(r.id, "skip", "", it.reason, "skip"))
|
|
351
|
+
continue
|
|
352
|
+
path, msg = it
|
|
353
|
+
f = Finding(r.id, level, str(path), str(msg))
|
|
354
|
+
for e in active:
|
|
355
|
+
if e.covers(r.id, f.path):
|
|
356
|
+
f.status, f.exemption = "exempt", e
|
|
357
|
+
e.matched += 1
|
|
358
|
+
break
|
|
359
|
+
findings.append(f)
|
|
360
|
+
for e in policy.exemptions:
|
|
361
|
+
if e.until is not None and e.until < today:
|
|
362
|
+
if policy.level("DR-001") != "off":
|
|
363
|
+
findings.append(Finding("DR-001", policy.level("DR-001"), e.path,
|
|
364
|
+
f"exemption #{e.index} for {e.rule} expired on {e.until}: {e.reason}"))
|
|
365
|
+
elif e.matched == 0 and policy.level("DR-002") != "off":
|
|
366
|
+
findings.append(Finding("DR-002", policy.level("DR-002"), e.path,
|
|
367
|
+
f"exemption #{e.index} for {e.rule} matched nothing: {e.reason}"))
|
|
368
|
+
return findings, ctx
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def failed(findings: list[Finding], strict: bool) -> bool:
|
|
372
|
+
open_ = [f for f in findings if f.status == "open"]
|
|
373
|
+
return any(f.level == "error" for f in open_) or (strict and any(f.level == "warn" for f in open_))
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"""Built-in rule packs, one per family, each in its DR number range (``core.RANGES``):
|
|
2
|
+
``skills`` (DR-010..099), ``claudemd`` (DR-100..199), ``mcp`` (DR-200..299), ``agents``
|
|
3
|
+
(DR-300..399), ``settings`` (DR-400..499). ``_shared`` holds helpers and registers nothing;
|
|
4
|
+
``data/`` holds the bundled Claude Code settings schema."""
|