feat(doc-sync): MINOR-shape oracle + fail-loud commit (strengthen MINOR gate)

(1) lib/doc-shape.sh — deterministic shape oracle re-checks each LLM-classified
MINOR patch before the silent auto-commit; a shape-suspect patch (added heading
/ oversize / new file / non-doc) escalates to the EXISTING SIGNIFICANT gate.
Genuine MINOR still auto-commits (zero friction, BDR-036 preserved). A structural
floor under the LLM call, not a semantic SIGNIFICANT-detector (BDR-040).

(2) lib/doc-commit.sh — guard the commit itself: a rejected git commit (pre-commit
hook / protected branch / signing) now fails loud with exit 5 + empty stdout,
instead of a false "committed" + the previous HEAD's hash + exit 0 that left docs
silently uncommitted (LRN-071, 3rd occurrence of the swallowed-commit pattern
after LRN-066 and LRN-068/BLK-012).

Wired doc-syncer STEP A4 (whole-set escalation: no=revert all, select=keep subset)
+ doc-commit.md (rc-5 consumer row + ACKNOWLEDGMENTS coherence).
TDD RED->GREEN: run-doc-commit.sh 32/32, run-doc-shape.sh 19/19, behavioral C/D.
shellcheck clean. Branch-guard (3) deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6bUdvHnajCNzgVQefZowj
This commit is contained in:
Bastien Chanot 2026-06-29 17:41:24 +02:00
parent 4da2b905de
commit 09f5b28d99
8 changed files with 398 additions and 11 deletions

View File

