From f6a54506be2486f6cae5b814eb582d3073e79b6f Mon Sep 17 00:00:00 2001 From: nthansen <5091299+nthansen@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:40:38 -0500 Subject: [PATCH] feat(doc-sweep): add PR-time docs-staleness CI check with [skip docs] ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships a deterministic (no-LLM, no-secret) GitHub Actions check that fails a PR when non-doc files changed but no docs did — catching drift on human commits, non-doc-sweep contributors, and fork PRs that the local push guard structurally can't reach. Baseline is the PR merge base (no marker). A code-only change clears it by updating docs or adding a `[skip docs]` token (in a commit message or the PR body). Installed via a new manual `install-docs-ci` skill that vendors a self-contained check script and scaffolds the workflow (no external action ref). The local revise-docs push guard now honors the same `[skip docs]` token (must be on every non-doc commit in range) and is repositioned as the optional pre-push companion. funbox itself stops running the local hook and dogfoods the CI check (advisory). Retiring the hook entirely is a deferred decision (noted in CLAUDE.md). Includes: docs-ci-check.sh + tests, install-docs-ci skill + eval benchmark (1.0), README/CHANGELOG/CLAUDE.md updates, and the archived OpenSpec change with synced living specs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/doc-sweep-revise-push.sh | 115 --------------- .claude/hooks/doc-sweep-revise.json | 1 - .claude/settings.json | 16 +-- .github/workflows/docs-staleness.yml | 29 ++++ CLAUDE.md | 23 ++- .../.openspec.yaml | 2 + .../design.md | 96 +++++++++++++ .../proposal.md | 54 +++++++ .../specs/docs-staleness-ci/spec.md | 93 ++++++++++++ .../specs/revise-docs-push-guard/spec.md | 29 ++++ .../2026-07-03-add-docs-staleness-ci/tasks.md | 33 +++++ openspec/specs/docs-staleness-ci/spec.md | 104 ++++++++++++++ openspec/specs/revise-docs-push-guard/spec.md | 13 +- plugins/doc-sweep/CHANGELOG.md | 17 +++ plugins/doc-sweep/README.md | 42 +++++- plugins/doc-sweep/hooks/docs-ci-check.sh | 118 +++++++++++++++ plugins/doc-sweep/hooks/revise-push-guard.sh | 36 ++++- plugins/doc-sweep/hooks/test-docs-ci-check.sh | 63 ++++++++ .../doc-sweep/hooks/test-revise-push-guard.sh | 11 ++ .../doc-sweep/skills/install-docs-ci/SKILL.md | 135 ++++++++++++++++++ .../install-docs-ci/evals/benchmark.json | 105 ++++++++++++++ .../skills/install-docs-ci/evals/evals.json | 49 +++++++ 22 files changed, 1039 insertions(+), 145 deletions(-) delete mode 100644 .claude/hooks/doc-sweep-revise-push.sh delete mode 100644 .claude/hooks/doc-sweep-revise.json create mode 100644 .github/workflows/docs-staleness.yml create mode 100644 openspec/changes/archive/2026-07-03-add-docs-staleness-ci/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-03-add-docs-staleness-ci/design.md create mode 100644 openspec/changes/archive/2026-07-03-add-docs-staleness-ci/proposal.md create mode 100644 openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/docs-staleness-ci/spec.md create mode 100644 openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/revise-docs-push-guard/spec.md create mode 100644 openspec/changes/archive/2026-07-03-add-docs-staleness-ci/tasks.md create mode 100644 openspec/specs/docs-staleness-ci/spec.md create mode 100644 plugins/doc-sweep/hooks/docs-ci-check.sh create mode 100644 plugins/doc-sweep/hooks/test-docs-ci-check.sh create mode 100644 plugins/doc-sweep/skills/install-docs-ci/SKILL.md create mode 100644 plugins/doc-sweep/skills/install-docs-ci/evals/benchmark.json create mode 100644 plugins/doc-sweep/skills/install-docs-ci/evals/evals.json diff --git a/.claude/hooks/doc-sweep-revise-push.sh b/.claude/hooks/doc-sweep-revise-push.sh deleted file mode 100644 index 5cad2fa..0000000 --- a/.claude/hooks/doc-sweep-revise-push.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# revise-push-guard.sh — Claude Code PreToolUse/Bash hook. -# Blocks `git push` when docs look stale: a non-doc file changed since the last -# revise-docs marker. Reads the event JSON on stdin. Usage: -# revise-push-guard.sh [CONFIG_JSON_PATH] -# Allow = exit 0, no stdout. Deny = print hookSpecificOutput JSON, exit 0. -# Fails OPEN: any internal error allows the push. Uses `node` for JSON (no jq). -set -uo pipefail - -allow(){ exit 0; } -emit_deny(){ # $1 = reason text - node -e 'process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:process.argv[1]}}))' "$1" 2>/dev/null - exit 0 -} - -input="$(cat 2>/dev/null)" || allow -[ -n "$input" ] || allow - -# Extract a dotted-path field from $input via node; exit 3 on parse error. -getfield(){ - printf '%s' "$input" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);const v=process.argv[1].split(".").reduce((a,k)=>(a==null?a:a[k]),o);process.stdout.write(v==null?"":String(v))}catch(e){process.exit(3)}})' "$1" 2>/dev/null -} - -tool="$(getfield tool_name)" || allow -[ "$tool" = "Bash" ] || allow -cmd="$(getfield tool_input.command)" || allow -cwd="$(getfield cwd)" || allow - -# Config (optional) via node — trigger and excludeDirs. -docmode="default"; reposcope="all"; trigger="push"; excludes="" -cfg="${1:-}" -if [ -n "$cfg" ] && [ -f "$cfg" ]; then - docmode="$(node -e 'try{process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).docMode||"default")}catch(e){process.stdout.write("default")}' "$cfg" 2>/dev/null || echo default)" - reposcope="$(node -e 'try{process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).repoScope||"all")}catch(e){process.stdout.write("all")}' "$cfg" 2>/dev/null || echo all)" - trigger="$(node -e 'try{process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).trigger||"push")}catch(e){process.stdout.write("push")}' "$cfg" 2>/dev/null || echo push)" - excludes="$(node -e 'try{const a=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).excludeDirs;process.stdout.write(Array.isArray(a)?a.join("\n"):"")}catch(e){}' "$cfg" 2>/dev/null || echo)" -fi - -# Only gate the configured trigger subcommand (push by default). -case "$trigger" in - commit) sub='commit' ;; - *) sub='push' ;; -esac -printf '%s' "$cmd" | grep -Eq "(^|[^[:alnum:]_])git([[:space:]]+-[^[:space:]]*([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+${sub}([[:space:]]|$)" || allow -# Explicit bypass. -printf '%s' "$cmd" | grep -Eq 'DOC_SWEEP_REVISE_SKIP=1|--no-verify' && allow - -if [ -z "$cwd" ] || [ ! -d "$cwd" ]; then allow; fi -cd "$cwd" 2>/dev/null || allow -git rev-parse --is-inside-work-tree >/dev/null 2>&1 || allow - -# Self-skip: doc-sweep-only scope in a repo without doc-sweep markers. -if [ "$reposcope" = "doc-sweep-only" ]; then - top="$(git rev-parse --show-toplevel 2>/dev/null || echo)" - [ -n "$top" ] || allow - { [ -f "$top/CLAUDE.md" ] || [ -f "$top/.claude/context/audience-rules.md" ]; } || allow -fi - -# Resolve the review marker → range. -gcd="$(git rev-parse --git-common-dir 2>/dev/null)" || allow -case "$gcd" in /*) : ;; *) gcd="$(pwd)/$gcd" ;; esac -marker_file="$gcd/doc-sweep-revise-marker" -range="" -if [ -f "$marker_file" ]; then - msha="$(tr -d '[:space:]' < "$marker_file" 2>/dev/null)" - if [ -n "$msha" ] && git cat-file -e "${msha}^{commit}" 2>/dev/null; then - range="${msha}..HEAD" - fi -fi -if [ -z "$range" ]; then - base="$(git merge-base HEAD origin/HEAD 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null || echo)" - if [ -n "$base" ]; then range="${base}..HEAD"; else allow; fi # can't determine → fail open -fi - -changed="$(git diff --name-only "$range" 2>/dev/null)" || allow -[ -n "$changed" ] || allow # nothing new since marker - -is_doc(){ # $1 = path; doc per $docmode - case "$docmode" in - minimal) - case "$1" in CLAUDE.md|*/CLAUDE.md|README.md|*/README.md) return 0;; esac ;; - with-skill) - case "$1" in SKILL.md|*/SKILL.md) return 0;; esac - case "$1" in CLAUDE*.md|*/CLAUDE*.md|README*.md|*/README*.md|CHANGELOG.md|*/CHANGELOG.md|docs/*|*/docs/*) return 0;; esac ;; - *) # default - case "$1" in CLAUDE*.md|*/CLAUDE*.md|README*.md|*/README*.md|CHANGELOG.md|*/CHANGELOG.md|docs/*|*/docs/*) return 0;; esac ;; - esac - return 1 -} - -nondoc="" -while IFS= read -r f; do - [ -n "$f" ] || continue - skip=0 - if [ -n "$excludes" ]; then - while IFS= read -r ex; do - [ -n "$ex" ] || continue - case "$f" in "$ex"/*|"$ex") skip=1; break;; esac - done <" marker mirroring the hook. +Rejected as needless churn and conflict surface once the merge base gives a free baseline. + +**2. Block, but clear via a `[skip docs]` ack (block + escape hatch).** +The check asserts "docs staleness was *considered*," not "docs ARE stale" (unknowable without an +LLM, and wrong for bug fixes). A code-only PR fails until the author signals consideration. +*Alternatives:* advisory-only (too weak — ignored); path-scoped trigger (crude proxy); LLM-judged +(needs secret/tokens). Rejected in favor of the deterministic ack. + +**3. Ack is a single `[skip docs]` token in a commit message OR the PR body.** +One token, placed in any commit message in the PR range (git-native) or in the PR body (the +forgot-it fallback — editable in-browser with **no history rewrite**). Chosen to mirror the +familiar `[skip ci]` convention, so there's nothing new to learn; a plain bracket token works +uniformly in both a commit message and a PR body, avoiding the trailer-vs-body split an earlier +`Docs-N/A:` trailer design would have forced. This is the direct answer to "what if someone does +a bunch of work and forgets the token." Docs actually changing is always an implicit pass. + +**4. Shared token with the hook.** +The same `[skip docs]` token (in a commit message) also clears the local hook (added to its bypass +set). Two guards, one token — nothing new to learn, no divergence to document. + +**5. Package as a manual install skill + shipped script (mirror `install-revise-hook`).** +New skill `install-docs-ci` (`disable-model-invocation: true`, scoped `allowed-tools`) scaffolds +`.github/workflows/*.yml` into the user's repo; the workflow calls +`plugins/doc-sweep/hooks/docs-ci-check.sh`. Parsing uses `node` (runners have it; consistent with +the hook's no-`jq` rule). *Alternatives:* a reusable/composite `uses:` action (couples users to +funbox's ref + pins — supply-chain surface the repo avoids); doc-only recipe (every user +reimplements the diff/ack parsing). Rejected. + +**6. Reposition the hook as optional secondary; funbox stops requiring it.** +CI becomes the primary guard. The hook stays shipped for users who want fast pre-push feedback, +but funbox's own `.claude/settings.json` drops the required local hook so the sole maintainer +isn't carrying the marker ceremony day-to-day. Whether to retire the hook entirely is deferred +until CI has been lived with — recorded as a `CLAUDE.md` / "considered & revisit" note, not +decided here (deletion is the one hard-to-reverse move under current uncertainty). + +## Risks / Trade-offs + +- **Feedback moves from pre-push to PR time** (a few CI minutes later) → the hook remains + available for anyone who wants the earlier local loop. +- **Ack could be used to rubber-stamp every PR** → a `[skip docs]` token is visible and auditable + in the commit/PR, so waving it through shows up in review; acceptable for a solo/low-traffic + repo, revisit if abused. +- **Two guards until the hook's fate is decided** → mitigated by the shared vocabulary and by + funbox no longer requiring the hook, so there's no day-to-day double friction. +- **Doc-set drift between hook config and CI config** → both read the same doc-file-set default + (`CLAUDE*.md`, `README*.md`, `CHANGELOG.md`, `docs/**`); keep them documented in one place. + +## Migration Plan + +1. Land the shipped script + `install-docs-ci` skill + push-guard shared-ack update (with tests). +2. Dogfood: scaffold the workflow into funbox's `.github/workflows/`; drop the required local hook + from funbox `.claude/settings.json`; update funbox `CLAUDE.md` with the CI guard + deferred + retirement note. +3. Update doc-sweep `README.md`/`CHANGELOG.md`; regenerate the new skill's `/skill-gate` benchmark. +4. Rollback: the change is additive and opt-in — remove the scaffolded workflow and revert the + push-guard bypass addition; nothing else depends on it. + +## Open Questions + +- None blocking. The retire-vs-keep-the-hook decision is intentionally deferred (see Decision 6). diff --git a/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/proposal.md b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/proposal.md new file mode 100644 index 0000000..9aaf2f5 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/proposal.md @@ -0,0 +1,54 @@ +## Why + +doc-sweep's only drift guard today is a local `PreToolUse` push hook: it catches **only +Claude-driven pushes, only in the clone where it's installed**. It structurally cannot enforce +docs review on human commits, on contributors who don't run doc-sweep, or on **any pull request +from a fork**. A PR-time CI check is the one layer that catches everyone — and doc-sweep, being +the documentation-drift plugin, is the natural home for it. + +## What Changes + +- **New CI docs-staleness check.** A deterministic (no-LLM, no API secret) script that, on a + pull request, fails when non-doc files changed but no doc file changed — *unless* the author + acknowledges "docs considered, none needed." Baseline is the PR's **merge base**, so it needs + no committed marker or state file. +- **Explicit ack token** so a legitimate bug fix is never a hard blocker: a single + `[skip docs]` token (mirroring the familiar `[skip ci]` convention) placed in **any commit + message in the PR range** *or* in the **PR body** (the forgot-it fallback, editable in-browser + with no history rewrite) — or an actual doc change (implicit pass). +- **New manual install skill** `install-docs-ci` (`disable-model-invocation: true`, mirroring + `install-revise-hook`) that scaffolds a `.github/workflows/*.yml` into a user's repo calling a + doc-sweep-shipped script; supports reconfigure/uninstall. +- **Unify the existing push-guard's ack vocabulary** with the CI check: the same `[skip docs]` + token that clears CI also clears the local hook, so the two guards speak one language. +- **Reposition the push-guard as optional/secondary** (CI becomes the primary guard). The hook + stays shipped; **funbox itself stops requiring it** locally. Retiring the hook entirely is a + deliberately deferred follow-up, recorded as a note — not done in this change. +- funbox **dogfoods** the new check (adds the workflow to this repo). + +Non-goals (out of scope): removing/deleting the push-guard hook; any LLM-in-CI judgement; +`vscode-thinking-display`; release/changelog automation. + +## Capabilities + +### New Capabilities +- `docs-staleness-ci`: a PR-time, deterministic docs-vs-code drift check plus its manual + installer skill and shipped script; defines the pass/fail logic, the ack vocabulary, and the + install/reconfigure/uninstall contract. + +### Modified Capabilities +- `revise-docs-push-guard`: the local hook SHALL recognize the shared `[skip docs]` ack + token defined by `docs-staleness-ci`, and is repositioned as an optional secondary guard + (funbox no longer requires it). No change to its marker/snapshot mechanism. + +## Impact + +- **New files**: `plugins/doc-sweep/hooks/docs-ci-check.sh` (+ a test harness alongside the + existing `test-revise-push-guard.sh`), `plugins/doc-sweep/skills/install-docs-ci/SKILL.md`, + a scaffolded workflow under funbox's own `.github/workflows/`. +- **Modified**: doc-sweep `README.md`/`CHANGELOG.md`; the push-guard hook script + its spec + (shared ack); funbox `.claude/settings.json` (drop the required local hook); funbox `CLAUDE.md` + (document CI guard + deferred-retirement note). +- **Validation surface**: new skill must pass `claude plugin validate`, the marketplace policy + validator (scoped `allowed-tools`), and its `/skill-gate` benchmark; new shell must pass + `bash -n` + ShellCheck. No new dependencies, no new secrets. diff --git a/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/docs-staleness-ci/spec.md b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/docs-staleness-ci/spec.md new file mode 100644 index 0000000..2dae158 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/docs-staleness-ci/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Deterministic PR-time staleness check + +doc-sweep SHALL provide a deterministic, no-LLM, no-secret check that runs on a pull request +and evaluates documentation staleness against the **merge base** of the PR (no committed marker +or persisted state). The check SHALL classify each file changed between the merge base and the +PR head as a documentation file or a non-documentation file using the configured doc-file set +(default: `CLAUDE*.md`, `README*.md`, `CHANGELOG.md`, `docs/**`); files under configured +excluded directories SHALL be ignored entirely (neither doc nor non-doc). The check SHALL +**fail** if and only if at least one non-doc, non-excluded file changed, no doc file changed, +and no acknowledgment is present. Otherwise it SHALL **pass**. When it fails, it SHALL emit a +message that names the offending non-doc paths and states every way to clear it (update docs, or +add a `[skip docs]` line to any commit message in the PR range or to the PR body). The check SHALL parse the +event/diff with `node` (not `jq`), and SHALL be self-contained in a doc-sweep-shipped script. + +#### Scenario: Code-only change fails + +- **WHEN** a PR changes at least one non-doc, non-excluded file, changes no doc file, and carries no acknowledgment +- **THEN** the check fails and names the offending non-doc paths and the ways to clear it + +#### Scenario: Code plus docs passes + +- **WHEN** a PR changes non-doc files and also changes at least one doc-set file +- **THEN** the check passes with no acknowledgment required + +#### Scenario: Docs-only change passes + +- **WHEN** a PR changes only doc-set files (or only excluded files) +- **THEN** the check passes + +#### Scenario: Excluded paths are ignored + +- **WHEN** a PR changes only files under a configured excluded directory +- **THEN** those files count as neither doc nor non-doc and the check passes + +### Requirement: Shared acknowledgment token + +The check SHALL treat a pull request as acknowledged — and pass despite a code-only diff — when +any one of the following is present: a `[skip docs]` token in any commit message within the PR +range; a `[skip docs]` token in the pull request body; or an actual doc-set change (implicit +pass). The commit-message form and the PR-body form SHALL be equivalent so that an author who +forgot to include the token in a commit can clear the check by editing the PR body without +rewriting git history. `[skip docs]` (chosen to mirror the familiar `[skip ci]` convention) SHALL +be the single acknowledgment token shared with the local `revise-docs-push-guard` hook. + +#### Scenario: Commit-message ack passes + +- **WHEN** a PR has a code-only diff and any commit message in range contains `[skip docs]` +- **THEN** the check passes + +#### Scenario: PR-body ack passes + +- **WHEN** a PR has a code-only diff, no `[skip docs]` in any commit message, but the PR body contains `[skip docs]` +- **THEN** the check passes + +#### Scenario: Ack token is shared with the hook + +- **WHEN** the acknowledgment token is defined +- **THEN** it is the same `[skip docs]` token the local push-guard hook recognizes, so both guards share one language + +### Requirement: Manual installer skill scaffolds the workflow + +doc-sweep SHALL provide a manual, model-non-invocable skill (`install-docs-ci`, +`disable-model-invocation: true`, with scoped `allowed-tools`) that installs the check only when +a user runs it, mirroring the `install-revise-hook` pattern. On a **fresh install** it SHALL +scaffold a GitHub Actions workflow file into the target repository's `.github/workflows/` that +invokes the doc-sweep-shipped check script on `pull_request`, collect the doc-file set and any +excluded directories, and print a structured summary: the workflow path, the doc-set, the ack +tokens, that blocking is the maintainer's branch-protection choice, and how to reconfigure or +uninstall by re-running the skill. When an install already exists it SHALL offer +Reconfigure / Uninstall / Cancel and SHALL be idempotent (it SHALL NOT duplicate the workflow). +Nothing SHALL be installed until the user runs the skill and confirms. + +#### Scenario: Fresh install scaffolds and summarizes + +- **WHEN** a user runs the installer and confirms scoping choices +- **THEN** a `pull_request` workflow calling the shipped check script is written to `.github/workflows/`, and a structured summary with reconfigure/uninstall instructions is printed + +#### Scenario: Idempotent re-run + +- **WHEN** the installer is run again in a repo that already has the workflow +- **THEN** it does not duplicate the workflow and offers Reconfigure / Uninstall / Cancel + +#### Scenario: Uninstall + +- **WHEN** the user chooses uninstall +- **THEN** the scaffolded workflow file is removed, leaving other workflows and settings intact + +#### Scenario: Plugin install alone is inert + +- **WHEN** doc-sweep is installed but the installer skill has not been run +- **THEN** no workflow is scaffolded and no pull request is gated diff --git a/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/revise-docs-push-guard/spec.md b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/revise-docs-push-guard/spec.md new file mode 100644 index 0000000..9ce8683 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/specs/revise-docs-push-guard/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Self-skip, bypass, and fail-open + +The hook SHALL NOT obstruct work outside its intended scope. When configured for +doc-sweep-enabled repos only, it SHALL allow immediately in a repository lacking +doc-sweep markers (e.g. no `.claude/context/audience-rules.md` or `CLAUDE.md`). It +SHALL allow when the push command carries an explicit one-shot bypass token +(`DOC_SWEEP_REVISE_SKIP=1` or `--no-verify`). It SHALL additionally allow when every +non-doc commit in the gated range carries the shared `[skip docs]` acknowledgment token +(in its commit message) defined by the `docs-staleness-ci` capability, so a change +acknowledged as not needing docs clears both the local hook and the CI check with one +token. On any internal error it SHALL fail open (allow the push) rather than block. + +#### Scenario: Unrelated repo is skipped +- **WHEN** repo applicability is "doc-sweep-enabled only" and the current repo has no doc-sweep markers +- **THEN** the hook allows the push without evaluating staleness + +#### Scenario: Explicit bypass +- **WHEN** the push command contains `DOC_SWEEP_REVISE_SKIP=1` or `--no-verify` +- **THEN** the hook allows the push + +#### Scenario: Shared [skip docs] acknowledgment clears the hook +- **WHEN** the non-doc commits in the gated range each contain `[skip docs]` in their commit message +- **THEN** the hook allows the push, treating the change as acknowledged as not needing docs + +#### Scenario: Internal error fails open +- **WHEN** the hook encounters an internal error (e.g. cannot resolve the marker or run git) +- **THEN** it allows the push and emits a non-blocking note rather than denying diff --git a/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/tasks.md b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/tasks.md new file mode 100644 index 0000000..038efb9 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-docs-staleness-ci/tasks.md @@ -0,0 +1,33 @@ +## 1. Shipped check script + +- [x] 1.1 Create `plugins/doc-sweep/hooks/docs-ci-check.sh` (LF; `#!/usr/bin/env bash`, `set -euo pipefail`) that: resolves the merge base, lists changed files, classifies doc vs non-doc vs excluded using the default doc-set (`CLAUDE*.md`, `README*.md`, `CHANGELOG.md`, `docs/**`), and exits non-zero only when non-doc changed, no doc changed, and no ack is present +- [x] 1.2 Parse the GitHub event + diff with `node` (not `jq`); read config for doc-set/excluded overrides consistent with the push-guard's config shape +- [x] 1.3 Recognize the `[skip docs]` ack in any commit message in the PR range OR in the PR body; treat any doc-set change as an implicit pass +- [x] 1.4 On failure, print a message naming the offending non-doc paths and every way to clear it (update docs, or add `[skip docs]` to a commit message or the PR body) +- [x] 1.5 Add `plugins/doc-sweep/hooks/test-docs-ci-check.sh` covering the scenario matrix: code-only→fail; code+docs→pass; docs-only→pass; excluded-only→pass; commit-message ack→pass; PR-body ack→pass +- [x] 1.6 Confirm `bash -n` and ShellCheck pass on both scripts (keep them LF per `.gitattributes`) + +## 2. Install skill + +- [x] 2.1 Create `plugins/doc-sweep/skills/install-docs-ci/SKILL.md` with `disable-model-invocation: true` and scoped `allowed-tools` (mirroring `install-revise-hook`) +- [x] 2.2 Implement fresh-install flow: collect doc-set/excluded choices, scaffold a `pull_request` workflow into `.github/workflows/` that invokes the shipped check script, and print the structured summary (workflow path, doc-set, `[skip docs]` token, branch-protection note, reconfigure/uninstall instructions) +- [x] 2.3 Implement idempotent detect + Reconfigure / Uninstall / Cancel; ensure re-run does not duplicate the workflow and uninstall removes only the scaffolded workflow +- [x] 2.4 Add the skill's `/`-command entry so it is invocable as `/doc-sweep:install-docs-ci` + +## 3. Unify the push-guard ack + +- [x] 3.1 Update `plugins/doc-sweep/hooks/revise-push-guard.sh` to additionally allow when every non-doc commit in the gated range carries `[skip docs]` in its commit message (keep existing `DOC_SWEEP_REVISE_SKIP=1` / `--no-verify` bypass and fail-open behavior) +- [x] 3.2 Extend `plugins/doc-sweep/hooks/test-revise-push-guard.sh` with a `[skip docs]`-clears-the-hook case; re-run `bash -n` + ShellCheck + +## 4. Dogfood in funbox + +- [x] 4.1 Scaffold the docs-staleness workflow into funbox's own `.github/workflows/` (or add it as a job) wired to the shipped check script +- [x] 4.2 Remove the required local revise-docs push hook from funbox `.claude/settings.json` (CI becomes the primary guard) +- [x] 4.3 Update funbox `CLAUDE.md`: document the CI docs guard + the `[skip docs]` token, and add a "considered / revisit-if" note that retiring the push-guard hook is a deferred decision pending experience with CI + +## 5. Docs, quality gate, validation + +- [x] 5.1 Update `plugins/doc-sweep/README.md` (new CI check + install skill + `[skip docs]`) and `CHANGELOG.md` +- [x] 5.2 Generate the `install-docs-ci` skill's `evals/benchmark.json` via `/skill-gate` so it clears the threshold; confirm `node scripts/check-skill-gate.mjs` passes +- [x] 5.3 Run `node scripts/validate-marketplace.mjs`, `claude plugin validate plugins/doc-sweep`, and `openspec validate --strict --all`; fix any findings +- [x] 5.4 Verify `node scripts/check-openspec-hygiene.mjs` is clean, then archive the change with `/opsx:archive` diff --git a/openspec/specs/docs-staleness-ci/spec.md b/openspec/specs/docs-staleness-ci/spec.md new file mode 100644 index 0000000..7ef5a5d --- /dev/null +++ b/openspec/specs/docs-staleness-ci/spec.md @@ -0,0 +1,104 @@ +# docs-staleness-ci Specification + +## Purpose +Give doc-sweep a PR-time, deterministic (no-LLM, no-secret) docs-staleness check that fails a +pull request when non-doc files changed but no documentation did — closing the enforcement gap +the local `revise-docs-push-guard` hook cannot reach (human commits, contributors without +doc-sweep, and fork PRs). The check is keyed on the PR merge base (no marker), and a code-only +change clears it by updating docs or adding a `[skip docs]` acknowledgment (in a commit message +or the PR body) — the same token the local hook honors. It ships as a manual installer skill +(`install-docs-ci`) that vendors a self-contained check script and scaffolds a `pull_request` +workflow, so there is no external action reference to trust. +## Requirements +### Requirement: Deterministic PR-time staleness check + +doc-sweep SHALL provide a deterministic, no-LLM, no-secret check that runs on a pull request +and evaluates documentation staleness against the **merge base** of the PR (no committed marker +or persisted state). The check SHALL classify each file changed between the merge base and the +PR head as a documentation file or a non-documentation file using the configured doc-file set +(default: `CLAUDE*.md`, `README*.md`, `CHANGELOG.md`, `docs/**`); files under configured +excluded directories SHALL be ignored entirely (neither doc nor non-doc). The check SHALL +**fail** if and only if at least one non-doc, non-excluded file changed, no doc file changed, +and no acknowledgment is present. Otherwise it SHALL **pass**. When it fails, it SHALL emit a +message that names the offending non-doc paths and states every way to clear it (update docs, or +add a `[skip docs]` line to any commit message in the PR range or to the PR body). The check SHALL parse the +event/diff with `node` (not `jq`), and SHALL be self-contained in a doc-sweep-shipped script. + +#### Scenario: Code-only change fails + +- **WHEN** a PR changes at least one non-doc, non-excluded file, changes no doc file, and carries no acknowledgment +- **THEN** the check fails and names the offending non-doc paths and the ways to clear it + +#### Scenario: Code plus docs passes + +- **WHEN** a PR changes non-doc files and also changes at least one doc-set file +- **THEN** the check passes with no acknowledgment required + +#### Scenario: Docs-only change passes + +- **WHEN** a PR changes only doc-set files (or only excluded files) +- **THEN** the check passes + +#### Scenario: Excluded paths are ignored + +- **WHEN** a PR changes only files under a configured excluded directory +- **THEN** those files count as neither doc nor non-doc and the check passes + +### Requirement: Shared acknowledgment token + +The check SHALL treat a pull request as acknowledged — and pass despite a code-only diff — when +any one of the following is present: a `[skip docs]` token in any commit message within the PR +range; a `[skip docs]` token in the pull request body; or an actual doc-set change (implicit +pass). The commit-message form and the PR-body form SHALL be equivalent so that an author who +forgot to include the token in a commit can clear the check by editing the PR body without +rewriting git history. `[skip docs]` (chosen to mirror the familiar `[skip ci]` convention) SHALL +be the single acknowledgment token shared with the local `revise-docs-push-guard` hook. + +#### Scenario: Commit-message ack passes + +- **WHEN** a PR has a code-only diff and any commit message in range contains `[skip docs]` +- **THEN** the check passes + +#### Scenario: PR-body ack passes + +- **WHEN** a PR has a code-only diff, no `[skip docs]` in any commit message, but the PR body contains `[skip docs]` +- **THEN** the check passes + +#### Scenario: Ack token is shared with the hook + +- **WHEN** the acknowledgment token is defined +- **THEN** it is the same `[skip docs]` token the local push-guard hook recognizes, so both guards share one language + +### Requirement: Manual installer skill scaffolds the workflow + +doc-sweep SHALL provide a manual, model-non-invocable skill (`install-docs-ci`, +`disable-model-invocation: true`, with scoped `allowed-tools`) that installs the check only when +a user runs it, mirroring the `install-revise-hook` pattern. On a **fresh install** it SHALL +scaffold a GitHub Actions workflow file into the target repository's `.github/workflows/` that +invokes the doc-sweep-shipped check script on `pull_request`, collect the doc-file set and any +excluded directories, and print a structured summary: the workflow path, the doc-set, the ack +tokens, that blocking is the maintainer's branch-protection choice, and how to reconfigure or +uninstall by re-running the skill. When an install already exists it SHALL offer +Reconfigure / Uninstall / Cancel and SHALL be idempotent (it SHALL NOT duplicate the workflow). +Nothing SHALL be installed until the user runs the skill and confirms. + +#### Scenario: Fresh install scaffolds and summarizes + +- **WHEN** a user runs the installer and confirms scoping choices +- **THEN** a `pull_request` workflow calling the shipped check script is written to `.github/workflows/`, and a structured summary with reconfigure/uninstall instructions is printed + +#### Scenario: Idempotent re-run + +- **WHEN** the installer is run again in a repo that already has the workflow +- **THEN** it does not duplicate the workflow and offers Reconfigure / Uninstall / Cancel + +#### Scenario: Uninstall + +- **WHEN** the user chooses uninstall +- **THEN** the scaffolded workflow file is removed, leaving other workflows and settings intact + +#### Scenario: Plugin install alone is inert + +- **WHEN** doc-sweep is installed but the installer skill has not been run +- **THEN** no workflow is scaffolded and no pull request is gated + diff --git a/openspec/specs/revise-docs-push-guard/spec.md b/openspec/specs/revise-docs-push-guard/spec.md index 19e6d2b..f26e2ff 100644 --- a/openspec/specs/revise-docs-push-guard/spec.md +++ b/openspec/specs/revise-docs-push-guard/spec.md @@ -90,9 +90,12 @@ so the gate can distinguish reviewed from unreviewed work and a retried command The hook SHALL NOT obstruct work outside its intended scope. When configured for doc-sweep-enabled repos only, it SHALL allow immediately in a repository lacking doc-sweep markers (e.g. no `.claude/context/audience-rules.md` or `CLAUDE.md`). It -SHALL allow when the push command carries an explicit bypass token -(`DOC_SWEEP_REVISE_SKIP=1` or `--no-verify`). On any internal error it SHALL fail open -(allow the push) rather than block. +SHALL allow when the push command carries an explicit one-shot bypass token +(`DOC_SWEEP_REVISE_SKIP=1` or `--no-verify`). It SHALL additionally allow when every +non-doc commit in the gated range carries the shared `[skip docs]` acknowledgment token +(in its commit message) defined by the `docs-staleness-ci` capability, so a change +acknowledged as not needing docs clears both the local hook and the CI check with one +token. On any internal error it SHALL fail open (allow the push) rather than block. #### Scenario: Unrelated repo is skipped - **WHEN** repo applicability is "doc-sweep-enabled only" and the current repo has no doc-sweep markers @@ -102,6 +105,10 @@ SHALL allow when the push command carries an explicit bypass token - **WHEN** the push command contains `DOC_SWEEP_REVISE_SKIP=1` or `--no-verify` - **THEN** the hook allows the push +#### Scenario: Shared [skip docs] acknowledgment clears the hook +- **WHEN** the non-doc commits in the gated range each contain `[skip docs]` in their commit message +- **THEN** the hook allows the push, treating the change as acknowledged as not needing docs + #### Scenario: Internal error fails open - **WHEN** the hook encounters an internal error (e.g. cannot resolve the marker or run git) - **THEN** it allows the push and emits a non-blocking note rather than denying diff --git a/plugins/doc-sweep/CHANGELOG.md b/plugins/doc-sweep/CHANGELOG.md index 9ba8354..7fab297 100644 --- a/plugins/doc-sweep/CHANGELOG.md +++ b/plugins/doc-sweep/CHANGELOG.md @@ -10,6 +10,23 @@ For what the plugin does and how to use it, see [README.md](README.md). ## Notable additions +**Docs-staleness CI check** (`add-docs-staleness-ci`, 2026-07) + +- New PR-time GitHub Actions check (`hooks/docs-ci-check.sh`) that **fails** a pull request when + non-doc files changed but no documentation did — catching drift on human commits, contributors + without doc-sweep, and **fork PRs**, which the local push guard can't reach. Deterministic: no + LLM, no API key, no secret; baseline is the PR merge base (no marker). Uses `node` (no `jq`) and + fails open if the base can't be resolved. +- New acknowledgment token **`[skip docs]`** (mirrors `[skip ci]`): a code-only PR clears the + check by updating docs, or putting `[skip docs]` in any commit message, or in the PR body + (editable in-browser — no rebase). The **same token now also clears the local push guard** (it + must be present on every non-doc commit in the range), so both guards share one vocabulary. +- New installer skill `install-docs-ci` (`/doc-sweep:install-docs-ci`) that vendors the check + script under `.github/doc-sweep/` and scaffolds a self-contained `pull_request` workflow — no + external action reference. Idempotent, with reconfigure/uninstall. +- The push guard is repositioned as the optional *local, pre-push* companion; CI is the primary + drift guard. (funbox itself now uses the CI check and no longer installs the local hook.) + **Guard improvements** (`revise-docs-push-guard` branch, 2026-06) - **Configurable trigger** — the guard can now gate `git commit` instead of `git push` diff --git a/plugins/doc-sweep/README.md b/plugins/doc-sweep/README.md index 73dccea..bfa5689 100644 --- a/plugins/doc-sweep/README.md +++ b/plugins/doc-sweep/README.md @@ -79,11 +79,43 @@ Installing doc-sweep **auto-installs `claude-md-management`** — as long as you `claude-plugins-official` marketplace added (most setups do). If you don't, Claude Code reports a `dependency-unsatisfied` error with the command to add it. +## CI docs check (opt-in) + +The **docs-staleness CI check** is a GitHub Actions workflow that runs on every pull request and +**fails** when non-doc files changed but no documentation did. Unlike the local push guard below, +it catches drift on *everyone's* contributions — human commits, contributors without doc-sweep, +and **fork PRs**. It's deterministic: no LLM, no API key, no secret. The baseline is the PR's +merge base, so there's no marker or state to maintain. + +A pull request clears the check when any one of these is true: + +- a documentation file was updated, or +- a **`[skip docs]`** token (mirroring `[skip ci]`) appears in any commit message in the PR, or +- a **`[skip docs]`** line appears in the PR description (editable in the browser — no rebase). + +So a legitimate code-only change — a bug fix that needs no docs — is never a hard blocker; you +just add `[skip docs]`. + +Nothing is installed automatically. To set it up, run: + +```text +/doc-sweep:install-docs-ci +``` + +The installer vendors the check script under `.github/doc-sweep/` and scaffolds a `pull_request` +workflow at `.github/workflows/doc-sweep-docs.yml` — self-contained, with no external action +reference to trust. Commit the scaffolded files so the check runs on future PRs. Whether a failing +check **blocks** merge is your branch-protection choice: make the `docs-staleness` job a required +status check to block, otherwise it's advisory. Re-run the command to reconfigure or uninstall. + +The CI check and the push guard below share the same **`[skip docs]`** token, so you learn one +acknowledgment for both. + ## Push guard (opt-in) -The **revise-docs push guard** is an optional `PreToolUse` hook that blocks a Claude-driven -`git push` when documentation looks stale — specifically, when a non-doc file has changed -since docs were last reviewed. It prompts Claude to run `/doc-sweep:revise-docs-and-mark` +The **revise-docs push guard** is an optional `PreToolUse` hook — the *local, pre-push* companion +to the CI check above — that blocks a Claude-driven `git push` when documentation looks stale — +specifically, when a non-doc file has changed since docs were last reviewed. It prompts Claude to run `/doc-sweep:revise-docs-and-mark` — a thin wrapper that runs the normal `revise-docs` review (unchanged) and then records the review snapshot the hook checks — commit any doc changes, and then push. The snapshot mechanism lives entirely in the guard; `revise-docs` itself is untouched. @@ -118,6 +150,10 @@ DOC_SWEEP_REVISE_SKIP=1 git push git push --no-verify ``` +You can also acknowledge a change as not needing docs with the shared **`[skip docs]`** token +(the same one the CI check honors): put `[skip docs]` in the commit message. The guard clears +once every non-doc commit in the range since the last review carries it. + ### Caveats - **Only Claude-driven pushes are gated.** A `git push` you run directly in a terminal diff --git a/plugins/doc-sweep/hooks/docs-ci-check.sh b/plugins/doc-sweep/hooks/docs-ci-check.sh new file mode 100644 index 0000000..6ffbadd --- /dev/null +++ b/plugins/doc-sweep/hooks/docs-ci-check.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# docs-ci-check.sh — doc-sweep PR-time docs-staleness check for GitHub Actions. +# Fails (exit 1) only when non-doc files changed in the PR but no doc file changed +# AND no `[skip docs]` acknowledgment is present. Otherwise passes (exit 0). +# +# Usage (in a `pull_request` workflow): +# plugins/doc-sweep/hooks/docs-ci-check.sh [CONFIG_JSON_PATH] +# +# Baseline is the PR's merge base — no committed marker or state file. +# Inputs (first available wins): +# - DOCS_CI_BASE : explicit base ref/sha (override; used by tests) +# - GITHUB_EVENT_PATH : the pull_request event payload (real GHA) +# - GITHUB_BASE_REF : the target branch name (real GHA fallback) +# - DOCS_CI_PR_BODY : PR body override (tests); else read from the event payload +# Uses `node` for JSON (no jq), matching revise-push-guard.sh. Fails OPEN (passes with a +# warning on stderr) if the base cannot be resolved, so infra hiccups never block a PR. +set -uo pipefail + +warn(){ printf 'docs-ci-check: %s\n' "$1" >&2; } +pass(){ exit 0; } + +# --- config (optional): docMode + excludeDirs, same shape as the push guard --- +docmode="default"; excludes="" +cfg="${1:-}" +if [ -n "$cfg" ] && [ -f "$cfg" ]; then + docmode="$(node -e 'try{process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).docMode||"default")}catch(e){process.stdout.write("default")}' "$cfg" 2>/dev/null || echo default)" + excludes="$(node -e 'try{const a=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).excludeDirs;process.stdout.write(Array.isArray(a)?a.join("\n"):"")}catch(e){}' "$cfg" 2>/dev/null || echo)" +fi + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { warn "not a git work tree; passing"; pass; } + +# --- resolve the PR body (for the [skip docs] ack) --- +pr_body="${DOCS_CI_PR_BODY:-}" +if [ -z "$pr_body" ] && [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "${GITHUB_EVENT_PATH}" ]; then + pr_body="$(node -e 'try{const e=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write((e.pull_request&&e.pull_request.body)||"")}catch(x){}' "$GITHUB_EVENT_PATH" 2>/dev/null || echo)" +fi + +# --- resolve the base commit (merge base of the PR) --- +base_ref="" +if [ -n "${DOCS_CI_BASE:-}" ]; then + base_ref="$DOCS_CI_BASE" +elif [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "${GITHUB_EVENT_PATH}" ]; then + base_ref="$(node -e 'try{const e=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write((e.pull_request&&e.pull_request.base&&e.pull_request.base.sha)||"")}catch(x){}' "$GITHUB_EVENT_PATH" 2>/dev/null || echo)" +elif [ -n "${GITHUB_BASE_REF:-}" ]; then + base_ref="origin/${GITHUB_BASE_REF}" +fi +[ -n "$base_ref" ] || { warn "cannot determine PR base ref; passing (fail-open)"; pass; } + +base_sha="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || echo)" +[ -n "$base_sha" ] || { warn "base ref '${base_ref}' not found; passing (fail-open)"; pass; } +mb="$(git merge-base "$base_sha" HEAD 2>/dev/null || echo "$base_sha")" + +# --- the [skip docs] acknowledgment: PR body OR any commit message in range --- +# Case-insensitive substring match via node (no grep: avoids fixed-string/locale quirks). +has_ack(){ + local text + text="$pr_body +$(git log --format=%B "${mb}..HEAD" 2>/dev/null || true)" + printf '%s' "$text" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.exit(/\[skip docs\]/i.test(s)?0:1)})' 2>/dev/null +} + +changed="$(git diff --name-only "${mb}..HEAD" 2>/dev/null)" || { warn "cannot diff ${mb}..HEAD; passing (fail-open)"; pass; } +[ -n "$changed" ] || pass # nothing changed + +is_doc(){ # $1 = path; doc per $docmode (identical classification to revise-push-guard.sh) + case "$docmode" in + minimal) + case "$1" in CLAUDE.md|*/CLAUDE.md|README.md|*/README.md) return 0;; esac ;; + with-skill) + case "$1" in SKILL.md|*/SKILL.md) return 0;; esac + case "$1" in CLAUDE*.md|*/CLAUDE*.md|README*.md|*/README*.md|CHANGELOG.md|*/CHANGELOG.md|docs/*|*/docs/*) return 0;; esac ;; + *) # default + case "$1" in CLAUDE*.md|*/CLAUDE*.md|README*.md|*/README*.md|CHANGELOG.md|*/CHANGELOG.md|docs/*|*/docs/*) return 0;; esac ;; + esac + return 1 +} + +nondoc=""; docchanged=0 +while IFS= read -r f; do + [ -n "$f" ] || continue + skip=0 + if [ -n "$excludes" ]; then + while IFS= read -r ex; do + [ -n "$ex" ] || continue + case "$f" in "$ex"/*|"$ex") skip=1; break;; esac + done <&2 +exit 1 diff --git a/plugins/doc-sweep/hooks/revise-push-guard.sh b/plugins/doc-sweep/hooks/revise-push-guard.sh index 5cad2fa..980906c 100644 --- a/plugins/doc-sweep/hooks/revise-push-guard.sh +++ b/plugins/doc-sweep/hooks/revise-push-guard.sh @@ -107,9 +107,43 @@ done </dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.exit(/\[skip docs\]/i.test(s)?0:1)})' 2>/dev/null +} + if [ -n "$nondoc" ]; then + # Shared `[skip docs]` acknowledgment (same token as the docs-staleness CI check). The hook's + # range is long-lived (marker..HEAD), so — unlike the single-PR CI check — the token must be + # present on EVERY non-doc commit to clear, otherwise one acked commit would disable the guard + # for all later work until the marker advances. Uses node (no grep) to match the CI check. + unacked=0 + while IFS= read -r c; do + [ -n "$c" ] || continue + has_skip "$c" && continue # this commit is acknowledged + while IFS= read -r f; do + [ -n "$f" ] || continue + skip=0 + if [ -n "$excludes" ]; then + while IFS= read -r ex; do + [ -n "$ex" ] || continue + case "$f" in "$ex"/*|"$ex") skip=1; break;; esac + done </dev/null) +CF + [ "$unacked" = 1 ] && break + done </dev/null) +CL + [ "$unacked" = 0 ] && allow # every non-doc commit carried [skip docs] + verb="push"; [ "$trigger" = "commit" ] && verb="commit" # shellcheck disable=SC2086 - emit_deny "Docs may be stale — non-doc file(s) changed since docs were last reviewed:${nondoc}. Run /doc-sweep:revise-docs-and-mark to review docs and record the review snapshot, commit any changes, then ${verb} again. (Add DOC_SWEEP_REVISE_SKIP=1 before the command, or --no-verify, to bypass.)" + emit_deny "Docs may be stale — non-doc file(s) changed since docs were last reviewed:${nondoc}. Run /doc-sweep:revise-docs-and-mark to review docs and record the review snapshot, commit any changes, then ${verb} again. (Add '[skip docs]' to the commit message, or DOC_SWEEP_REVISE_SKIP=1 / --no-verify before the command, to bypass.)" fi allow diff --git a/plugins/doc-sweep/hooks/test-docs-ci-check.sh b/plugins/doc-sweep/hooks/test-docs-ci-check.sh new file mode 100644 index 0000000..7a5e124 --- /dev/null +++ b/plugins/doc-sweep/hooks/test-docs-ci-check.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Scenario tests for docs-ci-check.sh. Run: bash test-docs-ci-check.sh +set -uo pipefail +SCRIPT="$(cd "$(dirname "$0")" && pwd)/docs-ci-check.sh" +fail=0 + +mkrepo(){ d="$(mktemp -d)"; ( cd "$d" && git init -q && git config user.email a@b.c && git config user.name t \ + && echo x > f.txt && git add . && git commit -qm init ) ; echo "$d"; } +basesha(){ git -C "$1" rev-parse HEAD; } +# commitfile REPO PATH [MESSAGE] +commitfile(){ local d="$1" f="$2" m="${3:-change $2}"; mkdir -p "$d/$(dirname "$f")"; echo x >> "$d/$f"; git -C "$d" add -A; git -C "$d" commit -qm "$m"; } +# run BASE REPO [PR_BODY] [CFG] -> exit code of the check +run(){ ( cd "$2" && DOCS_CI_BASE="$1" DOCS_CI_PR_BODY="${3:-}" bash "$SCRIPT" "${4:-}" >/dev/null 2>&1 ); } +assert_fail(){ if [ "$1" -ne 0 ]; then echo "ok: $2"; else echo "FAIL(expected fail): $2"; fail=1; fi; } +assert_pass(){ if [ "$1" -eq 0 ]; then echo "ok: $2"; else echo "FAIL(expected pass): $2"; fail=1; fi; } + +# 1. code-only → fail +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" src/app.js +run "$base" "$repo"; assert_fail $? "code-only change fails" + +# 2. code + docs → pass +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" src/app.js; commitfile "$repo" README.md +run "$base" "$repo"; assert_pass $? "code + docs passes" + +# 3. docs-only → pass +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" README.md +run "$base" "$repo"; assert_pass $? "docs-only passes" + +# 4. deep docs path → pass +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" docs/api/ref.md +run "$base" "$repo"; assert_pass $? "deep docs/ path passes" + +# 5. excluded-only → pass +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" vendor/lib/main.js +cfg="$(mktemp)"; echo '{"excludeDirs":["vendor"]}' > "$cfg" +run "$base" "$repo" "" "$cfg"; assert_pass $? "excluded-only change passes" + +# 6. commit-message ack → pass +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" src/app.js "fix: bug [skip docs]" +run "$base" "$repo"; assert_pass $? "commit-message [skip docs] passes" + +# 7. PR-body ack → pass +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" src/app.js +run "$base" "$repo" "Small fix. [skip docs] no docs needed."; assert_pass $? "PR-body [skip docs] passes" + +# 8. ack is case-insensitive → pass +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" src/app.js +run "$base" "$repo" "[SKIP DOCS]"; assert_pass $? "ack is case-insensitive" + +# 9. nothing changed (base = HEAD) → pass +repo="$(mkrepo)"; base="$(basesha "$repo")" +run "$base" "$repo"; assert_pass $? "no changes passes" + +# 10. unresolvable base → fail-open (pass) +repo="$(mkrepo)"; commitfile "$repo" src/app.js +run "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" "$repo"; assert_pass $? "unresolvable base fails open" + +# 11. minimal docMode: CHANGELOG counts as non-doc → fail +repo="$(mkrepo)"; base="$(basesha "$repo")"; commitfile "$repo" CHANGELOG.md +cfg="$(mktemp)"; echo '{"docMode":"minimal"}' > "$cfg" +run "$base" "$repo" "" "$cfg"; assert_fail $? "minimal docMode: CHANGELOG is non-doc" + +exit $fail diff --git a/plugins/doc-sweep/hooks/test-revise-push-guard.sh b/plugins/doc-sweep/hooks/test-revise-push-guard.sh index 003cf2b..e9a579d 100644 --- a/plugins/doc-sweep/hooks/test-revise-push-guard.sh +++ b/plugins/doc-sweep/hooks/test-revise-push-guard.sh @@ -84,4 +84,15 @@ out="$(run 'git push' "$repo" "$cfg")"; assert_allow "$out" "excluded vendor sou ( cd "$repo" && echo a > app.js && echo b > vendor/lib/README.md && git add . && git commit -qm mix ) out="$(run 'git push' "$repo" "$cfg")"; assert_deny "$out" "vendor README does not satisfy doc review" +# --- shared [skip docs] acknowledgment clears the hook (parity with the CI check) --- + +# non-doc change whose commit message carries [skip docs] → allow +repo="$(mkrepo)"; mark "$repo" +( cd "$repo" && echo y > new.js && git add . && git commit -qm "fix: bug [skip docs]" ) +out="$(run 'git push' "$repo" "$no_cfg")"; assert_allow "$out" "[skip docs] in commit message allows push" + +# a later un-acked non-doc commit still blocks (token must be present in the range) +commitfile "$repo" other.js +out="$(run 'git push' "$repo" "$no_cfg")"; assert_deny "$out" "un-acked later non-doc commit still denies" + exit $fail diff --git a/plugins/doc-sweep/skills/install-docs-ci/SKILL.md b/plugins/doc-sweep/skills/install-docs-ci/SKILL.md new file mode 100644 index 0000000..986ca14 --- /dev/null +++ b/plugins/doc-sweep/skills/install-docs-ci/SKILL.md @@ -0,0 +1,135 @@ +--- +name: install-docs-ci +description: Install (or remove) an opt-in GitHub Actions check that fails a pull request when code changed but docs did not, unless the author adds a [skip docs] acknowledgment. Use to set up, reconfigure, or uninstall the doc-sweep docs-staleness CI check. +allowed-tools: + - Read + - Write + - Edit + - AskUserQuestion + - Bash(git rev-parse*) + - Bash(mkdir -p*) + - Bash(cp *) + - Bash(rm -f*) + - Bash(git config*) + - Bash(git ls-files*) +disable-model-invocation: true +--- + +# Install the docs-staleness CI check + +Set up an opt-in GitHub Actions workflow that runs on every pull request and **fails** when +non-doc files changed but no documentation did — unless the author acknowledges the change +doesn't need docs with a `[skip docs]` token (mirroring `[skip ci]`). Unlike the local +`revise-docs-push-guard` hook, this catches **everyone** — human commits, contributors without +doc-sweep, and **fork PRs**. It is deterministic: no LLM, no API key, no secret. The check +parses the event with `node` (not `jq`) and fails open (passes) if it can't resolve the PR base. + +**Nothing is installed until you run this and confirm.** This scaffolds files into the target +repository; commit them yourself so the check runs on future PRs. + +The check clears when any one of these is present in a PR: an updated doc-set file; a `[skip docs]` +token in any commit message in the PR; or a `[skip docs]` line in the PR description (editable in +the browser — no rebase needed). This is the **same `[skip docs]` token** the local push-guard +hook recognizes, so the two guards share one vocabulary. + +## Steps + +1. **Detect an existing install.** Look for the scaffolded workflow at + `${CLAUDE_PROJECT_DIR}/.github/workflows/doc-sweep-docs.yml` (and the vendored script at + `.github/doc-sweep/docs-ci-check.sh`). + + - If **no install** is found → proceed to step 2 (fresh install). + - If an install **is found**, offer three choices via `AskUserQuestion`: + - **Reconfigure** — re-ask the choices in step 2 pre-filled from the existing + `.github/doc-sweep/docs-ci.json`; rewrite that config and re-copy the script; leave the + workflow file in place (rewrite it only if its path/name changed); print the summary + (step 6). Stop. + - **Uninstall** — follow the Uninstall section below. Stop. + - **Cancel** — do nothing and exit. Stop. + +2. **Collect scope (AskUserQuestion).** Ask both in one prompt, with defaults called out: + + - **Doc-file set** — `default` (CLAUDE*.md, README*.md, CHANGELOG.md, docs/**), + `with-skill` (also treats SKILL.md as a doc), or `minimal` (CLAUDE.md + README.md only). + Recommend `default`. Recorded as `docMode` (identical meaning to the push-guard config). + - **Excluded directories** — confirm the vendored/generated dirs whose changes should be + ignored (neither doc nor non-doc). Recorded as `excludeDirs`. + +3. **Scan for vendored directories and resolve `excludeDirs`.** + + If `.claude/context/audience-rules.md` already contains an `excludeDirs:` list, read it + silently. Otherwise scan the repo for likely-vendored dirs using three signals and present + candidates via `AskUserQuestion` (the user may remove any, add others, or confirm all): + - **Git submodules**: `git config --file .gitmodules --get-regexp path` (if `.gitmodules` exists). + - **Non-root package manifests**: `git ls-files` filtered for `package.json`, `composer.json`, + `Cargo.toml`, `go.mod`, `Gemfile`, `requirements.txt`, `pyproject.toml`, `pom.xml`, or + `build.gradle` whose dirname is not `.`; collect their parent directories. + - **Known vendor names**: any of `vendor`, `third_party`, `Pods`, `bower_components`, + `node_modules` existing as a root directory. + +4. **Copy the check script and write the config.** `mkdir -p` `.github/doc-sweep/`, then: + - Copy this skill's bundled `../../hooks/docs-ci-check.sh` to + `${CLAUDE_PROJECT_DIR}/.github/doc-sweep/docs-ci-check.sh` (keep it executable; it must stay + LF). Vendoring the script keeps the check self-contained — no external action ref, no + runtime download. + - Write `${CLAUDE_PROJECT_DIR}/.github/doc-sweep/docs-ci.json`: + ```json + { "docMode": "", "excludeDirs": [] } + ``` + +5. **Scaffold the workflow (idempotent).** Write + `${CLAUDE_PROJECT_DIR}/.github/workflows/doc-sweep-docs.yml` (do not overwrite an unrelated + workflow of the same name — if the file exists and is not this check, choose a + non-colliding name and report it): + ```yaml + name: doc-sweep docs check + # Fails a PR when code changed but docs did not, unless a `[skip docs]` token is present in a + # commit message or the PR body. Deterministic — no secrets. Managed by doc-sweep install-docs-ci. + on: + pull_request: + permissions: + contents: read + jobs: + docs-staleness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # full history so the merge base and commit messages resolve + - uses: actions/setup-node@v6 + with: + node-version: '20' + - name: Docs staleness check + run: bash .github/doc-sweep/docs-ci-check.sh .github/doc-sweep/docs-ci.json + ``` + Whether a failing check **blocks** merge is the maintainer's branch-protection choice (make + the `docs-staleness` job a required status check to block); the job itself just exits non-zero. + +6. **Print a structured summary.** + + ``` + doc-sweep docs-staleness CI check installed + ─────────────────────────────────────────── + Workflow file : + Check script : + Config file : + Doc-file set : + Excluded dirs : + Ack token : [skip docs] (in a commit message or the PR description) + + Next steps + • Commit the scaffolded files so the check runs on future PRs. + • To BLOCK merges on failure, make the `docs-staleness` job a required status check in + branch protection. Otherwise the check is advisory (red X, merge still allowed). + + Clears a PR : update docs · or `[skip docs]` in a commit message · or `[skip docs]` in the PR body + Edit/uninstall: re-run /doc-sweep:install-docs-ci + ``` + +## Uninstall + +Delete the scaffolded workflow `${CLAUDE_PROJECT_DIR}/.github/workflows/doc-sweep-docs.yml`, the +vendored `${CLAUDE_PROJECT_DIR}/.github/doc-sweep/docs-ci-check.sh`, and its +`docs-ci.json`. Remove the now-empty `.github/doc-sweep/` directory if nothing else remains. +Leave all other workflows and files untouched. Confirm what was removed (workflow path, script +path, config path). The change takes effect once you commit the removal. diff --git a/plugins/doc-sweep/skills/install-docs-ci/evals/benchmark.json b/plugins/doc-sweep/skills/install-docs-ci/evals/benchmark.json new file mode 100644 index 0000000..b0af01c --- /dev/null +++ b/plugins/doc-sweep/skills/install-docs-ci/evals/benchmark.json @@ -0,0 +1,105 @@ +{ + "skill": "install-docs-ci", + "pass_rate": 1.0, + "threshold": 0.9, + "model": "claude-opus-4-8[1m]", + "source_hash": "sha256:625d1a0d8d25baa351c06556b4d53278cff0a5478f9d22c299f34fa440d219d5", + "results": [ + { + "eval_id": 1, + "text": "Vendors the check script docs-ci-check.sh into the project's .github/doc-sweep/ directory", + "passed": true, + "evidence": "Copied bundled hooks/docs-ci-check.sh to $ws/.github/doc-sweep/docs-ci-check.sh; diff -q vs source = identical" + }, + { + "eval_id": 1, + "text": "Writes a config JSON at .github/doc-sweep/docs-ci.json capturing docMode AND an excludeDirs array", + "passed": true, + "evidence": "docs-ci.json = {docMode:\"default\", excludeDirs:[]}; node JSON.parse confirmed docMode + Array.isArray(excludeDirs)" + }, + { + "eval_id": 1, + "text": "Scaffolds a pull_request workflow at .github/workflows/doc-sweep-docs.yml that runs the vendored check script with checkout fetch-depth:0", + "passed": true, + "evidence": "workflow has on:pull_request, actions/checkout@v6 fetch-depth:0, and run: bash .github/doc-sweep/docs-ci-check.sh .github/doc-sweep/docs-ci.json" + }, + { + "eval_id": 1, + "text": "Prints a structured summary listing the workflow/script/config paths, the doc-file set, the [skip docs] token, the branch-protection note, and how to edit/uninstall", + "passed": true, + "evidence": "step-6 summary listed workflow/script/config paths, doc-file set=default, [skip docs] token, required-status-check note, and re-run /doc-sweep:install-docs-ci" + }, + { + "eval_id": 1, + "text": "Does not scaffold anything until the user confirms", + "passed": true, + "evidence": "SKILL.md gates scaffolding on confirmation ('Nothing is installed until you run this and confirm'); scope collected via AskUserQuestion before any copy/write; detection step is read-only" + }, + { + "eval_id": 1, + "text": "Re-running the install does not duplicate the workflow (idempotent)", + "passed": true, + "evidence": "Ran full install a SECOND time: exactly 1 workflow file and grep -c 'docs-staleness:' = 1 both times; rewrite-in-place, no duplicate" + }, + { + "eval_id": 2, + "text": "Deletes the scaffolded workflow .github/workflows/doc-sweep-docs.yml", + "passed": true, + "evidence": "BEFORE find listed the workflow; Uninstall rm -f executed; AFTER only the unrelated other.yml remained" + }, + { + "eval_id": 2, + "text": "Deletes the vendored check script and the docs-ci.json config under .github/doc-sweep/", + "passed": true, + "evidence": "Both .github/doc-sweep/docs-ci-check.sh and docs-ci.json rm -f'd; now-empty .github/doc-sweep/ rmdir'd; no doc-sweep files remain" + }, + { + "eval_id": 2, + "text": "Leaves unrelated workflows and files untouched", + "passed": true, + "evidence": "AFTER: .github/workflows/other.yml still present; unrelated README.md and src.txt still exist; only the three doc-sweep artifacts removed" + }, + { + "eval_id": 2, + "text": "Confirms what was removed (workflow, script, config paths)", + "passed": true, + "evidence": "SKILL.md Uninstall section directs confirming the three removed paths (workflow, script, config), all verified deleted" + }, + { + "eval_id": 3, + "text": "Detects the existing install and offers Reconfigure / Uninstall / Cancel rather than a blind fresh install", + "passed": true, + "evidence": "Detection keyed off existing doc-sweep-docs.yml + vendored script; on hit routes to AskUserQuestion with exactly Reconfigure/Uninstall/Cancel; fresh-install path skipped" + }, + { + "eval_id": 3, + "text": "On Reconfigure, rewrites the docs-ci.json config with docMode:\"minimal\" while preserving the other choices", + "passed": true, + "evidence": "Before {docMode:default, excludeDirs:[vendor]} -> After {docMode:minimal, excludeDirs:[vendor]}; docMode switched, excludeDirs preserved verbatim" + }, + { + "eval_id": 3, + "text": "Leaves the workflow file in place (rewrites it only if its path or name changed)", + "passed": true, + "evidence": "Path/name unchanged; git diff --stat of the workflow produced no output; git status --porcelain showed only the config modified" + }, + { + "eval_id": 4, + "text": "Scans for likely-vendored directories (submodules, non-root package manifests, known vendor names) and presents candidates for confirmation", + "passed": true, + "evidence": "All three signals fired: .gitmodules -> external/dep; non-root package.json -> frontend (root excluded); known names -> vendor; candidate set {external/dep, frontend, vendor} presented" + }, + { + "eval_id": 4, + "text": "Records the confirmed excludeDirs list into the .github/doc-sweep/docs-ci.json config", + "passed": true, + "evidence": "docs-ci.json excludeDirs=[external/dep, frontend, vendor]; functionally verified — PRs touching only those exited 0, an unexcluded src.js exited 1" + }, + { + "eval_id": 4, + "text": "Reads an existing excludeDirs list from .claude/context/audience-rules.md if one is already present rather than re-prompting", + "passed": true, + "evidence": "Pre-created audience-rules.md with excludeDirs list; skill read the three entries verbatim into docs-ci.json with no scan/re-prompt path" + } + ] +} diff --git a/plugins/doc-sweep/skills/install-docs-ci/evals/evals.json b/plugins/doc-sweep/skills/install-docs-ci/evals/evals.json new file mode 100644 index 0000000..c5def8c --- /dev/null +++ b/plugins/doc-sweep/skills/install-docs-ci/evals/evals.json @@ -0,0 +1,49 @@ +{ + "skill_name": "install-docs-ci", + "evals": [ + { + "id": 1, + "prompt": "Install the doc-sweep docs-staleness CI check for this project with the default doc-file set.", + "assertions": [ + "Vendors the check script docs-ci-check.sh into the project's .github/doc-sweep/ directory", + "Writes a config JSON at .github/doc-sweep/docs-ci.json capturing docMode AND an excludeDirs array", + "Scaffolds a pull_request workflow at .github/workflows/doc-sweep-docs.yml that runs the vendored check script with checkout fetch-depth:0", + "Prints a structured summary listing the workflow/script/config paths, the doc-file set, the [skip docs] token, the branch-protection note, and how to edit/uninstall", + "Does not scaffold anything until the user confirms", + "Re-running the install does not duplicate the workflow (idempotent)" + ], + "files": [] + }, + { + "id": 2, + "prompt": "Uninstall the doc-sweep docs-staleness CI check from this project.", + "assertions": [ + "Deletes the scaffolded workflow .github/workflows/doc-sweep-docs.yml", + "Deletes the vendored check script and the docs-ci.json config under .github/doc-sweep/", + "Leaves unrelated workflows and files untouched", + "Confirms what was removed (workflow, script, config paths)" + ], + "files": [] + }, + { + "id": 3, + "prompt": "I already set up the docs CI check here but I want to switch the doc-file set to minimal. Reconfigure it.", + "assertions": [ + "Detects the existing install and offers Reconfigure / Uninstall / Cancel rather than a blind fresh install", + "On Reconfigure, rewrites the docs-ci.json config with docMode:\"minimal\" while preserving the other choices", + "Leaves the workflow file in place (rewrites it only if its path or name changed)" + ], + "files": [] + }, + { + "id": 4, + "prompt": "Set up the docs CI check for this project. It vendors a third-party library under vendor/ and has a git submodule.", + "assertions": [ + "Scans for likely-vendored directories (submodules, non-root package manifests, known vendor names) and presents candidates for confirmation", + "Records the confirmed excludeDirs list into the .github/doc-sweep/docs-ci.json config", + "Reads an existing excludeDirs list from .claude/context/audience-rules.md if one is already present rather than re-prompting" + ], + "files": [] + } + ] +}