|
6 | 6 | one-line `@AGENTS.md` shim (optionally with a few Claude-only lines below — |
7 | 7 | Claude Code reads only CLAUDE.md and does not fall back), there are no |
8 | 8 | 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 |
10 | 13 | nested `AGENTS.md` gets a sibling `CLAUDE.md` shim so Claude Code picks it up in |
11 | 14 | that subtree, and the file is owned by a DRI via CODEOWNERS. |
12 | 15 |
|
|
22 | 25 | glob that would exclude the ROOT agents file or `CLAUDE.md` is rejected: root |
23 | 26 | compliance is the non-negotiable part of the standard. |
24 | 27 |
|
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). |
26 | 30 |
|
27 | 31 | Run locally: |
28 | 32 | python3 .github/agents-md-integrity/check_agents_md.py --root . |
|
31 | 35 | """ |
32 | 36 |
|
33 | 37 | import argparse |
| 38 | +import heapq |
34 | 39 | import os |
35 | 40 | import re |
36 | 41 | import sys |
@@ -67,6 +72,10 @@ class ExcludeConfigError(Exception): |
67 | 72 | """An `--exclude` glob is not usable (today: it would exclude the root).""" |
68 | 73 |
|
69 | 74 |
|
| 75 | +class LimitConfigError(Exception): |
| 76 | + """A character-limit env value is set but is not a whole number.""" |
| 77 | + |
| 78 | + |
70 | 79 | def _split_patterns(values): |
71 | 80 | """Flatten repeated / comma- / newline-separated `--exclude` values. |
72 | 81 |
|
@@ -191,10 +200,114 @@ def _validate_excludes(excludes, agents_file): |
191 | 200 | ) |
192 | 201 |
|
193 | 202 |
|
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 |
198 | 311 |
|
199 | 312 |
|
200 | 313 | def _has_import(path, import_token): |
@@ -328,8 +441,6 @@ def run_checks(root, config): |
328 | 441 | agents_file = config["agents_file"] |
329 | 442 | agents_basename = os.path.basename(agents_file) |
330 | 443 | import_token = "@" + agents_basename |
331 | | - max_lines = config["max_lines"] |
332 | | - warn_lines = config["warn_lines"] |
333 | 444 |
|
334 | 445 | # Validated unconditionally — a root-excluding glob is a config error even |
335 | 446 | # when `check_nested` is off and the globs would never have been consulted. |
@@ -357,18 +468,10 @@ def run_checks(root, config): |
357 | 468 | # anchor on, but CODEOWNERS/cursorrules are still worth reporting, so |
358 | 469 | # keep going rather than returning early. |
359 | 470 | 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) |
372 | 475 |
|
373 | 476 | # 3. CLAUDE.md shim. Claude Code reads only CLAUDE.md and does NOT fall |
374 | 477 | # back to AGENTS.md, so a missing root shim means the repo's instructions |
@@ -419,12 +522,9 @@ def run_checks(root, config): |
419 | 522 | f"'{import_token}', so Claude Code won't pick it up in that " |
420 | 523 | f"subtree. Add a one-line shim next to it." |
421 | 524 | ) |
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) |
428 | 528 |
|
429 | 529 | # 6. CODEOWNERS / DRI (warn unless require_codeowners). |
430 | 530 | checked, owned = _codeowners_owns(root, agents_file) |
@@ -464,6 +564,34 @@ def _env_int(name, default): |
464 | 564 | return default |
465 | 565 |
|
466 | 566 |
|
| 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 | + |
467 | 595 | def _esc_cmd(text): |
468 | 596 | """Escape a value before it is interpolated into a workflow-command line. |
469 | 597 |
|
@@ -535,10 +663,27 @@ def main(argv=None): |
535 | 663 | ) |
536 | 664 | args = parser.parse_args(argv) |
537 | 665 |
|
| 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 | + |
538 | 682 | config = { |
539 | 683 | "agents_file": os.environ.get("AGENTS_FILE", "AGENTS.md") or "AGENTS.md", |
540 | 684 | "max_lines": _env_int("MAX_LINES", 200), |
541 | 685 | "warn_lines": _env_int("WARN_LINES", 150), |
| 686 | + **char_limits, |
542 | 687 | "forbid_cursorrules": _env_bool("FORBID_CURSORRULES", True), |
543 | 688 | "check_nested": _env_bool("CHECK_NESTED", True), |
544 | 689 | "require_shim": _env_bool("REQUIRE_SHIM", True), |
|
0 commit comments