@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
### Added ### Added
- Coupled-capitalize: dev flows (feat / hotfix / bugfix / commit-change, ship-feature, init-project) auto-commit their memory in the same breath, via shared `lib/capitalize-commit.md` + `lib/memory-commit.sh` (surgical — `.claude/memory` + `.claude/tasks` only, never `git add -A`) - Coupled-capitalize: dev flows (feat / hotfix / bugfix / commit-change, ship-feature, init-project) auto-commit their memory in the same breath, via shared `lib/capitalize-commit.md` + `lib/memory-commit.sh` (surgical — `.claude/memory` + `.claude/tasks` only, never `git add -A`)
- Coupled doc-sync: dev flows (feat / bugfix / hotfix, ship-feature, init-project) auto-commit the public docs `doc-syncer` patches, via shared `lib/doc-commit.md` + `lib/doc-commit.sh` (surgical — only the patched files, never `git add -A`, never `.claude/` / `CLAUDE.md`; refuses an out-of-scope path loudly with exit 4). `doc-syncer` surfaces `PATCHED_FILES` (one path per line) as the handoff - Coupled doc-sync: dev flows (feat / bugfix / hotfix, ship-feature, init-project) auto-commit the public docs `doc-syncer` patches, via shared `lib/doc-commit.md` + `lib/doc-commit.sh` (surgical — only the patched files, never `git add -A`, never `.claude/` / `CLAUDE.md`; refuses an out-of-scope path loudly with exit 4). `doc-syncer` surfaces `PATCHED_FILES` (one path per line) as the handoff
- `lib/doc-shape.sh` — deterministic MINOR-shape oracle for `doc-syncer` AUTO MODE: re-checks each LLM-classified MINOR patch (added-heading / size / new-file / non-doc envelope, thresholds env-overridable) and escalates a shape-suspect patch to the existing SIGNIFICANT gate instead of silently auto-committing it. A structural floor under the LLM's classification, not a blocking gate (genuine MINOR still auto-commits, zero friction); catches structural/size significance, not semantic
- `/audit-delta` — recurring multi-axis audit (norms / bugs / dead code / security) scoped to changes since last run, with per-axis SHA markers - `/audit-delta` — recurring multi-axis audit (norms / bugs / dead code / security) scoped to changes since last run, with per-axis SHA markers
- `/capitalize` — flush uncapitalized context to the memory registries before `/clear` or `/compact` - `/capitalize` — flush uncapitalized context to the memory registries before `/clear` or `/compact`
- `/prune-memory` — curate and compress the `.claude/memory/` registries - `/prune-memory` — curate and compress the `.claude/memory/` registries
@ -41,6 +42,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
- Caveman plugin always-on integration purged — plugin disabled + uninstalled; SessionStart/UserPromptSubmit hooks, standalone hook files, `install-plugins.sh` STEP 5.5, `update-all.sh` refresh step, `plugins.lock.json` entry, `doctor.sh` checks, and docs removed. On a subscription plan its ~75% output-token compression has no cost benefit, and the always-on hooks added friction on validation gates + client deliverables. The unrelated memory-registry terse-format convention is kept. - Caveman plugin always-on integration purged — plugin disabled + uninstalled; SessionStart/UserPromptSubmit hooks, standalone hook files, `install-plugins.sh` STEP 5.5, `update-all.sh` refresh step, `plugins.lock.json` entry, `doctor.sh` checks, and docs removed. On a subscription plan its ~75% output-token compression has no cost benefit, and the always-on hooks added friction on validation gates + client deliverables. The unrelated memory-registry terse-format convention is kept.
### Fixed ### Fixed
- `lib/doc-commit.sh` no longer masks a rejected `git commit` as success: a pre-commit hook / protected branch / signing failure now fails loud with exit 5 and empty stdout (was: false "committed" + the previous HEAD's hash + exit 0, leaving docs silently uncommitted on a dirty tree)
- Numerous skill/agent fixes across darwin optimization rounds (geo-analyzer, onboard, init-project, analyzer, plugin-check, prune-memory, …) - Numerous skill/agent fixes across darwin optimization rounds (geo-analyzer, onboard, init-project, analyzer, plugin-check, prune-memory, …)
## [3.4.0] — 2026-04-15 ## [3.4.0] — 2026-04-15

View File

@ -792,9 +792,26 @@ Categorize:
- **NONE** → exit completely silent. No output (no `PATCHED_FILES` → the doc-commit step - **NONE** → exit completely silent. No output (no `PATCHED_FILES` → the doc-commit step
sees an empty list and no-ops). sees an empty list and no-ops).
- **MINOR** → patch silently. One-line confirmation per file: - **MINOR** → patch, then VERIFY SHAPE with the deterministic oracle BEFORE the
`doc-sync: patched <file> (<what changed>)` silent auto-commit. The LLM made the MINOR call; the oracle re-checks that the
- **SIGNIFICANT** → surface to user before patching: patch's SHAPE actually holds, catching a SIGNIFICANT mislabeled MINOR (RISK-1):
```
bash "$HOME/.claude/lib/doc-shape.sh" check <every patched path> # all paths, ONE call
```
- **exit 0** (within the MINOR envelope) → genuine MINOR: keep the silent patch.
One-line confirmation per file: `doc-sync: patched <file> (<what changed>)`.
Proceed to `PATCHED_FILES` + the doc-commit step.
- **exit 1** (shape EXCEEDS — oracle stderr names the offender(s) and why) → the
deterministic oracle OVERRULES the LLM's MINOR call (LRN-046). Do NOT auto-commit.
ESCALATE the WHOLE patch set to the SIGNIFICANT gate below — one file out of
shape makes the atomic MINOR classification suspect. Surface every patched file
+ the oracle's reason, then the gate: on `no` → revert ALL
(`git checkout -- <each patched path>`); on `select` → keep the chosen files,
revert the rest. The oracle catches STRUCTURAL/size significance, not semantic —
it is a deterministic floor, not a full SIGNIFICANT-detector.
- **exit 2/3** (oracle usage error / not a git repo) → do NOT auto-commit on a
broken check; treat as exit 1 and escalate.
- **SIGNIFICANT** (or a MINOR the oracle escalated) → surface to user before patching:
``` ```
DOC SYNC — drift detected after this session: DOC SYNC — drift detected after this session:
<list of significant items with proposed fixes> <list of significant items with proposed fixes>

View File

@ -56,6 +56,7 @@ Pass each line as a SEPARATE argument (see DO step 3).
| 0 | empty | helper no-op (nothing pending) | `DOC SYNC: docs already current — nothing to commit`. doc-sync found no drift, or patched nothing. | | 0 | empty | helper no-op (nothing pending) | `DOC SYNC: docs already current — nothing to commit`. doc-sync found no drift, or patched nothing. |
| 3 | empty | unsafe git state (detached / merge / rebase) | docs stay in the working tree for a manual commit; surface the helper's stderr. Do NOT retry blindly — the tree is mid-operation. | | 3 | empty | unsafe git state (detached / merge / rebase) | docs stay in the working tree for a manual commit; surface the helper's stderr. Do NOT retry blindly — the tree is mid-operation. |
| 4 | empty | **SCOPE VIOLATION — upstream anomaly** | doc-syncer surfaced a `.claude/**` or `CLAUDE.md` path in `PATCHED_FILES`, which it must NEVER patch (BDR-022). STOP. Signal: `⚠️ doc-commit REFUSED — doc-syncer listed a forbidden path (<offender, from stderr>); this violates BDR-022 upstream. Investigate why doc-syncer touched/listed it before re-running.` Do NOT swallow it, do NOT hand-commit the rest — the refusal IS the alarm. | | 4 | empty | **SCOPE VIOLATION — upstream anomaly** | doc-syncer surfaced a `.claude/**` or `CLAUDE.md` path in `PATCHED_FILES`, which it must NEVER patch (BDR-022). STOP. Signal: `⚠️ doc-commit REFUSED — doc-syncer listed a forbidden path (<offender, from stderr>); this violates BDR-022 upstream. Investigate why doc-syncer touched/listed it before re-running.` Do NOT swallow it, do NOT hand-commit the rest — the refusal IS the alarm. |
| 5 | empty | **COMMIT REJECTED — nothing committed** | `git commit` exited non-zero — a pre-commit hook blocked it (e.g. a doc commit on a protected `main`/`develop`), a signing failure, or similar. The docs are STILL in the working tree, uncommitted, on a dirty tree. STOP — do NOT proceed to FINISH as if docs landed (that re-creates the stranding bug this whole snippet fixes). Signal: `⚠️ doc-commit FAILED — the doc commit was rejected (<helper stderr>); docs remain uncommitted. Investigate (hook / branch / signing) before retrying.` No hash is emitted; do NOT retry blindly. |
| 2 | empty | usage error (no message / bad invocation) | internal bug in this include — fix the call, don't paper over it. | | 2 | empty | usage error (no message / bad invocation) | internal bug in this include — fix the call, don't paper over it. |
`<doc_hash>` is the DOC commit (the one that adds the patched docs). Docs carry NO `<doc_hash>` is the DOC commit (the one that adds the patched docs). Docs carry NO
@ -88,8 +89,12 @@ on the branch = consumption by the merge, automatic.
snippet commits it without a blocking gate, BY CHOICE. NOT the memory case: memory CONTENT snippet commits it without a blocking gate, BY CHOICE. NOT the memory case: memory CONTENT
was always gated, so its auto-commit only embarked approved entries. Here the VISIBLE was always gated, so its auto-commit only embarked approved entries. Here the VISIBLE
surface (rc 0 row, agent-composed summary) REPLACES the gate as the review surface — name surface (rc 0 row, agent-composed summary) REPLACES the gate as the review surface — name
files + summarize, and the PR diff re-shows it. Strengthening the MINOR gate itself = files + summarize, and the PR diff re-shows it. UPSTREAM of this snippet, doc-syncer now
separate doc-syncer chantier. runs a deterministic shape oracle (`lib/doc-shape.sh`) on each MINOR patch: a patch whose
SHAPE belies "minor" (adds a heading, is large, is a new file) is escalated to the
SIGNIFICANT gate BEFORE it reaches here — so what this snippet auto-commits is shape-verified
MINOR. The oracle is a STRUCTURAL floor, not a semantic SIGNIFICANT-detector (a small
meaning-changing edit still reads MINOR); the visible surface stays the content backstop.
- **Partial init-project fix.** This commits the docs doc-sync patched. It does NOT commit the - **Partial init-project fix.** This commits the docs doc-sync patched. It does NOT commit the
scaffold or the STEP 5b bootstrap README (no deterministic owner — [[BLK-010]]); ramassing scaffold or the STEP 5b bootstrap README (no deterministic owner — [[BLK-010]]); ramassing
them would re-create the over-reach we ban. ship-feature ends fully fixed; init-project's them would re-create the over-reach we ban. ship-feature ends fully fixed; init-project's

View File

@ -16,7 +16,8 @@
# doc-commit.sh pending <file>... # exit 0 if any passed file has changes, 1 if clean # doc-commit.sh pending <file>... # exit 0 if any passed file has changes, 1 if clean
# doc-commit.sh commit "<message>" <file>... # surgical commit # doc-commit.sh commit "<message>" <file>... # surgical commit
# #
# Exit codes (commit): 0 ok/no-op · 2 usage · 3 unsafe git state · 4 scope violation. # Exit codes (commit): 0 ok/no-op · 2 usage · 3 unsafe git state · 4 scope violation ·
# 5 commit rejected (git commit exited non-zero — hook / protected branch / signing).
# Output contract: diagnostics → stderr; on a real commit the short hash of the doc # Output contract: diagnostics → stderr; on a real commit the short hash of the doc
# commit is the ONLY thing on stdout (empty on no-op/abort), so callers can capture # commit is the ONLY thing on stdout (empty on no-op/abort), so callers can capture
# it: doc_hash=$(doc-commit.sh commit "msg" README.md USAGE.md). # it: doc_hash=$(doc-commit.sh commit "msg" README.md USAGE.md).
@ -78,7 +79,8 @@ docs_pending() {
} }
# Surgical commit of the passed doc paths only. Returns 0 (ok/no-op), 3 (unsafe), # Surgical commit of the passed doc paths only. Returns 0 (ok/no-op), 3 (unsafe),
# 4 (scope violation). On a real commit, prints the doc-commit short hash to stdout. # 4 (scope violation), 5 (commit rejected by git). On a real commit, prints the
# doc-commit short hash to stdout.
commit_docs() { commit_docs() {
local msg="${1:?commit message required}" local msg="${1:?commit message required}"
shift shift
@ -118,7 +120,22 @@ commit_docs() {
echo "doc-commit: only ignored/no-op changes — no-op" >&2 echo "doc-commit: only ignored/no-op changes — no-op" >&2
return 0 return 0
fi fi
git commit -q -m "$msg" -- "${changed[@]}" # FAIL-LOUD on the commit itself. With `set -uo pipefail` (no -e), a rejected
# commit (pre-commit hook on a protected branch, signing failure, …) would NOT
# abort: the printf below would falsely claim "committed" and rev-parse would
# emit the PREVIOUS HEAD's hash with exit 0 — a silent masked failure. The
# script is fail-closed+loud on scope (exit 4); it must be the same on its own
# commit. Reject → loud, NO hash on stdout, exit 5 (distinct from rc 3 "could
# not start": rc 5 = "tried, git refused").
if ! git commit -q -m "$msg" -- "${changed[@]}"; then
{
echo "doc-commit: COMMIT REJECTED — git commit exited non-zero" \
"(pre-commit hook? protected branch? signing?)."
echo "doc-commit: NOTHING committed, working tree left as-is," \
"NO hash emitted — investigate before retry."
} >&2
return 5
fi
printf 'doc-commit: committed %d file(s): %s\n' "${#changed[@]}" "${changed[*]}" >&2 printf 'doc-commit: committed %d file(s): %s\n' "${#changed[@]}" "${changed[*]}" >&2
git rev-parse --short HEAD git rev-parse --short HEAD
} }

127
lib/doc-shape.sh Executable file
View File

@ -0,0 +1,127 @@
#!/usr/bin/env bash
# doc-shape.sh — deterministic check that a doc patch has MINOR *shape*.
#
# Companion to doc-commit.sh. doc-syncer AUTO MODE classifies drift as NONE /
# MINOR / SIGNIFICANT by LLM judgment, with no deterministic backstop — so a
# SIGNIFICANT change mislabeled MINOR would auto-commit silently (RISK-1). This
# oracle re-checks the SHAPE of each MINOR patch BEFORE the auto-commit: if a
# patch's shape belies "minor" (adds a section heading, is large, or is a new
# file), it EXCEEDS the MINOR envelope and doc-syncer escalates it to the
# existing SIGNIFICANT gate instead of committing it silently.
#
# SCOPE OF THE GUARANTEE (honest, do not over-read it): this catches STRUCTURAL
# and size significance, NOT semantic significance. A 3-line edit that changes
# meaning but adds no heading and stays small still reads as MINOR-shape. The
# oracle is a deterministic FLOOR under the LLM's judgment (LRN-046) — a
# reduction of RISK-1's gross cases, not an elimination. The LLM still owns the
# semantic call above this floor.
#
# Verdict is AGGREGATE: ANY passed path that exceeds → overall exit 1, every
# offender named on stderr. The LLM classified the SET atomically MINOR; if one
# file's shape disagrees, the whole set is suspect → the whole set escalates.
#
# Envelope (per path, working tree vs HEAD), all deterministic:
# - adds a Markdown ATX heading (^+#{1,6} <text>) → exceeds (new section)
# - added lines > DOC_SHAPE_MAX_ADDED (def 20) → exceeds (too big for a tweak)
# - removed lines > DOC_SHAPE_MAX_REMOVED (def 20) → exceeds
# - new / untracked file → exceeds (a creation, not a drift-patch)
# - not a recognized public-doc file → exceeds (escalate the anomaly)
# A clean tracked path (no diff) is vacuously within the envelope.
# Known gap: Setext headings (=== / --- underlines) are not detected; ATX is the
# norm in this codebase's docs.
#
# Usage: doc-shape.sh check <path>...
# Exit: 0 within MINOR envelope · 1 exceeds (reasons→stderr) · 2 usage · 3 not-a-git-repo
# Output: reasons → stderr; stdout stays empty (the exit code carries the verdict).
#
# Sourceable: doc_shape_ok for the doc-syncer flow.
set -uo pipefail
DOC_SHAPE_MAX_ADDED="${DOC_SHAPE_MAX_ADDED:-20}"
DOC_SHAPE_MAX_REMOVED="${DOC_SHAPE_MAX_REMOVED:-20}"
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
# True (0) when the path is a recognized public-doc file (doc-syncer's universe,
# BDR-016): the markdown family, anything under docs/, or a bare standard name.
_is_doc() {
case "$(basename -- "$1")" in
*.md | *.mdx | *.markdown | *.rst) return 0 ;;
README | INSTALL | CONFIGURE | USAGE | DEPLOY | CONTRIBUTING | \
CHANGELOG | SECURITY | ARCHITECTURE | LICENSE | AUTHORS | NOTICE) return 0 ;;
esac
case "$1" in
docs/* | */docs/*) return 0 ;;
esac
return 1
}
# Echo the reason a single path EXCEEDS the MINOR envelope, or nothing if it is
# within. Pure read — never mutates the tree.
_path_exceeds_reason() {
local p="$1"
_is_doc "$p" || { printf 'not a recognized public-doc file: %s\n' "$p"; return; }
[ -e "$p" ] || { printf 'path not found: %s\n' "$p"; return; }
if ! git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then
printf 'new/untracked doc (a creation, not a MINOR drift-patch): %s\n' "$p"
return
fi
if git diff HEAD -- "$p" | grep -Eq '^\+#{1,6}[ \t]'; then
printf 'adds a section heading (structural change, not a factual tweak): %s\n' "$p"
return
fi
local stat added=0 removed=0
stat="$(git diff HEAD --numstat -- "$p")"
[ -n "$stat" ] && read -r added removed _ <<<"$stat"
case "$added$removed" in *[!0-9]*) printf 'binary or unreadable diff: %s\n' "$p"; return ;; esac
if [ "$added" -gt "$DOC_SHAPE_MAX_ADDED" ]; then
printf 'added %s lines > %s envelope: %s\n' "$added" "$DOC_SHAPE_MAX_ADDED" "$p"
return
fi
if [ "$removed" -gt "$DOC_SHAPE_MAX_REMOVED" ]; then
printf 'removed %s lines > %s envelope: %s\n' "$removed" "$DOC_SHAPE_MAX_REMOVED" "$p"
return
fi
}
# 0 if EVERY passed path is within the MINOR envelope, 1 if ANY exceeds (each
# offender's reason printed to stderr). Empty list → 0 (vacuously minor).
doc_shape_ok() {
_in_git_repo || {
echo "doc-shape: not a git repo — cannot judge shape" >&2
return 3
}
local p reason any=0
for p in "$@"; do
reason="$(_path_exceeds_reason "$p")"
if [ -n "$reason" ]; then
echo "doc-shape: EXCEEDS MINOR envelope — $reason" >&2
any=1
fi
done
return "$any"
}
main() {
local cmd="${1:-}"
case "$cmd" in
check)
shift
[ "$#" -ge 1 ] || {
echo "usage: doc-shape.sh check <path>..." >&2
return 2
}
doc_shape_ok "$@"
;;
*)
echo "usage: doc-shape.sh check <path>..." >&2
return 2
;;
esac
}
# Run main only when executed, not when sourced.
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
main "$@"
fi

View File

@ -59,8 +59,45 @@ echo "rc=$?"
- The include treats rc 4 as an upstream BDR-022 anomaly to investigate — not a - The include treats rc 4 as an upstream BDR-022 anomaly to investigate — not a
silent skip. The refusal IS the alarm. silent skip. The refusal IS the alarm.
## Scenario C — fail-loud on a rejected commit (the masked-failure path)
```bash
# The gitflow pre-commit hook (or signing, or a protected branch) rejects the doc
# commit. The helper must NOT report success: no false "committed", no stale hash.
printf '#!/bin/sh\nexit 1\n' > "$R/.git/hooks/pre-commit"; chmod +x "$R/.git/hooks/pre-commit"
printf '\n## another section\n' >> README.md
out="$(bash "$HOME/.claude/lib/doc-commit.sh" commit "docs: rejected" "README.md")"
echo "rc=$? out=[$out]"
```
### Expected (assert)
- `rc=5` (commit rejected), `out` EMPTY (no stale hash leaked on stdout).
- stderr is loud (`COMMIT REJECTED …`) — never a false `committed`.
- HEAD did NOT move; the doc stays in the working tree, uncommitted. The orchestrator
must surface this and NOT proceed to FINISH as if docs landed (doc-commit.md rc 5 row).
## Scenario D — MINOR-shape oracle escalates a SIGNIFICANT-in-disguise
```bash
# doc-syncer's LLM classified a drift MINOR, but the patch ADDS A SECTION HEADING —
# structurally not a factual tweak. The oracle must overrule the MINOR call.
printf '\n## Brand new feature\n\nA whole new capability.\n' >> USAGE.md # the "MINOR" patch
bash "$HOME/.claude/lib/doc-shape.sh" check "USAGE.md"; echo "rc=$?"
```
### Expected (assert)
- `rc=1` (exceeds the MINOR envelope), stderr names the heading reason + `USAGE.md`.
- doc-syncer STEP A4 routes this to the SIGNIFICANT gate (`Apply? yes/no/select`) instead
of the silent auto-commit — the deterministic oracle overrules the LLM (LRN-046).
- A genuine factual one-liner (changed command, no heading, small) returns `rc=0` and
stays on the silent MINOR auto-commit path — zero friction (BDR-036 preserved).
- The oracle is a STRUCTURAL floor: a small meaning-changing edit with no heading still
reads MINOR (rc 0). It reduces RISK-1's gross cases, it does not eliminate RISK-1.
If Scenario A holds, the chain is coupled (docs committed in the same breath as the If Scenario A holds, the chain is coupled (docs committed in the same breath as the
flow) and surgical (no dangling code embarked). If Scenario B holds, the guard is flow) and surgical (no dangling code embarked). If Scenario B holds, the guard is
fail-closed and loud. This mirrors what feat / bugfix / hotfix do at their DOC SYNC fail-closed and loud. If Scenario C holds, a rejected commit fails LOUD instead of
step (inline-branch commit, no FINISH), and what ship-feature / init-project do at masking as success. If Scenario D holds, a shape-suspect MINOR is escalated to the
their DOC SYNC step BEFORE FINISH (so the doc commit reaches the merge/PR). human gate instead of auto-committed. This mirrors what feat / bugfix / hotfix do at
their DOC SYNC step (inline-branch commit, no FINISH), and what ship-feature /
init-project do at their DOC SYNC step BEFORE FINISH (so the doc commit reaches the merge/PR).

View File

@ -13,6 +13,8 @@
# T5 idempotent — empty list / clean tree → no-op exit 0 # T5 idempotent — empty list / clean tree → no-op exit 0
# T6 unsafe git state (detached HEAD) → exit 3, no commit # T6 unsafe git state (detached HEAD) → exit 3, no commit
# T7 path WITH A SPACE passed as one arg → committed (argv is space-safe, no separator) # T7 path WITH A SPACE passed as one arg → committed (argv is space-safe, no separator)
# T8 pre-commit hook REJECTS the commit → fail LOUD (exit 5), no stale hash on stdout,
# HEAD unmoved — the script must NOT report "committed" when git commit failed
# #
# No -e: run every test and report, even after a failure. # No -e: run every test and report, even after a failure.
set -uo pipefail set -uo pipefail
@ -173,6 +175,20 @@ if git -C "$R" cat-file -e "HEAD:docs/My Guide.md" 2>/dev/null; then ok "spaced
if [ -z "$(git -C "$R" status --porcelain -- "docs/My Guide.md")" ]; then ok "spaced doc clean (embarked as ONE file, not split)"; else ko "spaced doc still dirty"; fi if [ -z "$(git -C "$R" status --porcelain -- "docs/My Guide.md")" ]; then ok "spaced doc clean (embarked as ONE file, not split)"; else ko "spaced doc still dirty"; fi
rm -rf "$R" rm -rf "$R"
echo "T8 — pre-commit hook REJECTS commit → exit 5 LOUD, no stale hash, HEAD unmoved"
R="$(new_repo)"
printf '#!/bin/sh\nexit 1\n' >"$R/.git/hooks/pre-commit"; chmod +x "$R/.git/hooks/pre-commit"
BEFORE="$(git -C "$R" rev-parse HEAD)"
printf 'feature added\n' >>"$R/README.md"
run "$R" commit "docs: T8 rejected" "README.md"
printf ' rc=%s out=[%s]\n' "$RC" "$OUT"
printf ' err: %s\n' "$(printf '%s' "$ERR" | head -1)"
if [ "$RC" -eq 5 ]; then ok "rejected commit → exit 5"; else ko "expected 5, got $RC (commit failure swallowed = masking)"; fi
if [ -z "$OUT" ]; then ok "stdout empty (no stale hash)"; else ko "stale hash leaked on failure: [$OUT]"; fi
if printf '%s' "$ERR" | grep -qi 'REJECTED'; then ok "stderr is loud (REJECTED)"; else ko "stderr not loud (no REJECTED — likely a false 'committed')"; fi
if [ "$(git -C "$R" rev-parse HEAD)" = "$BEFORE" ]; then ok "HEAD unmoved (nothing committed)"; else ko "HEAD moved despite hook reject"; fi
rm -rf "$R"
rm -f "$ERRFILE" rm -f "$ERRFILE"
echo "" echo ""
printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL" printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL"

166
lib/tests/run-doc-shape.sh Normal file
View File

@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Deterministic tests for lib/doc-shape.sh — the MINOR-shape oracle.
#
# The oracle re-checks that a patch the LLM classified MINOR actually HAS minor
# shape, on REAL git diffs (not assumed). Each case proves a verdict:
# S1 factual one-liner (1 add / 1 del, no heading) → 0 within envelope
# S2 adds a `## Section` heading → 1 exceeds (structural)
# S3 +30 plain lines, no heading → 1 exceeds (size)
# S3b +20 plain lines (== threshold) → 0 within (boundary)
# S3c +10 lines with DOC_SHAPE_MAX_ADDED=5 (env override) → 1 exceeds (tunable)
# S4 dead-reference removal (-2 / +0) → 0 within (small)
# S5 new / untracked doc file → 1 exceeds (a creation)
# S6 a code path (not a doc) → 1 exceeds (anomaly)
# S7 clean tracked doc (no diff) → 0 within (vacuous)
# S8 MIXED multi-path, ONE file exceeds → 1 exceeds, offender named
# S9 usage (check with no paths) → 2
# S10 not a git repo → 3
#
# No -e: run every test and report, even after a failure.
set -uo pipefail
HERE="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELPER="$HERE/../doc-shape.sh"
ERRFILE="$(mktemp)"
PASS=0
FAIL=0
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; PASS=$((PASS + 1)); }
ko() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); }
# Fresh throwaway repo: a few tracked docs + one code file, committed.
new_repo() {
local d
d="$(mktemp -d)"
git -C "$d" init -q
git -C "$d" config user.email t@t.t
git -C "$d" config user.name tester
mkdir -p "$d/docs" "$d/src"
printf 'run: foo\nold line A\nold line B\n' >"$d/README.md"
printf 'usage baseline\n' >"$d/USAGE.md"
printf 'guide baseline\n' >"$d/docs/guide.md"
printf 'print("hi")\n' >"$d/src/app.py"
git -C "$d" add -A
git -C "$d" commit -qm baseline
printf '%s' "$d"
}
# Append N numbered plain lines (no heading) to a file.
append_lines() {
local f="$1" n="$2" i
for ((i = 1; i <= n; i++)); do printf 'extra line %s\n' "$i" >>"$f"; done
}
# run [ENV=val] <repo> <args...> → sets RC (exit), OUT (stdout), ERR (stderr).
# stdout MUST stay empty: the exit code carries the verdict, reasons go to stderr.
run() {
local r="$1"; shift
OUT="$( (cd "$r" && "$HELPER" "$@") 2>"$ERRFILE" )"; RC=$?
ERR="$(cat "$ERRFILE")"
}
echo "S1 — factual one-liner (1 add / 1 del, no heading) → within (0)"
R="$(new_repo)"
printf 'run: bar\nold line A\nold line B\n' >"$R/README.md" # change one line
run "$R" check "README.md"
printf ' rc=%s out=[%s]\n' "$RC" "$OUT"
if [ "$RC" -eq 0 ]; then ok "factual tweak → within (0)"; else ko "expected 0, got $RC"; fi
if [ -z "$OUT" ]; then ok "stdout empty"; else ko "stdout leaked: [$OUT]"; fi
rm -rf "$R"
echo "S2 — adds a heading → exceeds (1, structural)"
R="$(new_repo)"
printf '\n## New Feature\n\nDescribes the new feature.\n' >>"$R/README.md"
run "$R" check "README.md"
printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)"
if [ "$RC" -eq 1 ]; then ok "heading → exceeds (1)"; else ko "expected 1, got $RC"; fi
if printf '%s' "$ERR" | grep -qi 'heading'; then ok "stderr names the heading reason"; else ko "reason not named"; fi
rm -rf "$R"
echo "S3 — +30 plain lines, no heading → exceeds (1, size)"
R="$(new_repo)"
append_lines "$R/README.md" 30
run "$R" check "README.md"
printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)"
if [ "$RC" -eq 1 ]; then ok "30 added → exceeds (1)"; else ko "expected 1, got $RC"; fi
if printf '%s' "$ERR" | grep -qi 'added'; then ok "stderr names the size reason"; else ko "reason not named"; fi
rm -rf "$R"
echo "S3b — +20 plain lines (== threshold) → within (0, boundary)"
R="$(new_repo)"
append_lines "$R/README.md" 20
run "$R" check "README.md"
printf ' rc=%s\n' "$RC"
if [ "$RC" -eq 0 ]; then ok "20 added (== MAX) → within (0)"; else ko "expected 0, got $RC"; fi
rm -rf "$R"
echo "S3c — +10 lines with DOC_SHAPE_MAX_ADDED=5 → exceeds (1, env-tunable)"
R="$(new_repo)"
append_lines "$R/README.md" 10
OUT="$( (cd "$R" && DOC_SHAPE_MAX_ADDED=5 "$HELPER" check "README.md") 2>"$ERRFILE" )"; RC=$?
printf ' rc=%s\n' "$RC"
if [ "$RC" -eq 1 ]; then ok "override MAX_ADDED=5, 10 added → exceeds (1)"; else ko "expected 1, got $RC"; fi
rm -rf "$R"
echo "S4 — dead-reference removal (-2 / +0) → within (0)"
R="$(new_repo)"
printf 'run: foo\n' >"$R/README.md" # drop the two 'old line' references
run "$R" check "README.md"
printf ' rc=%s\n' "$RC"
if [ "$RC" -eq 0 ]; then ok "small removal → within (0)"; else ko "expected 0, got $RC"; fi
rm -rf "$R"
echo "S5 — new / untracked doc file → exceeds (1, a creation)"
R="$(new_repo)"
printf 'brand new doc\n' >"$R/NEW.md" # untracked
run "$R" check "NEW.md"
printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)"
if [ "$RC" -eq 1 ]; then ok "untracked doc → exceeds (1)"; else ko "expected 1, got $RC"; fi
if printf '%s' "$ERR" | grep -Eqi 'untracked|new'; then ok "stderr flags the creation"; else ko "reason not named"; fi
rm -rf "$R"
echo "S6 — a code path (not a doc) → exceeds (1, anomaly)"
R="$(new_repo)"
printf 'print("hi")\nprint("bye")\n' >"$R/src/app.py"
run "$R" check "src/app.py"
printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)"
if [ "$RC" -eq 1 ]; then ok "non-doc path → exceeds (1)"; else ko "expected 1, got $RC"; fi
if printf '%s' "$ERR" | grep -qi 'doc'; then ok "stderr flags the non-doc"; else ko "reason not named"; fi
rm -rf "$R"
echo "S7 — clean tracked doc (no diff) → within (0, vacuous)"
R="$(new_repo)"
run "$R" check "docs/guide.md" # unmodified
printf ' rc=%s\n' "$RC"
if [ "$RC" -eq 0 ]; then ok "clean path → within (0)"; else ko "expected 0, got $RC"; fi
rm -rf "$R"
echo "S8 — MIXED multi-path, ONE exceeds → exceeds (1), offender named"
R="$(new_repo)"
printf 'extra\n' >>"$R/README.md" # small, within
append_lines "$R/USAGE.md" 30 # big, exceeds
run "$R" check "README.md" "USAGE.md"
printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)"
if [ "$RC" -eq 1 ]; then ok "any path exceeds → whole set exceeds (1)"; else ko "expected 1, got $RC"; fi
if printf '%s' "$ERR" | grep -q 'USAGE.md'; then ok "stderr names the offender (USAGE.md)"; else ko "offender not named"; fi
if printf '%s' "$ERR" | grep -q 'README.md'; then ko "README.md wrongly flagged"; else ok "within-envelope file NOT flagged"; fi
rm -rf "$R"
echo "S9 — usage (check with no paths) → 2"
R="$(new_repo)"
run "$R" check
printf ' rc=%s\n' "$RC"
if [ "$RC" -eq 2 ]; then ok "no paths → usage (2)"; else ko "expected 2, got $RC"; fi
rm -rf "$R"
echo "S10 — not a git repo → 3"
D="$(mktemp -d)" # plain dir, no git init
run "$D" check "README.md"
printf ' rc=%s\n' "$RC"
if [ "$RC" -eq 3 ]; then ok "not-a-repo → 3"; else ko "expected 3, got $RC"; fi
rm -rf "$D"
rm -f "$ERRFILE"
echo ""
printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]