diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31a5b36..22b0157 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,10 @@ jobs: # tests/test-repoman-setup.sh is deliberately NOT listed here because it # carries intentional test idioms (SC2088 literal-tilde, SC2034 var read # by a sourced function) that are load-bearing for what it asserts. - run: shellcheck --severity=warning tests/test-repoman-requirements.sh tests/test-github-api.sh tests/test-repoman-check.sh tests/test-repoman-run.sh tests/test-repo-sync.sh tests/test-repoman-index.sh tests/test-repoman-match-name.sh tests/test-automation-health-dashboard.sh tests/test-link-health-index-wiring.sh tests/test-dep-bump-index-wiring.sh tests/test-skill-meta.sh tests/test-attribution-consumers.sh tests/test-pr-review-integration.sh tests/test-pr-review-impact.sh tests/test-pr-review-state.sh tests/test-weekly-report.sh tests/test-link-health-subset-carryforward.sh tests/test-dep-bump-subset-carryforward.sh tests/test-pr-review-subset-carryforward.sh tests/test-link-health-dryrun-baseline.sh + run: shellcheck --severity=warning tests/test-repoman-requirements.sh tests/test-github-api.sh tests/test-repoman-check.sh tests/test-repoman-run.sh tests/test-repo-sync.sh tests/test-repoman-index.sh tests/test-repoman-match-name.sh tests/test-automation-health-dashboard.sh tests/test-link-health-index-wiring.sh tests/test-dep-bump-index-wiring.sh tests/test-skill-meta.sh tests/test-attribution-consumers.sh tests/test-pr-review-integration.sh tests/test-pr-review-impact.sh tests/test-pr-review-state.sh tests/test-weekly-report.sh tests/test-link-health-subset-carryforward.sh tests/test-dep-bump-subset-carryforward.sh tests/test-pr-review-subset-carryforward.sh tests/test-link-health-dryrun-baseline.sh tests/test-doc-drift-check.sh tests/test-doc-drift-precheck.sh + - name: shellcheck (doc-drift) + # New consumer scripts stay warning-clean (no SC2034 arg-parsing idiom). + run: shellcheck --severity=warning scripts/doc-drift-scanner.sh scripts/doc-drift-fixer.sh scripts/doc-drift-precheck.sh - name: Run test suite # NOTE: this list and the shellcheck lists above are EXPLICIT, not globs. # A new test file must be added here (and to the relevant shellcheck @@ -70,7 +73,9 @@ jobs: tests/test-link-health-subset-carryforward.sh \ tests/test-dep-bump-subset-carryforward.sh \ tests/test-pr-review-subset-carryforward.sh \ - tests/test-link-health-dryrun-baseline.sh; do + tests/test-link-health-dryrun-baseline.sh \ + tests/test-doc-drift-check.sh \ + tests/test-doc-drift-precheck.sh; do echo "== $t ==" bash "$t" done diff --git a/.gitignore b/.gitignore index 51ddc20..f60aee1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ reports/ # Per-skill provenance written at runtime by resolve_skill_meta (github-api.sh); # pinned to each checkout, never source-of-truth. _meta.*.json + +__pycache__/ +*.pyc diff --git a/README.md b/README.md index d5473e7..9eb36f3 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ reports/ (gitignored) Runtime data stays on remote host only | PR Review | `scripts/pr-review-scanner.sh` | `scripts/pr-review-fixer.sh` | Scanner finds PRs labeled for review; fixer performs the AI review (posts review + inline comments, advances the review-state label). `scripts/pr-review-impact.sh` measures time-to-merge impact | | Dependency Bump | `scripts/dep-bump-scanner.sh` | `scripts/dep-bump-fixer.sh` | Tracks and triages Dependabot PRs across repos (comments, closes stale, audits coverage) | | Health Dashboard | `scripts/automation-health-dashboard.sh` | — | Aggregates program run health into a dashboard | +| Doc Drift | `scripts/doc-drift-scanner.sh` | `scripts/doc-drift-fixer.sh` | Checks a pinned ref for doc drift (paths, flags, enumerations, versions, changelog). See `docs/specs/2026-11-02-doc-drift-design.md` | Shared helpers live in `scripts/program-lib.sh`, a thin aggregator that sources four focused library modules (see [Library structure & portability](#library-structure--portability)). Repo coverage comes from the `config/core-repos.txt` allowlist via `get_core_repos()` (one **bare repo name** per line -- the owner is prepended from the active org; `#` comments and blank lines allowed) -- edit that file to change which repos are scanned. @@ -50,7 +51,7 @@ bash scripts/repoman-run.sh owner/name [owner/name ...] bash scripts/repoman-run.sh --dry-run ``` -`` is one of `link-health`, `dep-bump`, `pr-review`, `weekly-report`, `automation-health`, `repo-sync` (the full set is whatever `repoman-run.sh --help` lists). The orchestrator resolves the repo set, runs the capability check and clone refresh the program needs, execs it, and writes its `_index.json` entry — it is the **sole writer of `_index.json`**, so a program run directly (the commands in the table above) no longer updates the registry the dashboard reads. `repoman run ` is the blessed path for an indexed run. +`` is one of `link-health`, `dep-bump`, `pr-review`, `weekly-report`, `automation-health`, `repo-sync`, `doc-drift` (the full set is whatever `repoman-run.sh --help` lists). The orchestrator resolves the repo set, runs the capability check and clone refresh the program needs, execs it, and writes its `_index.json` entry — it is the **sole writer of `_index.json`**, so a program run directly (the commands in the table above) no longer updates the registry the dashboard reads. `repoman run ` is the blessed path for an indexed run. There is no `repoman` executable in this repo yet; `repoman run ` maps to `bash scripts/repoman-run.sh `. In a RepoMan deployment the `/repoman` skill (in `rossoctl/agent-skills`) performs that mapping from a Claude Code / OpenClaw agent; scheduled DAM/OpenClaw jobs invoke the same command string. As of v0.1.0 that skill ships as a **self-contained plugin** that bundles this engine (the `scripts/` here are vendored into it), so the skill no longer assumes a checkout of this repo is present. Standing-orders that still call scanners directly are tracked for migration in [#118](https://github.com/rossoctl/automation/issues/118). See the run-path design in `docs/specs/` and the epic [#72](https://github.com/rossoctl/automation/issues/72). diff --git a/docs/specs/2026-11-02-doc-drift-design.md b/docs/specs/2026-11-02-doc-drift-design.md new file mode 100644 index 0000000..1cea07c --- /dev/null +++ b/docs/specs/2026-11-02-doc-drift-design.md @@ -0,0 +1,133 @@ +# Doc drift — design + +**Status:** implemented (2026-11-02) +**Issue:** [rossoctl/repoman#8](https://github.com/rossoctl/repoman/issues/8) + +## Summary + +`doc-drift` checks a repo at a pinned git ref and reports places where the +docs disagree with the tree. The checker is offline: `scripts/doc-drift-check.py` +reads only `git show` / `git ls-tree`, and the shell passes release and PR JSON +in. A finding is a correction or an addition. `mechanical: true` means the +fixer has a line-scoped edit; everything else stays on one rollup issue per repo. + +Programs: + +| Piece | Path | +|---|---| +| Checker | `scripts/doc-drift-check.py` | +| Scanner | `scripts/doc-drift-scanner.sh` | +| Fixer | `scripts/doc-drift-fixer.sh` | +| Precheck | `scripts/doc-drift-precheck.sh` | +| Run path | `doc-drift` row in `scripts/repoman-run.sh` | +| Standing order | `standing-orders/doc-drift.md` | + +`repoman-run.sh --help` lists `doc-drift` because the program table is the +source of that list. + +## What is checked + +| Check | Claim | +|---|---| +| `paths` | A repo-relative path written in a doc is in the tree at that ref | +| `flags` | A flag or dispatcher subcommand written next to a script is accepted by that script | +| `enumerations` | Tokens captured from a `sources_of_truth` regex appear in the named docs | +| `retired_names` | A configured retired string is gone | +| `versions` | A line that states the project version matches the declared version. Changelog history and dependency pins are not version drift | +| `absence` | A "not implemented" or "lives in the automation repo" claim is false because the file is in the tree | +| `release_date` / `release_draft` | A changelog date matches GitHub `publishedAt`, and merged PRs after the newest release are drafted under `Unreleased` | + +## Precision + +These rules are what keep a scan of this repo from turning into a list of +false corrections. + +**Flags bind to the nearest script before them on the same line.** A line +that names two scripts and then a flag is a claim about the second script +only. A flag that appears before any script, as in a shellcheck --severity +invocation that later names a test file, is not a claim about that test. +`--help`, `--version`, and the generic word `--flag` are not claims. Two +identical lines are two findings. + +**Subcommands are the dispatcher.** The header list (`Subcommands:` / +`Commands:`) is compared to the arms of `case "$1"` / `$cmd` / `$command` / +`$subcommand` only. Arms of `case "$mode"` are not subcommands. A header line +contributes command tokens until prose starts, so +`init-config, add-repo, set-output. Each writes` is three commands, and +`Each writes a report` is none. + +**A script citation names one file.** A path with a directory binds that +path. A bare basename binds only when it is unique. A script that sources +`program-lib.sh` also accepts the long options of that file and of the modules +it aggregates. + +**Paths are locations in this repo.** A backtick or link is a path when it has +a repo-file extension (`.sh`, `.py`, `.md`, `.rst`, `.txt`, `.yml`, `.yaml`, +`.json`, `.toml`, `.env`) or a trailing slash. `gemini-2.5-flash`, +`discord.accounts`, and `rossoctl/agent-skills` are not paths. A bare +`link-health-fixer.sh` exists when any tree path has that basename. A slash +path is a miss only when its first directory is in the tree and the rest is +not. Before that miss is reported, the same citation is also tried relative +to the document, so a bundled skill doc that says `scripts/org.sh` matches +`skills/repoman/scripts/org.sh`. Host paths, bare runtime names, and a +relative link that resolves to a real file are not misses. Extensions outside +the list above (`.csv`, `.ini`, `.cfg`, `.conf`, `.js`, `.ts`, `.jsonl`) are +not checked. Path findings are `mechanical: false` because there is no +replacement text. `ignore_paths` drops path and flag findings in those +documents, not only version and retired-name findings. + +## Config schema (`.repoman/doc-drift.yaml`) + +The file is optional. When it is absent the checker uses these defaults, which +turn enumeration and retired-name checks off: + +```yaml +boundary: main +stable_tag_pattern: '^v\d+\.\d+\.\d+$' +ignore_paths: + - CHANGELOG.md + - docs/history/** +sources_of_truth: [] +retired_names: [] +release: + changelog: CHANGELOG.md +``` + +| Key | Meaning | +|---|---| +| `boundary` | `main` (resolve `HEAD`) or `tags` (newest tag matching `stable_tag_pattern`) | +| `stable_tag_pattern` | Regex for release tags. An invalid pattern is a `config` finding, not a crash | +| `ignore_paths` | Glob-ish prefixes (a directory plus `/**`, or a trailing `*`) skipped by the version, retired-name, path, and flag checks | +| `sources_of_truth` | List of `{name, code: {file, pattern}, docs: [paths]}`. `pattern` is a regex; group 1 is the captured token list. Longer than 256 characters or an invalid regex is a `config` finding | +| `retired_names` | List of `{name, replace_with}`. `replace_with` may be empty | +| `release.changelog` | Keep-a-Changelog path, default `CHANGELOG.md` | + +Example that checks the program allowlist against a doc: + +```yaml +boundary: main +sources_of_truth: + - name: programs + code: + file: scripts/repoman-setup.sh + pattern: 'KNOWN_PROGRAMS="([^"]+)"' + docs: + - README.md +retired_names: + - name: kagenti + replace_with: "" +release: + changelog: CHANGELOG.md +``` + +An enumeration **correction** (a doc token that is not in the code capture) +is `mechanical: false`. An enumeration **addition** (a code token missing from +the doc) carries `draft` and the fixer appends it to +`.repoman/doc-drift-additions.md` on the rolling branch. + +## Fixer + +`versions` and `retired_names` replace only the flagged line, with word +boundaries, so `1.2.3` does not match inside `11.2.34`. A failed scan does not +close the rollup issue: the scanner keeps the previous findings. The rolling +additions branch pushes with `--force-with-lease`. diff --git a/scripts/automation-health-dashboard.sh b/scripts/automation-health-dashboard.sh index 2887e0d..5385b8d 100644 --- a/scripts/automation-health-dashboard.sh +++ b/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,28 @@ 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") + # 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 | .[] | + (.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 + # ============================================================================= # Step 5: Compute cross-program coverage # ============================================================================= @@ -454,7 +505,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 +565,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/scripts/doc-drift-check.py b/scripts/doc-drift-check.py new file mode 100755 index 0000000..e9e6488 --- /dev/null +++ b/scripts/doc-drift-check.py @@ -0,0 +1,1636 @@ +#!/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 posixpath +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"(?\s*(?:#\s*)?)(?:subcommands?|commands?)\s*:\s*(?P.*)$", + re.I, +) +_SUBCMD_STOPWORDS = frozenset( + {"subcommand", "subcommands", "commands", "command"} +) +# Keep a Changelog heading +CHANGELOG_HEADING = re.compile( + r"^## \[([^\]]+)\](?: - (\d{4}-\d{2}-\d{2}))?\s*$", re.M +) +# Conventional commit / PR title prefix +CONV_PREFIX = re.compile( + r"^(?Pfeat|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"} +# 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, + str(self.doc_line), + ] + ) + + 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": + 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 tag_rx.search(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 _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. + + List-vs-map for a bare ``key:`` is decided structurally: if the next + 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)] + lines = text.splitlines() + + for i, raw in enumerate(lines): + if not raw.strip() or raw.lstrip().startswith("#"): + continue + indent = len(raw) - len(raw.lstrip(" ")) + line = raw.strip() + + # 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("- "): + item_text = line[2:].strip() + if not isinstance(parent, list): + continue + if ":" in item_text and not item_text.startswith("{"): + 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 == "": + 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] = {} + 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) + # 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 = {} + 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 _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): + for m in rx.finditer(text): + raw = m.group(1) + if URLISH.match(raw) or raw.startswith("#"): + continue + if not _is_path_claim(raw): + continue + if raw in seen: + continue + seen.add(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: + 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=False, + 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 _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. + + 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() + 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 _list_command_tokens(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() + toks = _list_command_tokens(item) + if toks: + _add_subcommand_token(subs, toks[0]) + 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 + # 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): + tok = m.group(1) + if "-" not in tok: + continue + _add_subcommand_token(subs, tok) + 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]: + """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() + lines = script_text.splitlines() + i = 0 + n = len(lines) + while i < n: + if not _DISPATCH_CASE_RE.match(lines[i]): + i += 1 + continue + 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] = [] + 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 + 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: + 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=doc_line, + 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 _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 + + +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 + 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) + 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]]: + """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"(?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]] = {} + 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, + 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. 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 + 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: + 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: + 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 + 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 + + # PRs merged after the newest published release belong in Unreleased. + newest_ver = versioned[0][0] if versioned else None + + 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 + if pr_is_unreleased(pr, releases): + 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) + 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 + 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] = [] + 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, cfg) + 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/scripts/doc-drift-fixer.sh b/scripts/doc-drift-fixer.sh new file mode 100755 index 0000000..f657601 --- /dev/null +++ b/scripts/doc-drift-fixer.sh @@ -0,0 +1,420 @@ +#!/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 \ + > "$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 ' ')" + +# 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 + 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 ===" +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) + # 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" + 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 + 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 + ;; + *) + 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 + # 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 + 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 + + # 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 + # 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 + } + 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/scripts/doc-drift-precheck.sh b/scripts/doc-drift-precheck.sh new file mode 100755 index 0000000..e529801 --- /dev/null +++ b/scripts/doc-drift-precheck.sh @@ -0,0 +1,101 @@ +#!/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" + # 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 > "$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/scripts/doc-drift-scanner.sh b/scripts/doc-drift-scanner.sh new file mode 100755 index 0000000..e64474e --- /dev/null +++ b/scripts/doc-drift-scanner.sh @@ -0,0 +1,348 @@ +#!/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" +: > "$PROGRAM_TMPDIR/failed_repos.txt" +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" + + # 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 > "$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" + echo "$full_repo" >> "$PROGRAM_TMPDIR/failed_repos.txt" + 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, (.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" + +# 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/scripts/repoman-run.sh b/scripts/repoman-run.sh index fa66533..7583dce 100644 --- a/scripts/repoman-run.sh +++ b/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/scripts/repoman-setup.sh b/scripts/repoman-setup.sh index ff2f28b..63c1caa 100755 --- a/scripts/repoman-setup.sh +++ b/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/standing-orders/doc-drift.md b/standing-orders/doc-drift.md new file mode 100644 index 0000000..a24772a --- /dev/null +++ b/standing-orders/doc-drift.md @@ -0,0 +1,39 @@ +## Program: Doc Drift + +**Authority:** Read enrolled repos at a pinned git ref, file one rollup issue per +repo for doc drift, and open a fork PR only for a line-scoped mechanical edit. +**Trigger:** Not scheduled in this repo yet. Manual run: +`bash scripts/repoman-run.sh doc-drift` (add `--dry-run` to preview). +**Approval gate:** None for the rollup issue. A mechanical PR is opened on the +fork and left for a human to merge. +**Escalation:** + - Checker fails on a repo: keep the previous findings, do not close the rollup issue + - GitHub API rate limit: stop issue updates, report partial results + +### Scope +- The enrolled repo set in `~/.repoman/repos.json`, read at a pinned ref + (`boundary: main` resolves `HEAD` of the local clone; `boundary: tags` + resolves the newest tag matching `stable_tag_pattern`). +- Documentation files (`.md`, `.rst`, `.txt`) plus shell and Python for the + flag and subcommand checks. +- Per-repo behavior comes from `.repoman/doc-drift.yaml`. When that file is + absent, `sources_of_truth` and `retired_names` are empty (those checks are + off) and `ignore_paths` defaults to CHANGELOG.md and docs/history/**. + Those paths skip version, retired-name, path, and flag findings. + The schema is in `docs/specs/2026-11-02-doc-drift-design.md`. + +### What NOT to Do +- Do not edit a file outside the flagged line. Version and retired-name + replacements are line-scoped; a repo-wide replace is not allowed. +- Do not treat a path or enumeration correction with an empty replacement as + mechanical. Those stay on the rollup issue. +- Do not close the rollup issue when the checker fails. Carry the previous + findings forward. +- Do not publish GitHub Releases. Release-draft findings are text for a human. + +### Operational Notes +- Reports: `~/reports/doc-drift/latest.json` and `history.json`. +- Precheck: `scripts/doc-drift-precheck.sh` exits 0 when a repo has findings + (so a model turn is warranted) and 1 when the worklist is empty. +- Manual run: `bash scripts/repoman-run.sh doc-drift`. +- Issue: rossoctl/repoman#8. diff --git a/tests/test-doc-drift-check.sh b/tests/test-doc-drift-check.sh new file mode 100755 index 0000000..326142f --- /dev/null +++ b/tests/test-doc-drift-check.sh @@ -0,0 +1,657 @@ +#!/usr/bin/env bash +# Fixture tests for scripts/doc-drift-check.py (deterministic doc-drift checker). +# Part of RepoMan doc-drift v1 (rossoctl/repoman#8). +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECK="$SCRIPT_DIR/../scripts/doc-drift-check.py" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail=0 + +pass() { echo "PASS: $1"; } +fail_msg() { echo "FAIL: $1"; fail=1; } + +# --------------------------------------------------------------------------- +# Build a fixture clone that reproduces the five b3f128c shapes. +# --------------------------------------------------------------------------- +FIX="$TMP/fixture" +mkdir -p "$FIX/skills/repoman/scripts" \ + "$FIX/skills/repo-sync" \ + "$FIX/scripts" \ + "$FIX/.repoman" \ + "$FIX/.claude-plugin" + +# Code: KNOWN_PROGRAMS includes weekly-report (enum source of truth) +cat > "$FIX/skills/repoman/scripts/repoman-setup.sh" <<'EOF' +#!/usr/bin/env bash +# Subcommands: init-config, add-repo, enable-program, set-output. +KNOWN_PROGRAMS="link-health dep-bump pr-review automation-health repo-sync weekly-report" + +case "$1" in + init-config) ;; + add-repo) ;; + enable-program) ;; + set-output) ;; + set-requirements) ;; + *) exit 1 ;; +esac +EOF + +# Script that exists but docs claim is not implemented +cat > "$FIX/skills/repoman/scripts/repoman-run.sh" <<'EOF' +#!/usr/bin/env bash +echo "run path is implemented" +EOF + +# repo-sync.sh bundled here (docs claim it lives only in automation) +cat > "$FIX/scripts/repo-sync.sh" <<'EOF' +#!/usr/bin/env bash +echo "repo-sync" +EOF + +# Retired branding +cat > "$FIX/skills/repoman/scripts/dep-bump-fixer.sh" <<'EOF' +#!/usr/bin/env bash +FIXER_SIGNATURE="Automated analysis by Kagenti Dep Bump Fixer" +EOF + +# Docs: program list omits weekly-report (friendly-name table style) +cat > "$FIX/skills/repoman/SKILL.md" <<'EOF' +# RepoMan + +Canonical allowlist names remain the `KNOWN_PROGRAMS` allowlist in +`repoman-setup.sh` (`link-health`, `dep-bump`, `pr-review`, +`automation-health`, `repo-sync`). + +The **run path** is **not implemented in this skill**. Until it lands, +report the resolved set — do not attempt to exec `repoman-run.sh` yourself. + +| Friendly name | Canonical token | +|---------------|-----------------| +| Link-Health-Manager | `link-health` | +| Dep-Bump-Manager | `dep-bump` | +| PR-Review-Manager | `pr-review` | +| Automation-Health-Manager | `automation-health` | +| Repo-Sync-Manager | `repo-sync` | +EOF + +cat > "$FIX/skills/repo-sync/SKILL.md" <<'EOF' +# Repo Sync + +**Run scripts with `--help` first.** The script lives in the automation repo, +not in this repo, so run `repo-sync.sh` from an automation checkout. +EOF + +cat > "$FIX/README.md" <<'EOF' +# Fixture + +Version in docs: 0.1.0 +EOF + +cat > "$FIX/.claude-plugin/marketplace.json" <<'EOF' +{ + "name": "fixture", + "metadata": { "version": "0.1.0" } +} +EOF + +cat > "$FIX/CHANGELOG.md" <<'EOF' +# Changelog + +## [0.1.0] - 2026-10-23 + +### Added + +- First release (#1). +EOF + +cat > "$FIX/.repoman/doc-drift.yaml" <<'EOF' +boundary: main +stable_tag_pattern: '^v\d+\.\d+\.\d+$' +ignore_paths: [CHANGELOG.md, docs/history/**] +sources_of_truth: + - name: programs + code: { file: skills/repoman/scripts/repoman-setup.sh, pattern: 'KNOWN_PROGRAMS="([^"]+)"' } + docs: [skills/repoman/SKILL.md] +retired_names: + - { name: kagenti, replace_with: "" } +release: + changelog: CHANGELOG.md +EOF + +git -C "$FIX" init -q +git -C "$FIX" -c user.email=t@t -c user.name=t add -A +git -C "$FIX" -c user.email=t@t -c user.name=t commit -qm "fixture" +REF=$(git -C "$FIX" rev-parse HEAD) + +# Dirty worktree must not change results +echo "DIRTY" >> "$FIX/skills/repoman/SKILL.md" + +OUT1="$TMP/out1.json" +python3 "$CHECK" --clone "$FIX" --repo "fixture/repo" --ref "$REF" \ + --releases '[]' --prs '[]' > "$OUT1" + +# --- Five b3f128c shapes --- +has_check() { + local check="$1" claim_substr="$2" + jq -e --arg c "$check" --arg s "$claim_substr" \ + '[.findings[] | select(.check == $c and (.claim | test($s; "i")))] | length > 0' \ + "$OUT1" >/dev/null +} + +if has_check enumerations 'weekly-report'; then + pass "enum: weekly-report missing from docs" +else + fail_msg "enum: weekly-report missing from docs" + jq '[.findings[] | select(.check=="enumerations")]' "$OUT1" || true +fi + +if has_check absence 'not implemented'; then + pass "absence: repoman-run not-implemented claim" +else + fail_msg "absence: repoman-run not-implemented claim" + jq '[.findings[] | select(.check=="absence")]' "$OUT1" || true +fi + +if has_check absence 'automation repo'; then + pass "absence: repo-sync lives in automation claim" +else + fail_msg "absence: repo-sync lives in automation claim" +fi + +if has_check flags 'set-requirements'; then + pass "flags: set-requirements addition or doc mismatch" +else + fail_msg "flags: set-requirements not reported" + jq '[.findings[] | select(.check=="flags")]' "$OUT1" || true +fi + +if has_check retired_names 'kagenti'; then + pass "retired_names: kagenti" +else + fail_msg "retired_names: kagenti" +fi + +# Both-sides cites +if jq -e '[.findings[] | select(.doc_line < 1 or .code_line < 1)] | length == 0' "$OUT1" >/dev/null; then + pass "every finding has doc_line and code_line >= 1" +else + fail_msg "finding missing line cites" +fi + +# Dirty worktree invariance +OUT2="$TMP/out2.json" +python3 "$CHECK" --clone "$FIX" --repo "fixture/repo" --ref "$REF" \ + --releases '[]' --prs '[]' > "$OUT2" +if diff -q <(jq -S '.findings' "$OUT1") <(jq -S '.findings' "$OUT2") >/dev/null; then + pass "dirty worktree does not change findings" +else + fail_msg "dirty worktree changed findings" +fi + +# Idempotent keys across a second logical run +KEYS1=$(jq -r '[.findings[].key] | sort | .[]' "$OUT1") +KEYS2=$(jq -r '[.findings[].key] | sort | .[]' "$OUT2") +if [ "$KEYS1" = "$KEYS2" ]; then + pass "second run at same ref yields identical keys" +else + fail_msg "keys drifted across runs" +fi + +# --------------------------------------------------------------------------- +# Release assist fixtures (fed as JSON; no gh) +# --------------------------------------------------------------------------- +REL_JSON="$TMP/releases.json" +PR_JSON="$TMP/prs.json" +cat > "$REL_JSON" <<'EOF' +[ + { + "tagName": "v0.1.0", + "publishedAt": "2026-10-27T19:09:44Z", + "createdAt": "2026-10-27T19:04:04Z", + "name": "v0.1.0", + "body": "## [0.1.0] - 2026-10-23\n\nFirst release.\n" + } +] +EOF +cat > "$PR_JSON" <<'EOF' +[ + { + "number": 10, + "title": "feat: add doc-drift program", + "url": "https://github.com/fixture/repo/pull/10", + "labels": [], + "unreleased": true + }, + { + "number": 11, + "title": "fix: correct changelog date helper", + "url": "https://github.com/fixture/repo/pull/11", + "labels": [], + "unreleased": true + }, + { + "number": 12, + "title": "chore: ignore me", + "url": "https://github.com/fixture/repo/pull/12", + "labels": [], + "unreleased": true + }, + { + "number": 13, + "title": "Improve something without prefix", + "url": "https://github.com/fixture/repo/pull/13", + "labels": [], + "unreleased": true + } +] +EOF + +OUTR="$TMP/outr.json" +python3 "$CHECK" --clone "$FIX" --repo "fixture/repo" --ref "$REF" \ + --releases-json "$REL_JSON" --prs-json "$PR_JSON" > "$OUTR" + +if jq -e '[.findings[] | select(.check=="release_date" and (.claim|test("2026-10-27")))] | length == 1' "$OUTR" >/dev/null; then + pass "release_date: 2026-10-23 vs 2026-10-27" +else + fail_msg "release_date: expected one date correction" + jq '[.findings[] | select(.check=="release_date")]' "$OUTR" || true +fi + +# Draft release (null publishedAt) → no date finding for that release +cat > "$REL_JSON" <<'EOF' +[{ "tagName": "v0.1.0", "publishedAt": null, "body": "" }] +EOF +OUTD="$TMP/outd.json" +python3 "$CHECK" --clone "$FIX" --repo "fixture/repo" --ref "$REF" \ + --releases-json "$REL_JSON" --prs '[]' > "$OUTD" +if jq -e '[.findings[] | select(.check=="release_date")] | length == 0' "$OUTD" >/dev/null; then + pass "release_date: null publishedAt skipped" +else + fail_msg "release_date: draft should be skipped" +fi + +# Restore published release for draft checks +cat > "$REL_JSON" <<'EOF' +[ + { + "tagName": "v0.1.0", + "publishedAt": "2026-10-27T19:09:44Z", + "body": "## [0.1.0] - 2026-10-23\n" + } +] +EOF +python3 "$CHECK" --clone "$FIX" --repo "fixture/repo" --ref "$REF" \ + --releases-json "$REL_JSON" --prs-json "$PR_JSON" > "$OUTR" + +if jq -e '[.findings[] | select(.check=="release_draft")] | length == 1' "$OUTR" >/dev/null; then + pass "release_draft: Unreleased section addition" +else + fail_msg "release_draft: expected Unreleased addition" +fi + +DRAFT=$(jq -r '.findings[] | select(.check=="release_draft") | .draft' "$OUTR") +echo "$DRAFT" | grep -q '### Added' && echo "$DRAFT" | grep -q '#10' || { + fail_msg "release_draft: missing Added/#10" +} +echo "$DRAFT" | grep -q '### Fixed' && echo "$DRAFT" | grep -q '#11' || { + fail_msg "release_draft: missing Fixed/#11" +} +echo "$DRAFT" | grep -q 'chore' && fail_msg "release_draft: chore should be omitted" || pass "release_draft: chore omitted" +echo "$DRAFT" | grep -q '### Uncategorized' && echo "$DRAFT" | grep -q '#13' \ + && pass "release_draft: uncategorized #13" \ + || fail_msg "release_draft: expected Uncategorized #13" + +# Already-cited PR → no finding for that number +# Put #10 into Unreleased in changelog and rebuild +cat > "$FIX/CHANGELOG.md" <<'EOF' +# Changelog + +## [Unreleased] + +- add doc-drift program (#10). + +## [0.1.0] - 2026-10-23 + +### Added + +- First release (#1). +EOF +git -C "$FIX" -c user.email=t@t -c user.name=t add -A +git -C "$FIX" -c user.email=t@t -c user.name=t commit -qm "cite #10" +REF2=$(git -C "$FIX" rev-parse HEAD) +# Only PR 10 unreleased +cat > "$PR_JSON" <<'EOF' +[ + { + "number": 10, + "title": "feat: add doc-drift program", + "url": "https://github.com/fixture/repo/pull/10", + "labels": [], + "unreleased": true + } +] +EOF +OUTC="$TMP/outc.json" +python3 "$CHECK" --clone "$FIX" --repo "fixture/repo" --ref "$REF2" \ + --releases-json "$REL_JSON" --prs-json "$PR_JSON" > "$OUTC" +if jq -e '[.findings[] | select(.check=="release_draft")] | length == 0' "$OUTC" >/dev/null; then + pass "release_draft: already-cited PR produces no draft" +else + fail_msg "release_draft: cited PR should not draft" + jq '[.findings[] | select(.check|startswith("release"))]' "$OUTC" || true +fi + +# mergedAt before the newest publishedAt is already released, even if the +# caller stamped unreleased:true. A later merge still drafts. +cat > "$PR_JSON" <<'EOF' +[ + { + "number": 11, + "title": "feat: already shipped", + "url": "https://github.com/fixture/repo/pull/11", + "labels": [], + "mergedAt": "2026-10-01T00:00:00Z", + "unreleased": true + }, + { + "number": 12, + "title": "feat: after the release", + "url": "https://github.com/fixture/repo/pull/12", + "labels": [], + "mergedAt": "2026-10-28T00:00:00Z", + "unreleased": false + } +] +EOF +OUTD="$TMP/outd.json" +python3 "$CHECK" --clone "$FIX" --repo "fixture/repo" --ref "$REF2" \ + --releases-json "$REL_JSON" --prs-json "$PR_JSON" > "$OUTD" +if jq -e '[.findings[] | select(.check=="release_draft") | .draft | contains("#11")] | any' "$OUTD" >/dev/null; then + fail_msg "release_draft: PR merged before publishedAt should not draft" + jq '[.findings[] | select(.check=="release_draft")]' "$OUTD" || true +else + pass "release_draft: already-released PR is not unreleased" +fi +if jq -e '[.findings[] | select(.check=="release_draft") | .draft | contains("#12")] | any' "$OUTD" >/dev/null; then + pass "release_draft: PR merged after publishedAt drafts" +else + fail_msg "release_draft: PR merged after publishedAt should draft" + jq '[.findings[] | select(.check=="release_draft")]' "$OUTD" || true +fi + +# Compact YAML sequences, changelog history, and project-version lines. +python3 - "$CHECK" <<'PY' +import importlib.util, sys +spec = importlib.util.spec_from_file_location("dd", sys.argv[1]) +mod = importlib.util.module_from_spec(spec) +# Python 3.14 dataclasses look up cls.__module__ in sys.modules while +# processing the class. Register the module before exec or that lookup is None. +sys.modules[spec.name] = mod +spec.loader.exec_module(mod) +text = """retired_names: +- old-name +ignore_paths: +- vendor/ +sources_of_truth: +- name: version + code: + path: VERSION +""" +got = mod.parse_simple_yaml(text) +assert got["retired_names"] == ["old-name"], got +assert got["ignore_paths"] == ["vendor/"], got +assert got["sources_of_truth"][0]["code"]["path"] == "VERSION", got +assert "code" not in got, got +PY +pass "yaml: compact sequences stay lists" + +VER="$TMP/versions" +mkdir -p "$VER/.claude-plugin" "$VER/.repoman" +cat > "$VER/.claude-plugin/marketplace.json" <<'EOF' +{"version": "2.0.0"} +EOF +cat > "$VER/CHANGELOG.md" <<'EOF' +# Changelog + +## [1.0.0] - 2026-01-01 + +## [2.0.0] - 2026-02-01 +EOF +cat > "$VER/requirements.txt" <<'EOF' +foo==1.2.3 +EOF +cat > "$VER/README.md" <<'EOF' +# App + +The plugin version is 2.0.0. + +The plugin version is 9.9.9. +Also the version is 8.8.8. +EOF +cat > "$VER/.repoman/doc-drift.yaml" <<'EOF' +boundary: tags +stable_tag_pattern: '^v\d+\.\d+\.\d+$' +retired_names: +- old-name +ignore_paths: +- vendor/ +sources_of_truth: +- name: version + code: + path: VERSION +EOF +git -C "$VER" init -q +git -C "$VER" -c user.email=t@t -c user.name=t add -A +git -C "$VER" -c user.email=t@t -c user.name=t commit -qm "ver" +git -C "$VER" tag v1.0.0 +OUTV="$TMP/outv.json" +python3 "$CHECK" --clone "$VER" --repo "fixture/versions" \ + --releases '[]' --prs '[]' > "$OUTV" +if jq -e '[.findings[] | select(.check=="versions" and (.doc_path|test("CHANGELOG")))] | length == 0' "$OUTV" >/dev/null; then + pass "versions: changelog history is not drift" +else + fail_msg "versions: changelog history flagged" + jq '[.findings[] | select(.check=="versions")]' "$OUTV" || true +fi +if jq -e '[.findings[] | select(.doc_path=="requirements.txt")] | length == 0' "$OUTV" >/dev/null; then + pass "versions: dependency pins are not the project version" +else + fail_msg "versions: requirements.txt pin flagged" +fi +vcount=$(jq '[.findings[] | select(.check=="versions" and .doc_path=="README.md" and .mechanical==true)] | length' "$OUTV") +if [ "$vcount" = "2" ]; then + pass "versions: both project-version lines survive dedupe" +else + fail_msg "versions: expected 2 README findings, got $vcount" + jq '[.findings[] | select(.check=="versions")]' "$OUTV" || true +fi +if jq -e '[.findings[] | select(.check=="versions" and .mechanical==false and (.evidence|test("tag v1.0.0")))] | length == 1' "$OUTV" >/dev/null \ + && jq -e '[.findings[] | select(.check=="versions" and .mechanical==true and .replace_with=="1.0.0")] | length == 0' "$OUTV" >/dev/null; then + pass "versions: older tag is a non-mechanical mismatch, not the canon" +else + fail_msg "versions: tag should not override the declared version" + jq '[.findings[] | select(.check=="versions")]' "$OUTV" || true +fi + +BAD="$TMP/badre" +mkdir -p "$BAD/.repoman" +echo 'stable_tag_pattern: "("' > "$BAD/.repoman/doc-drift.yaml" +echo '{"version":"1.0.0"}' > "$BAD/package.json" +git -C "$BAD" init -q +git -C "$BAD" -c user.email=t@t -c user.name=t add -A +git -C "$BAD" -c user.email=t@t -c user.name=t commit -qm "bad" +OUTB="$TMP/outb.json" +if python3 "$CHECK" --clone "$BAD" --repo "fixture/bad" --releases '[]' --prs '[]' > "$OUTB"; then + if jq -e '[.findings[] | select(.check=="config")] | length > 0' "$OUTB" >/dev/null; then + pass "config: invalid stable_tag_pattern is a finding, not a crash" + else + fail_msg "config: invalid pattern produced no finding" + fi +else + fail_msg "config: invalid pattern crashed the checker" +fi + +# Line-scoped replace: 1.2.3 must not match inside 11.2.30, and only line 2 changes. +EDIT="$TMP/edit.md" +printf 'keep 11.2.30 and 1.2.3\nreplace 1.2.3 here\nkeep 1.2.3\n' > "$EDIT" +# shellcheck disable=SC1090 +eval "$(sed -n '/^replace_on_line() {$/,/^}$/p' "$SCRIPT_DIR/../scripts/doc-drift-fixer.sh")" +if replace_on_line "$EDIT" 2 "1.2.3" "2.0.0" \ + && [ "$(sed -n '1p' "$EDIT")" = "keep 11.2.30 and 1.2.3" ] \ + && [ "$(sed -n '2p' "$EDIT")" = "replace 2.0.0 here" ] \ + && [ "$(sed -n '3p' "$EDIT")" = "keep 1.2.3" ]; then + pass "fixer: replace touches only the flagged line" +else + fail_msg "fixer: line-scoped replace" + cat "$EDIT" || true +fi + +# Precision: flags bind to the span that names the script, prose and inner +# case arms are not subcommands, and a basename resolves to the tree. +PREC="$TMP/prec" +mkdir -p "$PREC/scripts" "$PREC/skills/repoman/scripts" "$PREC/docs/history" "$PREC/.repoman" +cat > "$PREC/scripts/program-lib.sh" <<'EOF' +#!/usr/bin/env bash +case "$1" in + --profile) ;; +esac +EOF +cat > "$PREC/scripts/alpha.sh" <<'EOF' +#!/usr/bin/env bash +. "$SCRIPT_DIR/program-lib.sh" +case "$1" in + --dry-run) ;; +esac +EOF +cat > "$PREC/scripts/beta.sh" <<'EOF' +#!/usr/bin/env bash +echo beta +EOF +cat > "$PREC/scripts/x.sh" <<'EOF' +#!/usr/bin/env bash +case "$1" in + --only-scripts) ;; +esac +EOF +cat > "$PREC/skills/repoman/scripts/x.sh" <<'EOF' +#!/usr/bin/env bash +echo other +EOF +cat > "$PREC/scripts/prose.sh" <<'EOF' +#!/usr/bin/env bash +# Subcommands: init-config +case "$mode" in + central) ;; + same) ;; + end) ;; +esac +case "$1" in + init-config) ;; +esac +EOF +cat > "$PREC/scripts/link-health-fixer.sh" <<'EOF' +#!/usr/bin/env bash +echo fixer +EOF +cat > "$PREC/skills/repoman/scripts/org.sh" <<'EOF' +#!/usr/bin/env bash +echo org +EOF +cat > "$PREC/skills/repoman/SKILL.md" <<'EOF' +The helper is `scripts/org.sh`. +EOF +cat > "$PREC/.repoman/doc-drift.yaml" <<'EOF' +ignore_paths: + - docs/history/** +EOF +cat > "$PREC/docs/history/old.md" <<'EOF' +Missing `scripts/nope.sh`. +Run `beta.sh --dry-run` from a history page. +EOF +cat > "$PREC/README.md" <<'EOF' +# Prec + +See `alpha.sh` and `beta.sh` for the scanners. + +- `--profile` selects an org profile. +- `--org` names the org. + +Run `alpha.sh --dry-run` to preview. +Run `beta.sh --dry-run` when that script has no such flag. +Run `beta.sh --dry-run` when that script has no such flag. +`scripts/alpha.sh` and `scripts/beta.sh --frobnicate` +`scripts/x.sh --only-scripts` names one of the two copies. +The bare `x.sh --only-scripts` name is ambiguous. +`alpha.sh` has no `--bogus` flag. +EOF +cat > "$PREC/docs/guide.md" <<'EOF' +See `link-health-fixer.sh` in the scripts directory. + +Models such as `gemini-2.5-flash` and `discord.accounts` are not paths. +The org repo is `rossoctl/agent-skills`. + +A dot [here](.) and [up](../README.md). + +Missing `scripts/missing.sh`. +EOF +git -C "$PREC" init -q +git -C "$PREC" -c user.email=t@t -c user.name=t add -A +git -C "$PREC" -c user.email=t@t -c user.name=t commit -qm "prec" +OUTP="$TMP/outp.json" +python3 "$CHECK" --clone "$PREC" --repo "fixture/prec" \ + --releases '[]' --prs '[]' > "$OUTP" +dry_lines=$(jq -r '[.findings[] | select(.check=="flags" and .code_path=="scripts/beta.sh" and (.claim|test("dry-run"))) | .doc_line] | unique | length' "$OUTP") +if jq -e '[.findings[] | select(.check=="flags" and (.claim|test("profile|org|only-scripts|bogus|Each|writes|central|same|\\bend\\b")))] | length == 0' "$OUTP" >/dev/null \ + && [ "$dry_lines" = "2" ] \ + && jq -e '[.findings[] | select(.check=="flags" and (.claim|test("frobnicate")) and .code_path=="scripts/beta.sh")] | length == 1' "$OUTP" >/dev/null \ + && jq -e '[.findings[] | select(.check=="flags" and (.claim|test("frobnicate")) and .code_path=="scripts/alpha.sh")] | length == 0' "$OUTP" >/dev/null \ + && jq -e '[.findings[] | select(.check=="flags" and .doc_path=="scripts/prose.sh")] | length == 0' "$OUTP" >/dev/null \ + && jq -e '[.findings[] | select(.check=="flags" and .doc_path=="docs/history/old.md")] | length == 0' "$OUTP" >/dev/null; then + pass "flags: nearest script, duplicate lines, and ignore_paths" +else + fail_msg "flags: span binding" + jq '[.findings[] | select(.check=="flags")]' "$OUTP" || true +fi +if jq -e '[.findings[] | select(.check=="paths")] | length == 1' "$OUTP" >/dev/null \ + && jq -e '[.findings[] | select(.check=="paths" and .mechanical==false and (.claim|test("scripts/missing.sh")))] | length == 1' "$OUTP" >/dev/null \ + && jq -e '[.findings[] | select(.check=="paths" and (.claim|test("org.sh|nope.sh")))] | length == 0' "$OUTP" >/dev/null; then + pass "paths: basename, bundle-relative, and ignore_paths" +else + fail_msg "paths: precision" + jq '[.findings[] | select(.check=="paths")]' "$OUTP" || true +fi + +python3 - "$CHECK" <<'PY' +import importlib.util, sys +spec = importlib.util.spec_from_file_location("ddprec", sys.argv[1]) +mod = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = mod +spec.loader.exec_module(mod) +got = mod._list_command_tokens( + "init-config, add-repo, enable-program, set-output. Each writes" +) +assert got == ["init-config", "add-repo", "enable-program", "set-output"], got +assert mod._list_command_tokens("Each writes a report") == [] +script = """ +# Subcommands: init-config +case "$mode" in + central) ;; + same) ;; + end) ;; +esac +case "$subcommand" in + init-config) ;; + set-requirements) ;; +esac +""" +assert mod._case_subcommands(script) == {"init-config", "set-requirements"}, mod._case_subcommands(script) +PY +pass "flags: prose header and dispatcher case" + +if [ "$fail" -ne 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASS" +exit 0 diff --git a/tests/test-doc-drift-precheck.sh b/tests/test-doc-drift-precheck.sh new file mode 100755 index 0000000..d571337 --- /dev/null +++ b/tests/test-doc-drift-precheck.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Precheck exit-code / stdout contract for doc-drift-precheck.sh. +# Exercises the real script against a fixture (gh stubbed; checker is live). +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRECHECK="$SCRIPT_DIR/../scripts/doc-drift-precheck.sh" +CHECK="$SCRIPT_DIR/../scripts/doc-drift-check.py" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail=0 +pass() { echo "PASS: $1"; } +fail_msg() { echo "FAIL: $1"; fail=1; } + +# --------------------------------------------------------------------------- +# Shared stubs: fake ~/.repoman state + gh that returns empty releases/PRs +# --------------------------------------------------------------------------- +STUB_BIN="$TMP/bin" +mkdir -p "$STUB_BIN" +cat > "$STUB_BIN/gh" <<'EOF' +#!/usr/bin/env bash +# Minimal stub: release/PR fetches fail → precheck falls back to []. +exit 1 +EOF +chmod +x "$STUB_BIN/gh" +export PATH="$STUB_BIN:$PATH" + +HOME_FAKE="$TMP/home" +mkdir -p "$HOME_FAKE" +export HOME="$HOME_FAKE" + +REPOS_DIR="$TMP/repos" +mkdir -p "$REPOS_DIR" +cat > "$TMP/config.json" < "$TMP/repos.json" <<'EOF' +[{"owner":"fixture","name":"clean"}] +EOF +export REPOMAN_CONFIG_FILE="$TMP/config.json" +export REPOMAN_REPOS_FILE="$TMP/repos.json" + +# --------------------------------------------------------------------------- +# Clean clone → exit 1, no worklist line +# --------------------------------------------------------------------------- +CLEAN="$REPOS_DIR/fixture/clean" +mkdir -p "$CLEAN" +cat > "$CLEAN/README.md" <<'EOF' +# Clean +EOF +git -C "$CLEAN" init -q +git -C "$CLEAN" -c user.email=t@t -c user.name=t add -A +git -C "$CLEAN" -c user.email=t@t -c user.name=t commit -qm init + +WL_CLEAN="$TMP/wl-clean.json" +set +e +out_clean=$(bash "$PRECHECK" --worklist "$WL_CLEAN" 2>/dev/null) +rc_clean=$? +set -e +if [ "$rc_clean" -eq 1 ]; then + pass "precheck exit 1 on empty worklist" +else + fail_msg "precheck expected exit 1 on clean fixture, got $rc_clean" +fi +case "$out_clean" in + *"worklist:"*) fail_msg "precheck must not print worklist: on empty" ;; + *) pass "precheck silent on empty worklist" ;; +esac + +# Direct checker sanity (same clean tree) +out=$(python3 "$CHECK" --clone "$CLEAN" --repo "fixture/clean" --releases '[]' --prs '[]') +n=$(printf '%s' "$out" | jq '.counts.total') +if [ "$n" = "0" ]; then + pass "clean fixture yields zero findings" +else + fail_msg "clean fixture unexpectedly has $n findings" +fi + +# --------------------------------------------------------------------------- +# Drift clone → exit 0 + worklist: +# --------------------------------------------------------------------------- +cat > "$TMP/repos.json" <<'EOF' +[{"owner":"fixture","name":"drift"}] +EOF +DRIFT="$REPOS_DIR/fixture/drift" +mkdir -p "$DRIFT/.repoman" "$DRIFT/scripts" +cat > "$DRIFT/README.md" <<'EOF' +# Drift fixture with Kagenti branding leftover +EOF +cat > "$DRIFT/scripts/note.sh" <<'EOF' +#!/usr/bin/env bash +# Automated by Kagenti helper +echo ok +EOF +cat > "$DRIFT/.repoman/doc-drift.yaml" <<'EOF' +boundary: main +retired_names: + - { name: kagenti, replace_with: "" } +ignore_paths: [CHANGELOG.md] +EOF +git -C "$DRIFT" init -q +git -C "$DRIFT" -c user.email=t@t -c user.name=t add -A +git -C "$DRIFT" -c user.email=t@t -c user.name=t commit -qm drift + +WL_DRIFT="$TMP/wl-drift.json" +set +e +out_drift=$(bash "$PRECHECK" --worklist "$WL_DRIFT" 2>/dev/null) +rc_drift=$? +set -e +if [ "$rc_drift" -eq 0 ]; then + pass "precheck exit 0 when findings exist" +else + fail_msg "precheck expected exit 0 on drift fixture, got $rc_drift (out=$out_drift)" +fi +case "$out_drift" in + "worklist: $WL_DRIFT") pass "precheck prints worklist: path" ;; + *) fail_msg "precheck worklist line wrong: [$out_drift]" ;; +esac +if [ -f "$WL_DRIFT" ] && jq -e '.count > 0' "$WL_DRIFT" >/dev/null 2>&1; then + pass "worklist file has count > 0" +else + fail_msg "worklist file missing or empty" +fi + +# --------------------------------------------------------------------------- +# Syntax + fixer fail-closed +# --------------------------------------------------------------------------- +bash -n "$PRECHECK" && pass "precheck bash -n" || fail_msg "precheck bash -n" +bash -n "$SCRIPT_DIR/../scripts/doc-drift-scanner.sh" && pass "scanner bash -n" || fail_msg "scanner bash -n" +bash -n "$SCRIPT_DIR/../scripts/doc-drift-fixer.sh" && pass "fixer bash -n" || fail_msg "fixer bash -n" + +REPORTS_DIR="$TMP/reports" +mkdir -p "$REPORTS_DIR" +if REPORTS_DIR="$REPORTS_DIR" bash "$SCRIPT_DIR/../scripts/doc-drift-fixer.sh" --dry-run >/dev/null 2>&1; then + fail_msg "fixer should fail without latest.json" +else + pass "fixer dry-run fails closed without latest.json (no PRs)" +fi + +if [ "$fail" -ne 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASS" +exit 0