Skip to content

Commit 1376eef

Browse files
mattmilleraiclaude
andauthored
feat(agents-md-integrity): add total and per-line character limits (#386)
* feat(agents-md-integrity): add total and per-line character limits The line ceiling alone lets an arbitrarily large file pass, since a paragraph is one line: a 111-line AGENTS.md carrying 213k chars, one paragraph 89k, was green. Add three optional inputs, applied to the root agents file and every nested one the walk already measures: - max_chars: hard ceiling on total chars; over it fails. Default 0 = off, so bumping the pin never turns an existing caller red (40000 suggested). - warn_chars: total-char target; over it warns. Default 25000. - max_line_chars: per-line ceiling; a longer line warns. Default 3000. Each finding names the file, its size (+ chars/4 token estimate), its longest lines, and the remedy (move rationale into docs/agents/, leave a pointer). Line checks are unchanged. * fix(agents-md-integrity): count both CRLF characters toward the char ceiling Read with newline="" so universal-newline translation no longer folds each CRLF to one char, which let a CRLF file slip under max_chars. Adds a CRLF boundary test (fails without the fix). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agents-md-integrity): reject a non-integer char limit; split lines on CR/LF only - MAX_CHARS/WARN_CHARS/MAX_LINE_CHARS that don't parse (40k, 40000.5) now exit 2 instead of falling back — the fallback for MAX_CHARS is 0, which silently switched off the ceiling the caller asked for. - Lines split on CRLF/LF/CR only; str.splitlines() also broke on U+2028, NEL and \f, letting a pasted paragraph slip under max_line_chars and shifting the L<n> numbers. - Longest-lines ranking is built only when a finding fires, via heapq. - The remedy text says the pointer must be a plain link, not an @ import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7de68e5 commit 1376eef

7 files changed

Lines changed: 500 additions & 34 deletions

File tree

