agentfile-lint 0.4.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.
- agentfile_lint/__init__.py +3 -0
- agentfile_lint/__main__.py +5 -0
- agentfile_lint/_vendor/entry.py.txt +18 -0
- agentfile_lint/_vendor/verify.py.txt +69 -0
- agentfile_lint/allowlist.py +104 -0
- agentfile_lint/check_pins.py +130 -0
- agentfile_lint/cli.py +128 -0
- agentfile_lint/config.py +169 -0
- agentfile_lint/context.py +26 -0
- agentfile_lint/discover.py +68 -0
- agentfile_lint/engine.py +49 -0
- agentfile_lint/errors.py +86 -0
- agentfile_lint/finding.py +21 -0
- agentfile_lint/presets/default.toml +145 -0
- agentfile_lint/rules/__init__.py +10 -0
- agentfile_lint/rules/_text.py +43 -0
- agentfile_lint/rules/claude_md.py +47 -0
- agentfile_lint/rules/constants.py +37 -0
- agentfile_lint/rules/duplicate.py +81 -0
- agentfile_lint/rules/paths.py +111 -0
- agentfile_lint/rules/rot.py +69 -0
- agentfile_lint/rules/shape.py +130 -0
- agentfile_lint/rules/size.py +86 -0
- agentfile_lint/rules/specrefs.py +101 -0
- agentfile_lint/rules/testnames.py +73 -0
- agentfile_lint/vendor.py +138 -0
- agentfile_lint-0.4.0.dist-info/METADATA +161 -0
- agentfile_lint-0.4.0.dist-info/RECORD +32 -0
- agentfile_lint-0.4.0.dist-info/WHEEL +5 -0
- agentfile_lint-0.4.0.dist-info/entry_points.txt +2 -0
- agentfile_lint-0.4.0.dist-info/licenses/LICENSE +21 -0
- agentfile_lint-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""agentfile-lint, vendored copy. Run: python3 agentfile-lint.py [options]
|
|
2
|
+
|
|
3
|
+
Standard library only. Verify the copy first with: python3 verify.py
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
if sys.version_info < (3, 11):
|
|
9
|
+
sys.stderr.write(f"agentfile-lint needs Python >= 3.11, found {sys.version.split()[0]}\n")
|
|
10
|
+
raise SystemExit(2)
|
|
11
|
+
|
|
12
|
+
sys.dont_write_bytecode = True
|
|
13
|
+
# `python3 -I` does not put the script directory on sys.path, so add it explicitly.
|
|
14
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
|
|
16
|
+
from agentfile_lint.cli import main # noqa: E402
|
|
17
|
+
|
|
18
|
+
sys.exit(main())
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Check that this directory still matches the manifest in README.md.
|
|
2
|
+
|
|
3
|
+
Detects accidental modification only: a deliberate change can edit the files and the manifest
|
|
4
|
+
together. The real gate is review of the diff that upgrades this copy.
|
|
5
|
+
|
|
6
|
+
Exit 0: every listed file matches and nothing else is present. Exit 1: mismatch, missing or
|
|
7
|
+
extra file (named on stderr). Exit 2: the manifest cannot be read.
|
|
8
|
+
"""
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
BEGIN = "<!-- manifest:begin -->"
|
|
14
|
+
END = "<!-- manifest:end -->"
|
|
15
|
+
README = "README.md"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _manifest(root):
|
|
19
|
+
try:
|
|
20
|
+
with open(os.path.join(root, README), encoding="utf-8", newline="") as fh:
|
|
21
|
+
lines = fh.read().split("\n")
|
|
22
|
+
except OSError as exc:
|
|
23
|
+
sys.stderr.write(f"verify: cannot read {README}: {exc}\n")
|
|
24
|
+
raise SystemExit(2)
|
|
25
|
+
if BEGIN not in lines or END not in lines:
|
|
26
|
+
sys.stderr.write(f"verify: {README} has no manifest block\n")
|
|
27
|
+
raise SystemExit(2)
|
|
28
|
+
entries = {}
|
|
29
|
+
for line in lines[lines.index(BEGIN) + 1:lines.index(END)]:
|
|
30
|
+
if not line or line.startswith("```"):
|
|
31
|
+
continue
|
|
32
|
+
digest, sep, name = line.partition(" ")
|
|
33
|
+
if not sep or len(digest) != 64:
|
|
34
|
+
sys.stderr.write(f"verify: malformed manifest line: {line}\n")
|
|
35
|
+
raise SystemExit(2)
|
|
36
|
+
entries[name] = digest
|
|
37
|
+
return entries
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
root = os.path.dirname(os.path.abspath(__file__))
|
|
42
|
+
entries = _manifest(root)
|
|
43
|
+
bad = []
|
|
44
|
+
for name, digest in sorted(entries.items()):
|
|
45
|
+
path = os.path.join(root, *name.split("/"))
|
|
46
|
+
try:
|
|
47
|
+
with open(path, "rb") as fh:
|
|
48
|
+
actual = hashlib.sha256(fh.read()).hexdigest()
|
|
49
|
+
except OSError:
|
|
50
|
+
bad.append(f"{name}: missing")
|
|
51
|
+
continue
|
|
52
|
+
if actual != digest:
|
|
53
|
+
bad.append(f"{name}: modified")
|
|
54
|
+
present = set()
|
|
55
|
+
for base, dirs, files in os.walk(root):
|
|
56
|
+
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
|
57
|
+
for f in files:
|
|
58
|
+
present.add(os.path.relpath(os.path.join(base, f), root).replace(os.sep, "/"))
|
|
59
|
+
for name in sorted(present - set(entries) - {README}):
|
|
60
|
+
bad.append(f"{name}: not listed in the manifest")
|
|
61
|
+
if bad:
|
|
62
|
+
for line in bad:
|
|
63
|
+
sys.stderr.write(f"verify: {line}\n")
|
|
64
|
+
return 1
|
|
65
|
+
print(f"verify: ok ({len(entries)} files)")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
sys.exit(main())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Allowlist: time-boxed exceptions. Every entry needs rule, path, reason and expires.
|
|
2
|
+
|
|
3
|
+
An entry that has expired fails, and so does an entry that is no longer needed (the file no
|
|
4
|
+
longer violates the rule, or the file is gone): exceptions can only shrink.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .errors import ConfigError, check_keys, expect_str
|
|
13
|
+
from .finding import Finding
|
|
14
|
+
|
|
15
|
+
RULE_ID = "allowlist"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Settings:
|
|
20
|
+
max_days: int
|
|
21
|
+
max_entries: int
|
|
22
|
+
warn_days: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Entry:
|
|
27
|
+
rule: str
|
|
28
|
+
path: str
|
|
29
|
+
reason: str
|
|
30
|
+
expires: datetime.date
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse(raw: Any, settings: Settings, known_rules: set[str], today: datetime.date) -> list[Entry]:
|
|
34
|
+
if not isinstance(raw, list):
|
|
35
|
+
raise ConfigError("allowlist.entries: expected an array of tables")
|
|
36
|
+
if len(raw) > settings.max_entries:
|
|
37
|
+
raise ConfigError(
|
|
38
|
+
f"allowlist.entries: {len(raw)} entries exceed the limit of {settings.max_entries}; "
|
|
39
|
+
"fix the files instead of listing more exceptions"
|
|
40
|
+
)
|
|
41
|
+
latest = today + datetime.timedelta(days=settings.max_days)
|
|
42
|
+
entries: list[Entry] = []
|
|
43
|
+
seen: set[tuple[str, str]] = set()
|
|
44
|
+
for i, item in enumerate(raw):
|
|
45
|
+
where = f"allowlist.entries[{i}]"
|
|
46
|
+
check_keys(item, {"rule", "path", "reason", "expires"}, where)
|
|
47
|
+
for key in ("rule", "path", "reason", "expires"):
|
|
48
|
+
if key not in item:
|
|
49
|
+
raise ConfigError(f"{where}: missing key {key}")
|
|
50
|
+
rule = expect_str(item["rule"], f"{where}.rule")
|
|
51
|
+
if rule not in known_rules:
|
|
52
|
+
raise ConfigError(f"{where}.rule: unknown rule {rule!r}")
|
|
53
|
+
path = expect_str(item["path"], f"{where}.path")
|
|
54
|
+
reason = expect_str(item["reason"], f"{where}.reason")
|
|
55
|
+
expires = item["expires"]
|
|
56
|
+
# TOML dates arrive as datetime.date; a quoted string is accepted too.
|
|
57
|
+
if isinstance(expires, str):
|
|
58
|
+
try:
|
|
59
|
+
expires = datetime.date.fromisoformat(expires)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise ConfigError(f"{where}.expires: not a YYYY-MM-DD date") from exc
|
|
62
|
+
if isinstance(expires, datetime.datetime) or not isinstance(expires, datetime.date):
|
|
63
|
+
raise ConfigError(f"{where}.expires: expected a YYYY-MM-DD date")
|
|
64
|
+
if expires > latest:
|
|
65
|
+
raise ConfigError(
|
|
66
|
+
f"{where}.expires: {expires} is more than {settings.max_days} days from {today} "
|
|
67
|
+
f"(latest allowed {latest})"
|
|
68
|
+
)
|
|
69
|
+
if (rule, path) in seen:
|
|
70
|
+
raise ConfigError(f"{where}: duplicate entry for {rule} {path}")
|
|
71
|
+
seen.add((rule, path))
|
|
72
|
+
entries.append(Entry(rule, path, reason, expires))
|
|
73
|
+
return entries
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def apply(
|
|
77
|
+
findings: list[Finding],
|
|
78
|
+
entries: list[Entry],
|
|
79
|
+
settings: Settings,
|
|
80
|
+
present: set[str],
|
|
81
|
+
today: datetime.date,
|
|
82
|
+
) -> list[Finding]:
|
|
83
|
+
"""Drop findings covered by a live entry; add findings about the entries themselves.
|
|
84
|
+
|
|
85
|
+
`present` is the full set of analyzed paths (decision: always the full set, never argv).
|
|
86
|
+
"""
|
|
87
|
+
live = {(e.rule, e.path) for e in entries if e.expires >= today}
|
|
88
|
+
violating = {(f.rule, f.path) for f in findings}
|
|
89
|
+
out = [f for f in findings if (f.rule, f.path) not in live]
|
|
90
|
+
for e in entries:
|
|
91
|
+
days = (e.expires - today).days
|
|
92
|
+
if days < 0:
|
|
93
|
+
out.append(Finding(
|
|
94
|
+
e.path, 0, RULE_ID,
|
|
95
|
+
f"entry for {e.rule} expired on {e.expires} ({-days} days ago); fix the file or renew with a new reason",
|
|
96
|
+
))
|
|
97
|
+
elif (e.rule, e.path) not in violating:
|
|
98
|
+
why = "file is not an analyzed instruction file" if e.path not in present else f"file no longer violates {e.rule}"
|
|
99
|
+
out.append(Finding(e.path, 0, RULE_ID, f"entry for {e.rule} is no longer needed ({why}); remove it"))
|
|
100
|
+
elif days <= settings.warn_days:
|
|
101
|
+
out.append(Finding(
|
|
102
|
+
e.path, 0, RULE_ID, f"entry for {e.rule} expires in {days} days ({e.expires})", level="warn",
|
|
103
|
+
))
|
|
104
|
+
return out
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""`agentfile-lint check-pins`: every reference to this tool in a repository pins the same full SHA.
|
|
2
|
+
|
|
3
|
+
A consumer references the tool from several places (a pre-commit hook, a lefthook command, CI
|
|
4
|
+
workflows). If they pin different commits, the local hook and CI quietly run different rules.
|
|
5
|
+
|
|
6
|
+
There is no YAML parser here (standard library only); the files are scanned line by line:
|
|
7
|
+
|
|
8
|
+
- pre-commit: a `repo:` line whose value contains the repo string starts an entry; the next `rev:`
|
|
9
|
+
line before the following `repo:` line is its pin.
|
|
10
|
+
- anywhere else (lefthook, workflows): a line that contains the repo string and `@<ref>`, as in
|
|
11
|
+
`uvx --from git+https://host/owner/agentfile-lint@<sha>` or `uses: owner/agentfile-lint@<sha>`.
|
|
12
|
+
A `uses:`/`git+` line naming the repo with no `@<ref>` is reported as unpinned.
|
|
13
|
+
|
|
14
|
+
Limits: no anchors/aliases, no multi-line scalars, no expression expansion (`${{ ... }}` is not a SHA
|
|
15
|
+
and is reported), no references built from several variables, and a reference inside a comment is
|
|
16
|
+
still read. Anything the scan does not recognise is simply not seen: it checks consistency of what
|
|
17
|
+
it finds, it does not prove there is nothing else.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import fnmatch
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import config as config_mod
|
|
29
|
+
from . import discover
|
|
30
|
+
from .errors import ConfigError
|
|
31
|
+
|
|
32
|
+
SHA = re.compile(r"[0-9a-f]{40}")
|
|
33
|
+
_REPO_LINE = re.compile(r"^\s*(?:-\s+)?repo:\s*(\S+)")
|
|
34
|
+
_REV_LINE = re.compile(r"^\s*(?:-\s+)?rev:\s*(?P<q>['\"]?)(?P<ref>[^\s'\"#]*)(?P=q)")
|
|
35
|
+
_PIN_KEYWORDS = ("uses:", "git+", "--from")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Ref:
|
|
40
|
+
path: str
|
|
41
|
+
line: int
|
|
42
|
+
ref: str # "" when the reference carries no @ref / rev
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scan(path: str, text: str, repo: str) -> list[Ref]:
|
|
46
|
+
refs: list[Ref] = []
|
|
47
|
+
lines = text.splitlines()
|
|
48
|
+
at_ref = re.compile(re.escape(repo) + r"[^\s'\"@]*@(?P<ref>[^\s'\"#]+)")
|
|
49
|
+
in_entry = False
|
|
50
|
+
entry_line = 0
|
|
51
|
+
for number, line in enumerate(lines, start=1):
|
|
52
|
+
m = _REPO_LINE.match(line)
|
|
53
|
+
if m:
|
|
54
|
+
if in_entry: # previous entry never had a rev
|
|
55
|
+
refs.append(Ref(path, entry_line, ""))
|
|
56
|
+
in_entry = repo in m.group(1) and m.group(1) != "local"
|
|
57
|
+
entry_line = number
|
|
58
|
+
continue
|
|
59
|
+
if in_entry:
|
|
60
|
+
rev = _REV_LINE.match(line)
|
|
61
|
+
if rev:
|
|
62
|
+
refs.append(Ref(path, number, rev.group("ref")))
|
|
63
|
+
in_entry = False
|
|
64
|
+
continue
|
|
65
|
+
if repo in line:
|
|
66
|
+
found = at_ref.search(line)
|
|
67
|
+
if found:
|
|
68
|
+
refs.append(Ref(path, number, found.group("ref")))
|
|
69
|
+
elif any(k in line for k in _PIN_KEYWORDS) and not line.lstrip().startswith("#"):
|
|
70
|
+
refs.append(Ref(path, number, ""))
|
|
71
|
+
if in_entry:
|
|
72
|
+
refs.append(Ref(path, entry_line, ""))
|
|
73
|
+
return refs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def evaluate(refs: list[Ref]) -> list[tuple[str, int, str]]:
|
|
77
|
+
"""Problems as (path, line, message): not a full SHA, or a SHA that differs from the first one."""
|
|
78
|
+
problems: list[tuple[str, int, str]] = []
|
|
79
|
+
first: Ref | None = None
|
|
80
|
+
for r in refs:
|
|
81
|
+
if not SHA.fullmatch(r.ref):
|
|
82
|
+
shown = r.ref or "(none)"
|
|
83
|
+
problems.append((r.path, r.line, f"reference is pinned to {shown!r}, not a full 40-hex commit SHA"))
|
|
84
|
+
elif first is None:
|
|
85
|
+
first = r
|
|
86
|
+
elif r.ref != first.ref:
|
|
87
|
+
problems.append((r.path, r.line, f"pinned to {r.ref}, but {first.path}:{first.line} pins {first.ref}"))
|
|
88
|
+
return problems
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: list[str]) -> int:
|
|
92
|
+
p = argparse.ArgumentParser(prog="agentfile-lint check-pins", description="Check that every reference to this tool pins the same full commit SHA.")
|
|
93
|
+
p.add_argument("--repo", help="string identifying this tool's repository in a reference (default: check_pins.repo from the config, else 'agentfile-lint')")
|
|
94
|
+
p.add_argument("--config", help=f"config file (default: {config_mod.CONFIG_NAME} at the repo root, if present)")
|
|
95
|
+
p.add_argument("--files", nargs="+", metavar="GLOB", help="files to scan, as globs relative to the repo root (default: check_pins.files)")
|
|
96
|
+
p.add_argument("--require-refs", action="store_true", help="fail when no reference is found (default: report and pass)")
|
|
97
|
+
args = p.parse_args(argv)
|
|
98
|
+
try:
|
|
99
|
+
root = discover.repo_root(Path.cwd())
|
|
100
|
+
cfg_path = Path(args.config) if args.config else None
|
|
101
|
+
if cfg_path is None:
|
|
102
|
+
cfg_path = config_mod.default_config_path(root)
|
|
103
|
+
import datetime
|
|
104
|
+
cfg = config_mod.load(cfg_path, datetime.date.today())
|
|
105
|
+
except (ConfigError, discover.DiscoverError) as exc:
|
|
106
|
+
sys.stderr.write(f"agentfile-lint: {exc}\n")
|
|
107
|
+
return 2
|
|
108
|
+
repo = args.repo or cfg.check_pins["repo"]
|
|
109
|
+
globs = args.files or cfg.check_pins["files"]
|
|
110
|
+
refs: list[Ref] = []
|
|
111
|
+
for path in discover.tracked_files(root):
|
|
112
|
+
if any(fnmatch.fnmatchcase(path, g) for g in globs) and (root / path).is_file():
|
|
113
|
+
try:
|
|
114
|
+
text = (root / path).read_text(encoding="utf-8")
|
|
115
|
+
except UnicodeDecodeError:
|
|
116
|
+
continue
|
|
117
|
+
refs.extend(scan(path, text, repo))
|
|
118
|
+
problems = evaluate(refs)
|
|
119
|
+
for path, line, message in problems:
|
|
120
|
+
print(f"{path}:{line}: check-pins: {message}", file=sys.stderr)
|
|
121
|
+
if problems:
|
|
122
|
+
return 1
|
|
123
|
+
if not refs:
|
|
124
|
+
print(f"check-pins: no reference to {repo!r} found in {len(globs)} file pattern(s)")
|
|
125
|
+
if args.require_refs:
|
|
126
|
+
print(f":0: check-pins: no reference to {repo!r} found", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
return 0
|
|
129
|
+
print(f"check-pins: {len(refs)} reference(s) to {repo!r}, all pinned to {refs[0].ref}")
|
|
130
|
+
return 0
|
agentfile_lint/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
Output: one line per finding, `file:line: rule-id: message`. Whole-file findings use line 0 and
|
|
4
|
+
the `:0` is always printed, so the format has exactly one shape. Errors go to stderr, warnings to
|
|
5
|
+
stdout. Exit codes: 0 no errors (warnings allowed), 1 at least one error, 2 usage or config problem.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
if sys.version_info < (3, 11): # checked before importing anything that needs tomllib
|
|
12
|
+
sys.stderr.write(f"agentfile-lint needs Python >= 3.11, found {sys.version.split()[0]}\n")
|
|
13
|
+
raise SystemExit(2)
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import datetime
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from . import config as config_mod
|
|
23
|
+
from . import discover, engine
|
|
24
|
+
from .errors import ConfigError
|
|
25
|
+
from .rules import RULES
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parser() -> argparse.ArgumentParser:
|
|
29
|
+
p = argparse.ArgumentParser(prog="agentfile-lint", description="Lint agent instruction files.")
|
|
30
|
+
p.add_argument("files", nargs="*", help="only decides whether to run; the full tracked set is always analyzed")
|
|
31
|
+
p.add_argument("--config", help=f"config file (default: {config_mod.CONFIG_NAME} at the repo root, if present)")
|
|
32
|
+
p.add_argument("--today", help="YYYY-MM-DD, replaces the current date (reproducible runs)")
|
|
33
|
+
p.add_argument("--version", action="version", version=f"agentfile-lint {__version__}")
|
|
34
|
+
p.add_argument("--list-rules", action="store_true", help="print the rules and their effective levels, then exit")
|
|
35
|
+
p.add_argument("--warn-as-error", action="store_true", help="treat warnings as errors")
|
|
36
|
+
return p
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _relevant(files: list[str], root: Path, cwd: Path, globs: list[str]) -> bool:
|
|
40
|
+
"""argv names only decide whether to run at all (full-set semantics)."""
|
|
41
|
+
for name in files:
|
|
42
|
+
try:
|
|
43
|
+
rel = Path(os.path.abspath(cwd / name)).relative_to(root).as_posix()
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
if rel == config_mod.CONFIG_NAME or discover.matches(rel, globs):
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _config_main(argv: list[str]) -> int:
|
|
52
|
+
"""`agentfile-lint config --format json`: the effective (merged, validated) configuration."""
|
|
53
|
+
p = argparse.ArgumentParser(prog="agentfile-lint config", description="Print the effective configuration.")
|
|
54
|
+
p.add_argument("--format", choices=["json"], default="json", help="output format (only json)")
|
|
55
|
+
p.add_argument("--config", help=f"config file (default: {config_mod.CONFIG_NAME} at the repo root, if present)")
|
|
56
|
+
p.add_argument("--today", help="YYYY-MM-DD, used to validate allowlist expiry dates")
|
|
57
|
+
args = p.parse_args(argv)
|
|
58
|
+
try:
|
|
59
|
+
today = datetime.date.fromisoformat(args.today) if args.today else datetime.date.today()
|
|
60
|
+
except ValueError:
|
|
61
|
+
sys.stderr.write("agentfile-lint: --today must be YYYY-MM-DD\n")
|
|
62
|
+
return 2
|
|
63
|
+
try:
|
|
64
|
+
cfg_path = Path(args.config) if args.config else None
|
|
65
|
+
if cfg_path is None:
|
|
66
|
+
try:
|
|
67
|
+
root = discover.repo_root(Path.cwd())
|
|
68
|
+
except discover.DiscoverError:
|
|
69
|
+
root = None
|
|
70
|
+
cfg_path = config_mod.default_config_path(root)
|
|
71
|
+
cfg = config_mod.load(cfg_path, today)
|
|
72
|
+
except ConfigError as exc:
|
|
73
|
+
sys.stderr.write(f"agentfile-lint: {exc}\n")
|
|
74
|
+
return 2
|
|
75
|
+
sys.stdout.write(json.dumps(config_mod.to_jsonable(cfg), sort_keys=True, indent=2, ensure_ascii=False) + "\n")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
81
|
+
if argv and argv[0] == "vendor": # subcommand; any other first word keeps the check CLI
|
|
82
|
+
try:
|
|
83
|
+
from . import vendor
|
|
84
|
+
except ImportError:
|
|
85
|
+
sys.stderr.write("agentfile-lint: vendor is only available from a source checkout\n")
|
|
86
|
+
return 2
|
|
87
|
+
return vendor.main(argv[1:])
|
|
88
|
+
if argv and argv[0] == "check-pins":
|
|
89
|
+
from . import check_pins
|
|
90
|
+
return check_pins.main(argv[1:])
|
|
91
|
+
if argv and argv[0] == "config":
|
|
92
|
+
return _config_main(argv[1:])
|
|
93
|
+
args = _parser().parse_args(argv)
|
|
94
|
+
try:
|
|
95
|
+
today = datetime.date.fromisoformat(args.today) if args.today else datetime.date.today()
|
|
96
|
+
except ValueError:
|
|
97
|
+
sys.stderr.write("agentfile-lint: --today must be YYYY-MM-DD\n")
|
|
98
|
+
return 2
|
|
99
|
+
cwd = Path.cwd()
|
|
100
|
+
try:
|
|
101
|
+
try:
|
|
102
|
+
root = discover.repo_root(cwd)
|
|
103
|
+
except discover.DiscoverError:
|
|
104
|
+
if not args.list_rules:
|
|
105
|
+
raise
|
|
106
|
+
root = None
|
|
107
|
+
cfg_path = Path(args.config) if args.config else None
|
|
108
|
+
if cfg_path is None:
|
|
109
|
+
cfg_path = config_mod.default_config_path(root)
|
|
110
|
+
cfg = config_mod.load(cfg_path, today)
|
|
111
|
+
except (ConfigError, discover.DiscoverError) as exc:
|
|
112
|
+
sys.stderr.write(f"agentfile-lint: {exc}\n")
|
|
113
|
+
return 2
|
|
114
|
+
|
|
115
|
+
if args.list_rules:
|
|
116
|
+
for rule_id, module in RULES.items():
|
|
117
|
+
print(f"{rule_id} {cfg.rules[rule_id]['level']:<5} {module.NAME}: {module.DESCRIPTION}")
|
|
118
|
+
print("allowlist error expired or no-longer-needed allowlist entries; warns before expiry (always on)")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
if args.files and not _relevant(args.files, root, cwd, cfg.globs):
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
findings = engine.analyze(root, cfg, today, args.warn_as_error)
|
|
125
|
+
for f in findings:
|
|
126
|
+
print(f.format(), file=sys.stderr if f.level == "error" else sys.stdout)
|
|
127
|
+
sys.stdout.flush()
|
|
128
|
+
return 1 if any(f.level == "error" for f in findings) else 0
|
agentfile_lint/config.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Configuration: shipped preset + optional repo config with `extends`; unknown keys fail.
|
|
2
|
+
|
|
3
|
+
Merge rule: tables merge key by key (the child wins); every other value, lists included,
|
|
4
|
+
is replaced as a whole.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import allowlist
|
|
16
|
+
from .errors import LEVELS, ConfigError, check_keys, expect_int, expect_str, expect_str_list
|
|
17
|
+
from .rules import RULES
|
|
18
|
+
|
|
19
|
+
CONFIG_NAME = ".agentfile-lint.toml"
|
|
20
|
+
# Migration aid for the 0.4.0 rename; remove at 1.0.
|
|
21
|
+
LEGACY_CONFIG_NAME = ".instruction-lint.toml"
|
|
22
|
+
PRESET_PREFIX = "preset:"
|
|
23
|
+
DEFAULT_PRESET = "preset:default"
|
|
24
|
+
_TOP_KEYS = {"extends", "discover", "allowlist", "rules", "check_pins"}
|
|
25
|
+
PIN_DEFAULTS = {
|
|
26
|
+
"repo": "agentfile-lint",
|
|
27
|
+
"files": [
|
|
28
|
+
".pre-commit-config.yaml", ".pre-commit-config.yml", "lefthook.yml", "lefthook.yaml",
|
|
29
|
+
".lefthook.yml", ".lefthook.yaml", ".github/workflows/*.yml", ".github/workflows/*.yaml",
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_config_path(root: Path | None) -> Path | None:
|
|
35
|
+
"""The repo-root config file, or None. Fails when only the pre-0.4.0 file name exists."""
|
|
36
|
+
if root is None:
|
|
37
|
+
return None
|
|
38
|
+
path = root / CONFIG_NAME
|
|
39
|
+
if path.is_file():
|
|
40
|
+
return path
|
|
41
|
+
if (root / LEGACY_CONFIG_NAME).is_file():
|
|
42
|
+
raise ConfigError(
|
|
43
|
+
f"found {LEGACY_CONFIG_NAME}, which agentfile-lint no longer reads; "
|
|
44
|
+
f"rename it to {CONFIG_NAME} (git mv {LEGACY_CONFIG_NAME} {CONFIG_NAME})"
|
|
45
|
+
)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Config:
|
|
51
|
+
globs: list[str]
|
|
52
|
+
allowlist: allowlist.Settings
|
|
53
|
+
entries: list[allowlist.Entry]
|
|
54
|
+
rules: dict[str, dict[str, Any]] # rule id -> validated params, including "level"
|
|
55
|
+
check_pins: dict[str, Any] = field(default_factory=lambda: dict(PIN_DEFAULTS)) # repo, files
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_jsonable(cfg: "Config") -> dict[str, Any]:
|
|
59
|
+
"""The effective configuration as plain JSON data (dates as ISO strings)."""
|
|
60
|
+
return {
|
|
61
|
+
"discover": {"globs": cfg.globs},
|
|
62
|
+
"allowlist": {
|
|
63
|
+
"max_days": cfg.allowlist.max_days,
|
|
64
|
+
"max_entries": cfg.allowlist.max_entries,
|
|
65
|
+
"warn_days": cfg.allowlist.warn_days,
|
|
66
|
+
"entries": [
|
|
67
|
+
{"rule": e.rule, "path": e.path, "reason": e.reason, "expires": e.expires.isoformat()}
|
|
68
|
+
for e in cfg.entries
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
"rules": cfg.rules,
|
|
72
|
+
"check_pins": cfg.check_pins,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _merge(base: dict[str, Any], over: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
out = dict(base)
|
|
78
|
+
for key, value in over.items():
|
|
79
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
80
|
+
out[key] = _merge(out[key], value)
|
|
81
|
+
else:
|
|
82
|
+
out[key] = value
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read(source: str, text: str) -> dict[str, Any]:
|
|
87
|
+
try:
|
|
88
|
+
return tomllib.loads(text)
|
|
89
|
+
except tomllib.TOMLDecodeError as exc:
|
|
90
|
+
raise ConfigError(f"{source}: invalid TOML: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_raw(ref: str, base_dir: Path | None, seen: tuple[str, ...]) -> dict[str, Any]:
|
|
94
|
+
if ref.startswith(PRESET_PREFIX):
|
|
95
|
+
name = ref[len(PRESET_PREFIX):]
|
|
96
|
+
key = ref
|
|
97
|
+
try:
|
|
98
|
+
text = (resources.files("agentfile_lint") / "presets" / f"{name}.toml").read_text(encoding="utf-8")
|
|
99
|
+
except (FileNotFoundError, OSError) as exc:
|
|
100
|
+
raise ConfigError(f"unknown preset {name!r}") from exc
|
|
101
|
+
data = _read(ref, text)
|
|
102
|
+
else:
|
|
103
|
+
path = Path(ref)
|
|
104
|
+
if not path.is_absolute() and base_dir is not None:
|
|
105
|
+
path = base_dir / path
|
|
106
|
+
key = str(path.resolve())
|
|
107
|
+
try:
|
|
108
|
+
text = path.read_text(encoding="utf-8")
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
raise ConfigError(f"cannot read config {path}: {exc.strerror}") from exc
|
|
111
|
+
data = _read(str(path), text)
|
|
112
|
+
base_dir = path.parent
|
|
113
|
+
if key in seen:
|
|
114
|
+
raise ConfigError(f"extends cycle at {ref}")
|
|
115
|
+
extends = data.get("extends", [DEFAULT_PRESET] if ref != DEFAULT_PRESET else [])
|
|
116
|
+
if isinstance(extends, str):
|
|
117
|
+
extends = [extends]
|
|
118
|
+
if not isinstance(extends, list) or not all(isinstance(e, str) for e in extends):
|
|
119
|
+
raise ConfigError(f"{ref}: extends must be a list of strings")
|
|
120
|
+
merged: dict[str, Any] = {}
|
|
121
|
+
for parent in extends:
|
|
122
|
+
merged = _merge(merged, _load_raw(parent, base_dir, seen + (key,)))
|
|
123
|
+
own = {k: v for k, v in data.items() if k != "extends"}
|
|
124
|
+
return _merge(merged, own)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load(config_path: Path | None, today: datetime.date) -> Config:
|
|
128
|
+
"""Load the preset plus `config_path` (when given). Raises ConfigError on any problem."""
|
|
129
|
+
if config_path is None:
|
|
130
|
+
raw = _load_raw(DEFAULT_PRESET, None, ())
|
|
131
|
+
else:
|
|
132
|
+
raw = _load_raw(str(config_path), None, ())
|
|
133
|
+
return _validate(raw, today)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _validate(raw: dict[str, Any], today: datetime.date) -> Config:
|
|
137
|
+
check_keys(raw, _TOP_KEYS, "config")
|
|
138
|
+
discover = raw.get("discover", {})
|
|
139
|
+
check_keys(discover, {"globs"}, "discover")
|
|
140
|
+
globs = expect_str_list(discover.get("globs"), "discover.globs")
|
|
141
|
+
|
|
142
|
+
al = raw.get("allowlist", {})
|
|
143
|
+
check_keys(al, {"max_days", "max_entries", "warn_days", "entries"}, "allowlist")
|
|
144
|
+
settings = allowlist.Settings(
|
|
145
|
+
max_days=expect_int(al.get("max_days"), "allowlist.max_days", 0),
|
|
146
|
+
max_entries=expect_int(al.get("max_entries"), "allowlist.max_entries", 0),
|
|
147
|
+
warn_days=expect_int(al.get("warn_days"), "allowlist.warn_days", 0),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
rules_raw = raw.get("rules", {})
|
|
151
|
+
check_keys(rules_raw, set(RULES), "rules")
|
|
152
|
+
rules: dict[str, dict[str, Any]] = {}
|
|
153
|
+
for rule_id, module in RULES.items():
|
|
154
|
+
params = rules_raw.get(rule_id)
|
|
155
|
+
if params is None:
|
|
156
|
+
raise ConfigError(f"rules.{rule_id}: missing")
|
|
157
|
+
level = params.get("level")
|
|
158
|
+
if level not in LEVELS:
|
|
159
|
+
raise ConfigError(f"rules.{rule_id}.level: expected one of {', '.join(LEVELS)}")
|
|
160
|
+
rules[rule_id] = module.validate(params)
|
|
161
|
+
|
|
162
|
+
entries = allowlist.parse(al.get("entries", []), settings, set(RULES), today)
|
|
163
|
+
pins = raw.get("check_pins", {})
|
|
164
|
+
check_keys(pins, set(PIN_DEFAULTS), "check_pins")
|
|
165
|
+
pins = {
|
|
166
|
+
"repo": expect_str(pins.get("repo", PIN_DEFAULTS["repo"]), "check_pins.repo"),
|
|
167
|
+
"files": expect_str_list(pins.get("files", PIN_DEFAULTS["files"]), "check_pins.files"),
|
|
168
|
+
}
|
|
169
|
+
return Config(globs=globs, allowlist=settings, entries=entries, rules=rules, check_pins=pins)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""What cross-file rules may look at: the full tracked file set, never just the command-line files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Context:
|
|
8
|
+
def __init__(self, root: Path, tracked: list[str], files: dict[str, str]):
|
|
9
|
+
self.root = root
|
|
10
|
+
self.tracked = tracked # every `git ls-files` entry
|
|
11
|
+
self.files = files # analyzed instruction files (valid UTF-8): path -> text
|
|
12
|
+
self.tracked_set = set(tracked)
|
|
13
|
+
self.dirs = {""} # every directory that holds a tracked file ("" is the root)
|
|
14
|
+
for path in tracked:
|
|
15
|
+
for parent in PurePosixPath(path).parents:
|
|
16
|
+
self.dirs.add("" if str(parent) == "." else str(parent))
|
|
17
|
+
self._cache: dict[str, str | None] = {}
|
|
18
|
+
|
|
19
|
+
def read(self, path: str) -> str | None:
|
|
20
|
+
"""Text of a tracked file, or None when it is missing or not valid UTF-8."""
|
|
21
|
+
if path not in self._cache:
|
|
22
|
+
try:
|
|
23
|
+
self._cache[path] = (self.root / path).read_text(encoding="utf-8")
|
|
24
|
+
except (OSError, UnicodeDecodeError):
|
|
25
|
+
self._cache[path] = None
|
|
26
|
+
return self._cache[path]
|