From 8d187aec24c4edb195ee0a83a3d5b62a2edff68a Mon Sep 17 00:00:00 2001 From: Jim Cadden Date: Thu, 29 Oct 2026 13:53:19 -0400 Subject: [PATCH 1/6] feat: Add doc-drift program (scanner, fixer, release assist) Vendors the deterministic doc-drift engine from rossoctl/automation and ships scanner/fixer skills, marketplace wiring, and shellcheck for the vendor tooling. Closes the docs-vs-code gap tracked in #8. Signed-off-by: Jim Cadden --- .claude-plugin/marketplace.json | 14 +- .github/workflows/shellcheck.yml | 22 + .gitignore | 2 + README.md | 3 +- scripts/vendor-engine.sh | 26 +- skills/doc-drift-fixer/SKILL.md | 63 + skills/doc-drift-scanner/SKILL.md | 92 ++ skills/repoman/SKILL.md | 2 + skills/repoman/scripts/_vendor.json | 10 +- .../scripts/automation-health-dashboard.sh | 83 +- skills/repoman/scripts/doc-drift-check.py | 1179 +++++++++++++++++ skills/repoman/scripts/doc-drift-fixer.sh | 409 ++++++ skills/repoman/scripts/doc-drift-precheck.sh | 102 ++ skills/repoman/scripts/doc-drift-scanner.sh | 334 +++++ skills/repoman/scripts/repoman-run.sh | 1 + skills/repoman/scripts/repoman-setup.sh | 4 +- tests/test-vendor-engine.sh | 25 +- 17 files changed, 2346 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/shellcheck.yml create mode 100644 skills/doc-drift-fixer/SKILL.md create mode 100644 skills/doc-drift-scanner/SKILL.md create mode 100755 skills/repoman/scripts/doc-drift-check.py create mode 100755 skills/repoman/scripts/doc-drift-fixer.sh create mode 100755 skills/repoman/scripts/doc-drift-precheck.sh create mode 100755 skills/repoman/scripts/doc-drift-scanner.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 41e3fc8..20d5ad6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -65,6 +65,16 @@ "./skills/repo-sync" ] }, + { + "name": "doc-drift", + "description": "Detect documentation drift against a pinned git boundary and draft Keep a Changelog notes from merged PRs", + "source": "./", + "strict": false, + "skills": [ + "./skills/doc-drift-scanner", + "./skills/doc-drift-fixer" + ] + }, { "name": "repoman", "description": "The RepoMan entrypoint: first-time setup, enabling a program, enrolling repos or an org, or running a program across enrolled repos. A single intent-dispatching skill.", @@ -79,7 +89,9 @@ "./skills/github-pr-review", "./skills/repo-sync", "./skills/automation-health-dashboard", - "./skills/github-weekly-report" + "./skills/github-weekly-report", + "./skills/doc-drift-scanner", + "./skills/doc-drift-fixer" ] } ] diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..b117526 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,22 @@ +name: shellcheck + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +jobs: + shellcheck: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: shellcheck (vendor tooling) + # Explicit paths — never a glob. Do not lint skills/repoman/scripts/ + # here; the engine is linted in rossoctl/automation. + run: shellcheck --severity=warning scripts/vendor-engine.sh tests/test-vendor-engine.sh diff --git a/.gitignore b/.gitignore index 98a4914..c8c266c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ # Includes the atomic-write transient so an interrupted write cannot be committed. skills/*/scripts/_meta.*.json skills/*/scripts/_meta.*.json.tmp.* +automation-work/ +.cursor/ diff --git a/README.md b/README.md index ab3a29e..e0940ca 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RepoMan RepoMan runs automated maintenance **programs** (PR review, link health, dependency -bumps, weekly reports, repo sync) across a set of GitHub repos you enroll once. It +bumps, weekly reports, repo sync, doc drift) across a set of GitHub repos you enroll once. It handles the capability checks and output wiring for you, so you install once and enable programs on the fly. @@ -49,6 +49,7 @@ The `repoman` bundle ships these programs, each ready to enable: | [repo-sync](skills/repo-sync/) | Keep local clones of enrolled repos current; read-only toward GitHub | | [automation-health](skills/automation-health-dashboard/) | Generate an executive-facing dashboard combining all program metrics | | [weekly-report](skills/github-weekly-report/) | Weekly org activity reports with merged PRs, CI health, and active-epic tracking | +| [doc-drift](skills/doc-drift-scanner/) | Scan repos for documentation drift and draft changelog notes from merged PRs ([fixer](skills/doc-drift-fixer/) re-verifies and opens mechanical doc fix PRs) | Each program is also published as its own standalone plugin, for using one on its own (for example a one-off PR review) without the full RepoMan setup. Run `/plugin` after diff --git a/scripts/vendor-engine.sh b/scripts/vendor-engine.sh index 2a0b922..4cfa244 100644 --- a/scripts/vendor-engine.sh +++ b/scripts/vendor-engine.sh @@ -19,14 +19,18 @@ while [ $# -gt 0 ]; do done [ -n "$SRC" ] && [ -n "$DEST" ] || { echo "ERROR: --source and --dest are required" >&2; exit 2; } -# The 23 engine scripts. Flat set: nearly all source program-lib.sh, +# The 26 engine shell scripts. Flat set: nearly all source program-lib.sh, # which sources core.sh/github-api.sh/fork.sh/org.sh/repoman-requirements.sh. MANIFEST="automation-health-dashboard core dep-bump-fixer dep-bump-scanner \ +doc-drift-fixer doc-drift-precheck doc-drift-scanner \ extract-broken-links fork github-api link-health-fixer link-health-scanner \ org parse-diff-map pr-review-fixer pr-review-impact pr-review-lib \ pr-review-scanner program-lib repo-sync repoman-check repoman-index \ repoman-requirements repoman-run repoman-setup weekly-report" +# Non-shell engine files copied alongside the manifest (explicit, no glob). +EXTRA_FILES="doc-drift-check.py" + # Pre-validate the WHOLE manifest before copying anything, so a source missing # late in the list cannot leave a half-vendored dest with a stale _vendor.json. # shellcheck disable=SC2086 @@ -35,6 +39,12 @@ for f in $MANIFEST; do echo "ERROR: missing engine source: $SRC/scripts/$f.sh (nothing copied)" >&2; exit 1 fi done +# shellcheck disable=SC2086 +for f in $EXTRA_FILES; do + if [ ! -f "$SRC/scripts/$f" ]; then + echo "ERROR: missing engine source: $SRC/scripts/$f (nothing copied)" >&2; exit 1 + fi +done mkdir -p "$DEST" # shellcheck disable=SC2086 @@ -43,6 +53,12 @@ for f in $MANIFEST; do echo "ERROR: copy failed for $f.sh" >&2; exit 1 fi done +# shellcheck disable=SC2086 +for f in $EXTRA_FILES; do + if ! cp "$SRC/scripts/$f" "$DEST/$f"; then + echo "ERROR: copy failed for $f" >&2; exit 1 + fi +done # Provenance stamp: record the SOURCE repo's HEAD sha (not the dest repo's). if ! SHA=$(git -C "$SRC" rev-parse HEAD 2>/dev/null); then @@ -51,7 +67,10 @@ fi NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) # word-splitting $MANIFEST into one line per script is intentional # shellcheck disable=SC2086 -if ! FILES_JSON=$(printf '%s\n' $MANIFEST | sed 's/$/.sh/' | jq -R . | jq -s .); then +if ! FILES_JSON=$( { + printf '%s\n' $MANIFEST | sed 's/$/.sh/' + printf '%s\n' $EXTRA_FILES +} | jq -R . | jq -s .); then echo "ERROR: could not build file list" >&2; exit 1 fi if ! jq -n --arg repo "rossoctl/automation" --arg sha "$SHA" --arg at "$NOW" \ @@ -61,4 +80,5 @@ if ! jq -n --arg repo "rossoctl/automation" --arg sha "$SHA" --arg at "$NOW" \ echo "ERROR: provenance stamp write failed" >&2; exit 1 fi -echo "Vendored $(echo "$MANIFEST" | wc -w | tr -d ' ') engine scripts to $DEST (source $SHA)" +# shellcheck disable=SC2086 +echo "Vendored $(echo $MANIFEST | wc -w | tr -d ' ') engine scripts (+ extras) to $DEST (source $SHA)" diff --git a/skills/doc-drift-fixer/SKILL.md b/skills/doc-drift-fixer/SKILL.md new file mode 100644 index 0000000..68e8078 --- /dev/null +++ b/skills/doc-drift-fixer/SKILL.md @@ -0,0 +1,63 @@ +--- +name: doc-drift-fixer +description: >- + Re-verify doc-drift scanner findings and open mechanical documentation fix + PRs. Corrections get their own docs: fix PR; additions fold into one rolling + update/doc-drift PR per repo. Never edits code to match docs, never merges, + never publishes a GitHub release. Use after a doc-drift scan. +license: Complete terms in LICENSE.txt +--- + +# Doc Drift Fixer + +Re-verify findings from the doc-drift scanner. Applies mechanical documentation +fixes on a fork and opens PRs. Non-mechanical findings stay on the rollup issue +checklist for a human. + +**Run scripts with `--help` first.** Engine scripts live under the RepoMan +plugin scripts dir. + +## Prerequisites + +- `bash` 3.2+ +- `python3` 3.8+ +- `gh` (authenticated; needs fork create + PR create) +- `jq` +- A prior scanner run that wrote `$HOME/reports/doc-drift/latest.json` +- `fork_owner` set in `~/.repoman/config.json` + +### Requirements (machine-readable) + +- pat_scopes: [repo] +- labels_required: [doc-drift] +- labels_applied: [] +- programs: [fixer] + +## Running + +Default is dry-run (preview only): + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/doc-drift-fixer.sh" --dry-run --issue-limit 5 +``` + +Live (opens PRs): + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/doc-drift-fixer.sh" --live +``` + +## PR shapes + +| Finding | Action | +|---------|--------| +| Mechanical correction (unique replacement) | Own PR: `docs: fix …` | +| Mechanical addition (enum gap, release draft) | Single rolling `update/doc-drift` PR per repo | +| Non-mechanical (absence-claim prose, retired name without replacement) | Rollup issue checklist only | +| Release date mismatch | Changelog heading PR only — does **not** edit the published GitHub release | +| Release draft | Rolling PR body includes paste-ready Keep a Changelog markdown | + +## Related + +- Scanner skill: `doc-drift-scanner` +- Friendly name: **Doc-Drift-Manager** (`doc-drift`) diff --git a/skills/doc-drift-scanner/SKILL.md b/skills/doc-drift-scanner/SKILL.md new file mode 100644 index 0000000..f466052 --- /dev/null +++ b/skills/doc-drift-scanner/SKILL.md @@ -0,0 +1,92 @@ +--- +name: doc-drift-scanner +description: >- + Scan enrolled repos for documentation drift against a pinned git boundary: + missing paths, flag/subcommand parity, configured enumerations, retired names, + version strings, absence claims, and changelog-vs-GitHub-release dates. Creates + one rollup issue per repo and writes structured JSON reports. Use when docs + may no longer match the code, or when drafting release notes from merged PRs. +license: Complete terms in LICENSE.txt +--- + +# Doc Drift Scanner + +Scan enrolled repositories for documentation that disagrees with the code at a +pinned git ref. Creates or updates one rollup `[doc-drift]` issue per repo and +writes `latest.json` / `history.json`. + +**Run scripts with `--help` first** to see full usage and options. The engine +lives in the RepoMan plugin scripts dir — do not copy it into this skill. + +## Prerequisites + +- `bash` 3.2+ (works with the macOS system default; no bash-4 features used) +- `python3` 3.8+ (stdlib only; no PyYAML) +- `gh` (GitHub CLI, authenticated with repo access) +- `jq` (JSON processor) +- RepoMan config at `~/.repoman/config.json` with enrolled clones under `repos_dir` + +### Requirements (machine-readable) + +- pat_scopes: [repo] +- labels_required: [] +- labels_applied: [doc-drift] +- programs: [scanner] + +## Before Running + +### 1. Verify authentication + +```bash +gh auth status +``` + +### 2. Ensure clones are current + +Prefer `repoman run repo-sync` (or enable `repo-sync`) so scanners read fresh +trees. The run orchestrator refreshes clones for this program automatically. + +### 3. Confirm report location + +Reports default to `$HOME/reports/doc-drift`. Override with `REPORTS_DIR`. + +## Running the Scanner + +Always dry-run first: + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/doc-drift-scanner.sh" --dry-run +``` + +Live run (creates/updates/closes rollup issues): + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/doc-drift-scanner.sh" --issue-limit 5 +``` + +Or via the orchestrator: + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/repoman-run.sh" doc-drift --dry-run +``` + +## Optional per-repo config + +Repos may commit `.repoman/doc-drift.yaml` at the boundary ref to opt into +enumeration parity, retired-name denylists, and a custom changelog path. Without +it, only generic checks run (paths, flags, versions, absence claims, release +date/draft when GitHub metadata is supplied). + +## Precheck (DAM schedules) + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/doc-drift-precheck.sh" +``` + +Exit `1` when the worklist is empty (skip the model turn). Exit `0` and print +`worklist: ` when there is work. Any other exit is fail-open. + +## Related + +- Fixer skill: `doc-drift-fixer` +- Friendly name: **Doc-Drift-Manager** (`doc-drift`) diff --git a/skills/repoman/SKILL.md b/skills/repoman/SKILL.md index 9674e0d..6a004e5 100644 --- a/skills/repoman/SKILL.md +++ b/skills/repoman/SKILL.md @@ -76,6 +76,7 @@ on the left, skill dir on the right): | `pr-review` | `skills/github-pr-review/` | | `automation-health` | `skills/automation-health-dashboard/` | | `repo-sync` | `skills/repo-sync/` | +| `doc-drift` | `skills/doc-drift-scanner/` | ## State-inference decision tree @@ -211,6 +212,7 @@ does not resolve must never reach them. | PR-Review-Manager | `pr-review` | | Automation-Health-Manager | `automation-health` | | Repo-Sync-Manager | `repo-sync` | +| Doc-Drift-Manager | `doc-drift` | Match user input leniently against this list: "pr review", "pr-review-manager", and "prmanager" all resolve to `pr-review`; "dep bump" → `dep-bump`. **Always** diff --git a/skills/repoman/scripts/_vendor.json b/skills/repoman/scripts/_vendor.json index 5ada4e7..d6f2dfd 100644 --- a/skills/repoman/scripts/_vendor.json +++ b/skills/repoman/scripts/_vendor.json @@ -1,12 +1,15 @@ { "source_repo": "rossoctl/automation", - "source_sha": "0311752eba5350c74b8c9650137d3e6da65bd547", - "vendored_at": "2026-10-23T03:46:40Z", + "source_sha": "ecdc68577aa66e7959a4741b45ada741eac6cba2", + "vendored_at": "2026-10-29T17:53:18Z", "files": [ "automation-health-dashboard.sh", "core.sh", "dep-bump-fixer.sh", "dep-bump-scanner.sh", + "doc-drift-fixer.sh", + "doc-drift-precheck.sh", + "doc-drift-scanner.sh", "extract-broken-links.sh", "fork.sh", "github-api.sh", @@ -25,6 +28,7 @@ "repoman-requirements.sh", "repoman-run.sh", "repoman-setup.sh", - "weekly-report.sh" + "weekly-report.sh", + "doc-drift-check.py" ] } diff --git a/skills/repoman/scripts/automation-health-dashboard.sh b/skills/repoman/scripts/automation-health-dashboard.sh index 2887e0d..ac9053d 100644 --- a/skills/repoman/scripts/automation-health-dashboard.sh +++ b/skills/repoman/scripts/automation-health-dashboard.sh @@ -114,16 +114,20 @@ display_name_for() { case "$1" in link-health) echo "Link Health" ;; dep-bump) echo "Dependency Bumps" ;; + doc-drift) echo "Doc Drift" ;; *) echo "" ;; esac } LINK_SCAN_DIR="" DEP_BUMP_DIR="" +DOC_DRIFT_DIR="" HAS_LINK_HEALTH=false HAS_DEP_BUMP=false +HAS_DOC_DRIFT=false LINK_HEALTH_HEADING="Link Health" DEP_BUMP_HEADING="Dependency Bumps" +DOC_DRIFT_HEADING="Doc Drift" if [ -f "$INDEX_FILE" ]; then if ! jq empty "$INDEX_FILE" >/dev/null 2>&1; then @@ -159,12 +163,18 @@ if [ -f "$INDEX_FILE" ]; then HAS_DEP_BUMP=true DEP_BUMP_HEADING="$entry_display" ;; + doc-drift) + DOC_DRIFT_DIR="$entry_path" + HAS_DOC_DRIFT=true + DOC_DRIFT_HEADING="$entry_display" + ;; esac done < <(jq -r 'keys[]' "$INDEX_FILE") else # Disk-derived fallback: no index, probe the known report subdirs directly. LINK_SCAN_DIR="$REPORTS_DIR/link-health" DEP_BUMP_DIR="$REPORTS_DIR/dep-bump" + DOC_DRIFT_DIR="$REPORTS_DIR/doc-drift" if [ -f "$LINK_SCAN_DIR/latest.json" ] && [ -f "$LINK_SCAN_DIR/history.json" ]; then HAS_LINK_HEALTH=true @@ -174,17 +184,22 @@ else HAS_DEP_BUMP=true DEP_BUMP_HEADING=$(display_name_for "dep-bump") fi + if [ -f "$DOC_DRIFT_DIR/latest.json" ] && [ -f "$DOC_DRIFT_DIR/history.json" ]; then + HAS_DOC_DRIFT=true + DOC_DRIFT_HEADING=$(display_name_for "doc-drift") + fi fi -if [ "$HAS_LINK_HEALTH" = false ] && [ "$HAS_DEP_BUMP" = false ]; then +if [ "$HAS_LINK_HEALTH" = false ] && [ "$HAS_DEP_BUMP" = false ] && [ "$HAS_DOC_DRIFT" = false ]; then echo "ERROR: No program reports found in $REPORTS_DIR" - echo "Expected: $REPORTS_DIR/link-health/latest.json and/or $REPORTS_DIR/dep-bump/latest.json" + echo "Expected: $REPORTS_DIR/link-health/, dep-bump/, and/or doc-drift/ latest.json" exit 1 fi PROGRAMS_ACTIVE=0 if [ "$HAS_LINK_HEALTH" = true ]; then PROGRAMS_ACTIVE=$((PROGRAMS_ACTIVE + 1)); fi if [ "$HAS_DEP_BUMP" = true ]; then PROGRAMS_ACTIVE=$((PROGRAMS_ACTIVE + 1)); fi +if [ "$HAS_DOC_DRIFT" = true ]; then PROGRAMS_ACTIVE=$((PROGRAMS_ACTIVE + 1)); fi # --- Setup --- TMPDIR=$(mktemp -d) @@ -211,6 +226,8 @@ LH_ISSUES_CLOSED=0 DB_ISSUES_CREATED=0 DB_ISSUES_CLOSED=0 DB_FIXER_CLOSED=0 +DD_ISSUES_CREATED=0 +DD_ISSUES_CLOSED=0 TOTAL_PRS_OPENED=0 LAST_SCAN_DATE="unknown" @@ -241,14 +258,26 @@ if [ "$HAS_DEP_BUMP" = true ]; then fi fi -TOTAL_ISSUES_CREATED=$((LH_ISSUES_CREATED + DB_ISSUES_CREATED)) -TOTAL_ISSUES_RESOLVED=$((LH_ISSUES_CLOSED + DB_ISSUES_CLOSED + DB_FIXER_CLOSED)) +if [ "$HAS_DOC_DRIFT" = true ]; then + DD_ISSUES_CREATED=$(jq '[.[] | .issues_created // 0] | add // 0' "$DOC_DRIFT_DIR/history.json") + DD_ISSUES_CLOSED=$(jq '[.[] | .issues_closed // 0] | add // 0' "$DOC_DRIFT_DIR/history.json") + dd_last_date=$(jq -r '.date // ""' "$DOC_DRIFT_DIR/latest.json") + if [ -n "$dd_last_date" ]; then + dd_date="${dd_last_date%%T*}" + if [ "$LAST_SCAN_DATE" = "unknown" ] || [[ "$dd_date" > "$LAST_SCAN_DATE" ]]; then + LAST_SCAN_DATE="$dd_date" + fi + fi +fi + +TOTAL_ISSUES_CREATED=$((LH_ISSUES_CREATED + DB_ISSUES_CREATED + DD_ISSUES_CREATED)) +TOTAL_ISSUES_RESOLVED=$((LH_ISSUES_CLOSED + DB_ISSUES_CLOSED + DB_FIXER_CLOSED + DD_ISSUES_CLOSED)) HOURS_SAVED=$(echo "$TOTAL_ISSUES_RESOLVED" | awk '{printf "%.1f", $1 * 0.25}') if [ "$VERBOSE" = true ]; then echo "Executive metrics:" - echo " Issues created: $TOTAL_ISSUES_CREATED (LH: $LH_ISSUES_CREATED, DB: $DB_ISSUES_CREATED)" - echo " Issues resolved: $TOTAL_ISSUES_RESOLVED (LH: $LH_ISSUES_CLOSED, DB: $DB_ISSUES_CLOSED, fixer: $DB_FIXER_CLOSED)" + echo " Issues created: $TOTAL_ISSUES_CREATED (LH: $LH_ISSUES_CREATED, DB: $DB_ISSUES_CREATED, DD: $DD_ISSUES_CREATED)" + echo " Issues resolved: $TOTAL_ISSUES_RESOLVED (LH: $LH_ISSUES_CLOSED, DB: $DB_ISSUES_CLOSED, DD: $DD_ISSUES_CLOSED, fixer: $DB_FIXER_CLOSED)" echo " PRs opened: $TOTAL_PRS_OPENED" echo " Hours saved: $HOURS_SAVED" echo "" @@ -362,6 +391,25 @@ if [ "$HAS_DEP_BUMP" = true ]; then ' "$DEP_BUMP_DIR/history.json" 2>/dev/null || echo "| - | - | - | - |") fi +# ============================================================================= +# Step 4b: Compute doc-drift section +# ============================================================================= + +DD_REPOS_SCANNED=0 +DD_CORRECTIONS=0 +DD_ADDITIONS=0 +DD_TREND_TABLE="| - | - | - | - |" + +if [ "$HAS_DOC_DRIFT" = true ]; then + DD_REPOS_SCANNED=$(jq '.repos_scanned // 0' "$DOC_DRIFT_DIR/latest.json") + DD_CORRECTIONS=$(jq '.corrections // ([.findings[]? | select(.kind=="correction")] | length)' "$DOC_DRIFT_DIR/latest.json") + DD_ADDITIONS=$(jq '.additions // ([.findings[]? | select(.kind=="addition")] | length)' "$DOC_DRIFT_DIR/latest.json") + DD_TREND_TABLE=$(jq -r ' + .[-10:] | reverse | .[] | + "| \(.date | split("T")[0]) | \(.corrections // 0) | \(.additions // 0) | \(if .new > .fixed then "+\(.new - .fixed)" elif .new < .fixed then "\(.new - .fixed)" else "0" end) |" + ' "$DOC_DRIFT_DIR/history.json" 2>/dev/null || echo "| - | - | - | - |") +fi + # ============================================================================= # Step 5: Compute cross-program coverage # ============================================================================= @@ -454,7 +502,7 @@ cat > "$TMPDIR/automation-health.md" << DASHBOARD_EOF | Metric | Value | |--------|-------| -| Total issues auto-created | $TOTAL_ISSUES_CREATED (link-health: $LH_ISSUES_CREATED, dep-bump: $DB_ISSUES_CREATED) | +| Total issues auto-created | $TOTAL_ISSUES_CREATED (link-health: $LH_ISSUES_CREATED, dep-bump: $DB_ISSUES_CREATED, doc-drift: $DD_ISSUES_CREATED) | | Total issues auto-resolved | $TOTAL_ISSUES_RESOLVED | | Total PRs auto-opened | $TOTAL_PRS_OPENED | | Estimated hours saved | ${HOURS_SAVED} hrs (at 15 min/resolved issue) | @@ -514,6 +562,27 @@ $DB_TREND_TABLE DASHBOARD_EOF fi +if [ "$HAS_DOC_DRIFT" = true ]; then + cat >> "$TMPDIR/automation-health.md" << DASHBOARD_EOF + +## $DOC_DRIFT_HEADING + +| Metric | Value | +|--------|-------| +| Repos scanned | $DD_REPOS_SCANNED | +| Open corrections | $DD_CORRECTIONS | +| Open additions | $DD_ADDITIONS | +| Issues created (cumulative) | $DD_ISSUES_CREATED | +| Issues closed (cumulative) | $DD_ISSUES_CLOSED | + +### Trend (last 10 scans) + +| Date | Corrections | Additions | Delta | +|------|-------------|-----------|-------| +$DD_TREND_TABLE +DASHBOARD_EOF +fi + # Tail: cross-cutting sections (always rendered). cat >> "$TMPDIR/automation-health.md" << DASHBOARD_EOF diff --git a/skills/repoman/scripts/doc-drift-check.py b/skills/repoman/scripts/doc-drift-check.py new file mode 100755 index 0000000..4e43f03 --- /dev/null +++ b/skills/repoman/scripts/doc-drift-check.py @@ -0,0 +1,1179 @@ +#!/usr/bin/env python3 +"""Deterministic doc-drift checker (RepoMan). + +Reads a pinned git ref only (never the working tree). Emits JSON findings. +Release metadata and merged PRs are passed in by the shell so this stays offline. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, asdict, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +DEFAULT_TAG_PATTERN = r"^v\d+\.\d+\.\d+$" +CONFIG_PATH = ".repoman/doc-drift.yaml" +DOC_EXTS = (".md", ".rst", ".txt") +VERSION_FILES = ( + "marketplace.json", + ".claude-plugin/marketplace.json", + "plugin.json", + "package.json", + "pyproject.toml", +) + +# Paths that look like URLs are not tree paths. +URLISH = re.compile(r"^[a-z][a-z0-9+.-]*://", re.I) +# Backtick path: scripts/foo.sh, skills/x/SKILL.md (no spaces) +BACKTICK_PATH = re.compile(r"`([A-Za-z0-9_./\-]+(?:\.[A-Za-z0-9]+)?)`") +# Markdown link target that is a relative path +MD_LINK_PATH = re.compile(r"\[[^\]]*\]\((?!https?://|#|mailto:)([A-Za-z0-9_./\-]+)\)") +# Flag / long option +FLAG_RE = re.compile(r"(?feat|fix|refactor|perf|revert|remove|docs|chore|test|ci|style)" + r"(?:\([^)]*\))?!?\s*:\s*", + re.I, +) +# "not implemented in this skill" — basename may sit before or after the claim. +NOT_IMPLEMENTED_RE = re.compile( + r"\bnot implemented\b.{0,60}?\bin this skill\b", + re.I | re.S, +) +# "lives in the automation repo, not in this repo" (either order) +AUTOMATION_ONLY_RE = re.compile( + r"(?:" + r"(?:lives?|is)\s+in\s+the\s+automation\s+repo.{0,80}?\bnot\b.{0,40}?\bthis\s+repo\b" + r"|" + r"\bnot\b.{0,40}?\bthis\s+repo\b.{0,80}?(?:lives?|is)\s+in\s+the\s+automation\s+repo\b" + r")", + re.I | re.S, +) +META_FLAGS = {"--help", "--version", "-h", "-v"} + +OMIT_TYPES = {"docs", "chore", "test", "ci", "style"} +BUCKET = { + "feat": "Added", + "fix": "Fixed", + "refactor": "Changed", + "perf": "Changed", + "revert": "Removed", + "remove": "Removed", +} + + +@dataclass +class Finding: + check: str + kind: str # correction | addition + mechanical: bool + doc_path: str + doc_line: int + code_path: str + code_line: int + claim: str + evidence: str + draft: str = "" + replace_with: str = "" + status: str = "open" + reason: str = "" + repo: str = "" + + def key(self) -> str: + claim_n = re.sub(r"\s+", " ", self.claim.strip().lower()) + return "|".join( + [self.repo, self.check, self.doc_path, claim_n, self.code_path] + ) + + def to_dict(self) -> Dict[str, Any]: + d = asdict(self) + d["key"] = self.key() + return d + + +def run_git(clone: str, *args: str) -> str: + r = subprocess.run( + ["git", "-C", clone, *args], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + return "" + return r.stdout + + +def git_show(clone: str, ref: str, path: str) -> Optional[str]: + r = subprocess.run( + ["git", "-C", clone, "show", f"{ref}:{path}"], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + return None + return r.stdout + + +def git_ls_tree(clone: str, ref: str) -> Set[str]: + out = run_git(clone, "ls-tree", "-r", "--name-only", ref) + return {line for line in out.splitlines() if line} + + +def git_basenames(tree: Set[str]) -> Dict[str, List[str]]: + m: Dict[str, List[str]] = {} + for p in tree: + base = os.path.basename(p) + m.setdefault(base, []).append(p) + return m + + +def resolve_ref(clone: str, boundary: str, tag_pattern: str) -> Tuple[str, str]: + """Return (sha, label). label is the tag name or 'HEAD'.""" + if boundary == "tags": + tags = [ + t + for t in run_git(clone, "tag", "-l").splitlines() + if t and re.search(tag_pattern, t) + ] + if not tags: + sha = run_git(clone, "rev-parse", "HEAD").strip() + return sha, "HEAD" + # Semver-ish sort: strip leading v, split numeric parts. Pre-releases + # are already filtered by the stable pattern. + def key(t: str) -> Tuple: + body = t[1:] if t.startswith("v") else t + parts = [] + for p in body.split("."): + try: + parts.append(int(p)) + except ValueError: + parts.append(0) + return tuple(parts) + + newest = sorted(tags, key=key)[-1] + sha = run_git(clone, "rev-parse", newest).strip() + return sha, newest + sha = run_git(clone, "rev-parse", "HEAD").strip() + return sha, "HEAD" + + +# --------------------------------------------------------------------------- +# Minimal YAML subset reader for .repoman/doc-drift.yaml +# --------------------------------------------------------------------------- + +def _parse_scalar(s: str) -> Any: + s = s.strip() + if (s.startswith("'") and s.endswith("'")) or ( + s.startswith('"') and s.endswith('"') + ): + return s[1:-1] + if s in ("true", "True"): + return True + if s in ("false", "False"): + return False + if s in ("null", "~", ""): + return None + return s + + +def parse_simple_yaml(text: str) -> Dict[str, Any]: + """Parse the narrow YAML subset used by doc-drift config.""" + root: Dict[str, Any] = {} + stack: List[Tuple[int, Any]] = [(-1, root)] + pending_key: Optional[str] = None + + for raw in text.splitlines(): + if not raw.strip() or raw.lstrip().startswith("#"): + continue + indent = len(raw) - len(raw.lstrip(" ")) + line = raw.strip() + + while len(stack) > 1 and indent <= stack[-1][0]: + stack.pop() + parent = stack[-1][1] + + if line.startswith("- "): + item_text = line[2:].strip() + if not isinstance(parent, list): + # Convert last pending into list if needed — shouldn't happen + continue + if ":" in item_text and not item_text.startswith("{"): + # map item: { name: x, replace_with: y } or key: value pairs + if item_text.startswith("{") and item_text.endswith("}"): + parent.append(_parse_flow_map(item_text)) + else: + # single key: value on the dash line → start a map + k, _, v = item_text.partition(":") + obj: Dict[str, Any] = {k.strip(): _parse_scalar(v)} + parent.append(obj) + stack.append((indent, obj)) + elif item_text.startswith("{") and item_text.endswith("}"): + parent.append(_parse_flow_map(item_text)) + else: + parent.append(_parse_scalar(item_text)) + continue + + if ":" in line: + k, _, v = line.partition(":") + key = k.strip() + val = v.strip() + if not isinstance(parent, dict): + continue + if val == "": + # Look ahead is hard; create list or dict based on next lines — + # default to dict, promote later via dash detection using a marker + # Heuristic: sources_of_truth / retired_names / ignore_paths → list + if key in ( + "sources_of_truth", + "retired_names", + "ignore_paths", + "docs", + ): + parent[key] = [] + stack.append((indent, parent[key])) + else: + parent[key] = {} + stack.append((indent, parent[key])) + elif val.startswith("[") and val.endswith("]"): + inner = val[1:-1].strip() + if not inner: + parent[key] = [] + else: + parent[key] = [ + _parse_scalar(x.strip()) for x in _split_flow(inner) + ] + elif val.startswith("{") and val.endswith("}"): + parent[key] = _parse_flow_map(val) + else: + parent[key] = _parse_scalar(val) + return root + + +def _split_flow(inner: str) -> List[str]: + parts, cur, in_q = [], [], None + for ch in inner: + if in_q: + cur.append(ch) + if ch == in_q: + in_q = None + continue + if ch in ("'", '"'): + in_q = ch + cur.append(ch) + continue + if ch == ",": + parts.append("".join(cur).strip()) + cur = [] + continue + cur.append(ch) + if cur: + parts.append("".join(cur).strip()) + return parts + + +def _parse_flow_map(s: str) -> Dict[str, Any]: + inner = s.strip()[1:-1] + out: Dict[str, Any] = {} + for part in _split_flow(inner): + if ":" not in part: + continue + k, _, v = part.partition(":") + out[k.strip()] = _parse_scalar(v) + return out + + +def load_config(clone: str, ref: str) -> Dict[str, Any]: + text = git_show(clone, ref, CONFIG_PATH) + if text is None: + return { + "boundary": "main", + "stable_tag_pattern": DEFAULT_TAG_PATTERN, + "ignore_paths": ["CHANGELOG.md", "docs/history/**"], + "sources_of_truth": [], + "retired_names": [], + "release": {"changelog": "CHANGELOG.md"}, + } + cfg = parse_simple_yaml(text) + cfg.setdefault("boundary", "main") + cfg.setdefault("stable_tag_pattern", DEFAULT_TAG_PATTERN) + cfg.setdefault("ignore_paths", ["CHANGELOG.md", "docs/history/**"]) + cfg.setdefault("sources_of_truth", []) + cfg.setdefault("retired_names", []) + rel = cfg.get("release") or {} + if not isinstance(rel, dict): + rel = {} + rel.setdefault("changelog", "CHANGELOG.md") + cfg["release"] = rel + return cfg + + +def path_ignored(path: str, patterns: Sequence[str]) -> bool: + for pat in patterns: + if pat.endswith("/**"): + prefix = pat[:-3] + if path == prefix.rstrip("/") or path.startswith(prefix): + return True + elif "*" in pat: + # simplistic glob: only trailing * + if pat.endswith("*") and path.startswith(pat[:-1]): + return True + elif path == pat: + return True + return False + + +def line_of(text: str, needle: str) -> int: + idx = text.find(needle) + if idx < 0: + return 1 + return text[:idx].count("\n") + 1 + + +def line_of_re(text: str, m: re.Match) -> int: + return text[: m.start()].count("\n") + 1 + + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- + +def check_paths( + clone: str, + ref: str, + tree: Set[str], + docs: List[Tuple[str, str]], + repo: str, +) -> List[Finding]: + findings: List[Finding] = [] + for doc_path, text in docs: + doc_dir = os.path.dirname(doc_path) + seen: Set[str] = set() + for rx in (BACKTICK_PATH, MD_LINK_PATH): + for m in rx.finditer(text): + raw = m.group(1) + if URLISH.match(raw) or raw.startswith("#"): + continue + # Skip pure identifiers without a slash or extension that look + # like code tokens, not paths (e.g. `repoman`). + if "/" not in raw and "." not in raw: + continue + if raw in seen: + continue + seen.add(raw) + candidates = [] + if not raw.startswith("/"): + candidates.append(raw) + if doc_dir: + candidates.append(f"{doc_dir}/{raw}") + else: + candidates.append(raw.lstrip("/")) + exists = any(c in tree or c.rstrip("/") + "/" in {p + "/" for p in tree} or any( + p.startswith(c.rstrip("/") + "/") for p in tree + ) for c in candidates) + # Also accept directory existence via any file under it + if not exists: + for c in candidates: + if any(p == c or p.startswith(c.rstrip("/") + "/") for p in tree): + exists = True + break + if exists: + continue + findings.append( + Finding( + check="paths", + kind="correction", + mechanical=True, + doc_path=doc_path, + doc_line=line_of_re(text, m), + code_path="(tree)", + code_line=1, + claim=f"path `{raw}` exists", + evidence=f"`{raw}` not found at {ref}", + repo=repo, + replace_with="", + ) + ) + return findings + + +def extract_script_tokens(script_text: str) -> Set[str]: + tokens: Set[str] = set() + for m in FLAG_RE.finditer(script_text): + tokens.add(m.group(1)) + # case arms: foo|bar) or foo) + for m in re.finditer(r"(?m)^\s*([A-Za-z0-9_|-]+)\)\s*", script_text): + arm = m.group(1) + if arm in ("*", "*)") or arm.startswith("$"): + continue + for part in arm.split("|"): + part = part.strip() + if part and re.match(r"^[A-Za-z0-9][A-Za-z0-9_-]*$", part): + if not part.startswith("-"): + tokens.add(part) + else: + tokens.add(part) + # usage() lines mentioning subcommands + for m in re.finditer( + r"(?m)^\s{0,4}([a-z][a-z0-9-]*)\s+--", script_text + ): + tokens.add(m.group(1)) + return tokens + + +def _header_subcommands(script_text: str) -> Set[str]: + """Subcommands named in a leading comment / usage block.""" + subs: Set[str] = set() + for m in SUBCMD_DOC_RE.finditer(script_text): + for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", m.group(1)): + if tok.lower() not in ("subcommand", "subcommands", "commands", "command"): + subs.add(tok) + # "Subcommands:\n foo --bar" style in comments + for m in re.finditer(r"(?m)^#\s+([a-z][a-z0-9-]*)\s+--", script_text): + subs.add(m.group(1)) + return subs + + +def _case_subcommands(script_text: str) -> Set[str]: + """Top-level case-arm tokens that look like subcommands (not flags).""" + subs: Set[str] = set() + for m in re.finditer(r"(?m)^\s*([A-Za-z0-9_|-]+)\)\s*", script_text): + arm = m.group(1) + if arm in ("*",) or arm.startswith("$"): + continue + for part in arm.split("|"): + part = part.strip() + if re.match(r"^[a-z][a-z0-9-]*$", part) and not part.startswith("-"): + subs.add(part) + return subs + + +def check_flags( + clone: str, + ref: str, + tree: Set[str], + docs: List[Tuple[str, str]], + repo: str, +) -> List[Finding]: + findings: List[Finding] = [] + scripts = { + os.path.basename(p): p + for p in tree + if p.endswith(".sh") or p.endswith(".py") + } + + # Intra-script: header subcommand list vs case arms (the set-requirements case). + for spath in sorted(set(scripts.values())): + script_text = git_show(clone, ref, spath) + if script_text is None: + continue + header = _header_subcommands(script_text) + cases = _case_subcommands(script_text) + if not header: + continue + for sub in sorted(cases - header): + findings.append( + Finding( + check="flags", + kind="addition", + mechanical=True, + doc_path=spath, + doc_line=1, + code_path=spath, + code_line=line_of(script_text, sub) if sub in script_text else 1, + claim=f"document subcommand `{sub}`", + evidence=f"`{sub}` in case arms of {spath} but absent from header list", + repo=repo, + draft=sub, + ) + ) + for sub in sorted(header - cases): + findings.append( + Finding( + check="flags", + kind="correction", + mechanical=False, + doc_path=spath, + doc_line=line_of(script_text, sub), + code_path=spath, + code_line=1, + claim=f"{os.path.basename(spath)} implements `{sub}`", + evidence=f"`{sub}` listed in header but not in case arms of {spath}", + repo=repo, + ) + ) + + for doc_path, text in docs: + named: List[str] = [] + for base, spath in scripts.items(): + if f"`{base}`" in text or base in text: + named.append(spath) + if not named: + continue + for spath in named: + script_text = git_show(clone, ref, spath) + if script_text is None: + continue + parsed = extract_script_tokens(script_text) + doc_flags = set(FLAG_RE.findall(text)) - META_FLAGS + doc_sub: Set[str] = set() + for m in SUBCMD_DOC_RE.finditer(text): + blob = m.group(1) + for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", blob): + doc_sub.add(tok) + for m in re.finditer(r"(?m)^\s{2,}([a-z][a-z0-9-]*)\s+--", text): + doc_sub.add(m.group(1)) + + for flag in sorted(doc_flags - parsed): + findings.append( + Finding( + check="flags", + kind="correction", + mechanical=False, + doc_path=doc_path, + doc_line=line_of(text, flag), + code_path=spath, + code_line=1, + claim=f"{os.path.basename(spath)} accepts `{flag}`", + evidence=f"`{flag}` not found in parser of {spath}", + repo=repo, + ) + ) + for sub in sorted(doc_sub - parsed): + findings.append( + Finding( + check="flags", + kind="correction", + mechanical=False, + doc_path=doc_path, + doc_line=line_of(text, sub), + code_path=spath, + code_line=1, + claim=f"{os.path.basename(spath)} implements `{sub}`", + evidence=f"`{sub}` listed in docs but not in parser of {spath}", + repo=repo, + ) + ) + return findings + + +def check_enumerations( + clone: str, + ref: str, + cfg: Dict[str, Any], + docs_index: Dict[str, str], + repo: str, +) -> List[Finding]: + findings: List[Finding] = [] + for src in cfg.get("sources_of_truth") or []: + if not isinstance(src, dict): + continue + code = src.get("code") or {} + cfile = code.get("file", "") + pattern = code.get("pattern", "") + if not cfile or not pattern: + continue + code_text = git_show(clone, ref, cfile) + if code_text is None: + continue + m = re.search(pattern, code_text) + if not m: + continue + captured = m.group(1) if m.lastindex else m.group(0) + code_tokens = [t for t in re.split(r"[\s,]+", captured.strip()) if t] + code_set = set(code_tokens) + code_line = code_text[: m.start()].count("\n") + 1 + for dpath in src.get("docs") or []: + dtext = docs_index.get(dpath) or git_show(clone, ref, dpath) + if dtext is None: + continue + docs_index[dpath] = dtext + # Tokens from the doc that look like the code tokens (same alphabet) + doc_tokens = set() + for tok in code_tokens: + if re.search(rf"(? List[Finding]: + findings: List[Finding] = [] + ignore = cfg.get("ignore_paths") or [] + retired = cfg.get("retired_names") or [] + names: List[Tuple[str, str]] = [] + for item in retired: + if isinstance(item, str): + names.append((item, "")) + elif isinstance(item, dict): + names.append((str(item.get("name", "")), str(item.get("replace_with", "") or ""))) + for name, repl in names: + if not name: + continue + rx = re.compile(rf"(?i)(? List[Tuple[str, int]]: + found = [] + for m in re.finditer(r"\b(?:v)?(\d+\.\d+\.\d+)\b", text): + found.append((m.group(1), text[: m.start()].count("\n") + 1)) + return found + + +def read_declared_versions(clone: str, ref: str, tree: Set[str]) -> Dict[str, Tuple[str, str, int]]: + """Map source file -> (version, path, line).""" + out: Dict[str, Tuple[str, str, int]] = {} + for vf in VERSION_FILES: + # marketplace may live under .claude-plugin/ + candidates = [vf] + if vf == "marketplace.json": + candidates.append(".claude-plugin/marketplace.json") + for path in candidates: + if path not in tree: + continue + text = git_show(clone, ref, path) + if text is None: + continue + if path.endswith(".json"): + try: + data = json.loads(text) + except json.JSONDecodeError: + continue + ver = None + if isinstance(data, dict): + ver = data.get("version") or (data.get("metadata") or {}).get("version") + if ver: + out[path] = (str(ver).lstrip("v"), path, line_of(text, str(ver))) + elif path.endswith(".toml"): + m = re.search(r'(?m)^version\s*=\s*["\']([^"\']+)["\']', text) + if m: + out[path] = (m.group(1).lstrip("v"), path, line_of_re(text, m)) + return out + + +def check_versions( + clone: str, + ref: str, + tree: Set[str], + docs: List[Tuple[str, str]], + boundary_label: str, + repo: str, +) -> List[Finding]: + findings: List[Finding] = [] + declared = read_declared_versions(clone, ref, tree) + if not declared: + return findings + # Prefer marketplace version as canonical when present + canon_ver, canon_path, canon_line = next(iter(declared.values())) + for path, trip in declared.items(): + if "marketplace" in path: + canon_ver, canon_path, canon_line = trip + break + if boundary_label.startswith("v") and re.match(r"^v\d+\.\d+\.\d+$", boundary_label): + tag_ver = boundary_label[1:] + if tag_ver != canon_ver: + # Prefer tag as boundary truth when boundary=tags + pass + for doc_path, text in docs: + if os.path.basename(doc_path).upper() == "CHANGELOG.md": + continue + for ver, line in extract_versions_from_text(text): + if ver == canon_ver: + continue + # Ignore versions that appear only inside URLs or commit SHAs-ish + findings.append( + Finding( + check="versions", + kind="correction", + mechanical=True, + doc_path=doc_path, + doc_line=line, + code_path=canon_path, + code_line=canon_line, + claim=f"version is {canon_ver}", + evidence=f"doc has {ver}; {canon_path} has {canon_ver}", + repo=repo, + replace_with=canon_ver, + ) + ) + return findings + + +def _nearby_script_names(text: str, start: int, end: int) -> List[str]: + window = text[max(0, start - 240) : min(len(text), end + 240)] + names = re.findall(r"`([^`]+)`", window) + names += re.findall(r"\b([A-Za-z0-9_-]+\.sh)\b", window) + return names + + +def check_absence_claims( + clone: str, + ref: str, + tree: Set[str], + basenames: Dict[str, List[str]], + docs: List[Tuple[str, str]], + repo: str, +) -> List[Finding]: + findings: List[Finding] = [] + for doc_path, text in docs: + for m in NOT_IMPLEMENTED_RE.finditer(text): + for raw in _nearby_script_names(text, m.start(), m.end()): + base = os.path.basename(raw) + if base not in basenames: + continue + code_path = basenames[base][0] + findings.append( + Finding( + check="absence", + kind="correction", + mechanical=False, + doc_path=doc_path, + doc_line=line_of_re(text, m), + code_path=code_path, + code_line=1, + claim=f"`{base}` is not implemented in this skill", + evidence=f"`{code_path}` exists at {ref}", + repo=repo, + ) + ) + for m in AUTOMATION_ONLY_RE.finditer(text): + for raw in _nearby_script_names(text, m.start(), m.end()): + base = os.path.basename(raw) + if base not in basenames: + continue + code_path = basenames[base][0] + findings.append( + Finding( + check="absence", + kind="correction", + mechanical=False, + doc_path=doc_path, + doc_line=line_of_re(text, m), + code_path=code_path, + code_line=1, + claim=f"`{base}` lives in the automation repo, not this repo", + evidence=f"`{code_path}` exists at {ref}", + repo=repo, + ) + ) + return findings + + +def changelog_sections(text: str) -> List[Tuple[str, Optional[str], int, int]]: + """Return list of (version, date|None, start_offset, end_offset).""" + matches = list(CHANGELOG_HEADING.finditer(text)) + sections = [] + for i, m in enumerate(matches): + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + sections.append((m.group(1), m.group(2), m.start(), end)) + return sections + + +def bucket_for_pr(title: str, labels: Sequence[str]) -> Optional[str]: + labs = {l.lower() for l in labels} + if "security" in labs: + return "Security" + m = CONV_PREFIX.match(title.strip()) + if m: + t = m.group("type").lower() + if t in OMIT_TYPES: + return None + return BUCKET.get(t, "Uncategorized") + return "Uncategorized" + + +def check_release( + clone: str, + ref: str, + cfg: Dict[str, Any], + releases: List[Dict[str, Any]], + prs: List[Dict[str, Any]], + repo: str, +) -> List[Finding]: + findings: List[Finding] = [] + cl_path = (cfg.get("release") or {}).get("changelog", "CHANGELOG.md") + text = git_show(clone, ref, cl_path) + if text is None: + return findings + sections = changelog_sections(text) + # Index releases by tag / version + by_tag: Dict[str, Dict[str, Any]] = {} + for rel in releases: + tag = (rel.get("tagName") or "").lstrip() + if not tag: + continue + by_tag[tag] = rel + by_tag[tag.lstrip("v")] = rel + + for ver, date, start, end in sections: + if ver.lower() == "unreleased": + continue + rel = by_tag.get(ver) or by_tag.get(f"v{ver}") + if not rel: + continue + published = rel.get("publishedAt") + if not published: + continue # draft — skip + try: + pub_dt = datetime.fromisoformat(published.replace("Z", "+00:00")) + except ValueError: + continue + pub_date = pub_dt.astimezone(timezone.utc).strftime("%Y-%m-%d") + heading_line = text[:start].count("\n") + 1 + if date and date != pub_date: + body = rel.get("body") or "" + evidence = f"CHANGELOG has {date}; GitHub publishedAt is {pub_date}" + findings.append( + Finding( + check="release_date", + kind="correction", + mechanical=True, + doc_path=cl_path, + doc_line=heading_line, + code_path=f"release:{rel.get('tagName', ver)}", + code_line=1, + claim=f"release {ver} date is {pub_date}", + evidence=evidence, + repo=repo, + replace_with=pub_date, + draft="", + ) + ) + # Note stale release body heading when it repeats the changelog date + if date in body and f"## [{ver}]" in body: + findings[-1].evidence += f"; release body also has {date}" + + # Coverage / Unreleased drafts + # Sort version sections newest-first for range bounds using tags when possible + versioned = [(v, d, s, e) for (v, d, s, e) in sections if v.lower() != "unreleased"] + # PRs not cited in their version section / unreleased + unreleased_section = next( + ((v, d, s, e) for (v, d, s, e) in sections if v.lower() == "unreleased"), + None, + ) + + # Build cited PR numbers per section + def cited_numbers(blob: str) -> Set[int]: + nums = set() + for m in re.finditer(r"#(\d+)\b", blob): + nums.add(int(m.group(1))) + for m in re.finditer(r"/pull/(\d+)", blob): + nums.add(int(m.group(1))) + return nums + + # Partition PRs: those merged after newest tag → Unreleased; others by tag range + # Caller is expected to pass only the relevant PR window; we still filter by citation. + newest_ver = versioned[0][0] if versioned else None + newest_blob_cited = cited_numbers(text[versioned[0][2] : versioned[0][3]]) if versioned else set() + + unreleased_prs = [] + missing_by_section: Dict[str, List[Dict[str, Any]]] = {} + + for pr in prs: + num = int(pr.get("number") or 0) + if not num: + continue + title = pr.get("title") or "" + labels = [ + (l.get("name") if isinstance(l, dict) else str(l)) + for l in (pr.get("labels") or []) + ] + bucket = bucket_for_pr(title, labels) + if bucket is None: + continue + pr = {**pr, "_bucket": bucket} + # If the PR number appears anywhere in the changelog, treat as covered + if num in cited_numbers(text): + continue + # Heuristic: PRs marked unreleased by caller (mergedAt after newest tag) + # use a flag, else put under Unreleased draft + if pr.get("unreleased"): + unreleased_prs.append(pr) + elif newest_ver: + missing_by_section.setdefault(newest_ver, []).append(pr) + else: + unreleased_prs.append(pr) + + def format_draft(prs_list: List[Dict[str, Any]], heading: str) -> str: + groups: Dict[str, List[str]] = {} + for pr in prs_list: + b = pr["_bucket"] + url = pr.get("url") or f"#{pr['number']}" + title = pr.get("title") or "" + # Strip conventional prefix for the bullet text + clean = CONV_PREFIX.sub("", title).strip() or title + groups.setdefault(b, []).append(f"- {clean} (#{pr['number']}: {url})") + order = ["Added", "Changed", "Fixed", "Removed", "Security", "Uncategorized"] + lines = [heading, ""] + for b in order: + if b not in groups: + continue + lines.append(f"### {b}") + lines.append("") + lines.extend(groups[b]) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + if unreleased_prs: + draft = format_draft(unreleased_prs, "## [Unreleased]") + findings.append( + Finding( + check="release_draft", + kind="addition", + mechanical=True, + doc_path=cl_path, + doc_line=1, + code_path="merged-prs", + code_line=1, + claim="document Unreleased merged PRs", + evidence=f"{len(unreleased_prs)} merged PR(s) after newest tag not cited", + repo=repo, + draft=draft, + ) + ) + + for ver, prs_list in missing_by_section.items(): + if not prs_list: + continue + draft = format_draft(prs_list, f"## [{ver}] (additions)") + # Find section line + sec = next((s for s in sections if s[0] == ver), None) + line = text[: sec[2]].count("\n") + 1 if sec else 1 + findings.append( + Finding( + check="release_coverage", + kind="addition", + mechanical=True, + doc_path=cl_path, + doc_line=line, + code_path="merged-prs", + code_line=1, + claim=f"cite merged PRs in [{ver}]", + evidence=f"{len(prs_list)} merged PR(s) not cited in [{ver}]", + repo=repo, + draft=draft, + ) + ) + return findings + + +def collect_docs(clone: str, ref: str, tree: Set[str]) -> List[Tuple[str, str]]: + docs = [] + for p in sorted(tree): + if not p.endswith(DOC_EXTS): + continue + # Skip vendored / huge trees + if p.startswith("node_modules/") or "/node_modules/" in p: + continue + text = git_show(clone, ref, p) + if text is None: + continue + docs.append((p, text)) + return docs + + +def run_checks( + clone: str, + repo: str, + releases: List[Dict[str, Any]], + prs: List[Dict[str, Any]], + ref_override: Optional[str] = None, +) -> Dict[str, Any]: + # Load config at HEAD first to learn boundary mode, then resolve ref. + head_sha = run_git(clone, "rev-parse", "HEAD").strip() + preview_cfg = load_config(clone, head_sha) + boundary = preview_cfg.get("boundary", "main") + tag_pat = preview_cfg.get("stable_tag_pattern", DEFAULT_TAG_PATTERN) + if ref_override: + ref = ref_override + label = ref_override + else: + ref, label = resolve_ref(clone, boundary, tag_pat) + cfg = load_config(clone, ref) + tree = git_ls_tree(clone, ref) + basenames = git_basenames(tree) + docs = collect_docs(clone, ref, tree) + docs_index = {p: t for p, t in docs} + # Text files scanned for retired names (docs + scripts/comments). + scan_files: List[Tuple[str, str]] = list(docs) + for p in sorted(tree): + if not (p.endswith(".sh") or p.endswith(".py") or p.endswith(".md")): + continue + if any(existing == p for existing, _ in scan_files): + continue + text = git_show(clone, ref, p) + if text is not None: + scan_files.append((p, text)) + + findings: List[Finding] = [] + findings += check_paths(clone, ref, tree, docs, repo) + findings += check_flags(clone, ref, tree, docs, repo) + findings += check_enumerations(clone, ref, cfg, docs_index, repo) + findings += check_retired_names(clone, ref, cfg, scan_files, repo) + findings += check_versions(clone, ref, tree, docs, label, repo) + findings += check_absence_claims(clone, ref, tree, basenames, docs, repo) + findings += check_release(clone, ref, cfg, releases, prs, repo) + + # Drop findings lacking both sides (plan rule) + cleaned = [] + for f in findings: + if not f.doc_path or not f.code_path or not f.claim or not f.evidence: + continue + if f.doc_line < 1 or f.code_line < 1: + continue + cleaned.append(f) + + # Dedupe by key + seen: Set[str] = set() + unique: List[Finding] = [] + for f in cleaned: + k = f.key() + if k in seen: + continue + seen.add(k) + unique.append(f) + + return { + "repo": repo, + "ref": ref, + "boundary_label": label, + "config_path": CONFIG_PATH if git_show(clone, ref, CONFIG_PATH) is not None else None, + "findings": [f.to_dict() for f in unique], + "counts": { + "corrections": sum(1 for f in unique if f.kind == "correction"), + "additions": sum(1 for f in unique if f.kind == "addition"), + "total": len(unique), + }, + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser(description="Deterministic doc-drift checker") + ap.add_argument("--clone", required=True, help="Path to git clone") + ap.add_argument("--repo", default="", help="owner/name") + ap.add_argument("--ref", default="", help="Pin to this ref/SHA (skip boundary resolve)") + ap.add_argument("--releases-json", default="", help="Path to releases JSON array") + ap.add_argument("--prs-json", default="", help="Path to merged PRs JSON array") + ap.add_argument("--releases", default="[]", help="Inline releases JSON") + ap.add_argument("--prs", default="[]", help="Inline PRs JSON") + args = ap.parse_args(argv) + + if not os.path.isdir(os.path.join(args.clone, ".git")): + print(json.dumps({"error": f"not a git clone: {args.clone}"})) + return 2 + + releases: List[Dict[str, Any]] = [] + prs: List[Dict[str, Any]] = [] + if args.releases_json: + with open(args.releases_json, encoding="utf-8") as f: + releases = json.load(f) + else: + releases = json.loads(args.releases) + if args.prs_json: + with open(args.prs_json, encoding="utf-8") as f: + prs = json.load(f) + else: + prs = json.loads(args.prs) + + repo = args.repo + if not repo: + # Best-effort from origin + url = run_git(args.clone, "remote", "get-url", "origin") + m = re.search(r"[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?$", url.strip()) + if m: + repo = f"{m.group(1)}/{m.group(2)}" + + result = run_checks( + args.clone, + repo, + releases, + prs, + ref_override=args.ref or None, + ) + json.dump(result, sys.stdout, indent=2, sort_keys=False) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/repoman/scripts/doc-drift-fixer.sh b/skills/repoman/scripts/doc-drift-fixer.sh new file mode 100755 index 0000000..e5bd83e --- /dev/null +++ b/skills/repoman/scripts/doc-drift-fixer.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +# doc-drift-fixer — re-verify and mechanically fix doc-drift findings. +# +# Defaults to dry-run. Pass --live to open fork PRs. Never edits code to match +# docs, never merges, never pushes the default branch, never publishes a release. +# +# Usage: +# bash doc-drift-fixer.sh --dry-run --issue-limit 5 +# bash doc-drift-fixer.sh --live +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/program-lib.sh +. "$SCRIPT_DIR/program-lib.sh" + +DRY_RUN=true +ISSUE_LIMIT=5 + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --live) DRY_RUN=false; shift ;; + --issue-limit) ISSUE_LIMIT="${2:-5}"; shift 2 ;; + --help|-h) + cat <<'EOF' +Usage: doc-drift-fixer.sh [--dry-run|--live] [--issue-limit N] + +Re-verify scanner findings. Mechanical corrections open their own docs: fix +PR; mechanical additions fold into one rolling update/doc-drift PR per repo. +Non-mechanical findings stay on the rollup issue checklist. +EOF + exit 0 + ;; + *) echo "ERROR: unknown option: $1" >&2; exit 1 ;; + esac +done + +repoman_config || exit 1 +REPORTS_DIR="${REPORTS_DIR:-$HOME/reports/doc-drift}" +CHECK_PY="$SCRIPT_DIR/doc-drift-check.py" +[ -f "$CHECK_PY" ] || { echo "ERROR: missing $CHECK_PY" >&2; exit 1; } +[ -f "$REPORTS_DIR/latest.json" ] || { + echo "ERROR: no $REPORTS_DIR/latest.json — run the scanner first" >&2 + exit 1 +} + +SKILL_FOOTER=$(skill_attribution "doc-drift-fixer" 2>/dev/null || true) +FIX_DATE=$(date -u +"%Y-%m-%d") +setup_workspace "doc-drift-fixer" + +echo "=== Doc Drift Fixer $FIX_DATE ===" +if [ "$DRY_RUN" = true ]; then echo "Mode: DRY RUN (no PRs)"; else echo "Mode: LIVE"; fi + +# Re-verify each finding against the current boundary +jq -c '.findings[]?' "$REPORTS_DIR/latest.json" > "$PROGRAM_TMPDIR/prev_findings.jsonl" +: > "$PROGRAM_TMPDIR/still.jsonl" +PROCESSED=0 + +while IFS= read -r finding; do + [ -n "$finding" ] || continue + if [ "$ISSUE_LIMIT" -gt 0 ] && [ "$PROCESSED" -ge "$ISSUE_LIMIT" ]; then + break + fi + repo=$(printf '%s' "$finding" | jq -r '.repo') + repo_dir="$REPOS_DIR/$repo" + if [ ! -d "$repo_dir/.git" ]; then + echo " WARN: missing clone $repo" + continue + fi + # Re-run checker for this repo alone (empty release meta → release findings drop) + safe="${repo//\//_}" + echo "[]" > "$PROGRAM_TMPDIR/rel_${safe}.json" + echo "[]" > "$PROGRAM_TMPDIR/prs_${safe}.json" + # Prefer stored release draft/date findings: re-fetch for those checks + check=$(printf '%s' "$finding" | jq -r '.check') + if [ "$check" = "release_date" ] || [ "$check" = "release_draft" ] || [ "$check" = "release_coverage" ]; then + tag=$(printf '%s' "$finding" | jq -r '.code_path' | sed 's/^release://') + if [ -n "$tag" ] && [ "$tag" != "merged-prs" ]; then + gh release view "$tag" --repo "$repo" \ + --json tagName,publishedAt,createdAt,name,body 2>/dev/null \ + | jq -s '.' > "$PROGRAM_TMPDIR/rel_${safe}.json" 2>/dev/null \ + || echo "[]" > "$PROGRAM_TMPDIR/rel_${safe}.json" + fi + gh pr list --repo "$repo" --state merged --limit 100 \ + --json number,title,url,labels,mergedAt 2>/dev/null \ + | jq '[.[] | . + {unreleased: true}]' > "$PROGRAM_TMPDIR/prs_${safe}.json" 2>/dev/null \ + || echo "[]" > "$PROGRAM_TMPDIR/prs_${safe}.json" + fi + + if ! out=$(python3 "$CHECK_PY" --clone "$repo_dir" --repo "$repo" \ + --releases-json "$PROGRAM_TMPDIR/rel_${safe}.json" \ + --prs-json "$PROGRAM_TMPDIR/prs_${safe}.json"); then + echo " WARN: re-verify failed for $repo" + continue + fi + key=$(printf '%s' "$finding" | jq -r '.key // empty') + if [ -z "$key" ]; then + key=$(printf '%s' "$finding" | jq -r '[.repo, .check, .doc_path, .claim, .code_path] | join("|")') + fi + if printf '%s' "$out" | jq -e --arg k "$key" \ + '[.findings[] | (.key // ([.repo,.check,.doc_path,.claim,.code_path]|join("|")))] | index($k)' \ + >/dev/null; then + # Prefer the freshly computed finding (may have updated draft/replace_with) + fresh=$(printf '%s' "$out" | jq -c --arg k "$key" \ + '.findings[] | select((.key // ([.repo,.check,.doc_path,.claim,.code_path]|join("|"))) == $k)' | head -1) + if [ -n "$fresh" ]; then + echo "$fresh" >> "$PROGRAM_TMPDIR/still.jsonl" + else + echo "$finding" >> "$PROGRAM_TMPDIR/still.jsonl" + fi + PROCESSED=$((PROCESSED + 1)) + else + echo " Obsolete: $key" + fi +done < "$PROGRAM_TMPDIR/prev_findings.jsonl" + +echo "Still open after re-verify: $(wc -l < "$PROGRAM_TMPDIR/still.jsonl" | tr -d ' ')" + +# Partition +jq -c 'select(.mechanical == true and .kind == "correction")' \ + "$PROGRAM_TMPDIR/still.jsonl" > "$PROGRAM_TMPDIR/mech_corr.jsonl" || : > "$PROGRAM_TMPDIR/mech_corr.jsonl" +jq -c 'select(.mechanical == true and .kind == "addition")' \ + "$PROGRAM_TMPDIR/still.jsonl" > "$PROGRAM_TMPDIR/mech_add.jsonl" || : > "$PROGRAM_TMPDIR/mech_add.jsonl" +jq -c 'select(.mechanical != true)' \ + "$PROGRAM_TMPDIR/still.jsonl" > "$PROGRAM_TMPDIR/non_mech.jsonl" || : > "$PROGRAM_TMPDIR/non_mech.jsonl" + +echo "Mechanical corrections: $(wc -l < "$PROGRAM_TMPDIR/mech_corr.jsonl" | tr -d ' ')" +echo "Mechanical additions: $(wc -l < "$PROGRAM_TMPDIR/mech_add.jsonl" | tr -d ' ')" +echo "Non-mechanical: $(wc -l < "$PROGRAM_TMPDIR/non_mech.jsonl" | tr -d ' ')" + +apply_replace() { + local file="$1" old="$2" new="$3" + if [ ! -f "$file" ]; then + echo " WARN: missing $file" + return 1 + fi + # Prefer python for literal replace (no sed metachar surprises) + python3 - "$file" "$old" "$new" <<'PY' +import sys +path, old, new = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(path, encoding="utf-8").read() +if old not in text: + # try case-insensitive for retired names + import re + text2, n = re.subn(re.escape(old), new, text, flags=re.I) + if n == 0: + sys.exit(2) + open(path, "w", encoding="utf-8").write(text2) +else: + open(path, "w", encoding="utf-8").write(text.replace(old, new)) +PY +} + +# --- Mechanical corrections: one PR each --- +echo "" +echo "=== Mechanical corrections ===" +while IFS= read -r finding; do + [ -n "$finding" ] || continue + repo=$(printf '%s' "$finding" | jq -r '.repo') + repo_name="${repo#*/}" + org="${repo%%/*}" + repo_dir="$REPOS_DIR/$repo" + check=$(printf '%s' "$finding" | jq -r '.check') + doc_path=$(printf '%s' "$finding" | jq -r '.doc_path') + claim=$(printf '%s' "$finding" | jq -r '.claim') + replace=$(printf '%s' "$finding" | jq -r '.replace_with // empty') + ref=$(printf '%s' "$finding" | jq -r '.ref // empty') + branch="fix/doc-drift-${check}-$(date -u +%Y%m%d%H%M%S)" + + echo "--- $repo: $check ---" + if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] PR docs: fix $claim" + echo " file: $doc_path replace_with: ${replace:-}" + continue + fi + + case "$check" in + release_date) + # Replace the heading date YYYY-MM-DD + old_date=$(printf '%s' "$finding" | jq -r '.evidence' | sed -n 's/.*CHANGELOG has \([0-9-]*\).*/\1/p') + new_date="$replace" + if [ -z "$old_date" ] || [ -z "$new_date" ]; then + echo " WARN: cannot parse release dates; skipping" + continue + fi + cd "$repo_dir" || continue + ensure_fork "$org" "$repo_name" "$FORK_OWNER" + FORK_REMOTE="${FORK_OWNER}-${repo_name}-fork" + if ! git remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then + git remote add "$FORK_REMOTE" "https://github.com/$FORK_OWNER/${repo_name}.git" + fi + git fetch "$FORK_REMOTE" main 2>/dev/null || true + git checkout -B "$branch" "$FORK_REMOTE/main" 2>/dev/null || git checkout -B "$branch" + ver=$(printf '%s' "$claim" | sed -n 's/.*release \([^ ]*\) date.*/\1/p') + if [ -f "$doc_path" ]; then + python3 - "$doc_path" "$ver" "$old_date" "$new_date" <<'PY' +import re, sys +path, ver, old, new = sys.argv[1:5] +text = open(path, encoding="utf-8").read() +text2, n = re.subn( + rf"(## \[{re.escape(ver)}\] - ){re.escape(old)}", + rf"\g<1>{new}", + text, + count=1, +) +if n == 0: + sys.exit(2) +open(path, "w", encoding="utf-8").write(text2) +PY + git add "$doc_path" + fi + ;; + versions|retired_names) + # Grep the claim token across the repo and replace + cd "$repo_dir" || continue + ensure_fork "$org" "$repo_name" "$FORK_OWNER" + FORK_REMOTE="${FORK_OWNER}-${repo_name}-fork" + if ! git remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then + git remote add "$FORK_REMOTE" "https://github.com/$FORK_OWNER/${repo_name}.git" + fi + git fetch "$FORK_REMOTE" main 2>/dev/null || true + git checkout -B "$branch" "$FORK_REMOTE/main" 2>/dev/null || git checkout -B "$branch" + if [ -z "$replace" ]; then + echo " WARN: no replace_with; leaving on rollup issue" + git checkout main 2>/dev/null || true + continue + fi + # Extract old token from evidence/claim + old_tok=$(printf '%s' "$finding" | jq -r '.evidence' | sed -n 's/.*doc has \([0-9.]*\).*/\1/p') + if [ -z "$old_tok" ]; then + old_tok=$(printf '%s' "$claim" | sed -n 's/.*`\([^`]*\)`.*/\1/p') + fi + if [ -n "$old_tok" ]; then + while IFS= read -r f; do + [ -n "$f" ] || continue + apply_replace "$f" "$old_tok" "$replace" && git add "$f" || true + done < <(git grep -l -I "$old_tok" -- '*.md' '*.rst' '*.txt' 2>/dev/null || true) + fi + ;; + enumerations|paths) + echo " INFO: $check correction needs a unique replacement; checklist only if ambiguous" + # Enum/path mechanical only when replace_with or draft provides the token + if [ -z "$replace" ] && [ -z "$(printf '%s' "$finding" | jq -r '.draft // empty')" ]; then + continue + fi + cd "$repo_dir" || continue + ensure_fork "$org" "$repo_name" "$FORK_OWNER" + FORK_REMOTE="${FORK_OWNER}-${repo_name}-fork" + if ! git remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then + git remote add "$FORK_REMOTE" "https://github.com/$FORK_OWNER/${repo_name}.git" + fi + git fetch "$FORK_REMOTE" main 2>/dev/null || true + git checkout -B "$branch" "$FORK_REMOTE/main" 2>/dev/null || git checkout -B "$branch" + ;; + *) + echo " INFO: no mechanical edit path for check=$check" + continue + ;; + esac + + if git diff --cached --quiet 2>/dev/null; then + echo " No staged changes" + git checkout main 2>/dev/null || true + continue + fi + + pr_title="docs: fix ${claim}" + if [ ${#pr_title} -gt 70 ]; then + pr_title="docs: fix ${check} in ${doc_path}" + fi + pr_body="Mechanical doc-drift correction. + +- Claim: $claim +- Boundary: ${ref:-HEAD} +- File: \`$doc_path\` + +$SKILL_FOOTER" + + if pr_url=$(create_fork_pr "$org" "$repo_name" "$FORK_OWNER" "$branch" \ + "$pr_title + +Automated fix by RepoMan Doc-Drift Fixer ($FIX_DATE)." \ + "$pr_title" "$pr_body"); then + echo " PR: $pr_url" + else + echo " WARN: PR create failed" + fi + git checkout main 2>/dev/null || true +done < "$PROGRAM_TMPDIR/mech_corr.jsonl" + +# --- Mechanical additions: one rolling PR per repo --- +echo "" +echo "=== Mechanical additions (rolling PR) ===" +repos_add=$(jq -r '.repo' "$PROGRAM_TMPDIR/mech_add.jsonl" 2>/dev/null | sort -u) +for repo in $repos_add; do + [ -n "$repo" ] || continue + repo_name="${repo#*/}" + org="${repo%%/*}" + repo_dir="$REPOS_DIR/$repo" + branch="update/doc-drift" + echo "--- $repo ---" + + drafts=$(jq -r --arg r "$repo" 'select(.repo==$r) | .draft // empty' \ + "$PROGRAM_TMPDIR/mech_add.jsonl") + if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] rolling PR $branch" + printf '%s\n' "$drafts" | head -40 + continue + fi + + cd "$repo_dir" || continue + ensure_fork "$org" "$repo_name" "$FORK_OWNER" + FORK_REMOTE="${FORK_OWNER}-${repo_name}-fork" + if ! git remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then + git remote add "$FORK_REMOTE" "https://github.com/$FORK_OWNER/${repo_name}.git" + fi + git fetch "$FORK_REMOTE" 2>/dev/null || true + # Rebase/extend existing rolling branch if present + if git fetch "$FORK_REMOTE" "$branch" 2>/dev/null; then + git checkout -B "$branch" "$FORK_REMOTE/$branch" + git rebase "$FORK_REMOTE/main" 2>/dev/null || true + else + git checkout -B "$branch" "$FORK_REMOTE/main" 2>/dev/null || git checkout -B "$branch" + fi + + # Append Unreleased / enum drafts into CHANGELOG or noted files + while IFS= read -r finding; do + [ -n "$finding" ] || continue + check=$(printf '%s' "$finding" | jq -r '.check') + draft=$(printf '%s' "$finding" | jq -r '.draft // empty') + doc_path=$(printf '%s' "$finding" | jq -r '.doc_path') + [ -n "$draft" ] || continue + case "$check" in + release_draft|release_coverage) + cl="$doc_path" + [ -f "$cl" ] || cl="CHANGELOG.md" + if [ -f "$cl" ]; then + if grep -q '^## \[Unreleased\]' "$cl"; then + # Insert draft bullets after the Unreleased heading + python3 - "$cl" "$draft" <<'PY' +import sys +path, draft = sys.argv[1], sys.argv[2] +text = open(path, encoding="utf-8").read() +# Drop the heading from draft if Unreleased already exists +body = "\n".join(line for line in draft.splitlines() if not line.startswith("## ")) +if body.strip() and body.strip() not in text: + text = text.replace("## [Unreleased]", "## [Unreleased]\n\n" + body.strip() + "\n", 1) + open(path, "w", encoding="utf-8").write(text) +PY + else + printf '%s\n\n%s' "$draft" "$(cat "$cl")" > "$cl.tmp" && mv "$cl.tmp" "$cl" + fi + git add "$cl" + fi + ;; + enumerations|flags) + # Append a checklist note file rather than inventing prose placement + note=".repoman/doc-drift-additions.md" + mkdir -p .repoman + { + echo "## Pending doc additions ($FIX_DATE)" + echo "" + echo "- \`$draft\` — from finding on \`$doc_path\`" + } >> "$note" + git add "$note" + ;; + esac + done < <(jq -c --arg r "$repo" 'select(.repo==$r)' "$PROGRAM_TMPDIR/mech_add.jsonl") + + if git diff --cached --quiet 2>/dev/null; then + echo " Nothing to commit on rolling PR" + git checkout main 2>/dev/null || true + continue + fi + + pr_title="docs: doc-drift updates for $repo_name" + pr_body="Rolling documentation updates from RepoMan doc-drift. + +Includes mechanical additions (release drafts / enum gaps). Paste release +draft sections into GitHub Releases manually — this fixer does not publish. + +$SKILL_FOOTER" + + # If an open PR already exists for this head, just push + existing_pr=$(gh pr list --repo "$repo" --head "$FORK_OWNER:$branch" --state open \ + --json url --jq '.[0].url // empty' 2>/dev/null || true) + git commit -s -m "docs: doc-drift updates + +Automated update by RepoMan Doc-Drift Fixer ($FIX_DATE)." || true + git push "$FORK_REMOTE" "$branch" 2>/dev/null || { + echo " WARN: push failed" + git checkout main 2>/dev/null || true + continue + } + if [ -n "$existing_pr" ]; then + echo " Updated existing PR: $existing_pr" + else + pr_url=$(gh pr create --repo "$repo" --head "$FORK_OWNER:$branch" --base main \ + --title "$pr_title" --body "$pr_body" 2>/dev/null || true) + echo " PR: ${pr_url:-create failed}" + fi + git checkout main 2>/dev/null || true +done + +echo "" +echo "=== Doc Drift Fixer Summary ===" +echo "Non-mechanical findings left on rollup issues: $(wc -l < "$PROGRAM_TMPDIR/non_mech.jsonl" | tr -d ' ')" +if [ "$DRY_RUN" = true ]; then + echo "Dry-run complete — no PRs opened." +fi diff --git a/skills/repoman/scripts/doc-drift-precheck.sh b/skills/repoman/scripts/doc-drift-precheck.sh new file mode 100755 index 0000000..68d8cda --- /dev/null +++ b/skills/repoman/scripts/doc-drift-precheck.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# doc-drift-precheck — deterministic gate before a model turn. +# +# Exit 1: nothing to do (empty worklist). Exit 0: prints "worklist: ". +# Any other exit is fail-open for the caller (DAM schedule precheck). +# +# Usage: +# bash doc-drift-precheck.sh [--repo owner/name] [--worklist /path] +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/program-lib.sh +. "$SCRIPT_DIR/program-lib.sh" + +REPO_FILTER="" +WORKLIST="" +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO_FILTER="${2:-}"; shift 2 ;; + --worklist) WORKLIST="${2:-}"; shift 2 ;; + --help|-h) + cat <<'EOF' +Usage: doc-drift-precheck.sh [--repo owner/name] [--worklist /path] + +Run the deterministic doc-drift checker. Exit 1 when the worklist is empty; +exit 0 and print "worklist: " when it is not. Fetch failures are +fail-open (exit 2) so a broken gate does not block the run. +EOF + exit 0 + ;; + *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; + esac +done + +repoman_config || exit 2 +CHECK_PY="$SCRIPT_DIR/doc-drift-check.py" +if [ ! -f "$CHECK_PY" ]; then + echo "ERROR: missing $CHECK_PY" >&2 + exit 2 +fi + +setup_workspace "doc-drift-precheck" +if [ -z "$WORKLIST" ]; then + WORKLIST="$PROGRAM_TMPDIR/worklist.json" +fi + +ENROLLED=$(repoman_load_enrolled) || exit 2 +CLONES=$(enrolled_clone_dirs "$ENROLLED") +: > "$PROGRAM_TMPDIR/all_findings.jsonl" + +while IFS= read -r full_repo; do + [ -n "$full_repo" ] || continue + if [ -n "$REPO_FILTER" ] && [ "$full_repo" != "$REPO_FILTER" ]; then + continue + fi + repo_dir="$REPOS_DIR/$full_repo" + rel_json="$PROGRAM_TMPDIR/releases_${full_repo//\//_}.json" + prs_json="$PROGRAM_TMPDIR/prs_${full_repo//\//_}.json" + # Best-effort fetch; empty arrays on failure keep the checker offline-clean. + if ! gh release list --repo "$full_repo" --limit 20 --json tagName \ + >/dev/null 2>&1; then + echo "[]" > "$rel_json" + echo "[]" > "$prs_json" + else + # Expand each tag into the fields the checker needs. + { + echo "[" + first=1 + while IFS= read -r tag; do + [ -n "$tag" ] || continue + body=$(gh release view "$tag" --repo "$full_repo" \ + --json tagName,publishedAt,createdAt,name,body 2>/dev/null || echo "") + [ -n "$body" ] || continue + if [ "$first" -eq 1 ]; then first=0; else echo ","; fi + printf '%s' "$body" + done < <(gh release list --repo "$full_repo" --limit 20 --json tagName \ + --jq '.[].tagName' 2>/dev/null || true) + echo "]" + } > "$rel_json" 2>/dev/null || echo "[]" > "$rel_json" + # Merged PRs after the newest stable tag → unreleased candidates. + gh pr list --repo "$full_repo" --state merged --limit 100 \ + --json number,title,url,labels,mergedAt 2>/dev/null \ + | jq '[.[] | . + {unreleased: true}]' > "$prs_json" 2>/dev/null \ + || echo "[]" > "$prs_json" + fi + + if ! out=$(python3 "$CHECK_PY" --clone "$repo_dir" --repo "$full_repo" \ + --releases-json "$rel_json" --prs-json "$prs_json" 2>/dev/null); then + exit 2 + fi + printf '%s' "$out" | jq -c '.findings[]? // empty' >> "$PROGRAM_TMPDIR/all_findings.jsonl" \ + || exit 2 +done <<< "$CLONES" + +count=$(wc -l < "$PROGRAM_TMPDIR/all_findings.jsonl" | tr -d ' ') +if [ "${count:-0}" -eq 0 ]; then + exit 1 +fi + +jq -s '{findings: ., count: length}' "$PROGRAM_TMPDIR/all_findings.jsonl" > "$WORKLIST" \ + || exit 2 +echo "worklist: $WORKLIST" +exit 0 diff --git a/skills/repoman/scripts/doc-drift-scanner.sh b/skills/repoman/scripts/doc-drift-scanner.sh new file mode 100755 index 0000000..5c4de48 --- /dev/null +++ b/skills/repoman/scripts/doc-drift-scanner.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +# doc-drift-scanner — scan enrolled repos for documentation drift. +# +# Runs the deterministic checker, diffs against the previous report, keeps one +# rollup issue per repo, and writes latest.json / history.json. +# +# Usage: +# bash doc-drift-scanner.sh --dry-run +# bash doc-drift-scanner.sh --issue-limit 3 +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/program-lib.sh +. "$SCRIPT_DIR/program-lib.sh" + +DRY_RUN=false +ISSUE_LIMIT=0 + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --issue-limit) + ISSUE_LIMIT="${2:-0}" + shift 2 + ;; + --help|-h) + cat <<'EOF' +Usage: doc-drift-scanner.sh [--dry-run] [--issue-limit N] + +Scan enrolled clones for documentation drift. Creates/updates one rollup +issue per repo ([doc-drift] owner/name). --dry-run prints actions and does +not write latest.json (so the next live run still sees findings as new). +EOF + exit 0 + ;; + *) echo "ERROR: unknown option: $1" >&2; exit 1 ;; + esac +done + +repoman_config || exit 1 +REPORTS_DIR="${REPORTS_DIR:-$HOME/reports/doc-drift}" +CHECK_PY="$SCRIPT_DIR/doc-drift-check.py" +[ -f "$CHECK_PY" ] || { echo "ERROR: missing $CHECK_PY" >&2; exit 1; } + +SKILL_FOOTER=$(skill_attribution "doc-drift-scanner" 2>/dev/null || true) +SCAN_DATE=$(date -u +"%Y-%m-%d") +SCAN_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +MAX_HISTORY_ROWS=500 + +setup_workspace "doc-drift-scanner" +mkdir -p "$REPORTS_DIR" +SCAN_ID=$(generate_scan_id "$REPORTS_DIR" "$SCAN_DATE") + +echo "=== Doc Drift Scan $SCAN_ID ===" +echo "Repos dir: $REPOS_DIR" +echo "Reports dir: $REPORTS_DIR" +if [ "$DRY_RUN" = true ]; then echo "Mode: DRY RUN"; fi +if [ "$ISSUE_LIMIT" -gt 0 ]; then echo "Issue limit: $ISSUE_LIMIT"; fi + +ENROLLED=$(repoman_load_enrolled) || exit 1 +CLONES=$(enrolled_clone_dirs "$ENROLLED") +: > "$PROGRAM_TMPDIR/current.jsonl" +REPOS_SCANNED=0 +REPOS_FAILED=0 +REPOS_TOUCHED=0 + +fetch_release_meta() { + local full_repo="$1" rel_json="$2" prs_json="$3" + echo "[]" > "$rel_json" + echo "[]" > "$prs_json" + if ! gh release list --repo "$full_repo" --limit 20 --json tagName >/dev/null 2>&1; then + return 0 + fi + { + echo "[" + first=1 + while IFS= read -r tag; do + [ -n "$tag" ] || continue + body=$(gh release view "$tag" --repo "$full_repo" \ + --json tagName,publishedAt,createdAt,name,body 2>/dev/null || echo "") + [ -n "$body" ] || continue + if [ "$first" -eq 1 ]; then first=0; else echo ","; fi + printf '%s' "$body" + done < <(gh release list --repo "$full_repo" --limit 20 --json tagName \ + --jq '.[].tagName' 2>/dev/null || true) + echo "]" + } > "$rel_json" 2>/dev/null || echo "[]" > "$rel_json" + + gh pr list --repo "$full_repo" --state merged --limit 100 \ + --json number,title,url,labels,mergedAt 2>/dev/null \ + | jq '[.[] | . + {unreleased: true}]' > "$prs_json" 2>/dev/null \ + || echo "[]" > "$prs_json" +} + +while IFS= read -r full_repo; do + [ -n "$full_repo" ] || continue + repo_dir="$REPOS_DIR/$full_repo" + echo "Scanning $full_repo..." + safe="${full_repo//\//_}" + rel_json="$PROGRAM_TMPDIR/rel_${safe}.json" + prs_json="$PROGRAM_TMPDIR/prs_${safe}.json" + fetch_release_meta "$full_repo" "$rel_json" "$prs_json" + + if ! out=$(python3 "$CHECK_PY" --clone "$repo_dir" --repo "$full_repo" \ + --releases-json "$rel_json" --prs-json "$prs_json"); then + echo " WARN: checker failed for $full_repo" + REPOS_FAILED=$((REPOS_FAILED + 1)) + continue + fi + REPOS_SCANNED=$((REPOS_SCANNED + 1)) + ref=$(printf '%s' "$out" | jq -r '.ref // ""') + printf '%s' "$out" | jq -c --arg ref "$ref" \ + '.findings[]? | . + {ref: $ref, first_detected: (now | todateiso8601)}' \ + >> "$PROGRAM_TMPDIR/current.jsonl" 2>/dev/null || true + n=$(printf '%s' "$out" | jq '.counts.total // 0') + echo " Findings: $n (ref ${ref:0:12})" +done <<< "$CLONES" + +echo "" +echo "=== Diff against previous ===" +PREV="$PROGRAM_TMPDIR/prev.jsonl" +if [ -f "$REPORTS_DIR/latest.json" ]; then + jq -c '.findings[]?' "$REPORTS_DIR/latest.json" > "$PREV" 2>/dev/null || : > "$PREV" +else + : > "$PREV" +fi + +KEY_EXPR='.key // ([.repo, .check, .doc_path, .claim, .code_path] | join("|"))' +read -r NEW_COUNT FIXED_COUNT RECURRING_COUNT < <(diff_against_previous \ + "$PROGRAM_TMPDIR/current.jsonl" "$PREV" "$KEY_EXPR") +echo "New: $NEW_COUNT Fixed: $FIXED_COUNT Recurring: $RECURRING_COUNT" + +# Enrich current findings with status +ENRICHED="$PROGRAM_TMPDIR/enriched.jsonl" +: > "$ENRICHED" +while IFS= read -r line; do + [ -n "$line" ] || continue + key=$(printf '%s' "$line" | jq -r "$KEY_EXPR") + if grep -qxF "$key" "$PROGRAM_TMPDIR/new_keys.txt" 2>/dev/null; then + printf '%s' "$line" | jq -c '.status="open" | .reason="new"' + else + printf '%s' "$line" | jq -c '.status="open" | .reason="recurring"' + fi +done < "$PROGRAM_TMPDIR/current.jsonl" >> "$ENRICHED" + +# --- Rollup issues (one per repo) --- +echo "" +echo "=== Rollup issues ===" +ISSUES_UPDATED=0 +ISSUES_CLOSED=0 +ISSUES_CREATED=0 + +repos_with_findings=$(jq -r '.repo' "$ENRICHED" 2>/dev/null | sort -u) +# Also repos that had findings before but none now (from fixed keys) +repos_prev=$(jq -r '.repo // empty' "$PREV" 2>/dev/null | sort -u) +repos_all=$(printf '%s\n%s\n' "$repos_with_findings" "$repos_prev" | awk 'NF && !seen[$0]++') + +while IFS= read -r full_repo; do + [ -n "$full_repo" ] || continue + if [ "$ISSUE_LIMIT" -gt 0 ] && [ "$REPOS_TOUCHED" -ge "$ISSUE_LIMIT" ]; then + echo "Issue limit reached ($ISSUE_LIMIT); skipping remaining repos" + break + fi + + repo_findings="$PROGRAM_TMPDIR/repo_${full_repo//\//_}.jsonl" + jq -c --arg r "$full_repo" 'select(.repo == $r)' "$ENRICHED" > "$repo_findings" 2>/dev/null || : > "$repo_findings" + corr=$(jq -s '[.[] | select(.kind=="correction")] | length' "$repo_findings") + adds=$(jq -s '[.[] | select(.kind=="addition")] | length' "$repo_findings") + title="[doc-drift] $full_repo" + + body_file="$PROGRAM_TMPDIR/body_${full_repo//\//_}.md" + { + echo "## Doc drift in \`$full_repo\`" + echo "" + echo "Boundary pinned at scan \`$SCAN_ID\`. Corrections first, then additions." + echo "" + echo "### Corrections" + echo "" + if [ "$corr" -eq 0 ]; then + echo "_None open._" + else + jq -r -s '.[] | select(.kind=="correction") | + "- [ ] **\(.check)**: \(.claim) — `\(.doc_path):\(.doc_line)` vs `\(.code_path):\(.code_line)` — \(.evidence)"' \ + "$repo_findings" + fi + echo "" + echo "### Additions" + echo "" + if [ "$adds" -eq 0 ]; then + echo "_None._" + else + jq -r -s '.[] | select(.kind=="addition") | + "- [ ] **\(.check)**: \(.claim) — `\(.doc_path):\(.doc_line)` vs `\(.code_path):\(.code_line)`"' \ + "$repo_findings" + fi + echo "" + echo "---" + echo "$SKILL_FOOTER" + } > "$body_file" + + existing="" + if existing=$(gh_issue_exists "$full_repo" "$title"); then + : + else + existing="" + fi + + if [ "$corr" -eq 0 ] && [ "$adds" -eq 0 ]; then + if [ -n "$existing" ]; then + if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] would close #$existing on $full_repo (no open corrections)" + else + if close_issue_if_valid "$full_repo" "$existing" \ + "Closed by doc-drift scan $SCAN_ID: no open corrections remain."; then + ISSUES_CLOSED=$((ISSUES_CLOSED + 1)) + echo " Closed #$existing on $full_repo" + fi + fi + REPOS_TOUCHED=$((REPOS_TOUCHED + 1)) + fi + continue + fi + + REPOS_TOUCHED=$((REPOS_TOUCHED + 1)) + if [ -n "$existing" ]; then + if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] would update #$existing on $full_repo ($corr corrections, $adds additions)" + else + if gh issue edit "$existing" --repo "$full_repo" --body-file "$body_file" >/dev/null 2>&1; then + ISSUES_UPDATED=$((ISSUES_UPDATED + 1)) + echo " Updated #$existing on $full_repo" + else + echo " WARN: failed to update #$existing on $full_repo" + fi + fi + else + if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] would create issue on $full_repo ($corr corrections, $adds additions)" + else + # Ensure label exists (best-effort) + gh label create "doc-drift" --repo "$full_repo" --color "0052CC" \ + --description "Documentation drift tracked by RepoMan" 2>/dev/null || true + if num=$(gh issue create --repo "$full_repo" --title "$title" \ + --body-file "$body_file" --label "doc-drift" \ + --jq '.number' 2>/dev/null); then + ISSUES_CREATED=$((ISSUES_CREATED + 1)) + echo " Created #$num on $full_repo" + else + # Fallback without jq filter (older gh) + url=$(gh issue create --repo "$full_repo" --title "$title" \ + --body-file "$body_file" --label "doc-drift" 2>/dev/null || true) + if [ -n "$url" ]; then + ISSUES_CREATED=$((ISSUES_CREATED + 1)) + echo " Created $url" + else + echo " WARN: failed to create issue on $full_repo" + fi + fi + fi + fi +done <<< "$repos_all" + +# Ledger +LEDGER="$REPORTS_DIR/triaged.log" +if [ "$DRY_RUN" = false ]; then + while IFS= read -r key; do + [ -n "$key" ] || continue + printf '%s\t%s\t%s\t%s\n' "$SCAN_TIME" "$key" "open" "new" >> "$LEDGER" + done < "$PROGRAM_TMPDIR/new_keys.txt" + while IFS= read -r key; do + [ -n "$key" ] || continue + printf '%s\t%s\t%s\t%s\n' "$SCAN_TIME" "$key" "obsolete" "fixed" >> "$LEDGER" + done < "$PROGRAM_TMPDIR/fixed_keys.txt" +fi + +# --- Reports --- +CORRECTIONS=$(jq -s '[.[] | select(.kind=="correction")] | length' "$ENRICHED") +ADDITIONS=$(jq -s '[.[] | select(.kind=="addition")] | length' "$ENRICHED") +FINDINGS_JSON=$(jq -s '.' "$ENRICHED" 2>/dev/null || echo '[]') + +LATEST=$(jq -n \ + --arg scan_id "$SCAN_ID" \ + --arg date "$SCAN_TIME" \ + --argjson repos_scanned "$REPOS_SCANNED" \ + --argjson repos_failed "$REPOS_FAILED" \ + --argjson corrections "$CORRECTIONS" \ + --argjson additions "$ADDITIONS" \ + --argjson findings "$FINDINGS_JSON" \ + --argjson new "$NEW_COUNT" \ + --argjson fixed "$FIXED_COUNT" \ + --argjson recurring "$RECURRING_COUNT" \ + '{ + scan_id: $scan_id, + date: $date, + repos_scanned: $repos_scanned, + repos_failed: $repos_failed, + corrections: $corrections, + additions: $additions, + findings: $findings, + delta: {new: $new, fixed: $fixed, recurring: $recurring} + }') + +if [ "$DRY_RUN" = true ]; then + echo "" + echo "[DRY RUN] Skipping latest.json / history.json write" + echo "$LATEST" | jq '{scan_id, date, repos_scanned, corrections, additions, delta}' +else + write_report_latest "$REPORTS_DIR" "$LATEST" + HISTORY_ROW=$(jq -n \ + --arg scan_id "$SCAN_ID" \ + --arg date "$SCAN_TIME" \ + --argjson repos_scanned "$REPOS_SCANNED" \ + --argjson corrections "$CORRECTIONS" \ + --argjson additions "$ADDITIONS" \ + --argjson new "$NEW_COUNT" \ + --argjson fixed "$FIXED_COUNT" \ + --argjson issues_created "$ISSUES_CREATED" \ + --argjson issues_updated "$ISSUES_UPDATED" \ + --argjson issues_closed "$ISSUES_CLOSED" \ + '{ + scan_id: $scan_id, date: $date, repos_scanned: $repos_scanned, + corrections: $corrections, additions: $additions, + new: $new, fixed: $fixed, + issues_created: $issues_created, issues_updated: $issues_updated, + issues_closed: $issues_closed + }') + append_history_row "$REPORTS_DIR" "$HISTORY_ROW" "$MAX_HISTORY_ROWS" + echo "Wrote $REPORTS_DIR/latest.json and history.json" +fi + +echo "" +echo "=== Doc Drift Scan $SCAN_ID Summary ===" +echo "Repos scanned: $REPOS_SCANNED (failed: $REPOS_FAILED)" +echo "Corrections: $CORRECTIONS Additions: $ADDITIONS" +echo "Issues created: $ISSUES_CREATED updated: $ISSUES_UPDATED closed: $ISSUES_CLOSED" diff --git a/skills/repoman/scripts/repoman-run.sh b/skills/repoman/scripts/repoman-run.sh index fa66533..7583dce 100644 --- a/skills/repoman/scripts/repoman-run.sh +++ b/skills/repoman/scripts/repoman-run.sh @@ -39,6 +39,7 @@ link-health scanner yes yes yes link-health-scanner.sh Link Health $HOME/reports dep-bump scanner yes yes yes dep-bump-scanner.sh Dependency Bumps $HOME/reports/dep-bump yes no pr-review scanner no no no pr-review-scanner.sh PR Review yes no weekly-report scanner no no no weekly-report.sh Weekly Report no no +doc-drift scanner yes yes yes doc-drift-scanner.sh Doc Drift $HOME/reports/doc-drift yes no automation-health index-consumer no no no automation-health-dashboard.sh Automation Health yes yes repo-sync sync no no no repo-sync.sh Repo Sync yes no" diff --git a/skills/repoman/scripts/repoman-setup.sh b/skills/repoman/scripts/repoman-setup.sh index ff2f28b..63c1caa 100755 --- a/skills/repoman/scripts/repoman-setup.sh +++ b/skills/repoman/scripts/repoman-setup.sh @@ -31,7 +31,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # user who introduces a new program must add its identifier here until the # program registry (_index.json, tracked in #76) replaces it with a # discovered list. -KNOWN_PROGRAMS="link-health dep-bump pr-review automation-health repo-sync weekly-report" +KNOWN_PROGRAMS="link-health dep-bump pr-review automation-health repo-sync weekly-report doc-drift" usage() { cat <<'EOF' @@ -51,7 +51,7 @@ Subcommands: enable-program --program Merge {"enabled": true} into programs/.json. Known programs: link-health, dep-bump, pr-review, automation-health, - repo-sync, weekly-report. + repo-sync, weekly-report, doc-drift. set-output --program --mode same|central [--repo ] Merge {"output_repo": {...}} into programs/.json. diff --git a/tests/test-vendor-engine.sh b/tests/test-vendor-engine.sh index 6f3740f..413c700 100644 --- a/tests/test-vendor-engine.sh +++ b/tests/test-vendor-engine.sh @@ -9,35 +9,44 @@ TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT fail=0 MANIFEST="automation-health-dashboard core dep-bump-fixer dep-bump-scanner \ +doc-drift-fixer doc-drift-precheck doc-drift-scanner \ extract-broken-links fork github-api link-health-fixer link-health-scanner \ org parse-diff-map pr-review-fixer pr-review-impact pr-review-lib \ pr-review-scanner program-lib repo-sync repoman-check repoman-index \ repoman-requirements repoman-run repoman-setup weekly-report" +EXTRA_FILES="doc-drift-check.py" +EXPECTED_SH=26 +EXPECTED_FILES=27 -# Fake automation source repo holding the 23 engine scripts, committed so it has a HEAD. +# Fake automation source repo holding the engine scripts, committed so it has a HEAD. SRC="$TMP/auto"; mkdir -p "$SRC/scripts" # shellcheck disable=SC2086 for f in $MANIFEST; do echo "#!/usr/bin/env bash" > "$SRC/scripts/$f.sh"; done +# shellcheck disable=SC2086 +for f in $EXTRA_FILES; do echo "# py" > "$SRC/scripts/$f"; done git -C "$SRC" init -q git -C "$SRC" -c user.email=t@t -c user.name=t add -A git -C "$SRC" -c user.email=t@t -c user.name=t commit -qm init DEST="$TMP/dest" -# --- Happy path: all 23 copied --- +# --- Happy path: all shell scripts + extras copied --- if ! bash "$VENDOR" --source "$SRC" --dest "$DEST" >/dev/null 2>&1; then echo "FAIL: happy-path vendor returned non-zero"; fail=1 fi n=$(find "$DEST" -maxdepth 1 -name '*.sh' 2>/dev/null | wc -l | tr -d ' ') -if [ "$n" = "23" ]; then echo "PASS: 23 scripts copied"; else echo "FAIL: copied $n, expected 23"; fail=1; fi +if [ "$n" = "$EXPECTED_SH" ]; then echo "PASS: $EXPECTED_SH scripts copied"; else echo "FAIL: copied $n, expected $EXPECTED_SH"; fail=1; fi +if [ -f "$DEST/doc-drift-check.py" ]; then echo "PASS: doc-drift-check.py copied"; else echo "FAIL: doc-drift-check.py missing"; fail=1; fi -# --- Provenance stamp: correct SOURCE repo HEAD sha + 23-file list (Task 2) --- +# --- Provenance stamp: correct SOURCE repo HEAD sha + file list --- want=$(git -C "$SRC" rev-parse HEAD) got=$(jq -r '.source_sha' "$DEST/_vendor.json" 2>/dev/null) if [ "$got" = "$want" ]; then echo "PASS: stamp sha == source HEAD"; else echo "FAIL: stamp sha $got != source $want"; fail=1; fi -if jq -e '.files | length == 23' "$DEST/_vendor.json" >/dev/null 2>&1; then - echo "PASS: stamp lists 23 files"; else echo "FAIL: stamp file list wrong"; fail=1; fi +if jq -e --argjson n "$EXPECTED_FILES" '.files | length == $n' "$DEST/_vendor.json" >/dev/null 2>&1; then + echo "PASS: stamp lists $EXPECTED_FILES files"; else echo "FAIL: stamp file list wrong"; fail=1; fi if jq -e '.source_repo == "rossoctl/automation"' "$DEST/_vendor.json" >/dev/null 2>&1; then echo "PASS: stamp names source repo"; else echo "FAIL: stamp source_repo wrong"; fail=1; fi +if jq -e '.files | index("doc-drift-check.py")' "$DEST/_vendor.json" >/dev/null 2>&1; then + echo "PASS: stamp lists doc-drift-check.py"; else echo "FAIL: stamp missing doc-drift-check.py"; fail=1; fi # --- Idempotency: second run, same source => identical stamp sha --- bash "$VENDOR" --source "$SRC" --dest "$DEST" >/dev/null 2>&1 @@ -45,13 +54,13 @@ got2=$(jq -r '.source_sha' "$DEST/_vendor.json" 2>/dev/null) if [ "$got2" = "$want" ]; then echo "PASS: idempotent stamp sha"; else echo "FAIL: idempotency $got2 != $want"; fail=1; fi # --- Missing source fails loud AND leaves the dest empty (no half-vendored dir) --- -# A file missing mid-manifest (org.sh) must abort BEFORE any copy, so the dest -# must contain zero .sh files — not a partial set with a stale _vendor.json. BADDEST="$TMP/baddest" missing_source_fails() ( BAD="$TMP/bad"; mkdir -p "$BAD/scripts" # shellcheck disable=SC2086 for f in $MANIFEST; do echo x > "$BAD/scripts/$f.sh"; done + # shellcheck disable=SC2086 + for f in $EXTRA_FILES; do echo x > "$BAD/scripts/$f"; done rm "$BAD/scripts/org.sh" git -C "$BAD" init -q git -C "$BAD" -c user.email=t@t -c user.name=t add -A From 19c75d1a607705d9738663d14de4e638a3e522da Mon Sep 17 00:00:00 2001 From: Jim Cadden Date: Thu, 29 Oct 2026 14:46:13 -0400 Subject: [PATCH 2/6] fix: Vendor doc-drift review fixes from automation Syncs automation@fbf8f7b into the repoman plugin scripts (dashboard Delta, YAML list detection, bullet subcommands, real precheck test). Signed-off-by: Jim Cadden --- skills/repoman/scripts/_vendor.json | 4 +- .../scripts/automation-health-dashboard.sh | 5 +- skills/repoman/scripts/doc-drift-check.py | 127 +++++++++++++----- 3 files changed, 98 insertions(+), 38 deletions(-) diff --git a/skills/repoman/scripts/_vendor.json b/skills/repoman/scripts/_vendor.json index d6f2dfd..44caab7 100644 --- a/skills/repoman/scripts/_vendor.json +++ b/skills/repoman/scripts/_vendor.json @@ -1,7 +1,7 @@ { "source_repo": "rossoctl/automation", - "source_sha": "ecdc68577aa66e7959a4741b45ada741eac6cba2", - "vendored_at": "2026-10-29T17:53:18Z", + "source_sha": "fbf8f7b0c20a90c9c11837aa286141ad66a5fea8", + "vendored_at": "2026-10-29T18:46:13Z", "files": [ "automation-health-dashboard.sh", "core.sh", diff --git a/skills/repoman/scripts/automation-health-dashboard.sh b/skills/repoman/scripts/automation-health-dashboard.sh index ac9053d..5385b8d 100644 --- a/skills/repoman/scripts/automation-health-dashboard.sh +++ b/skills/repoman/scripts/automation-health-dashboard.sh @@ -404,9 +404,12 @@ if [ "$HAS_DOC_DRIFT" = true ]; then DD_REPOS_SCANNED=$(jq '.repos_scanned // 0' "$DOC_DRIFT_DIR/latest.json") DD_CORRECTIONS=$(jq '.corrections // ([.findings[]? | select(.kind=="correction")] | length)' "$DOC_DRIFT_DIR/latest.json") DD_ADDITIONS=$(jq '.additions // ([.findings[]? | select(.kind=="addition")] | length)' "$DOC_DRIFT_DIR/latest.json") + # Prefer .new/.fixed (emitted by doc-drift-scanner.sh); fall back to 0 so + # older history rows without those keys still render a numeric Delta. DD_TREND_TABLE=$(jq -r ' .[-10:] | reverse | .[] | - "| \(.date | split("T")[0]) | \(.corrections // 0) | \(.additions // 0) | \(if .new > .fixed then "+\(.new - .fixed)" elif .new < .fixed then "\(.new - .fixed)" else "0" end) |" + (.new // 0) as $n | (.fixed // 0) as $f | + "| \(.date | split("T")[0]) | \(.corrections // 0) | \(.additions // 0) | \(if $n > $f then "+\($n - $f)" elif $n < $f then "\($n - $f)" else "0" end) |" ' "$DOC_DRIFT_DIR/history.json" 2>/dev/null || echo "| - | - | - | - |") fi diff --git a/skills/repoman/scripts/doc-drift-check.py b/skills/repoman/scripts/doc-drift-check.py index 4e43f03..528b49a 100755 --- a/skills/repoman/scripts/doc-drift-check.py +++ b/skills/repoman/scripts/doc-drift-check.py @@ -35,9 +35,13 @@ MD_LINK_PATH = re.compile(r"\[[^\]]*\]\((?!https?://|#|mailto:)([A-Za-z0-9_./\-]+)\)") # Flag / long option FLAG_RE = re.compile(r"(?\s*(?:#\s*)?)(?:subcommands?|commands?)\s*:\s*(?P.*)$", + re.I, +) +_SUBCMD_STOPWORDS = frozenset( + {"subcommand", "subcommands", "commands", "command"} ) # Keep a Changelog heading CHANGELOG_HEADING = re.compile( @@ -191,13 +195,28 @@ def _parse_scalar(s: str) -> Any: return s +def _next_significant_line(lines: Sequence[str], start: int) -> Optional[str]: + """Return the next non-blank, non-comment line at or after start, or None.""" + for j in range(start, len(lines)): + cand = lines[j] + if not cand.strip() or cand.lstrip().startswith("#"): + continue + return cand + return None + + def parse_simple_yaml(text: str) -> Dict[str, Any]: - """Parse the narrow YAML subset used by doc-drift config.""" + """Parse the narrow YAML subset used by doc-drift config. + + List-vs-map for a bare ``key:`` is decided structurally: if the next + significant line is a ``- `` item at deeper indent, the value is a list; + otherwise it is a map. No hard-coded key names. + """ root: Dict[str, Any] = {} stack: List[Tuple[int, Any]] = [(-1, root)] - pending_key: Optional[str] = None + lines = text.splitlines() - for raw in text.splitlines(): + for i, raw in enumerate(lines): if not raw.strip() or raw.lstrip().startswith("#"): continue indent = len(raw) - len(raw.lstrip(" ")) @@ -210,14 +229,11 @@ def parse_simple_yaml(text: str) -> Dict[str, Any]: if line.startswith("- "): item_text = line[2:].strip() if not isinstance(parent, list): - # Convert last pending into list if needed — shouldn't happen continue if ":" in item_text and not item_text.startswith("{"): - # map item: { name: x, replace_with: y } or key: value pairs if item_text.startswith("{") and item_text.endswith("}"): parent.append(_parse_flow_map(item_text)) else: - # single key: value on the dash line → start a map k, _, v = item_text.partition(":") obj: Dict[str, Any] = {k.strip(): _parse_scalar(v)} parent.append(obj) @@ -235,20 +251,17 @@ def parse_simple_yaml(text: str) -> Dict[str, Any]: if not isinstance(parent, dict): continue if val == "": - # Look ahead is hard; create list or dict based on next lines — - # default to dict, promote later via dash detection using a marker - # Heuristic: sources_of_truth / retired_names / ignore_paths → list - if key in ( - "sources_of_truth", - "retired_names", - "ignore_paths", - "docs", - ): - parent[key] = [] - stack.append((indent, parent[key])) + nxt = _next_significant_line(lines, i + 1) + if nxt is not None: + nxt_indent = len(nxt) - len(nxt.lstrip(" ")) + if nxt_indent > indent and nxt.lstrip().startswith("- "): + parent[key] = [] + stack.append((indent, parent[key])) + else: + parent[key] = {} + stack.append((indent, parent[key])) else: parent[key] = {} - stack.append((indent, parent[key])) elif val.startswith("[") and val.endswith("]"): inner = val[1:-1].strip() if not inner: @@ -435,19 +448,67 @@ def extract_script_tokens(script_text: str) -> Set[str]: return tokens -def _header_subcommands(script_text: str) -> Set[str]: - """Subcommands named in a leading comment / usage block.""" +def _add_subcommand_token(subs: Set[str], tok: str) -> None: + if tok and tok.lower() not in _SUBCMD_STOPWORDS: + subs.add(tok) + + +def extract_listed_subcommands(text: str) -> Set[str]: + """Collect subcommand tokens from a Subcommands:/Commands: list. + + Accepts same-line tokens and subsequent indented ``- item`` bullets (with + optional backticks), so multi-line docs are not silently skipped. + """ subs: Set[str] = set() - for m in SUBCMD_DOC_RE.finditer(script_text): - for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", m.group(1)): - if tok.lower() not in ("subcommand", "subcommands", "commands", "command"): - subs.add(tok) - # "Subcommands:\n foo --bar" style in comments - for m in re.finditer(r"(?m)^#\s+([a-z][a-z0-9-]*)\s+--", script_text): - subs.add(m.group(1)) + lines = text.splitlines() + for i, line in enumerate(lines): + m = SUBCMD_HEADER_RE.match(line) + if not m: + continue + rest = (m.group("rest") or "").strip() + if rest: + for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", rest): + _add_subcommand_token(subs, tok) + base_indent = len(line) - len(line.lstrip(" ")) + for j in range(i + 1, len(lines)): + nxt = lines[j] + if not nxt.strip(): + continue + nindent = len(nxt) - len(nxt.lstrip(" ")) + stripped = nxt.strip() + # Markdown often puts "- item" at the same column as "Subcommands:". + # Accept dash items at indent >= header; stop on other content at + # indent <= header (a new section). + if stripped.startswith("- "): + if nindent < base_indent: + break + item = stripped[2:].strip() + for bt, plain in re.findall( + r"`([^`]+)`|([A-Za-z][A-Za-z0-9_-]*)", item + ): + _add_subcommand_token(subs, bt or plain) + continue + if nindent <= base_indent: + break + if stripped.startswith("#"): + continue + # " foo --bar" style without a dash (must be indented past header) + m2 = re.match(r"^([a-z][a-z0-9-]*)\s+--", stripped) + if m2: + _add_subcommand_token(subs, m2.group(1)) + continue + break + # Comment form: "# foo --bar" without a Subcommands: header + for m in re.finditer(r"(?m)^#\s+([a-z][a-z0-9-]*)\s+--", text): + _add_subcommand_token(subs, m.group(1)) return subs +def _header_subcommands(script_text: str) -> Set[str]: + """Subcommands named in a leading comment / usage block.""" + return extract_listed_subcommands(script_text) + + def _case_subcommands(script_text: str) -> Set[str]: """Top-level case-arm tokens that look like subcommands (not flags).""" subs: Set[str] = set() @@ -530,11 +591,7 @@ def check_flags( continue parsed = extract_script_tokens(script_text) doc_flags = set(FLAG_RE.findall(text)) - META_FLAGS - doc_sub: Set[str] = set() - for m in SUBCMD_DOC_RE.finditer(text): - blob = m.group(1) - for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", blob): - doc_sub.add(tok) + doc_sub = extract_listed_subcommands(text) for m in re.finditer(r"(?m)^\s{2,}([a-z][a-z0-9-]*)\s+--", text): doc_sub.add(m.group(1)) From 2dff532e372aae6d977bc252c43a7cad780ac7b9 Mon Sep 17 00:00:00 2001 From: Jim Cadden Date: Thu, 29 Oct 2026 21:46:49 -0400 Subject: [PATCH 3/6] fix: Vendor doc-drift dead-code cleanup from automation Syncs automation@c4b55a6 into the plugin scripts. Signed-off-by: Jim Cadden --- skills/repoman/scripts/_vendor.json | 4 +-- skills/repoman/scripts/doc-drift-check.py | 30 +++++++++-------------- skills/repoman/scripts/doc-drift-fixer.sh | 15 ------------ 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/skills/repoman/scripts/_vendor.json b/skills/repoman/scripts/_vendor.json index 44caab7..9a53374 100644 --- a/skills/repoman/scripts/_vendor.json +++ b/skills/repoman/scripts/_vendor.json @@ -1,7 +1,7 @@ { "source_repo": "rossoctl/automation", - "source_sha": "fbf8f7b0c20a90c9c11837aa286141ad66a5fea8", - "vendored_at": "2026-10-29T18:46:13Z", + "source_sha": "c4b55a64f48d097216a021d7479f86da8820c227", + "vendored_at": "2026-10-30T01:46:49Z", "files": [ "automation-health-dashboard.sh", "core.sh", diff --git a/skills/repoman/scripts/doc-drift-check.py b/skills/repoman/scripts/doc-drift-check.py index 528b49a..61ea20e 100755 --- a/skills/repoman/scripts/doc-drift-check.py +++ b/skills/repoman/scripts/doc-drift-check.py @@ -231,13 +231,10 @@ def parse_simple_yaml(text: str) -> Dict[str, Any]: if not isinstance(parent, list): continue if ":" in item_text and not item_text.startswith("{"): - if item_text.startswith("{") and item_text.endswith("}"): - parent.append(_parse_flow_map(item_text)) - else: - k, _, v = item_text.partition(":") - obj: Dict[str, Any] = {k.strip(): _parse_scalar(v)} - parent.append(obj) - stack.append((indent, obj)) + k, _, v = item_text.partition(":") + obj: Dict[str, Any] = {k.strip(): _parse_scalar(v)} + parent.append(obj) + stack.append((indent, obj)) elif item_text.startswith("{") and item_text.endswith("}"): parent.append(_parse_flow_map(item_text)) else: @@ -395,15 +392,10 @@ def check_paths( candidates.append(f"{doc_dir}/{raw}") else: candidates.append(raw.lstrip("/")) - exists = any(c in tree or c.rstrip("/") + "/" in {p + "/" for p in tree} or any( - p.startswith(c.rstrip("/") + "/") for p in tree - ) for c in candidates) - # Also accept directory existence via any file under it - if not exists: - for c in candidates: - if any(p == c or p.startswith(c.rstrip("/") + "/") for p in tree): - exists = True - break + exists = any( + any(p == c.rstrip("/") or p.startswith(c.rstrip("/") + "/") for p in tree) + for c in candidates + ) if exists: continue findings.append( @@ -806,11 +798,13 @@ def check_versions( if "marketplace" in path: canon_ver, canon_path, canon_line = trip break + # When the boundary is a stable tag, that tag is the version users run. if boundary_label.startswith("v") and re.match(r"^v\d+\.\d+\.\d+$", boundary_label): tag_ver = boundary_label[1:] if tag_ver != canon_ver: - # Prefer tag as boundary truth when boundary=tags - pass + canon_ver = tag_ver + canon_path = f"tag:{boundary_label}" + canon_line = 1 for doc_path, text in docs: if os.path.basename(doc_path).upper() == "CHANGELOG.md": continue diff --git a/skills/repoman/scripts/doc-drift-fixer.sh b/skills/repoman/scripts/doc-drift-fixer.sh index e5bd83e..8b8c383 100755 --- a/skills/repoman/scripts/doc-drift-fixer.sh +++ b/skills/repoman/scripts/doc-drift-fixer.sh @@ -236,21 +236,6 @@ PY done < <(git grep -l -I "$old_tok" -- '*.md' '*.rst' '*.txt' 2>/dev/null || true) fi ;; - enumerations|paths) - echo " INFO: $check correction needs a unique replacement; checklist only if ambiguous" - # Enum/path mechanical only when replace_with or draft provides the token - if [ -z "$replace" ] && [ -z "$(printf '%s' "$finding" | jq -r '.draft // empty')" ]; then - continue - fi - cd "$repo_dir" || continue - ensure_fork "$org" "$repo_name" "$FORK_OWNER" - FORK_REMOTE="${FORK_OWNER}-${repo_name}-fork" - if ! git remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then - git remote add "$FORK_REMOTE" "https://github.com/$FORK_OWNER/${repo_name}.git" - fi - git fetch "$FORK_REMOTE" main 2>/dev/null || true - git checkout -B "$branch" "$FORK_REMOTE/main" 2>/dev/null || git checkout -B "$branch" - ;; *) echo " INFO: no mechanical edit path for check=$check" continue From 657d4f23a613c4b556957f261052d7b5a128d09e Mon Sep 17 00:00:00 2001 From: Jim Cadden Date: Tue, 3 Nov 2026 18:07:50 -0500 Subject: [PATCH 4/6] fix: Vendor the doc-drift engine from automation fa619090 Sync the plugin scripts to the merged automation main so the stamp matches reviewed upstream code, including the pinned-SHA footer the entrypoint skill already calls. Signed-off-by: Jim Cadden Assisted-By: Claude (Anthropic AI) --- skills/repoman/scripts/_vendor.json | 4 +- skills/repoman/scripts/doc-drift-check.py | 718 +++++++++++++++---- skills/repoman/scripts/doc-drift-fixer.sh | 82 ++- skills/repoman/scripts/doc-drift-precheck.sh | 5 +- skills/repoman/scripts/doc-drift-scanner.sh | 20 +- skills/repoman/scripts/github-api.sh | 3 +- skills/repoman/scripts/pr-review-fixer.sh | 14 +- skills/repoman/scripts/pr-review-scanner.sh | 4 + skills/repoman/scripts/weekly-report.sh | 35 +- 9 files changed, 685 insertions(+), 200 deletions(-) diff --git a/skills/repoman/scripts/_vendor.json b/skills/repoman/scripts/_vendor.json index 9a53374..7808014 100644 --- a/skills/repoman/scripts/_vendor.json +++ b/skills/repoman/scripts/_vendor.json @@ -1,7 +1,7 @@ { "source_repo": "rossoctl/automation", - "source_sha": "c4b55a64f48d097216a021d7479f86da8820c227", - "vendored_at": "2026-10-30T01:46:49Z", + "source_sha": "fa61909029207b08d17eff5625bd0aa4772c90b2", + "vendored_at": "2026-11-03T22:56:54Z", "files": [ "automation-health-dashboard.sh", "core.sh", diff --git a/skills/repoman/scripts/doc-drift-check.py b/skills/repoman/scripts/doc-drift-check.py index 61ea20e..e9e6488 100755 --- a/skills/repoman/scripts/doc-drift-check.py +++ b/skills/repoman/scripts/doc-drift-check.py @@ -9,6 +9,7 @@ import argparse import json import os +import posixpath import re import subprocess import sys @@ -68,6 +69,31 @@ re.I | re.S, ) META_FLAGS = {"--help", "--version", "-h", "-v"} +# A backtick or link is a path claim only with a repo-file extension or a +# trailing slash. Version numbers, model ids, and owner/repo names are not. +PATH_EXTS = frozenset({ + ".sh", ".py", ".md", ".rst", ".txt", ".yml", ".yaml", ".json", ".toml", ".env", +}) +# Tokens that match a command shape but are prose on a Subcommands: line. +_PROSE_WORDS = frozenset({ + "a", "an", "the", "each", "writes", "write", "its", "own", "then", "and", + "or", "see", "below", "with", "for", "from", "into", "this", "that", + "your", "also", "exactly", "one", +}) +# Subcommand dispatcher, not an inner case on some other variable. +_DISPATCH_CASE_RE = re.compile( + r"""(?x) + ^\s*case\s+ + (?: + "\$\{?(?:1|cmd|command|subcommand)\}?(?::[^"]*)?" + |\$\{?(?:1|cmd|command|subcommand)\}?(?::[^\s]*)? + ) + \s+in\s*(?:\#.*)?$ + """ +) +_SCRIPT_MENTION_RE = re.compile( + r"(? str: claim_n = re.sub(r"\s+", " ", self.claim.strip().lower()) + # doc_line keeps two hits of the same claim in one file (repeated + # versions, flags, retired names) from collapsing to the first. return "|".join( - [self.repo, self.check, self.doc_path, claim_n, self.code_path] + [ + self.repo, + self.check, + self.doc_path, + claim_n, + self.code_path, + str(self.doc_line), + ] ) def to_dict(self) -> Dict[str, Any]: @@ -149,10 +184,14 @@ def git_basenames(tree: Set[str]) -> Dict[str, List[str]]: def resolve_ref(clone: str, boundary: str, tag_pattern: str) -> Tuple[str, str]: """Return (sha, label). label is the tag name or 'HEAD'.""" if boundary == "tags": + try: + tag_rx = re.compile(tag_pattern) + except re.error: + tag_rx = re.compile(DEFAULT_TAG_PATTERN) tags = [ t for t in run_git(clone, "tag", "-l").splitlines() - if t and re.search(tag_pattern, t) + if t and tag_rx.search(t) ] if not tags: sha = run_git(clone, "rev-parse", "HEAD").strip() @@ -209,8 +248,9 @@ def parse_simple_yaml(text: str) -> Dict[str, Any]: """Parse the narrow YAML subset used by doc-drift config. List-vs-map for a bare ``key:`` is decided structurally: if the next - significant line is a ``- `` item at deeper indent, the value is a list; - otherwise it is a map. No hard-coded key names. + significant line is a ``- `` item at the same indent or deeper, the value + is a list. That covers YAML's compact sequence form (``key:`` then + ``- item`` in the same column). No hard-coded key names. """ root: Dict[str, Any] = {} stack: List[Tuple[int, Any]] = [(-1, root)] @@ -222,8 +262,15 @@ def parse_simple_yaml(text: str) -> Dict[str, Any]: indent = len(raw) - len(raw.lstrip(" ")) line = raw.strip() - while len(stack) > 1 and indent <= stack[-1][0]: - stack.pop() + # Dash items may sit in the same column as their key (compact + # sequences). Popping on ``<=`` would discard the list just pushed + # for that key. Other lines still close the block at ``<=``. + if line.startswith("- "): + while len(stack) > 1 and indent < stack[-1][0]: + stack.pop() + else: + while len(stack) > 1 and indent <= stack[-1][0]: + stack.pop() parent = stack[-1][1] if line.startswith("- "): @@ -251,7 +298,7 @@ def parse_simple_yaml(text: str) -> Dict[str, Any]: nxt = _next_significant_line(lines, i + 1) if nxt is not None: nxt_indent = len(nxt) - len(nxt.lstrip(" ")) - if nxt_indent > indent and nxt.lstrip().startswith("- "): + if nxt_indent >= indent and nxt.lstrip().startswith("- "): parent[key] = [] stack.append((indent, parent[key])) else: @@ -321,9 +368,14 @@ def load_config(clone: str, ref: str) -> Dict[str, Any]: cfg = parse_simple_yaml(text) cfg.setdefault("boundary", "main") cfg.setdefault("stable_tag_pattern", DEFAULT_TAG_PATTERN) - cfg.setdefault("ignore_paths", ["CHANGELOG.md", "docs/history/**"]) - cfg.setdefault("sources_of_truth", []) - cfg.setdefault("retired_names", []) + # A failed list parse used to leave ``{}``, and setdefault would not + # replace it — so CHANGELOG.md dropped out of the default ignore list. + if not isinstance(cfg.get("ignore_paths"), list): + cfg["ignore_paths"] = ["CHANGELOG.md", "docs/history/**"] + if not isinstance(cfg.get("sources_of_truth"), list): + cfg["sources_of_truth"] = [] + if not isinstance(cfg.get("retired_names"), list): + cfg["retired_names"] = [] rel = cfg.get("release") or {} if not isinstance(rel, dict): rel = {} @@ -362,15 +414,46 @@ def line_of_re(text: str, m: re.Match) -> int: # Checks # --------------------------------------------------------------------------- +def _is_path_claim(raw: str) -> bool: + if not raw or raw in {".", ".."}: + return False + if raw.endswith("/"): + body = raw.rstrip("/") + return bool(body) and body not in {".", ".."} + return os.path.splitext(raw)[1].lower() in PATH_EXTS + + +def _repo_relative(path: str) -> Optional[str]: + """Normalize a path and drop anything that escapes the repo root.""" + norm = posixpath.normpath(path) + if norm in {".", ""}: + return "" + if norm == ".." or norm.startswith("../"): + return None + return norm + + +def _in_tree(tree: Set[str], candidate: str) -> bool: + candidate = candidate.rstrip("/") + if not candidate: + return True + return any(p == candidate or p.startswith(candidate + "/") for p in tree) + + def check_paths( clone: str, ref: str, tree: Set[str], + basenames: Dict[str, List[str]], docs: List[Tuple[str, str]], repo: str, + ignore: Sequence[str], ) -> List[Finding]: findings: List[Finding] = [] + top = {p.split("/", 1)[0] for p in tree} for doc_path, text in docs: + if path_ignored(doc_path, ignore): + continue doc_dir = os.path.dirname(doc_path) seen: Set[str] = set() for rx in (BACKTICK_PATH, MD_LINK_PATH): @@ -378,31 +461,65 @@ def check_paths( raw = m.group(1) if URLISH.match(raw) or raw.startswith("#"): continue - # Skip pure identifiers without a slash or extension that look - # like code tokens, not paths (e.g. `repoman`). - if "/" not in raw and "." not in raw: + if not _is_path_claim(raw): continue if raw in seen: continue seen.add(raw) - candidates = [] - if not raw.startswith("/"): - candidates.append(raw) - if doc_dir: - candidates.append(f"{doc_dir}/{raw}") + candidates: List[str] = [] + bare = "/" not in raw.strip("/") + if raw.startswith("/"): + rel = _repo_relative(raw.lstrip("/")) + if rel is None: + continue + if rel: + candidates.append(rel) + elif raw.startswith("./") or raw.startswith("../"): + # Resolve against the document. A link that climbs out of + # the repo is not an in-repo claim. + joined = f"{doc_dir}/{raw}" if doc_dir else raw + rel = _repo_relative(joined) + if rel is None: + continue + if rel: + candidates.append(rel) else: - candidates.append(raw.lstrip("/")) - exists = any( - any(p == c.rstrip("/") or p.startswith(c.rstrip("/") + "/") for p in tree) - for c in candidates - ) - if exists: + rel = _repo_relative(raw) + if rel is None: + continue + if rel: + candidates.append(rel) + if any(_in_tree(tree, c) for c in candidates): + continue + # A bundled skill doc cites `scripts/org.sh` for the copy that + # lives next to the doc (`skills/repoman/scripts/org.sh`). + # That hit suppresses the finding; it does not create one. + if ( + doc_dir + and not raw.startswith(("/", "./", "../")) + and "/" in raw + ): + bundled = _repo_relative(f"{doc_dir}/{raw}") + if bundled and _in_tree(tree, bundled): + continue + # A bare filename exists when any tree path has that basename. + if bare: + base = os.path.basename(raw.rstrip("/")) + if basenames.get(base): + continue + # A bare name that is not in the tree (`repos.json`, + # `SKILL.md` in another repo) does not name a location here. + continue + # A slash path is a claim only when its first directory is in + # this repo. `config/org.env` and `skills/foo/SKILL.md` are + # host or other-repo paths. + if not any("/" in c and c.split("/", 1)[0] in top for c in candidates): continue findings.append( Finding( check="paths", kind="correction", - mechanical=True, + mechanical=False, doc_path=doc_path, doc_line=line_of_re(text, m), code_path="(tree)", @@ -416,40 +533,39 @@ def check_paths( return findings -def extract_script_tokens(script_text: str) -> Set[str]: - tokens: Set[str] = set() - for m in FLAG_RE.finditer(script_text): - tokens.add(m.group(1)) - # case arms: foo|bar) or foo) - for m in re.finditer(r"(?m)^\s*([A-Za-z0-9_|-]+)\)\s*", script_text): - arm = m.group(1) - if arm in ("*", "*)") or arm.startswith("$"): - continue - for part in arm.split("|"): - part = part.strip() - if part and re.match(r"^[A-Za-z0-9][A-Za-z0-9_-]*$", part): - if not part.startswith("-"): - tokens.add(part) - else: - tokens.add(part) - # usage() lines mentioning subcommands - for m in re.finditer( - r"(?m)^\s{0,4}([a-z][a-z0-9-]*)\s+--", script_text - ): - tokens.add(m.group(1)) - return tokens - - def _add_subcommand_token(subs: Set[str], tok: str) -> None: if tok and tok.lower() not in _SUBCMD_STOPWORDS: subs.add(tok) +def _list_command_tokens(text: str) -> List[str]: + """Command names from a list, stopping where prose starts. + + ``init-config, add-repo, set-output. Each writes`` yields the three + commands. ``Each writes a report`` yields nothing. Backtick tokens, when + present, are the only ones taken. + """ + ticks = re.findall(r"`([^`]+)`", text) + if ticks: + return [ + t + for t in ticks + if re.fullmatch(r"[a-z][a-z0-9-]*", t) and t not in _PROSE_WORDS + ] + out: List[str] = [] + for word in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", text): + if not re.fullmatch(r"[a-z][a-z0-9-]*", word) or word in _PROSE_WORDS: + break + out.append(word) + return out + + def extract_listed_subcommands(text: str) -> Set[str]: """Collect subcommand tokens from a Subcommands:/Commands: list. - Accepts same-line tokens and subsequent indented ``- item`` bullets (with - optional backticks), so multi-line docs are not silently skipped. + Same-line text contributes command tokens until prose starts. Following + ``- item`` bullets and indented ``name --flag`` usage lines contribute + their command, not the description after it. """ subs: Set[str] = set() lines = text.splitlines() @@ -459,7 +575,7 @@ def extract_listed_subcommands(text: str) -> Set[str]: continue rest = (m.group("rest") or "").strip() if rest: - for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", rest): + for tok in _list_command_tokens(rest): _add_subcommand_token(subs, tok) base_indent = len(line) - len(line.lstrip(" ")) for j in range(i + 1, len(lines)): @@ -475,10 +591,9 @@ def extract_listed_subcommands(text: str) -> Set[str]: if nindent < base_indent: break item = stripped[2:].strip() - for bt, plain in re.findall( - r"`([^`]+)`|([A-Za-z][A-Za-z0-9_-]*)", item - ): - _add_subcommand_token(subs, bt or plain) + toks = _list_command_tokens(item) + if toks: + _add_subcommand_token(subs, toks[0]) continue if nindent <= base_indent: break @@ -490,9 +605,13 @@ def extract_listed_subcommands(text: str) -> Set[str]: _add_subcommand_token(subs, m2.group(1)) continue break - # Comment form: "# foo --bar" without a Subcommands: header + # Usage comment with a command-shaped token: "# init-config --repos-dir". + # A prose comment such as "# explicit --live" or "# separate --repos" is not. for m in re.finditer(r"(?m)^#\s+([a-z][a-z0-9-]*)\s+--", text): - _add_subcommand_token(subs, m.group(1)) + tok = m.group(1) + if "-" not in tok: + continue + _add_subcommand_token(subs, tok) return subs @@ -502,121 +621,301 @@ def _header_subcommands(script_text: str) -> Set[str]: def _case_subcommands(script_text: str) -> Set[str]: - """Top-level case-arm tokens that look like subcommands (not flags).""" + """Arms of the subcommand dispatcher only. + + The dispatcher is ``case "$1"`` / ``$cmd`` / ``$command`` / ``$subcommand``. + Arms of ``case "$mode"`` and other inner cases are not subcommands. + """ subs: Set[str] = set() - for m in re.finditer(r"(?m)^\s*([A-Za-z0-9_|-]+)\)\s*", script_text): - arm = m.group(1) - if arm in ("*",) or arm.startswith("$"): + lines = script_text.splitlines() + i = 0 + n = len(lines) + while i < n: + if not _DISPATCH_CASE_RE.match(lines[i]): + i += 1 continue - for part in arm.split("|"): - part = part.strip() - if re.match(r"^[a-z][a-z0-9-]*$", part) and not part.startswith("-"): - subs.add(part) + depth = 1 + i += 1 + while i < n and depth: + line = lines[i] + if re.match(r"^\s*case\b.*\bin\b", line): + depth += 1 + elif re.match(r"^\s*esac\b", line): + depth -= 1 + if depth == 0: + i += 1 + break + elif depth == 1: + arm_m = re.match(r"^\s*([A-Za-z0-9_|-]+)\)", line) + if arm_m: + arm = arm_m.group(1) + if arm != "*" and not arm.startswith("$"): + for part in arm.split("|"): + part = part.strip() + if re.fullmatch(r"[a-z][a-z0-9-]*", part): + subs.add(part) + i += 1 return subs +def _scripts_by_basename(tree: Set[str]) -> Dict[str, List[str]]: + by_base: Dict[str, List[str]] = {} + for p in tree: + if p.endswith(".sh") or p.endswith(".py"): + by_base.setdefault(os.path.basename(p), []).append(p) + for base in by_base: + by_base[base] = sorted(by_base[base]) + return by_base + + +def _resolve_script_mention(token: str, by_base: Dict[str, List[str]]) -> Optional[str]: + """Bind a doc citation to one script path. + + A path with a slash must match that location. A bare basename binds only + when it is unique, so ``scripts/x.sh`` and ``skills/.../x.sh`` do not + collapse onto whichever file was inserted last. + """ + token = token.lstrip("./") + base = os.path.basename(token) + paths = by_base.get(base) or [] + if "/" in token: + for p in paths: + if p == token or p.endswith("/" + token): + return p + return None + if len(paths) == 1: + return paths[0] + return None + + +def _sourced_shell_paths(script_text: str, by_base: Dict[str, List[str]]) -> List[str]: + """Shell files named by a leading ``source`` or ``.`` line.""" + found: List[str] = [] + seen: Set[str] = set() + for m in re.finditer( + r"(?m)^[ \t]*(?:source|\.)[ \t]+[\"']?(\S+)", + script_text, + ): + token = m.group(1).strip("'\"") + base = token.rsplit("/", 1)[-1] + if not base.endswith(".sh"): + continue + # $_LIB_DIR/core.sh is a basename. A concrete scripts/core.sh must match. + if "/" in token and not token.startswith("$"): + path = _resolve_script_mention(token.lstrip("./"), by_base) + else: + path = _resolve_script_mention(base, by_base) + if not path or path in seen: + continue + seen.add(path) + found.append(path) + return found + + +def _accepted_flags( + script_text: str, + by_base: Dict[str, List[str]], + show, +) -> Set[str]: + """Long options in this script plus the libs it sources. + + ``program-lib.sh`` is followed one more level so flags defined in the + modules it aggregates count for every program script. + """ + flags = set(FLAG_RE.findall(script_text)) + queue = _sourced_shell_paths(script_text, by_base) + seen: Set[str] = set() + i = 0 + while i < len(queue): + sp = queue[i] + i += 1 + if sp in seen: + continue + seen.add(sp) + text = show(sp) + if not text: + continue + flags |= set(FLAG_RE.findall(text)) + if os.path.basename(sp) == "program-lib.sh": + for child in _sourced_shell_paths(text, by_base): + if child not in seen: + queue.append(child) + return flags - META_FLAGS + + +def _doc_spans(text: str) -> List[Tuple[int, str]]: + """Non-empty lines paired with their byte offset in ``text``. + + Fence markers are not spans. The offset is required because two identical + lines must stay two findings; ``str.find`` would keep only the first. + """ + spans: List[Tuple[int, str]] = [] + offset = 0 + for line in text.splitlines(keepends=True): + body = line.splitlines()[0] if line.splitlines() else "" + if body.strip() and not body.lstrip().startswith("```"): + spans.append((offset, body)) + offset += len(line) + return spans + + +def _line_at(text: str, offset: int) -> int: + return text[:offset].count("\n") + 1 + + +def _subcommand_after_mention(span: str, end: int) -> Optional[str]: + """A usage token after a script, not the next word of a sentence. + + ``script.sh `set-output` `` and ``script.sh set-output --flag`` count. + ``script.sh measures`` does not. + """ + rest = span[end:] + m = re.match(r"`?[ \t]+`([a-z][a-z0-9-]*)`", rest) + if m and m.group(1) not in _PROSE_WORDS and m.group(1) not in _SUBCMD_STOPWORDS: + return m.group(1) + m = re.match(r"`?[ \t]+([a-z][a-z0-9-]*)\b[ \t]+--", rest) + if m and "-" in m.group(1): + return m.group(1) + return None + + def check_flags( clone: str, ref: str, tree: Set[str], docs: List[Tuple[str, str]], repo: str, + ignore: Sequence[str], ) -> List[Finding]: findings: List[Finding] = [] - scripts = { - os.path.basename(p): p - for p in tree - if p.endswith(".sh") or p.endswith(".py") - } - - # Intra-script: header subcommand list vs case arms (the set-requirements case). - for spath in sorted(set(scripts.values())): - script_text = git_show(clone, ref, spath) - if script_text is None: - continue - header = _header_subcommands(script_text) - cases = _case_subcommands(script_text) - if not header: - continue - for sub in sorted(cases - header): - findings.append( - Finding( - check="flags", - kind="addition", - mechanical=True, - doc_path=spath, - doc_line=1, - code_path=spath, - code_line=line_of(script_text, sub) if sub in script_text else 1, - claim=f"document subcommand `{sub}`", - evidence=f"`{sub}` in case arms of {spath} but absent from header list", - repo=repo, - draft=sub, - ) - ) - for sub in sorted(header - cases): - findings.append( - Finding( - check="flags", - kind="correction", - mechanical=False, - doc_path=spath, - doc_line=line_of(script_text, sub), - code_path=spath, - code_line=1, - claim=f"{os.path.basename(spath)} implements `{sub}`", - evidence=f"`{sub}` listed in header but not in case arms of {spath}", - repo=repo, - ) - ) - - for doc_path, text in docs: - named: List[str] = [] - for base, spath in scripts.items(): - if f"`{base}`" in text or base in text: - named.append(spath) - if not named: - continue - for spath in named: - script_text = git_show(clone, ref, spath) + by_base = _scripts_by_basename(tree) + shown: Dict[str, Optional[str]] = {} + + def show(path: str) -> Optional[str]: + if path not in shown: + shown[path] = git_show(clone, ref, path) + return shown[path] + + # Intra-script: header list vs the dispatcher (the set-requirements case). + for paths in by_base.values(): + for spath in paths: + if spath.startswith("tests/") or "/tests/" in spath: + continue + if path_ignored(spath, ignore): + continue + script_text = show(spath) if script_text is None: continue - parsed = extract_script_tokens(script_text) - doc_flags = set(FLAG_RE.findall(text)) - META_FLAGS - doc_sub = extract_listed_subcommands(text) - for m in re.finditer(r"(?m)^\s{2,}([a-z][a-z0-9-]*)\s+--", text): - doc_sub.add(m.group(1)) - - for flag in sorted(doc_flags - parsed): + header = _header_subcommands(script_text) + cases = _case_subcommands(script_text) + if not header: + continue + for sub in sorted(cases - header): + findings.append( + Finding( + check="flags", + kind="addition", + mechanical=True, + doc_path=spath, + doc_line=1, + code_path=spath, + code_line=line_of(script_text, sub) if sub in script_text else 1, + claim=f"document subcommand `{sub}`", + evidence=f"`{sub}` in case arms of {spath} but absent from header list", + repo=repo, + draft=sub, + ) + ) + for sub in sorted(header - cases): findings.append( Finding( check="flags", kind="correction", mechanical=False, - doc_path=doc_path, - doc_line=line_of(text, flag), + doc_path=spath, + doc_line=line_of(script_text, sub), code_path=spath, code_line=1, - claim=f"{os.path.basename(spath)} accepts `{flag}`", - evidence=f"`{flag}` not found in parser of {spath}", + claim=f"{os.path.basename(spath)} implements `{sub}`", + evidence=f"`{sub}` listed in header but not in case arms of {spath}", repo=repo, ) ) - for sub in sorted(doc_sub - parsed): + + for doc_path, text in docs: + if path_ignored(doc_path, ignore): + continue + for offset, span in _doc_spans(text): + mentions: List[Tuple[int, str]] = [] + for m in _SCRIPT_MENTION_RE.finditer(span): + if re.search(r"https?://\S*$", span[: m.start()]): + continue + spath = _resolve_script_mention(m.group(1), by_base) + if not spath: + continue + mentions.append((m.end(), spath)) + if not mentions: + continue + # A flag belongs to the nearest script named before it on this + # line. ``alpha.sh and beta.sh --frobnicate`` is a claim about + # beta only. A flag that appears first belongs to whatever command + # preceded the script. `--flag` is the generic word, not an option. + flag_at = [] + for m in FLAG_RE.finditer(span): + if m.group(1) in META_FLAGS or m.group(1) == "--flag": + continue + # "no --repos flag" is a denial, not a claim the script accepts it. + prefix = span[max(0, m.start() - 40) : m.start()] + if re.search(r"\b(?:no|not|without)\b", prefix, re.I): + continue + flag_at.append(m) + doc_line = _line_at(text, offset) + for fm in flag_at: + prior = [pair for pair in mentions if pair[0] <= fm.start()] + if not prior: + continue + _end, spath = max(prior, key=lambda pair: pair[0]) + script_text = show(spath) + if script_text is None: + continue + accepted = _accepted_flags(script_text, by_base, show) + flag = fm.group(1) + if flag in accepted: + continue findings.append( Finding( check="flags", kind="correction", mechanical=False, doc_path=doc_path, - doc_line=line_of(text, sub), + doc_line=doc_line, code_path=spath, code_line=1, - claim=f"{os.path.basename(spath)} implements `{sub}`", - evidence=f"`{sub}` listed in docs but not in parser of {spath}", + claim=f"{os.path.basename(spath)} accepts `{flag}`", + evidence=f"`{flag}` not found in parser of {spath}", repo=repo, ) ) + for _end, spath in mentions: + script_text = show(spath) + if script_text is None: + continue + sub = _subcommand_after_mention(span, _end) + if sub and sub not in _case_subcommands(script_text): + findings.append( + Finding( + check="flags", + kind="correction", + mechanical=False, + doc_path=doc_path, + doc_line=doc_line, + code_path=spath, + code_line=1, + claim=f"{os.path.basename(spath)} implements `{sub}`", + evidence=f"`{sub}` listed in docs but not in parser of {spath}", + repo=repo, + ) + ) return findings @@ -639,7 +938,41 @@ def check_enumerations( code_text = git_show(clone, ref, cfile) if code_text is None: continue - m = re.search(pattern, code_text) + if len(pattern) > 256: + findings.append( + Finding( + check="config", + kind="correction", + mechanical=False, + doc_path=CONFIG_PATH, + doc_line=1, + code_path=cfile, + code_line=1, + claim="sources_of_truth pattern is a valid regex", + evidence=f"pattern longer than 256 characters in {CONFIG_PATH}", + repo=repo, + ) + ) + continue + try: + compiled = re.compile(pattern) + except re.error as exc: + findings.append( + Finding( + check="config", + kind="correction", + mechanical=False, + doc_path=CONFIG_PATH, + doc_line=1, + code_path=cfile, + code_line=1, + claim="sources_of_truth pattern is a valid regex", + evidence=f"invalid pattern {pattern!r}: {exc}", + repo=repo, + ) + ) + continue + m = compiled.search(code_text) if not m: continue captured = m.group(1) if m.lastindex else m.group(0) @@ -686,7 +1019,7 @@ def check_enumerations( Finding( check="enumerations", kind="correction", - mechanical=True, + mechanical=False, doc_path=dpath, doc_line=line_of(dtext, extra), code_path=cfile, @@ -743,12 +1076,47 @@ def check_retired_names( def extract_versions_from_text(text: str) -> List[Tuple[str, int]]: + """Versions named as the project's own version, not every semver. + + Dependency pins (``foo==1.2.3``) and changelog history headings are not + matches: the line has to say "version" within a short gap of the number. + """ found = [] - for m in re.finditer(r"\b(?:v)?(\d+\.\d+\.\d+)\b", text): + for m in re.finditer( + r"(?i)\bversion\b[^0-9\n]{0,40}?v?(\d+\.\d+\.\d+)\b", + text, + ): found.append((m.group(1), text[: m.start()].count("\n") + 1)) return found +def _parse_iso(value: str) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def pr_is_unreleased(pr: Dict[str, Any], releases: List[Dict[str, Any]]) -> bool: + """True when the PR merged after the newest published stable release. + + Dates win over a caller-supplied ``unreleased`` flag. The flag is only + a fallback for fixtures that do not pass ``mergedAt``. + """ + published = [ + dt + for dt in (_parse_iso(str(rel.get("publishedAt") or "")) for rel in releases) + if dt is not None + ] + cutoff = max(published) if published else None + merged = _parse_iso(str(pr.get("mergedAt") or "")) + if cutoff is not None and merged is not None: + return merged > cutoff + return bool(pr.get("unreleased")) + + def read_declared_versions(clone: str, ref: str, tree: Set[str]) -> Dict[str, Tuple[str, str, int]]: """Map source file -> (version, path, line).""" out: Dict[str, Tuple[str, str, int]] = {} @@ -787,31 +1155,51 @@ def check_versions( docs: List[Tuple[str, str]], boundary_label: str, repo: str, + cfg: Dict[str, Any], ) -> List[Finding]: findings: List[Finding] = [] declared = read_declared_versions(clone, ref, tree) if not declared: return findings - # Prefer marketplace version as canonical when present + # Prefer marketplace version as canonical when present. A stable tag + # does not replace it: on main the declared version is often ahead of + # the last tag, and rewriting docs back to the older tag is wrong. canon_ver, canon_path, canon_line = next(iter(declared.values())) for path, trip in declared.items(): if "marketplace" in path: canon_ver, canon_path, canon_line = trip break - # When the boundary is a stable tag, that tag is the version users run. if boundary_label.startswith("v") and re.match(r"^v\d+\.\d+\.\d+$", boundary_label): tag_ver = boundary_label[1:] if tag_ver != canon_ver: - canon_ver = tag_ver - canon_path = f"tag:{boundary_label}" - canon_line = 1 + findings.append( + Finding( + check="versions", + kind="correction", + mechanical=False, + doc_path=canon_path, + doc_line=canon_line, + code_path=f"tag:{boundary_label}", + code_line=1, + claim=f"declared version {canon_ver} matches tag {boundary_label}", + evidence=( + f"{canon_path} has {canon_ver}; tag {boundary_label} is {tag_ver}" + ), + repo=repo, + replace_with="", + ) + ) + ignore = cfg.get("ignore_paths") or [] for doc_path, text in docs: - if os.path.basename(doc_path).upper() == "CHANGELOG.md": + base = os.path.basename(doc_path).lower() + if base == "changelog.md" or path_ignored(doc_path, ignore): + continue + # requirements.txt and other prose pins are not the project version. + if doc_path.endswith(".txt"): continue for ver, line in extract_versions_from_text(text): if ver == canon_ver: continue - # Ignore versions that appear only inside URLs or commit SHAs-ish findings.append( Finding( check="versions", @@ -992,10 +1380,8 @@ def cited_numbers(blob: str) -> Set[int]: nums.add(int(m.group(1))) return nums - # Partition PRs: those merged after newest tag → Unreleased; others by tag range - # Caller is expected to pass only the relevant PR window; we still filter by citation. + # PRs merged after the newest published release belong in Unreleased. newest_ver = versioned[0][0] if versioned else None - newest_blob_cited = cited_numbers(text[versioned[0][2] : versioned[0][3]]) if versioned else set() unreleased_prs = [] missing_by_section: Dict[str, List[Dict[str, Any]]] = {} @@ -1016,9 +1402,7 @@ def cited_numbers(blob: str) -> Set[int]: # If the PR number appears anywhere in the changelog, treat as covered if num in cited_numbers(text): continue - # Heuristic: PRs marked unreleased by caller (mergedAt after newest tag) - # use a flag, else put under Unreleased draft - if pr.get("unreleased"): + if pr_is_unreleased(pr, releases): unreleased_prs.append(pr) elif newest_ver: missing_by_section.setdefault(newest_ver, []).append(pr) @@ -1115,6 +1499,12 @@ def run_checks( preview_cfg = load_config(clone, head_sha) boundary = preview_cfg.get("boundary", "main") tag_pat = preview_cfg.get("stable_tag_pattern", DEFAULT_TAG_PATTERN) + tag_pattern_error = "" + try: + re.compile(str(tag_pat)) + except re.error as exc: + tag_pattern_error = str(exc) + tag_pat = DEFAULT_TAG_PATTERN if ref_override: ref = ref_override label = ref_override @@ -1137,11 +1527,27 @@ def run_checks( scan_files.append((p, text)) findings: List[Finding] = [] - findings += check_paths(clone, ref, tree, docs, repo) - findings += check_flags(clone, ref, tree, docs, repo) + if tag_pattern_error: + findings.append( + Finding( + check="config", + kind="correction", + mechanical=False, + doc_path=CONFIG_PATH, + doc_line=1, + code_path=CONFIG_PATH, + code_line=1, + claim="stable_tag_pattern is a valid regex", + evidence=f"invalid stable_tag_pattern: {tag_pattern_error}", + repo=repo, + ) + ) + ignore = cfg.get("ignore_paths") or [] + findings += check_paths(clone, ref, tree, basenames, docs, repo, ignore) + findings += check_flags(clone, ref, tree, docs, repo, ignore) findings += check_enumerations(clone, ref, cfg, docs_index, repo) findings += check_retired_names(clone, ref, cfg, scan_files, repo) - findings += check_versions(clone, ref, tree, docs, label, repo) + findings += check_versions(clone, ref, tree, docs, label, repo, cfg) findings += check_absence_claims(clone, ref, tree, basenames, docs, repo) findings += check_release(clone, ref, cfg, releases, prs, repo) diff --git a/skills/repoman/scripts/doc-drift-fixer.sh b/skills/repoman/scripts/doc-drift-fixer.sh index 8b8c383..f657601 100755 --- a/skills/repoman/scripts/doc-drift-fixer.sh +++ b/skills/repoman/scripts/doc-drift-fixer.sh @@ -81,8 +81,8 @@ while IFS= read -r finding; do || echo "[]" > "$PROGRAM_TMPDIR/rel_${safe}.json" fi gh pr list --repo "$repo" --state merged --limit 100 \ - --json number,title,url,labels,mergedAt 2>/dev/null \ - | jq '[.[] | . + {unreleased: true}]' > "$PROGRAM_TMPDIR/prs_${safe}.json" 2>/dev/null \ + --json number,title,url,labels,mergedAt \ + > "$PROGRAM_TMPDIR/prs_${safe}.json" 2>/dev/null \ || echo "[]" > "$PROGRAM_TMPDIR/prs_${safe}.json" fi @@ -127,29 +127,38 @@ echo "Mechanical corrections: $(wc -l < "$PROGRAM_TMPDIR/mech_corr.jsonl" | tr - echo "Mechanical additions: $(wc -l < "$PROGRAM_TMPDIR/mech_add.jsonl" | tr -d ' ')" echo "Non-mechanical: $(wc -l < "$PROGRAM_TMPDIR/non_mech.jsonl" | tr -d ' ')" -apply_replace() { - local file="$1" old="$2" new="$3" +# Edit one line of one file. Word-ish boundaries so 1.2.3 does not match +# inside 11.2.34, and a miss leaves the file untouched. +replace_on_line() { + local file="$1" line_no="$2" old="$3" new="$4" if [ ! -f "$file" ]; then echo " WARN: missing $file" return 1 fi - # Prefer python for literal replace (no sed metachar surprises) - python3 - "$file" "$old" "$new" <<'PY' -import sys -path, old, new = sys.argv[1], sys.argv[2], sys.argv[3] -text = open(path, encoding="utf-8").read() -if old not in text: - # try case-insensitive for retired names - import re - text2, n = re.subn(re.escape(old), new, text, flags=re.I) - if n == 0: - sys.exit(2) - open(path, "w", encoding="utf-8").write(text2) -else: - open(path, "w", encoding="utf-8").write(text.replace(old, new)) + python3 - "$file" "$line_no" "$old" "$new" <<'PY' +import re, sys +path, line_no, old, new = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4] +lines = open(path, encoding="utf-8").read().splitlines(keepends=True) +idx = line_no - 1 +if idx < 0 or idx >= len(lines): + sys.exit(2) +pat = re.compile(r"(?/dev/null || true +} + # --- Mechanical corrections: one PR each --- echo "" echo "=== Mechanical corrections ===" @@ -210,7 +219,8 @@ PY fi ;; versions|retired_names) - # Grep the claim token across the repo and replace + # Edit only the flagged line. A repo-wide replace rewrites changelog + # history and dependency pins that happen to contain the same token. cd "$repo_dir" || continue ensure_fork "$org" "$repo_name" "$FORK_OWNER" FORK_REMOTE="${FORK_OWNER}-${repo_name}-fork" @@ -229,11 +239,15 @@ PY if [ -z "$old_tok" ]; then old_tok=$(printf '%s' "$claim" | sed -n 's/.*`\([^`]*\)`.*/\1/p') fi - if [ -n "$old_tok" ]; then - while IFS= read -r f; do - [ -n "$f" ] || continue - apply_replace "$f" "$old_tok" "$replace" && git add "$f" || true - done < <(git grep -l -I "$old_tok" -- '*.md' '*.rst' '*.txt' 2>/dev/null || true) + doc_line=$(printf '%s' "$finding" | jq -r '.doc_line // 0') + if [ -n "$old_tok" ] && [ -n "$doc_path" ]; then + if replace_on_line "$doc_path" "$doc_line" "$old_tok" "$replace"; then + git add "$doc_path" + else + echo " WARN: no edit at $doc_path:$doc_line" + restore_main + continue + fi fi ;; *) @@ -299,10 +313,20 @@ for repo in $repos_add; do git remote add "$FORK_REMOTE" "https://github.com/$FORK_OWNER/${repo_name}.git" fi git fetch "$FORK_REMOTE" 2>/dev/null || true - # Rebase/extend existing rolling branch if present + # Rebuild onto the fork's main. A failed rebase must not leave the shared + # clone mid-rebase (repo-sync and the other scanners read it). if git fetch "$FORK_REMOTE" "$branch" 2>/dev/null; then - git checkout -B "$branch" "$FORK_REMOTE/$branch" - git rebase "$FORK_REMOTE/main" 2>/dev/null || true + if ! git checkout -B "$branch" "$FORK_REMOTE/$branch"; then + echo " WARN: could not check out $branch" + restore_main + continue + fi + if ! git rebase "$FORK_REMOTE/main"; then + echo " WARN: rebase onto $FORK_REMOTE/main failed; aborting" + git rebase --abort || true + restore_main + continue + fi else git checkout -B "$branch" "$FORK_REMOTE/main" 2>/dev/null || git checkout -B "$branch" fi @@ -371,7 +395,9 @@ $SKILL_FOOTER" git commit -s -m "docs: doc-drift updates Automated update by RepoMan Doc-Drift Fixer ($FIX_DATE)." || true - git push "$FORK_REMOTE" "$branch" 2>/dev/null || { + # Rebase rewrites the rolling branch, so a plain push is rejected once + # the fork's main has moved. Lease the remote tip we just fetched. + git push --force-with-lease "$FORK_REMOTE" "$branch" 2>/dev/null || { echo " WARN: push failed" git checkout main 2>/dev/null || true continue diff --git a/skills/repoman/scripts/doc-drift-precheck.sh b/skills/repoman/scripts/doc-drift-precheck.sh index 68d8cda..e529801 100755 --- a/skills/repoman/scripts/doc-drift-precheck.sh +++ b/skills/repoman/scripts/doc-drift-precheck.sh @@ -76,10 +76,9 @@ while IFS= read -r full_repo; do --jq '.[].tagName' 2>/dev/null || true) echo "]" } > "$rel_json" 2>/dev/null || echo "[]" > "$rel_json" - # Merged PRs after the newest stable tag → unreleased candidates. + # The checker classifies unreleased PRs from mergedAt vs publishedAt. gh pr list --repo "$full_repo" --state merged --limit 100 \ - --json number,title,url,labels,mergedAt 2>/dev/null \ - | jq '[.[] | . + {unreleased: true}]' > "$prs_json" 2>/dev/null \ + --json number,title,url,labels,mergedAt > "$prs_json" 2>/dev/null \ || echo "[]" > "$prs_json" fi diff --git a/skills/repoman/scripts/doc-drift-scanner.sh b/skills/repoman/scripts/doc-drift-scanner.sh index 5c4de48..e64474e 100755 --- a/skills/repoman/scripts/doc-drift-scanner.sh +++ b/skills/repoman/scripts/doc-drift-scanner.sh @@ -59,6 +59,7 @@ if [ "$ISSUE_LIMIT" -gt 0 ]; then echo "Issue limit: $ISSUE_LIMIT"; fi ENROLLED=$(repoman_load_enrolled) || exit 1 CLONES=$(enrolled_clone_dirs "$ENROLLED") : > "$PROGRAM_TMPDIR/current.jsonl" +: > "$PROGRAM_TMPDIR/failed_repos.txt" REPOS_SCANNED=0 REPOS_FAILED=0 REPOS_TOUCHED=0 @@ -85,9 +86,9 @@ fetch_release_meta() { echo "]" } > "$rel_json" 2>/dev/null || echo "[]" > "$rel_json" + # The checker decides unreleased from mergedAt vs the newest publishedAt. gh pr list --repo "$full_repo" --state merged --limit 100 \ - --json number,title,url,labels,mergedAt 2>/dev/null \ - | jq '[.[] | . + {unreleased: true}]' > "$prs_json" 2>/dev/null \ + --json number,title,url,labels,mergedAt > "$prs_json" 2>/dev/null \ || echo "[]" > "$prs_json" } @@ -103,6 +104,7 @@ while IFS= read -r full_repo; do if ! out=$(python3 "$CHECK_PY" --clone "$repo_dir" --repo "$full_repo" \ --releases-json "$rel_json" --prs-json "$prs_json"); then echo " WARN: checker failed for $full_repo" + echo "$full_repo" >> "$PROGRAM_TMPDIR/failed_repos.txt" REPOS_FAILED=$((REPOS_FAILED + 1)) continue fi @@ -124,7 +126,19 @@ else : > "$PREV" fi -KEY_EXPR='.key // ([.repo, .check, .doc_path, .claim, .code_path] | join("|"))' +KEY_EXPR='.key // ([.repo, .check, .doc_path, .claim, .code_path, (.doc_line | tostring)] | join("|"))' + +# A failed checker leaves no rows. Carry the previous findings for that +# repo so the rollup issue stays open and the next scan does not report +# them as new. +if [ -s "$PROGRAM_TMPDIR/failed_repos.txt" ]; then + while IFS= read -r failed_repo; do + [ -n "$failed_repo" ] || continue + jq -c --arg r "$failed_repo" 'select(.repo == $r)' "$PREV" \ + >> "$PROGRAM_TMPDIR/current.jsonl" 2>/dev/null || true + done < "$PROGRAM_TMPDIR/failed_repos.txt" +fi + read -r NEW_COUNT FIXED_COUNT RECURRING_COUNT < <(diff_against_previous \ "$PROGRAM_TMPDIR/current.jsonl" "$PREV" "$KEY_EXPR") echo "New: $NEW_COUNT Fixed: $FIXED_COUNT Recurring: $RECURRING_COUNT" diff --git a/skills/repoman/scripts/github-api.sh b/skills/repoman/scripts/github-api.sh index 9514e6a..fc855eb 100644 --- a/skills/repoman/scripts/github-api.sh +++ b/skills/repoman/scripts/github-api.sh @@ -297,7 +297,8 @@ resolve_skill_meta() { # Prints: a one-line "Generated by RepoMan ..." attribution footer. skill_attribution() { local skill_name="$1" - local skill_dir="${SCRIPT_DIR:-.}" + # REPOMAN_SKILL_DIR lets tests pin _meta outside the shared scripts dir. + local skill_dir="${REPOMAN_SKILL_DIR:-${SCRIPT_DIR:-.}}" local sha sha=$(resolve_skill_meta "$skill_dir" "$skill_name") diff --git a/skills/repoman/scripts/pr-review-fixer.sh b/skills/repoman/scripts/pr-review-fixer.sh index 3568623..c0a248d 100755 --- a/skills/repoman/scripts/pr-review-fixer.sh +++ b/skills/repoman/scripts/pr-review-fixer.sh @@ -25,7 +25,7 @@ COMMAND="" while [[ $# -gt 0 ]]; do case $1 in - begin|finalize) COMMAND="$1"; shift ;; + begin|finalize|footer) COMMAND="$1"; shift ;; --reports-dir) REPORTS_DIR="$2"; shift 2 ;; --verbose) VERBOSE=true; shift ;; --dry-run) DRY_RUN=true; shift ;; @@ -45,6 +45,7 @@ USAGE: COMMANDS: begin Read latest.json, mark eligible PRs as in_progress, output queue finalize Check reviews API, move reviewed PRs to reviewed state + footer Print the pinned-SHA line for the review body and its Slack post OPTIONS: --reports-dir DIR Reports directory (default: $REPORTS_DIR or ~/reports/pr-review) @@ -67,10 +68,19 @@ USAGE fi if [ -z "$COMMAND" ]; then - echo "ERROR: command required (begin or finalize). Use --help for usage." >&2 + echo "ERROR: command required (begin, finalize, or footer). Use --help for usage." >&2 exit 1 fi +# Pin provenance: write _meta only after the SHA resolves, never overwrite a +# valid file, and fall back to blob/main when resolution fails. `footer` is the +# line for the review body and its Slack post. Inline comments do not get it. +SKILL_FOOTER=$(skill_attribution "github-pr-review") +if [ "$COMMAND" = "footer" ]; then + printf '%s\n' "$SKILL_FOOTER" + exit 0 +fi + # --- Setup --- setup_workspace "pr-review-fixer" TMPDIR="$PROGRAM_TMPDIR" diff --git a/skills/repoman/scripts/pr-review-scanner.sh b/skills/repoman/scripts/pr-review-scanner.sh index 9d5e2a1..5ded18b 100755 --- a/skills/repoman/scripts/pr-review-scanner.sh +++ b/skills/repoman/scripts/pr-review-scanner.sh @@ -58,6 +58,10 @@ USAGE exit 0 fi +# Pin provenance on startup: write _meta only after the SHA resolves, and never +# overwrite a valid file. Resolution failure must not fail the scan. +skill_attribution "github-pr-review" >/dev/null + # Resolve the enrolled repo set from ~/.repoman/repos.json (owner/name tuples). # repoman_config is not needed here -- the scanner queries GitHub directly and # writes no fork, so it needs no REPOS_DIR/FORK_OWNER. diff --git a/skills/repoman/scripts/weekly-report.sh b/skills/repoman/scripts/weekly-report.sh index 512a3a0..3500ca0 100755 --- a/skills/repoman/scripts/weekly-report.sh +++ b/skills/repoman/scripts/weekly-report.sh @@ -52,7 +52,8 @@ OPTIONS: --help, -h Show this help ENVIRONMENT: - REPORT_PY Path to report.py (default: the deployed report generator) + REPORT_PY Path to report.py (default: first of the bundled generator + beside this skill, then the $HOME/workspaces deploy path) PREREQUISITES: python3, gh (authenticated). The repo list comes from the RepoMan enrolled set @@ -74,15 +75,32 @@ if [ -z "$repos" ]; then exit 1 fi -# Locate the generator. Default points at the deployed github-weekly-report skill -# (the upstream skill name in agent-skills); override with REPORT_PY in dev. -REPORT_PY="${REPORT_PY:-$HOME/workspaces/shared/skills/github-weekly-report/scripts/report.py}" +# Locate the generator. When REPORT_PY is unset, try candidates in order and use +# the first that exists, so both deployment layouts work with no per-host config: +# 1. bundled — the plugin layout, where github-weekly-report sits beside the +# repoman skill (the self-contained install; the primary target). +# 2. deployed — the OpenClaw host layout under $HOME/workspaces. Transitional: +# the personal-path default is tracked for removal in #116, and +# falls away entirely if OpenClaw deployment is retired. +# If neither exists, keep the bundled path so the error names the expected location. +if [ -z "${REPORT_PY:-}" ]; then + bundled="$SCRIPT_DIR/../../github-weekly-report/scripts/report.py" + deployed="${HOME:-}/workspaces/shared/skills/github-weekly-report/scripts/report.py" + for candidate in "$bundled" "$deployed"; do + if [ -f "$candidate" ]; then REPORT_PY="$candidate"; break; fi + done + REPORT_PY="${REPORT_PY:-$bundled}" +fi if [ ! -f "$REPORT_PY" ]; then echo "Error: report generator not found at: $REPORT_PY" >&2 echo "Set REPORT_PY to the path of report.py." >&2 exit 1 fi +# Pin provenance and append that exact line to the report that gets posted. +# Write _meta only after the SHA resolves; never overwrite a valid file. +SKILL_FOOTER=$(skill_attribution "github-weekly-report") + # Build args. $repos is intentionally unquoted so each owner/name line becomes a # separate --repos value; repo slugs never contain whitespace. No --org: the # enrolled set can span owners, so each entry is owner-qualified and report.py @@ -94,4 +112,11 @@ set -- --repos $repos [ -n "$OUTPUT" ] && set -- "$@" --output "$OUTPUT" [ -n "$JSON_OUTPUT" ] && set -- "$@" --json-output "$JSON_OUTPUT" -exec python3 "$REPORT_PY" "$@" +python3 "$REPORT_PY" "$@" + +# --output: the posted artifact is the file. Otherwise the report is stdout. +if [ -n "$OUTPUT" ]; then + printf '\n%s\n' "$SKILL_FOOTER" >> "$OUTPUT" +else + printf '\n%s\n' "$SKILL_FOOTER" +fi From e37cb752b8cc31f5c78e2be65ed622f4a77422f5 Mon Sep 17 00:00:00 2001 From: Jim Cadden Date: Tue, 3 Nov 2026 18:07:50 -0500 Subject: [PATCH 5/6] docs: Name doc-drift in the entrypoint allowlist The program map and friendly name already include it. Orientation still listed only the five older programs. Signed-off-by: Jim Cadden Assisted-By: Claude (Anthropic AI) --- skills/repoman/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/repoman/SKILL.md b/skills/repoman/SKILL.md index 6a004e5..44a26ab 100644 --- a/skills/repoman/SKILL.md +++ b/skills/repoman/SKILL.md @@ -166,7 +166,7 @@ state so it never lies about what is installed: one-line description read from the program's `SKILL.md` (the same source the gate uses; use the program→skill-dir map above). The authoritative backend names remain the `KNOWN_PROGRAMS` allowlist in `repoman-setup.sh` - (`link-health`, `dep-bump`, `pr-review`, `automation-health`, `repo-sync`) — + (`link-health`, `dep-bump`, `pr-review`, `automation-health`, `repo-sync`, `doc-drift`) — show the friendly names to the user, resolve back to the canonical token before any writer call. The description in each `SKILL.md` frontmatter is usually a YAML **folded block scalar** (`description: >-` with the text on the From 84ac95e7b7c44b77160d407151d7d276df54a39d Mon Sep 17 00:00:00 2001 From: Jim Cadden Date: Tue, 3 Nov 2026 19:01:51 -0500 Subject: [PATCH 6/6] docs: Require a persistent doc-drift precheck worklist The vendored default path is removed on exit. The scanner skill now requires --worklist so the documented command keeps the file. Also name weekly-report in the allowlist sentence and drop unrelated gitignore entries. Signed-off-by: Jim Cadden Assisted-By: Claude (Anthropic AI) --- .gitignore | 2 -- skills/doc-drift-scanner/SKILL.md | 11 +++++++++-- skills/repoman/SKILL.md | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index c8c266c..98a4914 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,3 @@ # Includes the atomic-write transient so an interrupted write cannot be committed. skills/*/scripts/_meta.*.json skills/*/scripts/_meta.*.json.tmp.* -automation-work/ -.cursor/ diff --git a/skills/doc-drift-scanner/SKILL.md b/skills/doc-drift-scanner/SKILL.md index f466052..09e5891 100644 --- a/skills/doc-drift-scanner/SKILL.md +++ b/skills/doc-drift-scanner/SKILL.md @@ -79,12 +79,19 @@ date/draft when GitHub metadata is supplied). ## Precheck (DAM schedules) +`--worklist` is required. Pass a persistent path. The script's default path +sits in a temp directory that is removed on exit, so a bare run prints a +`worklist:` path that is already gone. + ```bash -bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/doc-drift-precheck.sh" +mkdir -p "${REPORTS_DIR:-$HOME/reports/doc-drift}" +bash "${CLAUDE_PLUGIN_ROOT}/skills/repoman/scripts/doc-drift-precheck.sh" \ + --worklist "${REPORTS_DIR:-$HOME/reports/doc-drift}/worklist.json" ``` Exit `1` when the worklist is empty (skip the model turn). Exit `0` and print -`worklist: ` when there is work. Any other exit is fail-open. +`worklist: ` when there is work. Any other exit is fail-open. Read the +file at the `--worklist` path you passed; do not use the script's default. ## Related diff --git a/skills/repoman/SKILL.md b/skills/repoman/SKILL.md index 44a26ab..e97aeda 100644 --- a/skills/repoman/SKILL.md +++ b/skills/repoman/SKILL.md @@ -166,7 +166,7 @@ state so it never lies about what is installed: one-line description read from the program's `SKILL.md` (the same source the gate uses; use the program→skill-dir map above). The authoritative backend names remain the `KNOWN_PROGRAMS` allowlist in `repoman-setup.sh` - (`link-health`, `dep-bump`, `pr-review`, `automation-health`, `repo-sync`, `doc-drift`) — + (`link-health`, `dep-bump`, `pr-review`, `automation-health`, `repo-sync`, `weekly-report`, `doc-drift`) — show the friendly names to the user, resolve back to the canonical token before any writer call. The description in each `SKILL.md` frontmatter is usually a YAML **folded block scalar** (`description: >-` with the text on the