‎.github/agents-md-integrity/README.md‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ rewrite the check).
1010
- **`check_agents_md.py`** — the check. Operates on a repo tree and exits
1111
non-zero (with GitHub annotations) when any hard check fails. Enforces the
1212
Comfy `AGENTS.md` standard ("AGENTS.md, done right", Comfy Engineering Guide
13-
§10): a thin top-level `AGENTS.md` source of truth under a hard line ceiling,
14-
a one-line `@AGENTS.md` `CLAUDE.md` shim, no divergent `.cursorrules`,
13+
§10): a thin top-level `AGENTS.md` source of truth under a hard line ceiling
14+
and character limits (root and nested), a one-line `@AGENTS.md` `CLAUDE.md` shim, no divergent `.cursorrules`,
1515
per-subtree shims in monorepos, and a CODEOWNERS DRI. Inputs come from env
16-
vars (`MAX_LINES`, `WARN_LINES`, `FORBID_CURSORRULES`, `CHECK_NESTED`,
16+
vars (`MAX_LINES`, `WARN_LINES`, `MAX_CHARS`, `WARN_CHARS`,
17+
`MAX_LINE_CHARS`, `FORBID_CURSORRULES`, `CHECK_NESTED`, `REQUIRE_SHIM`,
1718
`REQUIRE_CODEOWNERS`, `AGENTS_FILE`) plus the `--exclude` flag; see the
1819
workflow header for the mapping.
1920
- **`tests/`** — `unittest` suite, run by
@@ -71,3 +72,28 @@ with:
7172
to nothing (`/`, `.`, `//`) and a glob made only of wildcard segments (`*`,
7273
`**`, `*/**`, `*/*`) — the latter would otherwise prune every top-level
7374
directory while `check_nested` still read `true`. Name the subtree.
75+
76+
## Character limits (`max_chars` / `warn_chars` / `max_line_chars`)
77+
78+
The line ceiling does not bound size: a paragraph is one line, so a file can sit
79+
at 111 lines and still carry 200k+ characters, all of it auto-loaded into every
80+
session through the `@AGENTS.md` shim. Three limits close that, applied to the
81+
root file **and** every nested one the walk finds:
82+
83+
| Env / input | Default | Over it |
84+
|---|---|---|
85+
| `MAX_CHARS` / `max_chars` | `0` (off) | **fails** |
86+
| `WARN_CHARS` / `warn_chars` | `25000` | warns |
87+
| `MAX_LINE_CHARS` / `max_line_chars` | `3000` | warns |
88+
89+
`0` turns a limit off. A value that is not a whole number (`40k`, `40000.5`)
90+
is a config error (exit 2), never a silent fallback — falling back would turn
91+
the default-off hard ceiling off. The hard ceiling defaults off so a caller
92+
bumping its pin never goes red on the bump; `40000` is the suggested value,
93+
matching the size at which Claude Code starts warning about an oversized memory
94+
file. Characters are decoded characters, newlines included (a CRLF counts as
95+
two); lines split on CRLF / LF / CR only, so `L<n>` matches an editor. Every
96+
finding names the file, its size (with a chars/4 token estimate) and its
97+
longest lines, and points at the remedy: move rationale into `docs/agents/` and
98+
leave a one-line pointer — a plain link, not an `@` import, which Claude Code
99+
would still expand at session start.

‎.github/agents-md-integrity/check_agents_md.py‎

Lines changed: 171 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
one-line `@AGENTS.md` shim (optionally with a few Claude-only lines below —
77
Claude Code reads only CLAUDE.md and does not fall back), there are no
88
divergent `.cursorrules`, and the file stays under a hard line ceiling (200,
9-
per Anthropic guidance) with an aspirational target (150). In a monorepo every
9+
per Anthropic guidance) with an aspirational target (150). Lines alone do not
10+
bound size — a paragraph is one line — so total and per-line CHARACTER limits
11+
apply too (env MAX_CHARS / WARN_CHARS / MAX_LINE_CHARS; the hard character
12+
ceiling is off unless set, the other two only warn). In a monorepo every
1013
nested `AGENTS.md` gets a sibling `CLAUDE.md` shim so Claude Code picks it up in
1114
that subtree, and the file is owned by a DRI via CODEOWNERS.
1215
@@ -22,7 +25,8 @@
2225
glob that would exclude the ROOT agents file or `CLAUDE.md` is rejected: root
2326
compliance is the non-negotiable part of the standard.
2427
25-
Exit codes: 0 pass, 1 one or more checks failed, 2 bad `--exclude` config.
28+
Exit codes: 0 pass, 1 one or more checks failed, 2 bad config (an `--exclude`
29+
glob, or a character limit that is not a whole number).
2630
2731
Run locally:
2832
python3 .github/agents-md-integrity/check_agents_md.py --root .
@@ -31,6 +35,7 @@
3135
"""
3236

3337
import argparse
38+
import heapq
3439
import os
3540
import re
3641
import sys
@@ -67,6 +72,10 @@ class ExcludeConfigError(Exception):
6772
"""An `--exclude` glob is not usable (today: it would exclude the root)."""
6873

6974

75+
class LimitConfigError(Exception):
76+
"""A character-limit env value is set but is not a whole number."""
77+
78+
7079
def _split_patterns(values):
7180
"""Flatten repeated / comma- / newline-separated `--exclude` values.
7281
@@ -191,10 +200,114 @@ def _validate_excludes(excludes, agents_file):
191200
)
192201

193202

