fix(core): strip a BOM before the adapter import check, and split usage exits

Narrowing has_reference to bool(import_lines) meant a UTF-8 BOM hid the import
line, since the BOM is not \s: a CLAUDE.md whose first line is @AGENTS.md failed
with 'no reference to AGENTS.md' and was told to add the line already in front of
it. Decoding is now strict too, so a non-UTF-8 adapter gets an encoding
diagnostic instead of being mangled and then graded on the mangling.

Usage errors move to exit 2. They shared exit 1 with real findings, while the
skill tells the agent to fix any non-zero exit by editing the provider file.

The whole-line import rule is kept deliberately -- accepting an inline @AGENTS.md
would also accept one inside backticks, which is the silent-drop failure the
validator exists to catch -- and the message now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
This commit is contained in:
2026-08-31 19:46:07 +00:00
parent 00daf285ec
commit a8cd5e881d
9 changed files with 162 additions and 31 deletions

View File

@@ -31,6 +31,12 @@ Exit codes:
0 Adapter file passes all checks
1 One or more checks failed (empty file, no reference to AGENTS.md,
excessive duplication, or file too long)
2 Usage or input error — a bad or missing argument, a path that is not a
file, or a file that is not UTF-8. Nothing was graded, so there is no
FAIL line and no adapter edit to make: fix the invocation or the file's
encoding and re-run. Kept distinct from 1 because the skill's own
closeout tells the agent to fix every non-zero exit by editing the
provider file, which for a mistyped flag edits the wrong file forever.
EOF
}
@@ -51,12 +57,12 @@ while [[ $# -gt 0 ]]; do
--max-lines)
if [[ $# -lt 2 ]]; then
echo "Error: --max-lines requires a value (a non-negative integer)." >&2
exit 1
exit 2
fi
MAX_LINES="$2"
if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then
echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2
exit 1
exit 2
fi
shift 2
;;
@@ -71,7 +77,7 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then
echo "Error: adapter-file and agents-md-file are required." >&2
echo "" >&2
usage >&2
exit 1
exit 2
fi
python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON'
@@ -85,15 +91,43 @@ max_lines = int(max_lines)
if not os.path.isfile(adapter_path):
print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr)
sys.exit(1)
sys.exit(2)
if not os.path.isfile(agents_md_path):
print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr)
sys.exit(1)
sys.exit(2)
with open(adapter_path, encoding="utf-8", errors="replace") as f:
adapter_content = f.read()
with open(agents_md_path, encoding="utf-8", errors="replace") as f:
agents_md_content = f.read()
def read_text(path):
r"""File contents as text, UTF-8, BOM stripped.
The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is
not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had
its first line — the `@AGENTS.md` import, which is the whole adapter —
silently treated as prose. The check then said "no reference to AGENTS.md"
told the author to add the line already sitting in front of them. Same
class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it
at the reader so no later check has to know about it.
Decoding is strict, not errors="replace". Replacement mangles the file and
the checks then grade the mangling: a UTF-16 adapter whose first line is
`@AGENTS.md` decoded to interleaved NULs and failed as "no reference",
which is a true FAIL for a false reason and points the fix at the wrong
thing. A file this gate cannot read gets an encoding diagnostic and exit 2,
the same policy the ADR-0020 validators' read_text() uses.
"""
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
except UnicodeDecodeError as exc:
print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte "
f"{exc.start}) — re-save it as UTF-8; this check does not guess "
"at other encodings.", file=sys.stderr)
sys.exit(2)
return text[1:] if text.startswith("\ufeff") else text
adapter_content = read_text(adapter_path)
agents_md_content = read_text(agents_md_path)
has_fail = False
@@ -124,8 +158,8 @@ if not has_reference:
print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.")
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"")
else:
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.")
print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.")
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.")
print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.")
print()
# --- Duplication check ---