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.
@@ -0,0 +1,3 @@
1
+ """agentfile-lint: a linter for agent instruction files (AGENTS.md / CLAUDE.md)."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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
@@ -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]