refactor(skills): retrofit the corpus to the ADR-0020 context contract (#129)
Retrofits all 39 skills to ADR-0020's description/body context contract, then fixes what six rounds of independent review found in that retrofit — including four ways the hot gate itself failed open. Closes #99, #107, #108, #110, #111, #114, #115, #120. ## The retrofit (waves 1-5) | | Start | Now | |---|---|---| | Description FAILs (>400 chars) | 26 | **0** | | Body FAILs (>900 words, body-only) | 9 | **0** | | Dangling routing targets | 2 | **0** | | `Kyberforge.CompositionNote` | 10 | **0** | | Preload tax | 21,005 chars | **~10,500** | Under the 12,000-char success criterion. Per-wave detail is on #99. ## The review fixes **The gate failed open four ways, three of them found after the retrofit shipped.** An unrecognised follower token made a dangling target vanish. A skill directory with no `SKILL.md` resolved as a valid target, so a commit could be green locally and red in a fresh clone — three existing fixtures were relying on that, one of which made the install-leak A/B pass vacuously. Then the free-standing `/name` sweep turned out to be gated on the sentence carrying a boundary marker, so route notation in any other sentence was invisible — not an ERROR, not a SUGGESTION, not an INFO — which left the documented "`/name` always blocks" promise false from a second direction. All four fixed and pinned. **Two checks were silently not running.** `validate-provenance.sh` checks 7-8 were dead across nine skills. Waking them exposed a deeper problem: they assume `Research doc:` names a source index, but 30 of 121 entries point at topic content documents, so every new check-7 INFO was a false positive and check 8 was saved from a false-FAIL flood only by an *unannounced* skip. Checks 7/8 are now scoped to source indexes and every skip announces itself (#121). **The retrofit's own anti-goal, four times.** ADR-0020 warns that a blunt gate gets satisfied by deleting content rather than relocating it. `diagnose` and `skill-audit` relocated prose and then read it unconditionally; `prototype` and `vale-config` deleted rules outright that survived nowhere. All four addressed. ## Verification - `bash tests/run-tests.sh --strict` — 24 suites, 0 skipped, 0 failed - `bash tests/run-bats.sh` — 325 tests, 0 failures - `pre-commit run --all-files` — 17/17 - `pre-commit run --hook-stage pre-push --all-files` — 16/16, with `apm marketplace check` and `apm pack --check-clean` run against the remote, not skipped - `scripts/skill-size-check.sh` over all 39 skills — rc 0, 0 ERROR/FAIL, SUGGESTION-only - Preload tax measured at **10,498 chars**, max description 390 — both inside budget - Every new test proven non-vacuous by a deliberate mutation of the behaviour it covers **Per-commit sync, stated accurately:** the ten commits from the latest review round each pass `check-plugin-content-sync` in isolation, verified by checking each out in a detached worktree with a clean between. The earlier gitea window (`dfacf05..bedbd1d`, nine commits) does **not** — its mirror was regenerated in one batch at `bbc7300`. An earlier revision of this description claimed the property held for every commit; it does not, and a bisect through that window lands on a red commit. **Squash-merge** to collapse it, or accept that this range is not bisectable. ## Version bump Six plugins and the catalog take a **patch**, not a minor. The branch is **89 commits — 40 `fix` / 30 `refactor` / 12 `docs` / 5 `chore` / 2 `test` — zero `feat`, zero `!`, zero `BREAKING CHANGE`** — and adds no skill, agent, command or hook. (Two earlier revisions of this section cited a stale histogram, most recently 78 commits; the figures above are measured at HEAD.) Both rules this repo ships (`forge/references/version-bump.md`, landing in this PR, and `git-commits/references/conventional-commits-spec.md`) make that a patch, and the catalog set is unchanged at 7 entries. Not settled by that: four published files were removed from the installed tree, three moved, and `caveman` gained `disable-model-invocation`, retiring its old triggers. Under a strict reading those are major-class and currently ship under `refactor:` with no marker. Whether the deployed skill surface is a public contract is written down nowhere — worth deciding, but it outlives this PR. ## Deliberately not in scope #112 (cherry-pick ownership, now resolved in favour of `git-commits`), #113 (`rtk git` normalisation), #116 (research fan-out), #101 (audit-skill merge), #122 (non-spec skill-root files), #123 (no PRD producer) stay open. #117 is the one worth reading: the contract's remedy is to move prose into `references/`, which is exactly where neither the size gate nor Vale looks — and the blind spot is wider than #117 currently records, since there is no root `.vale.ini` at all, so every ADR, `CONTEXT.md` and `README.md` is unlinted too. That blind spot let this branch carry two `level: error` `Kyberforge.SentenceOpenerThereIs` violations into `references/` files it created — `provider-adapter-author/references/provider-matrix.md:31` and `agent-audit/references/finding-criteria.md:95`. Both are reworded in `afadaae`, confirmed by routing each file through the audit's own `vale-wrap.sh` (1 error each before, 0 after). Five further occurrences sit in `references/` files already on `main`; those are the pre-existing corpus and stay with #117, which is the real fix. Also unfixed and not this PR's: `apm install` appends a duplicate `SessionStart` entry to `.claude/settings.json`, so a fresh clone cannot get pre-push green without an edit AGENTS.md warns against. Reproduces identically on `main`. Co-authored-by: Defame1297 <gitea@rkdr.net> Reviewed-on: https://git.dev.rkdr.net/Defame1297/holocron/pulls/129 Co-authored-by: Claude Code AI - Gitea MCP <claude@noreply.git.dev.rkdr.net> Co-committed-by: Claude Code AI - Gitea MCP <claude@noreply.git.dev.rkdr.net>
This commit was merged in pull request #129.
This commit is contained in:
@@ -4,6 +4,25 @@ Deterministic self-check this skill shells out to instead of relying on LLM judg
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold |
|
||||
| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a working reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold |
|
||||
|
||||
Takes `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure.
|
||||
Takes exactly `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags (also accepted as `--max-lines=N`). A third positional argument or an unknown option is an error, not something quietly ignored.
|
||||
|
||||
## What counts as a reference to AGENTS.md
|
||||
|
||||
Both modes require the named path to be a real path segment ending in `AGENTS.md` — `AGENTS.md` or `…/AGENTS.md`, not `NOTAGENTS.md` — that resolves on disk, relative to the adapter file, to a non-empty file. An adapter deferring to a path that is not there defers to nothing, so the check has to touch the disk rather than pattern-match the line.
|
||||
|
||||
A reference only counts where something would actually resolve it. A line inside a fenced code block, an indented code block, or an HTML comment is not credited in either mode: Claude Code resolves an import in none of those, so a fenced `@AGENTS.md` is the silent-drop failure this gate exists to catch, not a pass.
|
||||
|
||||
Default mode wants a real import: `@AGENTS.md` alone on its own line, indented no more than three spaces. `--no-import-syntax` wants a prose pointer that reads as one — the sentence naming `AGENTS.md` must carry a deference cue (see, read, refer to, documented in, conventions, …) and must not be negated. `Do NOT read AGENTS.md; it is obsolete.` and `We deleted AGENTS.md last year.` name the file while pointing the reader away from it, and neither is a pointer.
|
||||
|
||||
## Exit codes
|
||||
|
||||
The distinction matters because the skill's closeout tells the agent to fix any non-zero exit by editing the provider file. That is right for exactly one of these.
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|------|---------|------------|
|
||||
| `0` | Passes every check | Nothing |
|
||||
| `1` | One or more `FAIL` findings printed to stdout — empty adapter, no working reference to AGENTS.md, excessive duplication, or not thin | Edit the provider file |
|
||||
| `2` | Usage or input error: a bad, missing, or extra argument, an unknown option, a path that is not a file, or a file that is not UTF-8. Nothing was graded, so there is no `FAIL` line | Fix the invocation or the file's encoding — do not edit the adapter |
|
||||
| `3` | A named input file exists but could not be read (permissions, I/O error). Nothing was graded and the adapter's contents are unknown | Fix the file's readability — do not edit the adapter |
|
||||
|
||||
@@ -14,27 +14,85 @@ Arguments:
|
||||
adapter-file Path to the provider-specific file to check.
|
||||
agents-md-file Path to the AGENTS.md file it should defer to.
|
||||
|
||||
Exactly two positional arguments are accepted. Extra ones are rejected
|
||||
rather than ignored: a third path silently graded nothing but the first
|
||||
two, so a typo'd invocation passed against the wrong file.
|
||||
|
||||
Options:
|
||||
--no-import-syntax The target provider has no native cross-file import
|
||||
mechanism. Accept a plain-text pointer mention of
|
||||
"AGENTS.md" instead of requiring an @import-style line.
|
||||
mechanism. Require a plain-text pointer line naming
|
||||
"AGENTS.md" instead of an @import-style line; an
|
||||
@AGENTS.md line alone does not satisfy it, because
|
||||
such a provider never resolves it. Without this flag
|
||||
an actual @import line is required, and naming
|
||||
AGENTS.md in prose alone does not satisfy it.
|
||||
--max-lines N Max non-blank lines allowed in the adapter file before
|
||||
it's considered no longer "thin". Default: 60.
|
||||
it's considered no longer "thin". Must be a
|
||||
non-negative integer. Default: 60.
|
||||
--help, -h Show this help and exit 0.
|
||||
-- End of options; every later argument is positional.
|
||||
|
||||
Both flags also accept the --flag=value form (--max-lines=40). An unknown
|
||||
option is reported as an unknown option, not as a missing file.
|
||||
|
||||
What counts as a reference:
|
||||
|
||||
In both modes the named path must be a real path segment ending in
|
||||
AGENTS.md ("AGENTS.md" or ".../AGENTS.md" — not NOTAGENTS.md), and it must
|
||||
resolve on disk, relative to the adapter file, to a non-empty file. An
|
||||
adapter deferring to a path that is not there defers to nothing.
|
||||
|
||||
A mention inside a fenced code block, an indented code block, or an HTML
|
||||
comment is not credited in either mode. Nothing resolves those, so an
|
||||
adapter whose only "import" is fenced silently defers to nothing.
|
||||
|
||||
With --no-import-syntax the pointer must read as a pointer: the sentence
|
||||
naming AGENTS.md has to carry a deference cue (see, read, refer to,
|
||||
documented in, conventions, ...) and must not be a negation ("do not read
|
||||
AGENTS.md", "we deleted AGENTS.md"). A bare mention is not a pointer.
|
||||
|
||||
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, missing, or extra argument, an unknown
|
||||
option, 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.
|
||||
3 A named input file exists but could not be read (permissions, a
|
||||
directory swapped in mid-run, I/O error). Also not a FAIL: nothing was
|
||||
graded and the adapter's contents are unknown, so editing it is
|
||||
guesswork. Fix the file's readability and re-run.
|
||||
EOF
|
||||
}
|
||||
|
||||
NO_IMPORT_SYNTAX=0
|
||||
MAX_LINES=60
|
||||
ARGS=()
|
||||
END_OF_OPTS=0
|
||||
|
||||
require_int() {
|
||||
# $1 = the value to validate
|
||||
if [[ ! "$1" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: --max-lines expects a non-negative integer, got '$1'." >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
if [[ $END_OF_OPTS -eq 1 ]]; then
|
||||
ARGS+=("$1")
|
||||
shift
|
||||
continue
|
||||
fi
|
||||
case "$1" in
|
||||
--)
|
||||
END_OF_OPTS=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
@@ -43,10 +101,38 @@ while [[ $# -gt 0 ]]; do
|
||||
NO_IMPORT_SYNTAX=1
|
||||
shift
|
||||
;;
|
||||
--no-import-syntax=*)
|
||||
echo "Error: --no-import-syntax is a flag and takes no value (got '$1')." >&2
|
||||
exit 2
|
||||
;;
|
||||
--max-lines)
|
||||
MAX_LINES="${2:-}"
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --max-lines requires a value (a non-negative integer)." >&2
|
||||
exit 2
|
||||
fi
|
||||
MAX_LINES="$2"
|
||||
require_int "$MAX_LINES"
|
||||
shift 2
|
||||
;;
|
||||
--max-lines=*)
|
||||
MAX_LINES="${1#--max-lines=}"
|
||||
if [[ -z "$MAX_LINES" ]]; then
|
||||
echo "Error: --max-lines requires a value (a non-negative integer)." >&2
|
||||
exit 2
|
||||
fi
|
||||
require_int "$MAX_LINES"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
# Reported as an unknown option rather than falling through to the
|
||||
# positional bucket, where it used to surface as "'--bogus' is not a
|
||||
# file" — the right exit code attached to a diagnostic that sends the
|
||||
# reader looking for a path they never typed.
|
||||
echo "Error: unknown option '$1'." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$1")
|
||||
shift
|
||||
@@ -58,7 +144,14 @@ 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
|
||||
|
||||
if [[ ${#ARGS[@]} -gt 2 ]]; then
|
||||
echo "Error: expected exactly 2 positional arguments (adapter-file and agents-md-file), got ${#ARGS[@]}: ${ARGS[*]}." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON'
|
||||
@@ -70,17 +163,80 @@ adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5]
|
||||
no_import_syntax = no_import_syntax == "1"
|
||||
max_lines = int(max_lines)
|
||||
|
||||
EXIT_FAIL = 1
|
||||
EXIT_USAGE = 2
|
||||
EXIT_UNREADABLE = 3
|
||||
|
||||
if not os.path.isfile(adapter_path):
|
||||
print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
sys.exit(EXIT_USAGE)
|
||||
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(EXIT_USAGE)
|
||||
|
||||
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, every BOM stripped.
|
||||
|
||||
The BOM strip is not cosmetic. IMPORT_RE anchors on `^ {0,3}@`, and a BOM
|
||||
is not whitespace 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" and 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.
|
||||
|
||||
Every U+FEFF goes, not just one at offset 0. Stripping exactly the first
|
||||
one left the mirror-image false FAIL for a doubled BOM (two concatenated
|
||||
files, or a tool that re-adds one) and for a BOM mid-file at the head of
|
||||
the import line. U+FEFF has no meaning as a character in a markdown
|
||||
instruction file, so removing all of them cannot lose signal.
|
||||
|
||||
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. But strict UTF-8 alone does not catch it — BOM-less UTF-16LE/BE and
|
||||
UTF-32LE are *valid* UTF-8, because NUL is a legal code point, so they
|
||||
decoded clean and produced exactly that false diagnosis anyway. The NUL
|
||||
byte is the complete signal and is checked first: no plausible markdown
|
||||
adapter contains one, and every UTF-16/32 encoding of ASCII is full of
|
||||
them. A file this gate cannot read gets an encoding diagnostic and exit 2,
|
||||
the same policy the ADR-0020 validators' read_text() uses.
|
||||
|
||||
A file that exists but cannot be read at all is neither a pass nor a FAIL —
|
||||
nothing was graded — so it exits 3 rather than 1. Exit 1 sends the skill's
|
||||
closeout into "fix the FAIL by editing the provider file", which for a file
|
||||
it cannot open is an instruction to edit blind.
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
raw = fh.read()
|
||||
except OSError as exc:
|
||||
print(f"Error: '{path}' exists but could not be read ({exc.strerror}). "
|
||||
"Nothing was checked — fix whatever is blocking the read "
|
||||
"(permissions, ownership, the underlying device) and re-run; do "
|
||||
"not edit the adapter on the strength of this.", file=sys.stderr)
|
||||
sys.exit(EXIT_UNREADABLE)
|
||||
if b"\x00" in raw:
|
||||
print(f"Error: '{path}' is not valid UTF-8 — it contains NUL bytes, so "
|
||||
"it is almost certainly UTF-16 or UTF-32 (with or without a BOM). "
|
||||
"Re-save it as UTF-8; this check does not guess at other "
|
||||
"encodings.", file=sys.stderr)
|
||||
sys.exit(EXIT_USAGE)
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
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(EXIT_USAGE)
|
||||
return text.replace("\ufeff", "")
|
||||
|
||||
|
||||
adapter_content = read_text(adapter_path)
|
||||
agents_md_content = read_text(agents_md_path)
|
||||
adapter_dir = os.path.dirname(os.path.abspath(adapter_path))
|
||||
|
||||
has_fail = False
|
||||
|
||||
@@ -89,30 +245,229 @@ if not adapter_content.strip():
|
||||
print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.")
|
||||
print(" Fix: Add at least an import (or text pointer) to AGENTS.md.")
|
||||
print()
|
||||
sys.exit(1)
|
||||
sys.exit(EXIT_FAIL)
|
||||
|
||||
IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$')
|
||||
lines = adapter_content.splitlines()
|
||||
import_lines = [ln for ln in lines if IMPORT_RE.match(ln)]
|
||||
|
||||
# --- Inert regions -----------------------------------------------------------
|
||||
#
|
||||
# A reference only counts where something would actually resolve it. Fenced
|
||||
# code blocks, indented code blocks and HTML comments are shown to the reader
|
||||
# (or hidden from them) as literal text; Claude Code resolves an @import in
|
||||
# none of them. Without this, a ```-fenced `@AGENTS.md` — the exact
|
||||
# copy-the-example-into-the-file mistake this gate exists to catch — exited 0
|
||||
# with the adapter deferring to nothing.
|
||||
#
|
||||
# Indented code blocks are handled by IMPORT_RE's `^ {0,3}` instead of by the
|
||||
# mask: four leading spaces is what opens an indented code block in CommonMark,
|
||||
# so an import has to sit within three. The mask deliberately does not apply
|
||||
# that rule to prose pointers, where four-space indentation is ordinary list
|
||||
# continuation rather than code.
|
||||
FENCE_RE = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$')
|
||||
COMMENT_RE = re.compile(r'<!--.*?(?:-->|\Z)', re.DOTALL)
|
||||
|
||||
|
||||
def line_offsets(text):
|
||||
"""[(char offset, line without its terminator)] over `text`."""
|
||||
out = []
|
||||
off = 0
|
||||
for raw in text.splitlines(keepends=True):
|
||||
out.append((off, raw.rstrip("\r\n")))
|
||||
off += len(raw)
|
||||
return out
|
||||
|
||||
|
||||
def build_inert_mask(text, offsets):
|
||||
"""Per-character flags: 1 where a reference would never be resolved."""
|
||||
mask = bytearray(len(text))
|
||||
fence = None # (fence char, opening run length)
|
||||
for start, line in offsets:
|
||||
m = FENCE_RE.match(line)
|
||||
if fence is None:
|
||||
if m:
|
||||
fence = (m.group(2)[0], len(m.group(2)))
|
||||
for i in range(start, start + len(line)):
|
||||
mask[i] = 1
|
||||
continue
|
||||
for i in range(start, start + len(line)):
|
||||
mask[i] = 1
|
||||
if (m and m.group(2)[0] == fence[0]
|
||||
and len(m.group(2)) >= fence[1]
|
||||
and not m.group(3).strip()):
|
||||
fence = None
|
||||
for m in COMMENT_RE.finditer(text):
|
||||
if m.start() < len(mask) and mask[m.start()]:
|
||||
continue # a literal "<!--" printed inside a fence opens nothing
|
||||
for i in range(m.start(), min(m.end(), len(mask))):
|
||||
mask[i] = 1
|
||||
return mask
|
||||
|
||||
|
||||
# --- Reference shapes --------------------------------------------------------
|
||||
#
|
||||
# `\S*AGENTS\.md` had no path-separator boundary, so `@NOTAGENTS.md` and
|
||||
# `@zzzAGENTS.md` counted as imports of AGENTS.md. The matched path must end in
|
||||
# AGENTS.md as a whole segment.
|
||||
IMPORT_RE = re.compile(r'^ {0,3}@(?P<path>\S+?)\s*$')
|
||||
# A mention in prose: an optional relative path, then AGENTS.md, with no
|
||||
# identifier character glued to the front (so NOTAGENTS.md does not match) and
|
||||
# nothing glued to the back.
|
||||
MENTION_RE = re.compile(r'(?<![0-9A-Za-z_.\-/])((?:[\w.\-~]+/)*AGENTS\.md)(?![0-9A-Za-z])')
|
||||
|
||||
# A pointer has to read as a pointer. `"AGENTS.md" in ln` passed
|
||||
# "Do NOT read AGENTS.md; it is obsolete." and "We deleted AGENTS.md last
|
||||
# year." — both of which point the reader away from the file. Require a
|
||||
# deference cue in the naming sentence, and reject a negated one.
|
||||
DIRECTIVE_RE = re.compile(
|
||||
r'\b(see|read|refer|refers|referring|consult|consults|follow|follows|'
|
||||
r'defer|defers|deferring|described|documented|documents|covered|covers|'
|
||||
r'found|listed|specified|defined|governed|per|use|uses|using|apply|obey|'
|
||||
r'start|check|live|lives|contains|holds|carries|inherit|inherits|import|'
|
||||
r'imports|conventions|instructions|guidelines|guidance|rules|standards|'
|
||||
r'reference|setup)\b', re.I)
|
||||
NEGATION_RE = re.compile(
|
||||
r"(\bnot\b|n't\b|\bnever\b|\bno longer\b|\bdeleted\b|\bremoved\b|"
|
||||
r"\bobsolete\b|\bdeprecated\b|\bignore\b|\bignores\b|\bignoring\b|"
|
||||
r"\bdisregard\b|\bsuperseded\b|\bgone\b|\bunused\b|\bstale\b)", re.I)
|
||||
SENTENCE_SPLIT_RE = re.compile(r'(?<=[.;:!?])\s+')
|
||||
|
||||
|
||||
def sentence_around(line, index):
|
||||
"""(sentence of `line` containing character `index`, its start offset)."""
|
||||
bounds = [0]
|
||||
for m in SENTENCE_SPLIT_RE.finditer(line):
|
||||
bounds.append(m.end())
|
||||
bounds.append(len(line) + 1)
|
||||
for i in range(len(bounds) - 1):
|
||||
if bounds[i] <= index < bounds[i + 1]:
|
||||
return line[bounds[i]:bounds[i + 1]], bounds[i]
|
||||
return line, 0
|
||||
|
||||
|
||||
def reads_as_pointer(line, match):
|
||||
"""Does the sentence naming AGENTS.md actually point the reader at it?
|
||||
|
||||
The matched path is blanked out before the cues are applied. It is a
|
||||
filename, not prose, and leaving it in let its own characters vote: the
|
||||
perfectly ordinary `docs/does/not/exist/AGENTS.md` tripped the negation
|
||||
cue on the `not` path segment, so a pointer got rejected for the wrong
|
||||
reason and the near-miss line then reported the wrong diagnosis.
|
||||
"""
|
||||
sentence, sentence_start = sentence_around(line, match.start())
|
||||
rel_start = match.start() - sentence_start
|
||||
rel_end = match.end() - sentence_start
|
||||
probe = sentence[:rel_start] + " AGENTS.md " + sentence[rel_end:]
|
||||
if NEGATION_RE.search(probe):
|
||||
return False
|
||||
return bool(DIRECTIVE_RE.search(probe))
|
||||
|
||||
|
||||
def resolve(raw_path):
|
||||
"""An import/pointer path resolved the way the provider would resolve it."""
|
||||
p = os.path.expanduser(raw_path)
|
||||
if not os.path.isabs(p):
|
||||
p = os.path.join(adapter_dir, p)
|
||||
return os.path.normpath(p)
|
||||
|
||||
|
||||
def target_problem(raw_path):
|
||||
"""None if `raw_path` names a real, non-empty file; else why not."""
|
||||
resolved = resolve(raw_path)
|
||||
if not os.path.isfile(resolved):
|
||||
return f"'{raw_path}' resolves to {resolved}, which does not exist"
|
||||
try:
|
||||
if os.path.getsize(resolved) == 0:
|
||||
return f"'{raw_path}' resolves to {resolved}, which is empty"
|
||||
with open(resolved, "rb") as fh:
|
||||
if not fh.read().strip():
|
||||
return f"'{raw_path}' resolves to {resolved}, which is blank"
|
||||
except OSError as exc:
|
||||
return f"'{raw_path}' resolves to {resolved}, which cannot be read ({exc.strerror})"
|
||||
return None
|
||||
|
||||
|
||||
def names_agents_md(path):
|
||||
return path == "AGENTS.md" or path.endswith("/AGENTS.md")
|
||||
|
||||
|
||||
offsets = line_offsets(adapter_content)
|
||||
lines = [line for _, line in offsets]
|
||||
mask = build_inert_mask(adapter_content, offsets)
|
||||
|
||||
|
||||
def is_inert(abs_index):
|
||||
return abs_index < len(mask) and bool(mask[abs_index])
|
||||
|
||||
|
||||
# Lines shaped like an @AGENTS.md import, whether or not the target resolves.
|
||||
# Used to exclude them from the duplication denominator and from the prose
|
||||
# pointer scan, both of which only care about the shape.
|
||||
import_shaped_lines = set()
|
||||
# (line, raw path) for every import whose target actually resolves.
|
||||
live_imports = []
|
||||
# Diagnostics for imports that are the right shape but resolve to nothing.
|
||||
dead_imports = []
|
||||
# Imports that exist only inside a fence or an HTML comment.
|
||||
inert_imports = []
|
||||
|
||||
for start, line in offsets:
|
||||
m = IMPORT_RE.match(line)
|
||||
if not m or not names_agents_md(m.group("path")):
|
||||
continue
|
||||
at_index = start + line.index("@")
|
||||
if is_inert(at_index):
|
||||
inert_imports.append(line.strip())
|
||||
continue
|
||||
import_shaped_lines.add(line)
|
||||
problem = target_problem(m.group("path"))
|
||||
if problem:
|
||||
dead_imports.append(problem)
|
||||
else:
|
||||
live_imports.append(line)
|
||||
|
||||
live_pointers = []
|
||||
dead_pointers = []
|
||||
inert_pointers = []
|
||||
mention_only = []
|
||||
|
||||
for start, line in offsets:
|
||||
if line in import_shaped_lines:
|
||||
continue
|
||||
for m in MENTION_RE.finditer(line):
|
||||
if is_inert(start + m.start()):
|
||||
inert_pointers.append(line.strip())
|
||||
continue
|
||||
if not reads_as_pointer(line, m):
|
||||
mention_only.append(sentence_around(line, m.start())[0].strip())
|
||||
continue
|
||||
problem = target_problem(m.group(1))
|
||||
if problem:
|
||||
dead_pointers.append(problem)
|
||||
else:
|
||||
live_pointers.append(line)
|
||||
|
||||
if no_import_syntax:
|
||||
has_reference = "AGENTS.md" in adapter_content
|
||||
has_reference = bool(live_pointers)
|
||||
near_misses = dead_pointers + [f"{d} (inside a code fence or HTML comment)" for d in inert_pointers]
|
||||
near_misses += [f"'{s}' names AGENTS.md but does not point at it" for s in mention_only]
|
||||
else:
|
||||
has_reference = bool(import_lines) or "AGENTS.md" in adapter_content
|
||||
has_reference = bool(live_imports)
|
||||
near_misses = dead_imports + [f"'{d}' is inside a code fence or HTML comment, where no import is resolved" for d in inert_imports]
|
||||
|
||||
if not has_reference:
|
||||
has_fail = True
|
||||
print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}")
|
||||
if no_import_syntax:
|
||||
print(" Why: This provider has no import syntax, so the adapter must at least mention AGENTS.md as a text pointer.")
|
||||
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"")
|
||||
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. The pointer has to read as a pointer and name a file that is really there — a bare or negated mention (\"we deleted AGENTS.md\") defers nothing, and neither does a mention buried in a code fence or an HTML comment.")
|
||||
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\", outside any fence, naming a path that exists relative to this file.")
|
||||
else:
|
||||
print(" Why: A thin adapter must import AGENTS.md (e.g. `@AGENTS.md`) rather than silently omitting it.")
|
||||
print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line.")
|
||||
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own, indented no more than three spaces, and the path must resolve to a real non-empty file. Naming the file mid-sentence or inside backticks is prose this check will not credit; putting the line inside a ``` fence, an indented code block, or an HTML comment is worse, because nothing resolves it and it looks right.")
|
||||
print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line at the top level of the file, or pass --no-import-syntax if this provider resolves no imports.")
|
||||
for miss in near_misses:
|
||||
print(f" Near miss: {miss}")
|
||||
print()
|
||||
|
||||
# --- Duplication check ---
|
||||
non_import_lines = [ln for ln in lines if not IMPORT_RE.match(ln)]
|
||||
non_import_lines = [ln for ln in lines if ln not in import_shaped_lines]
|
||||
adapter_lines = [ln.strip() for ln in non_import_lines if ln.strip()]
|
||||
agents_lines = {ln.strip() for ln in agents_md_content.splitlines() if ln.strip()}
|
||||
|
||||
@@ -132,10 +487,10 @@ if non_blank_count > max_lines:
|
||||
has_fail = True
|
||||
print(f"FAIL Adapter is not thin — {adapter_path}")
|
||||
print(f" Why: {non_blank_count} non-blank lines exceeds the {max_lines}-line threshold for a thin adapter.")
|
||||
print(" Fix: Move provider-agnostic content into AGENTS.md; keep only genuinely provider-specific additions here.")
|
||||
print(" Fix: Delete the lines already covered by AGENTS.md; keep only genuinely provider-specific additions here.")
|
||||
print()
|
||||
|
||||
if has_fail:
|
||||
sys.exit(1)
|
||||
sys.exit(EXIT_FAIL)
|
||||
sys.exit(0)
|
||||
PYTHON
|
||||
|
||||
Reference in New Issue
Block a user