diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6b706..36067f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ### 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 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 - `/capitalize` — flush uncapitalized context to the memory registries before `/clear` or `/compact` - `/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. ### 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, …) ## [3.4.0] — 2026-04-15 diff --git a/agents/doc-syncer.md b/agents/doc-syncer.md index b443d64..69c5bbe 100644 --- a/agents/doc-syncer.md +++ b/agents/doc-syncer.md @@ -792,9 +792,26 @@ Categorize: - **NONE** → exit completely silent. No output (no `PATCHED_FILES` → the doc-commit step sees an empty list and no-ops). -- **MINOR** → patch silently. One-line confirmation per file: - `doc-sync: patched ()` -- **SIGNIFICANT** → surface to user before patching: +- **MINOR** → patch, then VERIFY SHAPE with the deterministic oracle BEFORE the + silent auto-commit. The LLM made the MINOR call; the oracle re-checks that the + patch's SHAPE actually holds, catching a SIGNIFICANT mislabeled MINOR (RISK-1): + ``` + bash "$HOME/.claude/lib/doc-shape.sh" check # all paths, ONE call + ``` + - **exit 0** (within the MINOR envelope) → genuine MINOR: keep the silent patch. + One-line confirmation per file: `doc-sync: patched ()`. + 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 -- `); 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: diff --git a/lib/doc-commit.md b/lib/doc-commit.md index 6aca8da..44103f1 100644 --- a/lib/doc-commit.md +++ b/lib/doc-commit.md @@ -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. | | 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 (); 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 (); 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. | `` 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 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 - files + summarize, and the PR diff re-shows it. Strengthening the MINOR gate itself = - separate doc-syncer chantier. + files + summarize, and the PR diff re-shows it. UPSTREAM of this snippet, doc-syncer now + 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 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 diff --git a/lib/doc-commit.sh b/lib/doc-commit.sh index 61772a9..536f8b6 100755 --- a/lib/doc-commit.sh +++ b/lib/doc-commit.sh @@ -16,7 +16,8 @@ # doc-commit.sh pending ... # exit 0 if any passed file has changes, 1 if clean # doc-commit.sh commit "" ... # 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 # 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). @@ -78,7 +79,8 @@ docs_pending() { } # 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() { local msg="${1:?commit message required}" shift @@ -118,7 +120,22 @@ commit_docs() { echo "doc-commit: only ignored/no-op changes — no-op" >&2 return 0 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 git rev-parse --short HEAD } diff --git a/lib/doc-shape.sh b/lib/doc-shape.sh new file mode 100755 index 0000000..4bfb9a3 --- /dev/null +++ b/lib/doc-shape.sh @@ -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} ) → 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 ... +# 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 ..." >&2 + return 2 + } + doc_shape_ok "$@" + ;; + *) + echo "usage: doc-shape.sh check ..." >&2 + return 2 + ;; + esac +} + +# Run main only when executed, not when sourced. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi diff --git a/lib/tests/run-doc-behavioral.md b/lib/tests/run-doc-behavioral.md index 65c6a69..6a5c6c7 100644 --- a/lib/tests/run-doc-behavioral.md +++ b/lib/tests/run-doc-behavioral.md @@ -59,8 +59,45 @@ echo "rc=$?" - The include treats rc 4 as an upstream BDR-022 anomaly to investigate — not a 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 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 -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). +fail-closed and loud. If Scenario C holds, a rejected commit fails LOUD instead of +masking as success. If Scenario D holds, a shape-suspect MINOR is escalated to the +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). diff --git a/lib/tests/run-doc-commit.sh b/lib/tests/run-doc-commit.sh index 73baef2..f337d9c 100755 --- a/lib/tests/run-doc-commit.sh +++ b/lib/tests/run-doc-commit.sh @@ -13,6 +13,8 @@ # T5 idempotent — empty list / clean tree → no-op exit 0 # 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) +# 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. 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 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" echo "" printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL" diff --git a/lib/tests/run-doc-shape.sh b/lib/tests/run-doc-shape.sh new file mode 100644 index 0000000..3a536e0 --- /dev/null +++ b/lib/tests/run-doc-shape.sh @@ -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] → 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 ]