agentfile-lint 0.4.0__tar.gz

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.
Files changed (48) hide show
  1. agentfile_lint-0.4.0/LICENSE +21 -0
  2. agentfile_lint-0.4.0/PKG-INFO +161 -0
  3. agentfile_lint-0.4.0/README.md +151 -0
  4. agentfile_lint-0.4.0/agentfile_lint/__init__.py +3 -0
  5. agentfile_lint-0.4.0/agentfile_lint/__main__.py +5 -0
  6. agentfile_lint-0.4.0/agentfile_lint/_vendor/entry.py.txt +18 -0
  7. agentfile_lint-0.4.0/agentfile_lint/_vendor/verify.py.txt +69 -0
  8. agentfile_lint-0.4.0/agentfile_lint/allowlist.py +104 -0
  9. agentfile_lint-0.4.0/agentfile_lint/check_pins.py +130 -0
  10. agentfile_lint-0.4.0/agentfile_lint/cli.py +128 -0
  11. agentfile_lint-0.4.0/agentfile_lint/config.py +169 -0
  12. agentfile_lint-0.4.0/agentfile_lint/context.py +26 -0
  13. agentfile_lint-0.4.0/agentfile_lint/discover.py +68 -0
  14. agentfile_lint-0.4.0/agentfile_lint/engine.py +49 -0
  15. agentfile_lint-0.4.0/agentfile_lint/errors.py +86 -0
  16. agentfile_lint-0.4.0/agentfile_lint/finding.py +21 -0
  17. agentfile_lint-0.4.0/agentfile_lint/presets/default.toml +145 -0
  18. agentfile_lint-0.4.0/agentfile_lint/rules/__init__.py +10 -0
  19. agentfile_lint-0.4.0/agentfile_lint/rules/_text.py +43 -0
  20. agentfile_lint-0.4.0/agentfile_lint/rules/claude_md.py +47 -0
  21. agentfile_lint-0.4.0/agentfile_lint/rules/constants.py +37 -0
  22. agentfile_lint-0.4.0/agentfile_lint/rules/duplicate.py +81 -0
  23. agentfile_lint-0.4.0/agentfile_lint/rules/paths.py +111 -0
  24. agentfile_lint-0.4.0/agentfile_lint/rules/rot.py +69 -0
  25. agentfile_lint-0.4.0/agentfile_lint/rules/shape.py +130 -0
  26. agentfile_lint-0.4.0/agentfile_lint/rules/size.py +86 -0
  27. agentfile_lint-0.4.0/agentfile_lint/rules/specrefs.py +101 -0
  28. agentfile_lint-0.4.0/agentfile_lint/rules/testnames.py +73 -0
  29. agentfile_lint-0.4.0/agentfile_lint/vendor.py +138 -0
  30. agentfile_lint-0.4.0/agentfile_lint.egg-info/PKG-INFO +161 -0
  31. agentfile_lint-0.4.0/agentfile_lint.egg-info/SOURCES.txt +46 -0
  32. agentfile_lint-0.4.0/agentfile_lint.egg-info/dependency_links.txt +1 -0
  33. agentfile_lint-0.4.0/agentfile_lint.egg-info/entry_points.txt +2 -0
  34. agentfile_lint-0.4.0/agentfile_lint.egg-info/top_level.txt +1 -0
  35. agentfile_lint-0.4.0/pyproject.toml +21 -0
  36. agentfile_lint-0.4.0/setup.cfg +4 -0
  37. agentfile_lint-0.4.0/tests/test_allowlist.py +109 -0
  38. agentfile_lint-0.4.0/tests/test_check_pins.py +148 -0
  39. agentfile_lint-0.4.0/tests/test_config.py +114 -0
  40. agentfile_lint-0.4.0/tests/test_contract.py +133 -0
  41. agentfile_lint-0.4.0/tests/test_discover.py +61 -0
  42. agentfile_lint-0.4.0/tests/test_perf.py +28 -0
  43. agentfile_lint-0.4.0/tests/test_reproducible.py +35 -0
  44. agentfile_lint-0.4.0/tests/test_rules.py +344 -0
  45. agentfile_lint-0.4.0/tests/test_selfcheck.py +29 -0
  46. agentfile_lint-0.4.0/tests/test_size.py +98 -0
  47. agentfile_lint-0.4.0/tests/test_vendor.py +139 -0
  48. agentfile_lint-0.4.0/tests/test_versioning.py +49 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Will413028
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentfile-lint
3
+ Version: 0.4.0
4
+ Summary: A linter for agent instruction files (AGENTS.md / CLAUDE.md), configured by TOML.
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # agentfile-lint
12
+
13
+ A linter for agent instruction files (`AGENTS.md`, `CLAUDE.md`). Rules and limits live in one
14
+ TOML file; the runner is a thin Python program that uses only the standard library
15
+ (Python 3.11 or newer). A local git hook and CI call the same program with the same config.
16
+
17
+ Status: version 0.4.0. Every rule except `R1` ships at `warn`; raise a rule to `error` in your own config once it is clean.
18
+
19
+ | Rule | Level (default) | What it checks |
20
+ |---|---|---|
21
+ | `R1` | error | byte and line limits chosen by path glob (first match wins) and a character limit per line; optional marker blocks (for example text a tool injects) are not counted |
22
+ | `R2` | warn | file shape: required section headings in order, optional title, one-line purpose, bullet-only sections |
23
+ | `R3a` | warn | a test name in backticks is defined in a tracked test file |
24
+ | `R3b` | warn | a path in backticks exists (resolved from the file, its ancestors, the root and configured bases; heuristic) |
25
+ | `R3c` | warn | a `§N` reference matches a numbered heading in the mapped spec document |
26
+ | `R4` | warn | dates and issue numbers (rule level), narrative words such as "currently" (`narrative_level`) |
27
+ | `R5` | warn | identical lines in an ancestor and a descendant file (rule level) or in sibling files (`sibling_level`) |
28
+ | `R6` | warn | candidates for constants copied from code (always a warning) |
29
+ | `C1` | warn | `CLAUDE.md` is only the line `@AGENTS.md` (`import-only`) or absent (`forbid`) |
30
+ | `allowlist` | error | time-boxed exceptions: expired or no-longer-needed entries fail, entries warn before they expire |
31
+
32
+ ## Usage
33
+
34
+ ```sh
35
+ python3 -m agentfile_lint # from a git repository; analyzes tracked files only
36
+ agentfile-lint --list-rules # rules and their effective levels
37
+ agentfile-lint --today 2026-01-31 # replace the current date (reproducible runs)
38
+ agentfile-lint --config path.toml # explicit config instead of .agentfile-lint.toml
39
+ agentfile-lint --warn-as-error
40
+ ```
41
+
42
+ Other commands:
43
+
44
+ ```sh
45
+ agentfile-lint check-pins # every reference to this tool pins the same full commit SHA
46
+ agentfile-lint config --format json # the effective merged configuration, sorted keys
47
+ agentfile-lint vendor --to DIR # a pinned, self-contained copy (see below)
48
+ ```
49
+
50
+ Output is one line per finding: `file:line: rule-id: message`. Findings about a whole file use
51
+ line `0` (printed as `:0`). Errors go to stderr, warnings to stdout. Exit codes: `0` no errors,
52
+ `1` at least one error, `2` usage or configuration problem.
53
+
54
+ Files given on the command line are ignored; the full tracked set is always analyzed
55
+ (cross-file checks and allowlist checks need it). Untracked local instruction
56
+ files (`*.local.md`) are never analyzed.
57
+
58
+ ## Configuration
59
+
60
+ `.agentfile-lint.toml` at the repository root extends the shipped preset. Unknown keys fail.
61
+
62
+ ```toml
63
+ extends = ["preset:default"] # default; other entries are paths relative to this file
64
+
65
+ [discover]
66
+ globs = ["AGENTS.md", "**/AGENTS.md"]
67
+
68
+ [rules.R1]
69
+ level = "error" # error | warn | off
70
+ max_line_chars = 500
71
+ excluded_blocks = [{ begin = "<!-- BEGIN:x -->", end = "<!-- END:x -->" }]
72
+
73
+ # First matching glob wins; a file matching no entry has no size limit.
74
+ [[rules.R1.limits]]
75
+ glob = "AGENTS.md"
76
+ max_bytes = 12000
77
+ max_lines = 200 # optional; either or both
78
+
79
+ [[rules.R1.limits]]
80
+ glob = "**/AGENTS.md"
81
+ max_bytes = 6000
82
+
83
+ [allowlist]
84
+ max_days = 90
85
+
86
+ [[allowlist.entries]]
87
+ rule = "R1"
88
+ path = "docs/AGENTS.md"
89
+ reason = "split planned in the tracker"
90
+ expires = 2026-12-31
91
+ ```
92
+
93
+ ## Install and wire up
94
+
95
+ pre-commit:
96
+
97
+ ```yaml
98
+ - repo: https://github.com/Will413028/agentfile-lint
99
+ rev: <tag or full commit SHA>
100
+ hooks:
101
+ - id: agentfile-lint
102
+ ```
103
+
104
+ Run on every commit and push: rules R3a, R3b and R3c read test files, source and docs, and cross-file
105
+ rules read the whole tracked set, so do not filter the hook, lefthook entry or workflow by instruction
106
+ files (no `files:`, `glob:` or `paths:` filters). The pre-commit hook sets `always_run: true`.
107
+
108
+ lefthook (no path filter):
109
+
110
+ ```yaml
111
+ pre-commit:
112
+ commands:
113
+ agentfile-lint:
114
+ run: python3 -m agentfile_lint
115
+ ```
116
+
117
+ GitHub Actions (pin to a full commit SHA; no `paths:` filter, plus a weekly run so allowlist expiry
118
+ warnings appear even when nobody commits):
119
+
120
+ ```yaml
121
+ on:
122
+ push:
123
+ pull_request:
124
+ schedule:
125
+ - cron: "0 6 * * 1" # weekly
126
+
127
+ # steps:
128
+ - uses: actions/checkout@<sha>
129
+ - uses: Will413028/agentfile-lint@<sha>
130
+ ```
131
+
132
+ Rules above `R1` need configuration to be useful (section lists, base directories, spec mappings);
133
+ the preset documents every key. A rule at `error` can still emit some findings as warnings.
134
+ The review criteria for what no program can decide are in `rubric.md`, with a pull request snippet in
135
+ `docs/pr-template-snippet.md`; the release and upgrade process is in `docs/versioning.md`.
136
+
137
+ Development: `make check` runs the unit tests (needs Python 3.11+, override with `PYTHON=...`).
138
+ Design notes are in `docs/design.md`, decisions in `docs/adr/`.
139
+
140
+ ## Vendored copy
141
+
142
+ For environments that cannot install from git, `agentfile-lint vendor --to <dir>` writes one
143
+ self-contained directory: the runner package with the default preset, an entry script
144
+ (`agentfile-lint.py`), `verify.py` and a `README.md` that records the upstream tag, the upstream
145
+ commit and a SHA-256 for every file. Options: `--ref-tag` / `--ref-commit` (default: the exact tag
146
+ and HEAD of the source checkout, else `untagged`), `--force` (replace a non-empty target).
147
+
148
+ - Run it from the repository root: `python3 <dir>/agentfile-lint.py` (Python >= 3.11, standard
149
+ library only, no network). Its output and exit codes equal `python3 -m agentfile_lint`.
150
+ - The output is reproducible: same source and same two upstream values give a byte-identical tree
151
+ (sorted order, fixed newlines, no timestamps, no machine paths or user names).
152
+ - `python3 <dir>/verify.py` recomputes the hashes and fails (exit 1, naming the file) on a
153
+ modified, missing or unlisted file.
154
+
155
+ Known limitation: verification only detects accidental modification. A deliberate change can edit
156
+ the files and the manifest together, so it is not tamper-proof. The real gate is review of the
157
+ diff of the pull request that upgrades the vendored copy.
158
+
159
+ ## License
160
+
161
+ MIT, see `LICENSE`.
@@ -0,0 +1,151 @@
1
+ # agentfile-lint
2
+
3
+ A linter for agent instruction files (`AGENTS.md`, `CLAUDE.md`). Rules and limits live in one
4
+ TOML file; the runner is a thin Python program that uses only the standard library
5
+ (Python 3.11 or newer). A local git hook and CI call the same program with the same config.
6
+
7
+ Status: version 0.4.0. Every rule except `R1` ships at `warn`; raise a rule to `error` in your own config once it is clean.
8
+
9
+ | Rule | Level (default) | What it checks |
10
+ |---|---|---|
11
+ | `R1` | error | byte and line limits chosen by path glob (first match wins) and a character limit per line; optional marker blocks (for example text a tool injects) are not counted |
12
+ | `R2` | warn | file shape: required section headings in order, optional title, one-line purpose, bullet-only sections |
13
+ | `R3a` | warn | a test name in backticks is defined in a tracked test file |
14
+ | `R3b` | warn | a path in backticks exists (resolved from the file, its ancestors, the root and configured bases; heuristic) |
15
+ | `R3c` | warn | a `§N` reference matches a numbered heading in the mapped spec document |
16
+ | `R4` | warn | dates and issue numbers (rule level), narrative words such as "currently" (`narrative_level`) |
17
+ | `R5` | warn | identical lines in an ancestor and a descendant file (rule level) or in sibling files (`sibling_level`) |
18
+ | `R6` | warn | candidates for constants copied from code (always a warning) |
19
+ | `C1` | warn | `CLAUDE.md` is only the line `@AGENTS.md` (`import-only`) or absent (`forbid`) |
20
+ | `allowlist` | error | time-boxed exceptions: expired or no-longer-needed entries fail, entries warn before they expire |
21
+
22
+ ## Usage
23
+
24
+ ```sh
25
+ python3 -m agentfile_lint # from a git repository; analyzes tracked files only
26
+ agentfile-lint --list-rules # rules and their effective levels
27
+ agentfile-lint --today 2026-01-31 # replace the current date (reproducible runs)
28
+ agentfile-lint --config path.toml # explicit config instead of .agentfile-lint.toml
29
+ agentfile-lint --warn-as-error
30
+ ```
31
+
32
+ Other commands:
33
+
34
+ ```sh
35
+ agentfile-lint check-pins # every reference to this tool pins the same full commit SHA
36
+ agentfile-lint config --format json # the effective merged configuration, sorted keys
37
+ agentfile-lint vendor --to DIR # a pinned, self-contained copy (see below)
38
+ ```
39
+
40
+ Output is one line per finding: `file:line: rule-id: message`. Findings about a whole file use
41
+ line `0` (printed as `:0`). Errors go to stderr, warnings to stdout. Exit codes: `0` no errors,
42
+ `1` at least one error, `2` usage or configuration problem.
43
+
44
+ Files given on the command line are ignored; the full tracked set is always analyzed
45
+ (cross-file checks and allowlist checks need it). Untracked local instruction
46
+ files (`*.local.md`) are never analyzed.
47
+
48
+ ## Configuration
49
+
50
+ `.agentfile-lint.toml` at the repository root extends the shipped preset. Unknown keys fail.
51
+
52
+ ```toml
53
+ extends = ["preset:default"] # default; other entries are paths relative to this file
54
+
55
+ [discover]
56
+ globs = ["AGENTS.md", "**/AGENTS.md"]
57
+
58
+ [rules.R1]
59
+ level = "error" # error | warn | off
60
+ max_line_chars = 500
61
+ excluded_blocks = [{ begin = "<!-- BEGIN:x -->", end = "<!-- END:x -->" }]
62
+
63
+ # First matching glob wins; a file matching no entry has no size limit.
64
+ [[rules.R1.limits]]
65
+ glob = "AGENTS.md"
66
+ max_bytes = 12000
67
+ max_lines = 200 # optional; either or both
68
+
69
+ [[rules.R1.limits]]
70
+ glob = "**/AGENTS.md"
71
+ max_bytes = 6000
72
+
73
+ [allowlist]
74
+ max_days = 90
75
+
76
+ [[allowlist.entries]]
77
+ rule = "R1"
78
+ path = "docs/AGENTS.md"
79
+ reason = "split planned in the tracker"
80
+ expires = 2026-12-31
81
+ ```
82
+
83
+ ## Install and wire up
84
+
85
+ pre-commit:
86
+
87
+ ```yaml
88
+ - repo: https://github.com/Will413028/agentfile-lint
89
+ rev: <tag or full commit SHA>
90
+ hooks:
91
+ - id: agentfile-lint
92
+ ```
93
+
94
+ Run on every commit and push: rules R3a, R3b and R3c read test files, source and docs, and cross-file
95
+ rules read the whole tracked set, so do not filter the hook, lefthook entry or workflow by instruction
96
+ files (no `files:`, `glob:` or `paths:` filters). The pre-commit hook sets `always_run: true`.
97
+
98
+ lefthook (no path filter):
99
+
100
+ ```yaml
101
+ pre-commit:
102
+ commands:
103
+ agentfile-lint:
104
+ run: python3 -m agentfile_lint
105
+ ```
106
+
107
+ GitHub Actions (pin to a full commit SHA; no `paths:` filter, plus a weekly run so allowlist expiry
108
+ warnings appear even when nobody commits):
109
+
110
+ ```yaml
111
+ on:
112
+ push:
113
+ pull_request:
114
+ schedule:
115
+ - cron: "0 6 * * 1" # weekly
116
+
117
+ # steps:
118
+ - uses: actions/checkout@<sha>
119
+ - uses: Will413028/agentfile-lint@<sha>
120
+ ```
121
+
122
+ Rules above `R1` need configuration to be useful (section lists, base directories, spec mappings);
123
+ the preset documents every key. A rule at `error` can still emit some findings as warnings.
124
+ The review criteria for what no program can decide are in `rubric.md`, with a pull request snippet in
125
+ `docs/pr-template-snippet.md`; the release and upgrade process is in `docs/versioning.md`.
126
+
127
+ Development: `make check` runs the unit tests (needs Python 3.11+, override with `PYTHON=...`).
128
+ Design notes are in `docs/design.md`, decisions in `docs/adr/`.
129
+
130
+ ## Vendored copy
131
+
132
+ For environments that cannot install from git, `agentfile-lint vendor --to <dir>` writes one
133
+ self-contained directory: the runner package with the default preset, an entry script
134
+ (`agentfile-lint.py`), `verify.py` and a `README.md` that records the upstream tag, the upstream
135
+ commit and a SHA-256 for every file. Options: `--ref-tag` / `--ref-commit` (default: the exact tag
136
+ and HEAD of the source checkout, else `untagged`), `--force` (replace a non-empty target).
137
+
138
+ - Run it from the repository root: `python3 <dir>/agentfile-lint.py` (Python >= 3.11, standard
139
+ library only, no network). Its output and exit codes equal `python3 -m agentfile_lint`.
140
+ - The output is reproducible: same source and same two upstream values give a byte-identical tree
141
+ (sorted order, fixed newlines, no timestamps, no machine paths or user names).
142
+ - `python3 <dir>/verify.py` recomputes the hashes and fails (exit 1, naming the file) on a
143
+ modified, missing or unlisted file.
144
+
145
+ Known limitation: verification only detects accidental modification. A deliberate change can edit
146
+ the files and the manifest together, so it is not tamper-proof. The real gate is review of the
147
+ diff of the pull request that upgrades the vendored copy.
148
+
149
+ ## License
150
+
151
+ MIT, see `LICENSE`.
@@ -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