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:
@@ -331,13 +331,32 @@ PY
|
||||
#
|
||||
# The sibling plugin is what makes "every plugin in the monorepo contributes its
|
||||
# names" testable; without it a cross-plugin target and a typo are the same.
|
||||
#
|
||||
# Both sibling skill directories get a real SKILL.md, and that is load-bearing
|
||||
# rather than tidiness: a skill directory is a resolvable name only if it HOLDS
|
||||
# a SKILL.md. An empty leftover directory is untracked by git, so counting one
|
||||
# made a target resolve on the machine that made it and dangle in a fresh clone
|
||||
# — the same install-dependence the deployed-tree rule exists to remove. This
|
||||
# fixture used to `mkdir` the two siblings and write nothing into them, so it
|
||||
# was itself relying on the behaviour the resolver no longer has.
|
||||
make_tree_fixture() {
|
||||
local label="$1" desc="$2" body_words="$3" root apm
|
||||
local label="$1" desc="$2" body_words="$3" root apm sib
|
||||
root="$TMPDIR/tree-$label"
|
||||
apm="$root/plugins/subject-plugin/.apm"
|
||||
mkdir -p "$apm/skills/$label" "$apm/skills/sibling-skill" "$apm/agents" \
|
||||
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"
|
||||
: > "$apm/agents/sibling-agent.agent.md"
|
||||
for sib in "$apm/skills/sibling-skill" \
|
||||
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"; do
|
||||
{
|
||||
echo "---"
|
||||
echo "name: $(basename "$sib")"
|
||||
echo "description: Use when doing the other thing. Do not use for anything else."
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "Do the thing."
|
||||
} > "$sib/SKILL.md"
|
||||
done
|
||||
{
|
||||
echo "---"
|
||||
echo "name: $label"
|
||||
@@ -364,8 +383,17 @@ expect_gate() {
|
||||
fail "$label (exit $status, output: ${out:-<empty>})"
|
||||
fi
|
||||
;;
|
||||
# The tier and the needle are matched ADJACENTLY — `*"SUGGESTION"*"$needle"*`
|
||||
# — not as two independent substring tests. Independently, any output
|
||||
# carrying a SUGGESTION anywhere and the needle anywhere satisfied the
|
||||
# assertion, so a needle emitted at the WRONG TIER still passed: a finding
|
||||
# that moved from SUGGESTION to a blocking ERROR line would be caught only
|
||||
# by the exit-status test, and one that moved from SUGGESTION to INFO would
|
||||
# not be caught at all. grammar_case's `suggests` branch in
|
||||
# tests/test-adr0020-targets.sh has always matched them adjacently; this is
|
||||
# the same rule.
|
||||
suggest)
|
||||
if [[ $status -eq 0 && "$out" == *"SUGGESTION"* && "$out" == *"$needle"* ]]; then
|
||||
if [[ $status -eq 0 && "$out" == *"SUGGESTION"*"$needle"* ]]; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label (exit $status, output: ${out:-<empty>})"
|
||||
@@ -450,9 +478,14 @@ expect_gate "body at $((BODY_MAX_WORDS + 1)) words fails" \
|
||||
echo ""
|
||||
echo "--- the body gate and the whole-file gate are independent measurements ---"
|
||||
BODY_ONLY_DESC="$(python3 -c "print(' '.join(['w'] * 100))")"
|
||||
# The needle pins the COUNT, not the bare word "words". "words" appears in the
|
||||
# whole-file ceiling message, in the body ceiling message and in the body target
|
||||
# message alike, so it was satisfied by any of the three — including the one
|
||||
# this case exists to prove does NOT fire. Naming the number is what makes the
|
||||
# assertion about the body-only measurement.
|
||||
expect_gate "frontmatter words do not count toward the $BODY_MAX_WORDS-word body ceiling" \
|
||||
suggest "$(make_budget_fixture body-independent "$BODY_ONLY_DESC" "$((BODY_MAX_WORDS - 5))")" \
|
||||
"words"
|
||||
"body is $((BODY_MAX_WORDS - 5)) words"
|
||||
BIG_BODY="$(make_budget_fixture body-over-not-whole-file "$CLEAN_DESC" "$((BODY_MAX_WORDS + 1))")"
|
||||
BIG_BODY_WORDS="$(wc -w < "$BIG_BODY")"
|
||||
if [[ "$BIG_BODY_WORDS" -le "$MAX_WORDS" ]]; then
|
||||
@@ -461,6 +494,96 @@ else
|
||||
fail "the body-gate fixture is $BIG_BODY_WORDS whole-file words, which also trips MAX_WORDS=$MAX_WORDS — the test no longer isolates the body gate"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-0020's hand-invocation carve-out (issue #108)
|
||||
# ---------------------------------------------------------------------------
|
||||
# A skill carrying `disable-model-invocation: true` is removed from the
|
||||
# model-visible listing entirely — it is not preloaded, and the Skill tool
|
||||
# refuses to call it — so its description is never matched against user intent.
|
||||
# ADR-0020, skill-author Step 2 and skill-audit's own Step 0 all give it ONE
|
||||
# plain human-facing sentence: no trigger list, no boundary clause. No validator
|
||||
# knew the field existed, so the boundary-clause SUGGESTION fired on exactly the
|
||||
# shape the contract mandates, and its remedy — "so the router knows where NOT
|
||||
# to send this skill" — named a router that cannot see the skill at all.
|
||||
#
|
||||
# The carve-out is NARROW and the half it does not cover is the half worth
|
||||
# testing: the body is still loaded on invocation, so the body budget stands,
|
||||
# and the 400-character ceiling stands because it is an outlier stop rather than
|
||||
# a routing-quality target. Every case below asserts one of those two halves.
|
||||
make_hand_invoked_fixture() {
|
||||
local name="$1" desc="$2" body_words="$3" file
|
||||
file="$TMPDIR/$name.md"
|
||||
{
|
||||
echo "---"
|
||||
echo "name: $name"
|
||||
echo "description: $desc"
|
||||
echo "disable-model-invocation: true"
|
||||
echo "---"
|
||||
echo ""
|
||||
python3 -c "print(' '.join(['word'] * $body_words))"
|
||||
} > "$file"
|
||||
echo "$file"
|
||||
}
|
||||
|
||||
# A description over the 250-character target, carrying no boundary clause and
|
||||
# no routing target — the exact shape `zoom-out` and `caveman` ship. Built with
|
||||
# no hyphens so nothing in it reads as a target.
|
||||
HAND_DESC="$(python3 -c "
|
||||
prefix = 'Tell the agent to zoom out and give broader context. '
|
||||
print(prefix + 'x' * (300 - len(prefix)))")"
|
||||
|
||||
echo ""
|
||||
echo "--- a hand-invoked skill is exempt from the routing rules, and only those ---"
|
||||
expect_gate "a hand-invoked skill with a 300-char description and no boundary clause is silent" \
|
||||
pass "$(make_hand_invoked_fixture hand-quiet "$HAND_DESC" 10)"
|
||||
# The control that makes the case above mean something. Same description, same
|
||||
# body, only the frontmatter flag removed: both findings must appear, or the
|
||||
# exemption is being credited for silence it did not cause.
|
||||
expect_gate "control: the SAME description without the flag is over the 250-char target" \
|
||||
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
|
||||
"description is 300 characters"
|
||||
expect_gate "control: the SAME description without the flag has no boundary clause" \
|
||||
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
|
||||
"has no boundary clause"
|
||||
|
||||
echo ""
|
||||
echo "--- the carve-out lifts the routing rules ONLY: both size gates still bite ---"
|
||||
# The description ceiling is not a routing budget: a hand-invoked description is
|
||||
# still the one line a human reads in the `/` menu, and 400 characters is the
|
||||
# outlier stop either way.
|
||||
HAND_OVER_MAX="$(python3 -c "
|
||||
prefix = 'Tell the agent to zoom out and give broader context. '
|
||||
print(prefix + 'x' * (401 - len(prefix)))")"
|
||||
expect_gate "a hand-invoked description over $DESC_MAX_CHARS chars still FAILS" \
|
||||
fail "$(make_hand_invoked_fixture hand-over-max "$HAND_OVER_MAX" 10)" \
|
||||
"$DESC_MAX_CHARS-character ceiling"
|
||||
# The body is loaded on invocation like any other body and competes with the
|
||||
# caller's live conversation exactly the same way, so neither body tier moves.
|
||||
expect_gate "a hand-invoked body over $BODY_MAX_WORDS words still FAILS" \
|
||||
fail "$(make_hand_invoked_fixture hand-over-body "$HAND_DESC" "$((BODY_MAX_WORDS + 1))")" \
|
||||
"$BODY_MAX_WORDS-word ceiling"
|
||||
expect_gate "a hand-invoked body over $BODY_SUGGEST_WORDS words is still suggested" \
|
||||
suggest "$(make_hand_invoked_fixture hand-over-body-suggest "$HAND_DESC" "$((BODY_SUGGEST_WORDS + 1))")" \
|
||||
"body is $((BODY_SUGGEST_WORDS + 1)) words"
|
||||
|
||||
echo ""
|
||||
echo "--- the flag is read as a BOOLEAN, not as any mention of the key ---"
|
||||
# `disable-model-invocation: false` is the model-invoked case written out
|
||||
# longhand. Reading the key's presence instead of its value would hand every
|
||||
# routing exemption to anyone who typed the field at all.
|
||||
HAND_FALSE="$TMPDIR/hand-false.md"
|
||||
{
|
||||
echo "---"
|
||||
echo "name: hand-false"
|
||||
echo "description: $HAND_DESC"
|
||||
echo "disable-model-invocation: false"
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "Do the thing."
|
||||
} > "$HAND_FALSE"
|
||||
expect_gate "disable-model-invocation: false is NOT the carve-out" \
|
||||
suggest "$HAND_FALSE" "has no boundary clause"
|
||||
|
||||
echo ""
|
||||
echo "--- resolvable boundary targets ---"
|
||||
# Resolution is against the AUTHORING SOURCE (plugins/*/.apm/skills/ and
|
||||
@@ -534,10 +657,12 @@ expect_gate "a fixture with no authoring root reports DID NOT RUN and exits 0" \
|
||||
"Use when doing the thing. Do not use for improvements — use some-other-skill instead." 10)" \
|
||||
"Unchecked target(s): some-other-skill"
|
||||
|
||||
echo ""
|
||||
echo "--- the live dangling routing targets are caught (issue #100) ---"
|
||||
# NOTE: this section prints no header and runs no assertions any more — see why
|
||||
# below. The commentary is kept because it records why probes are removed rather
|
||||
# than skipped, which is the rule the next person to touch this file needs.
|
||||
#
|
||||
# ADR-0020 records the broken routing targets and splits fixing them into its own
|
||||
# issue. This asserts the gate actually sees them rather than the check being
|
||||
# issue. This asserted the gate actually sees them rather than the check being
|
||||
# vacuous in the corpus it was written against.
|
||||
#
|
||||
# There used to be a third probe here, for `skill-improve` in skill-audit's
|
||||
@@ -546,34 +671,205 @@ echo "--- the live dangling routing targets are caught (issue #100) ---"
|
||||
# in the totals, which is worse than no probe at all because it makes the suite
|
||||
# look one test stronger than it is. It also contradicted
|
||||
# tests/test-adr0020-targets.sh, which pins the live dangling set as EXACTLY
|
||||
# {gitea-labels, neuledge-context}; that file is the authority on the set, this
|
||||
# one only checks the two are individually detected.
|
||||
# {neuledge-context}; that file is the authority on the set, this one only
|
||||
# checks each member is individually detected.
|
||||
#
|
||||
# Both SKIP branches are gone with it, for the same reason. A probe whose fixture
|
||||
# has been retrofitted is not "still passing" — it is a pin that needs updating,
|
||||
# here and in the exact-set assertion in test-adr0020-targets.sh, and it should
|
||||
# say so out loud rather than quietly agreeing with whatever it finds.
|
||||
for probe in \
|
||||
"plugins/bin/.apm/skills/research/SKILL.md:neuledge-context" \
|
||||
"plugins/gitea/.apm/skills/gitea-issues/SKILL.md:gitea-labels"; do
|
||||
probe_file="$REPO_ROOT/${probe%%:*}"
|
||||
probe_name="${probe##*:}"
|
||||
if [[ ! -f "$probe_file" ]]; then
|
||||
fail "the probe fixture ${probe%%:*} no longer exists — this pin has become vacuous; update it and EXPECTED_DANGLING in tests/test-adr0020-targets.sh together"
|
||||
continue
|
||||
fi
|
||||
# Captured, not piped: the script exits non-zero on these files and
|
||||
# `set -o pipefail` would make the whole pipeline non-zero regardless of what
|
||||
# grep found.
|
||||
# The gitea-labels probe was dropped when the issue #99 retrofit cut the
|
||||
# composition sentence whose YAML fold produced that target. Per the rule above
|
||||
# it is removed, not skipped.
|
||||
#
|
||||
# The `neuledge-context` probe — the last one — went the same way in wave 3 of
|
||||
# that retrofit, which deleted the boundary clause naming it. **The corpus now
|
||||
# has zero dangling targets**, so this loop is removed entirely rather than left
|
||||
# to iterate over an empty list.
|
||||
#
|
||||
# That is deliberate and follows the rule stated above. A loop over no probes
|
||||
# produces no assertion while still returning success, which is the vacuous-pass
|
||||
# shape this comment block exists to reject — it would make the suite look one
|
||||
# test stronger than it is, exactly the complaint levelled at the old
|
||||
# `skill-improve` SKIP branch.
|
||||
#
|
||||
# Nothing is lost. This file only ever checked that each member of the live
|
||||
# dangling set is individually detected; tests/test-adr0020-targets.sh remains
|
||||
# the authority on the set itself, and now pins it as EMPTY, which is what
|
||||
# catches a newly-authored clause naming a target that does not resolve. That
|
||||
# file also carries synthetic fixtures built inside a real plugin tree, which
|
||||
# exercise the detection path without depending on the corpus staying broken.
|
||||
#
|
||||
# If a real dangling target ever reappears, add its probe back here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage tier: zero arguments is exit 2, not a clean run
|
||||
# ---------------------------------------------------------------------------
|
||||
# The script used to print nothing and exit 0 when handed no paths, which made
|
||||
# a mis-scoped `files:` pattern indistinguishable from a corpus with no
|
||||
# findings — the whole ADR-0020 gate family silently disabled while every hook
|
||||
# reported green. Exit 2 (not 1) is the same split a8cd5e8 made in
|
||||
# provider-adapter-author's validate-adapter.sh and the one vale-wrap.sh already
|
||||
# used: {0,1} are verdicts, 2 is "you invoked this wrong".
|
||||
#
|
||||
# SAFE FOR THE HOOK. Both manifests declare pass_filenames: true and neither
|
||||
# sets always_run, and pre-commit skips a filename-passing hook outright when
|
||||
# its `files:` pattern matches nothing, so pre-commit never invokes this script
|
||||
# with an empty argument list. That claim is asserted below rather than left in
|
||||
# prose, so a config edit that turns it false fails here.
|
||||
echo ""
|
||||
echo "--- zero arguments is a usage error (exit 2), not a silent clean run ---"
|
||||
set +e
|
||||
USAGE_OUT="$("$SCRIPT" 2>&1)"
|
||||
USAGE_RC=$?
|
||||
set -e
|
||||
if [[ $USAGE_RC -eq 2 ]]; then
|
||||
pass "no arguments exits 2"
|
||||
else
|
||||
fail "no arguments exited $USAGE_RC, expected 2 (output: ${USAGE_OUT:-<empty>})"
|
||||
fi
|
||||
if [[ "$USAGE_OUT" == *usage* ]]; then
|
||||
pass "no arguments prints a usage message"
|
||||
else
|
||||
fail "no arguments produced no usage message (output: ${USAGE_OUT:-<empty>})"
|
||||
fi
|
||||
# The exit code must be DISTINCT from both verdicts, or the split buys nothing.
|
||||
# $SMALL is the clean fixture built at the top of this file; $MANY_LINES is over
|
||||
# the line ceiling.
|
||||
set +e
|
||||
"$SCRIPT" "$SMALL" > /dev/null 2>&1
|
||||
CLEAN_RC=$?
|
||||
"$SCRIPT" "$MANY_LINES" > /dev/null 2>&1
|
||||
FINDING_RC=$?
|
||||
set -e
|
||||
if [[ $CLEAN_RC -eq 0 && $FINDING_RC -eq 1 && $USAGE_RC -eq 2 ]]; then
|
||||
pass "the three exit codes are distinct: clean=0, findings=1, usage=2"
|
||||
else
|
||||
fail "exit codes collide — clean=$CLEAN_RC findings=$FINDING_RC usage=$USAGE_RC"
|
||||
fi
|
||||
# The hook contract the usage exit depends on. If either manifest ever stops
|
||||
# passing filenames, or starts always_run, pre-commit could invoke the script
|
||||
# with no paths and exit 2 would break the hook rather than diagnose a caller.
|
||||
HOOK_CONTRACT="$(python3 - "$REPO_ROOT" <<'PYHOOK'
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
root = sys.argv[1]
|
||||
problems = []
|
||||
|
||||
|
||||
def check(label, hook):
|
||||
if hook is None:
|
||||
problems.append('%s declares no such hook' % label)
|
||||
return
|
||||
if hook.get('pass_filenames') is False:
|
||||
problems.append('%s sets pass_filenames: false' % label)
|
||||
if hook.get('always_run'):
|
||||
problems.append('%s sets always_run: true' % label)
|
||||
|
||||
|
||||
with open(os.path.join(root, '.pre-commit-config.yaml'), encoding='utf-8') as fh:
|
||||
cfg = yaml.safe_load(fh) or {}
|
||||
found = None
|
||||
for repo in cfg.get('repos') or []:
|
||||
for hook in (repo.get('hooks') or []):
|
||||
if hook.get('id') == 'skill-size-check':
|
||||
found = hook
|
||||
check('.pre-commit-config.yaml skill-size-check', found)
|
||||
|
||||
with open(os.path.join(root, '.pre-commit-hooks.yaml'), encoding='utf-8') as fh:
|
||||
hooks = yaml.safe_load(fh) or []
|
||||
found = None
|
||||
for hook in hooks:
|
||||
if isinstance(hook, dict) and hook.get('id') == 'kyberforge-skill-size-check':
|
||||
found = hook
|
||||
check('.pre-commit-hooks.yaml kyberforge-skill-size-check', found)
|
||||
|
||||
print('; '.join(problems))
|
||||
PYHOOK
|
||||
)"
|
||||
if [[ -z "$HOOK_CONTRACT" ]]; then
|
||||
pass "both manifests pass filenames and neither is always_run, so pre-commit never invokes the script with no paths"
|
||||
else
|
||||
fail "the usage exit would break the hook: $HOOK_CONTRACT"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# An unreadable path is diagnosed ONCE
|
||||
# ---------------------------------------------------------------------------
|
||||
# The stat dance lived twice — a bash pre-loop and the Python per-file loop —
|
||||
# and both printed the same sentence, so one broken file produced two ERROR
|
||||
# lines with two different "so ... could not be measured" clauses. Duplicated
|
||||
# output on a blocking gate reads as two problems and sends the author hunting
|
||||
# for a second one. The check must still FIRE (silence is the failure this
|
||||
# script forbids itself); it must fire exactly once.
|
||||
echo ""
|
||||
echo "--- an unreadable path produces exactly one ERROR line, not two ---"
|
||||
UNREADABLE_DIR="$TMPDIR/unreadable"
|
||||
mkdir -p "$UNREADABLE_DIR/a-directory.md"
|
||||
ln -sf "$TMPDIR/definitely-not-here.md" "$UNREADABLE_DIR/broken-link.md"
|
||||
|
||||
# unreadable_case <label> <path>
|
||||
unreadable_case() {
|
||||
local label="$1" path="$2" out status=0 count
|
||||
set +e
|
||||
probe_out="$("$SCRIPT" "$probe_file" 2>&1)"
|
||||
out="$("$SCRIPT" "$path" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$probe_out" == *"routes to '$probe_name'"* ]]; then
|
||||
pass "detects the dangling '$probe_name' target in ${probe%%:*}"
|
||||
count="$(printf '%s\n' "$out" | grep -cF "ERROR: $path" || true)"
|
||||
if [[ $status -eq 0 ]]; then
|
||||
fail "$label: exited 0 — an unmeasurable path passed in silence (output: ${out:-<empty>})"
|
||||
elif [[ "$count" != "1" ]]; then
|
||||
fail "$label: $count ERROR lines name the path, expected exactly 1 (output: $out)"
|
||||
else
|
||||
fail "did not detect the dangling '$probe_name' target in ${probe%%:*}. If issue #100 retrofitted it, drop this probe and update EXPECTED_DANGLING in tests/test-adr0020-targets.sh; if a false-positive fix took a true positive with it, that is the regression this asserts."
|
||||
pass "$label"
|
||||
fi
|
||||
done
|
||||
}
|
||||
unreadable_case "a path that does not exist is reported once" \
|
||||
"$TMPDIR/no-such-file.md"
|
||||
unreadable_case "a DIRECTORY named *.md is reported once" \
|
||||
"$UNREADABLE_DIR/a-directory.md"
|
||||
unreadable_case "a broken symlink is reported once" \
|
||||
"$UNREADABLE_DIR/broken-link.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encoding, write side: under LC_ALL=C the report must still print
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_text() in the shared ADR-0020 resolver block pins the READS to UTF-8.
|
||||
# That moved the crash to the WRITE: this script's own message text carries em
|
||||
# dashes (the boundary SUGGESTION is one), so under LC_ALL=C the streams' ASCII
|
||||
# default raised UnicodeEncodeError while PRINTING -- after every check had
|
||||
# already run, losing the whole report at the last step and turning a
|
||||
# SUGGESTION-only exit 0 into a traceback and an exit 1.
|
||||
echo ""
|
||||
echo "--- under LC_ALL=C the SUGGESTION is printed, not lost to a UnicodeEncodeError ---"
|
||||
LOCALE_SKILL="$TMPDIR/locale-skill"
|
||||
mkdir -p "$LOCALE_SKILL"
|
||||
cat > "$LOCALE_SKILL/SKILL.md" <<'LOCALEEOF'
|
||||
---
|
||||
name: locale-skill
|
||||
description: A valid skill description that is well within the limit.
|
||||
---
|
||||
|
||||
## Step 1
|
||||
|
||||
Do the thing.
|
||||
LOCALEEOF
|
||||
set +e
|
||||
LOCALE_OUT="$(env LC_ALL=C PYTHONUTF8=0 "$SCRIPT" "$LOCALE_SKILL/SKILL.md" 2>&1)"
|
||||
LOCALE_STATUS=$?
|
||||
set -e
|
||||
if [[ $LOCALE_STATUS -ne 0 ]]; then
|
||||
fail "a SUGGESTION-only subject exited $LOCALE_STATUS under LC_ALL=C (output: ${LOCALE_OUT:-<empty>})"
|
||||
elif [[ "$LOCALE_OUT" == *UnicodeEncodeError* || "$LOCALE_OUT" == *Traceback* ]]; then
|
||||
fail "the report died encoding its own message text under LC_ALL=C (output: $LOCALE_OUT)"
|
||||
elif [[ "$LOCALE_OUT" != *"description has no boundary clause"* ]]; then
|
||||
fail "the SUGGESTION never reached stdout under LC_ALL=C (output: ${LOCALE_OUT:-<empty>})"
|
||||
else
|
||||
pass "the SUGGESTION survives LC_ALL=C, streams pinned to UTF-8"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
||||
Reference in New Issue
Block a user