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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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."""