194-
def _count_lines(path):
195-
"""Line count of a text file (a trailing newline doesn't add a phantom line)."""
196-
with open(path, "r", encoding="utf-8", errors="replace") as f:
197-
return len(f.read().splitlines())
203+
# How many over-limit lines a size finding names. Enough to point at the
204+
# offending paragraphs without turning one annotation into a wall.
205+
LONGEST_LINES_SHOWN = 3
206+
207+
# The remedy every character finding points at. A file can stay under the line
208+
# ceiling while carrying unbounded text (a paragraph is one line), and the fix
209+
# is the same either way: rationale belongs in docs the agent reads on demand.
210+
SIZE_REMEDY = (
211+
"Move rationale into docs/agents/ and leave a one-line pointer in its "
212+
"place (a plain link, not an @ import, which still loads at session start)."
213+
)
214+
215+
216+
def _measure(path):
217+
"""(lines, chars, line_lengths) of a text file.
218+
219+
`chars` counts decoded characters (newlines included), the unit Claude
220+
Code's oversized-memory-file warning is expressed in. `line_lengths`
221+
excludes the line terminator. `newline=""` turns off universal-newline
222+
translation, so each CRLF counts as the two characters it is on disk —
223+
otherwise a CRLF file reads one char short per line and slips under the
224+
ceiling. Lines split on CRLF / LF / CR only — not `splitlines()`, which
225+
also breaks on `\f`, NEL, U+2028 and friends, so it would let a pasted
226+
paragraph carrying U+2028 slip under `max_line_chars` in pieces and make
227+
the `L<n>` numbers drift from what an editor shows.
228+
"""
229+
with open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
230+
text = f.read()
231+
lines = re.split(r"\r\n|[\r\n]", text)
232+
if lines[-1] == "":
233+
lines.pop() # a trailing newline doesn't add a phantom line
234+
lengths = [len(line) for line in lines]
235+
return len(lengths), len(text), lengths
236+
237+
238+
def _longest_lines(lengths, floor=0):
239+
"""Describe the longest lines over `floor`, longest first, as `L12 (89015 chars)`."""
240+
ranked = heapq.nsmallest(
241+
LONGEST_LINES_SHOWN,
242+
((-n, i + 1) for i, n in enumerate(lengths) if n > floor),
243+
)
244+
return ", ".join(f"L{ln} ({-n} chars)" for n, ln in ranked)
245+
246+
247+
def _size_findings(label, path, config, nested):
248+
"""Line + character ceilings for one agents file -> (failures, warnings).
249+
250+
`label` is how the file is named in messages (`'AGENTS.md'` or
251+
`nested 'pkg/AGENTS.md'`). The line checks are exactly the pre-existing
252+
ones: root fails over `max_lines` and warns over `warn_lines`; nested fails
253+
over `max_lines` only. The character checks apply identically to both. A
254+
character limit of 0 (or less) is OFF.
255+
"""
256+
failures = []
257+
warnings = []
258+
max_lines = config["max_lines"]
259+
warn_lines = config["warn_lines"]
260+
max_chars = config.get("max_chars", 0)
261+
warn_chars = config.get("warn_chars", 0)
262+
max_line_chars = config.get("max_line_chars", 0)
263+
264+
n, chars, lengths = _measure(path)
265+
if n > max_lines:
266+
if nested:
267+
failures.append(
268+
f"{label} is {n} lines, over the hard ceiling of {max_lines}."
269+
)
270+
else:
271+
failures.append(
272+
f"{label} is {n} lines, over the hard ceiling of "
273+
f"{max_lines}. Trim it — AGENTS.md must stay thin."
274+
)
275+
elif not nested and n > warn_lines:
276+
warnings.append(
277+
f"{label} is {n} lines, over the aspirational target "
278+
f"of {warn_lines} (hard ceiling {max_lines})."
279+
)
280+
281+
def longest():
282+
# Name the over-long lines when there are any (they are the likely
283+
# culprit), else just the longest few. Only built when a finding fires.
284+
return (
285+
max_line_chars > 0 and _longest_lines(lengths, max_line_chars)
286+
) or _longest_lines(lengths)
287+
288+
size = f"{chars} chars (~{chars // 4} est. tokens) across {n} lines"
289+
if max_chars > 0 and chars > max_chars:
290+
failures.append(
291+
f"{label} is {size}, over the hard character ceiling of "
292+
f"{max_chars}. Longest lines: {longest()}. {SIZE_REMEDY}"
293+
)
294+
elif warn_chars > 0 and chars > warn_chars:
295+
ceiling = f"hard ceiling {max_chars}" if max_chars > 0 else "no hard ceiling set"
296+
warnings.append(
297+
f"{label} is {size}, over the character target of {warn_chars} "
298+
f"({ceiling}). Longest lines: {longest()}. {SIZE_REMEDY}"
299+
)
300+
301+
if max_line_chars > 0:
302+
over = [x for x in lengths if x > max_line_chars]
303+
if over:
304+
warnings.append(
305+
f"{label} has {len(over)} line(s) over the per-line ceiling of "
306+
f"{max_line_chars} chars — a paragraph kept on one line slips "
307+
f"past the line count. Longest: "
308+
f"{_longest_lines(lengths, max_line_chars)}. {SIZE_REMEDY}"
309+
)
310+
return failures, warnings
198311

199312

200313
def _has_import(path, import_token):
@@ -328,8 +441,6 @@ def run_checks(root, config):
328441
agents_file = config["agents_file"]
329442
agents_basename = os.path.basename(agents_file)
330443
import_token = "@" + agents_basename
331-
max_lines = config["max_lines"]
332-
warn_lines = config["warn_lines"]
333444

334445
# Validated unconditionally — a root-excluding glob is a config error even
335446
# when `check_nested` is off and the globs would never have been consulted.
@@ -357,18 +468,10 @@ def run_checks(root, config):
357468
# anchor on, but CODEOWNERS/cursorrules are still worth reporting, so
358469
# keep going rather than returning early.
359470
else:
360-
# 2. Line ceiling (+ aspirational warn).
361-
n = _count_lines(agents_path)
362-
if n > max_lines:
363-
failures.append(
364-
f"'{agents_file}' is {n} lines, over the hard ceiling of "
365-
f"{max_lines}. Trim it — AGENTS.md must stay thin."
366-
)
367-
elif n > warn_lines:
368-
warnings.append(
369-
f"'{agents_file}' is {n} lines, over the aspirational target "
370-
f"of {warn_lines} (hard ceiling {max_lines})."
371-
)
471+
# 2. Line + character ceilings (+ aspirational warns).
472+
f, w = _size_findings(f"'{agents_file}'", agents_path, config, nested=False)
473+
failures.extend(f)
474+
warnings.extend(w)
372475

373476
# 3. CLAUDE.md shim. Claude Code reads only CLAUDE.md and does NOT fall
374477
# back to AGENTS.md, so a missing root shim means the repo's instructions
@@ -419,12 +522,9 @@ def run_checks(root, config):
419522
f"'{import_token}', so Claude Code won't pick it up in that "
420523
f"subtree. Add a one-line shim next to it."
421524
)
422-
n = _count_lines(nested_path)
423-
if n > max_lines:
424-
failures.append(
425-
f"nested '{rel}' is {n} lines, over the hard ceiling of "
426-
f"{max_lines}."
427-
)
525+
f, w = _size_findings(f"nested '{rel}'", nested_path, config, nested=True)
526+
failures.extend(f)
527+
warnings.extend(w)
428528

429529
# 6. CODEOWNERS / DRI (warn unless require_codeowners).
430530
checked, owned = _codeowners_owns(root, agents_file)
@@ -464,6 +564,34 @@ def _env_int(name, default):
464564
return default
465565

466566

567+
def _env_limit(name, default):
568+
"""A character limit from the env; unset/empty -> `default`.
569+
570+
Stricter than `_env_int`: the hard ceiling defaults to 0 (OFF), so falling
571+
back on a value that won't parse (`40k`, `40000.5`) would silently switch
572+
off the very ceiling the caller asked for. A whole number in float form
573+
(`40000.0`, which a `type: number` input may render as) is accepted;
574+
anything else raises LimitConfigError (exit 2).
575+
"""
576+
val = os.environ.get(name)
577+
if val is None or val.strip() == "":
578+
return default
579+
try:
580+
return int(val)
581+
except ValueError:
582+
pass
583+
try:
584+
num = float(val)
585+
except ValueError:
586+
num = None
587+
if num is not None and num.is_integer():
588+
return int(num)
589+
raise LimitConfigError(
590+
f"{name}={val!r} is not a whole number of characters. Set an integer "
591+
f"(0 turns the limit off)."
592+
)
593+
594+
467595
def _esc_cmd(text):
468596
"""Escape a value before it is interpolated into a workflow-command line.
469597
@@ -535,10 +663,27 @@ def main(argv=None):
535663
)
536664
args = parser.parse_args(argv)
537665

666+
try:
667+
char_limits = {
668+
# Character limits: 0 is OFF. The hard ceiling defaults OFF so a
669+
# caller bumping its pin never goes red on the bump; the two
670+
# warn-only limits default ON.
671+
"max_chars": _env_limit("MAX_CHARS", 0),
672+
"warn_chars": _env_limit("WARN_CHARS", 25000),
673+
"max_line_chars": _env_limit("MAX_LINE_CHARS", 3000),
674+
}
675+
except LimitConfigError as exc:
676+
msg = _esc_cmd(exc)
677+
print(f"FAIL: {msg}")
678+
print(f"::error::AGENTS.md integrity: {msg}")
679+
print("\nResult: invalid character-limit configuration.")
680+
return 2
681+
538682
config = {
539683
"agents_file": os.environ.get("AGENTS_FILE", "AGENTS.md") or "AGENTS.md",
540684
"max_lines": _env_int("MAX_LINES", 200),
541685
"warn_lines": _env_int("WARN_LINES", 150),
686+
**char_limits,
542687
"forbid_cursorrules": _env_bool("FORBID_CURSORRULES", True),
543688
"check_nested": _env_bool("CHECK_NESTED", True),
544689
"require_shim": _env_bool("REQUIRE_SHIM", True),

0 commit comments

Comments
 (0)