diff --git a/.github/sync-manifest.yml b/.github/sync-manifest.yml index 4f83f5260..03c117e1c 100644 --- a/.github/sync-manifest.yml +++ b/.github/sync-manifest.yml @@ -129,6 +129,10 @@ workflows: - source: .github/workflows/maint-76-claude-code-review.yml description: "Claude Code review (opt-in) - runs only on labeled PRs or manual dispatch" + - source: .github/workflows/maint-87-docs-drift-fix-agent.yml + description: "Docs drift fix agent - reports deterministic documentation drift weekly and creates idempotent repair issues only on an explicit apply dispatch" + template_sync: exact + # Renovate configuration - extends the shared fleet preset (renovate-presets/fleet.json). # create_only so a consumer that already onboarded (e.g. a Mend generic config) is not clobbered; # supersedes per-repo Renovate onboarding by giving consumers the fleet preset (dev-tool exclusions @@ -222,6 +226,15 @@ scripts: - source: scripts/check_agents_md_freshness.py description: "Warns when the generated Orchestrator AGENTS.md playbook section cites stale repo paths or commands" template_sync: exact + - source: scripts/check_docs_drift.py + description: "Detects workflow-inventory and repository-path documentation drift for the docs drift fix agent" + template_sync: exact + - source: scripts/docs_drift_fix_agent.py + description: "Builds bounded deterministic documentation-drift repair plans and idempotent issue batches" + template_sync: exact + - source: config/source_of_truth_docs.yml + description: "Defines the canonical documentation paths scanned by the docs drift fix agent" + template_sync: exact - source: .github/scripts/issue_format.py description: "Pure-stdlib validator for AGENT_ISSUE_FORMAT compliance. Single fleet definition of agent-processable; used by agents-issue-format-guard.yml and callable directly by local filers to pre-flight before gh issue create (non-zero exit = unfit). Do not fork per repo." template_sync: exact diff --git a/.github/workflows/maint-87-docs-drift-fix-agent.yml b/.github/workflows/maint-87-docs-drift-fix-agent.yml new file mode 100644 index 000000000..253dfa76a --- /dev/null +++ b/.github/workflows/maint-87-docs-drift-fix-agent.yml @@ -0,0 +1,113 @@ +name: Maint 87 Docs Drift Fix Agent + +# The missing CALLER for scripts/docs_drift_fix_agent.py. +# +# That script has existed and been selftested for months, but nothing invoked it: it appears in no +# workflow and in no external caller, so it has never produced a repair batch. Maint 48 seeds a +# monthly issue asking a lane agent to diff the docs by hand; this runs the DETERMINISTIC check +# instead and turns its output into bounded, agent-ready repair batches. +# +# The two are complementary, not duplicates. Maint 48 catches semantic rot an LLM must judge +# ("this claim is no longer true"). This catches mechanical drift the checker proves +# (dangling refs, workflow-inventory omissions) and is cheap enough to run weekly. +# +# REPORT-ONLY by default. `--apply` creates one issue per repair batch and is reachable only via +# workflow_dispatch, so a scheduled run can never open issues on its own. The script never edits +# repository files in either mode. + +on: + schedule: + - cron: '0 8 * * 1' # Mondays 08:00 UTC, after the weekly maintenance cluster + workflow_dispatch: + inputs: + apply: + description: 'Create one GitHub issue per repair batch' + type: boolean + default: false + +permissions: + contents: read + issues: write + +concurrency: + group: >- + ${{ github.workflow }}-${{ github.ref }}-${{ + github.event_name == 'workflow_dispatch' && inputs.apply && 'apply' || 'plan' + }} + # Never interrupt issue creation; a report-only run may still supersede an older report. + cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.apply) }} + +jobs: + docs-drift-fix-agent: + name: Build bounded docs-drift repair batches + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.12' + + - name: Install runtime dependency + run: python -m pip install "pyyaml==6.0.3" + + - name: Build repair plan (report-only) + id: plan + run: | + set -euo pipefail + set +e + python3 scripts/docs_drift_fix_agent.py \ + --repo "${GITHUB_REPOSITORY}" \ + --out-dir docs-drift-plan \ + --json > docs-drift-plan.json + agent_status=$? + set -e + if [ "${agent_status}" -gt 1 ]; then + exit "${agent_status}" + fi + cat docs-drift-plan.json + findings=$(python3 -c "import json;print(json.load(open('docs-drift-plan.json'))['finding_count'])") + batches=$(python3 -c "import json;print(json.load(open('docs-drift-plan.json'))['batch_count'])") + echo "findings=${findings}" >> "$GITHUB_OUTPUT" + echo "batches=${batches}" >> "$GITHUB_OUTPUT" + { + echo "### Docs-drift fix agent" + echo "" + echo "- findings: ${findings}" + echo "- repair batches: ${batches}" + echo "" + if [ "${findings}" = "0" ]; then + echo "No deterministic docs drift. This is the healthy state, not a skipped run." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload repair plan + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: docs-drift-plan + path: | + docs-drift-plan.json + docs-drift-plan/ + if-no-files-found: warn + retention-days: 14 + + # Issue creation is dispatch-only AND requires findings, so a scheduled run never opens + # issues and a clean tree never opens an empty one. + - name: Create repair issues (dispatch-only) + if: >- + github.event_name == 'workflow_dispatch' && + inputs.apply && + steps.plan.outputs.findings != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + python3 scripts/docs_drift_fix_agent.py \ + --repo "${GITHUB_REPOSITORY}" \ + --out-dir docs-drift-plan \ + --apply diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index bf70db09f..fe869c36b 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -125,6 +125,7 @@ _Inline Gate helper_ - **`maint-85-keepalive-durability-export.yml`** — Weekly + manual Workflows-owned export that scans recently merged `agents:keepalive` PRs after the grace window, classifies them as durable/reverted/reopened, and uploads safe `langsmith-fleet/v1` durability records. It is evidence export only, not a merge gate. ### Health & Maintenance Highlights +- **`maint-87-docs-drift-fix-agent.yml`** — Runs the deterministic docs-drift detector weekly, uploads bounded repair plans, and permits idempotent issue creation only from an explicit `apply` dispatch. Apply runs use a non-cancelling concurrency lane so an interrupted workflow cannot leave duplicate repair issues; the workflow and its runtime files are exact-synced to consumers. - **`health-72-template-sync.yml`** — Guards manifest-declared exact template-sync files between the repo and the consumer template. On PRs it auto-runs `scripts/sync_templates.sh` (when the PR comes from this repo), commits/pushes template changes, and then `scripts/validate_template_sync.py` enforces parity; on `push` it just runs the validator. The workflow uses the default installation token; no extra GitHub App mint is needed. - **`agents-keepalive-branch-sync.yml`** — Dispatch-triggered utility that syncs PR branches with their base branch (merges base into head). It still selects an App token/PAT for git pushes because keepalive needs to merge into automation-owned branches, but all logic stays confined to git + summary updates—no extra API calls beyond pushing the merge. - **`agents-keepalive-dispatch-handler.yml`** — Repository-dispatch handler that receives `codex-pr-comment-command` payloads, selects a write-capable token (App → PAT → installation), and runs `keepalive_post_work.js` to apply sync-required labels, rerun keepalive legs, or emit debugging breadcrumbs. Token selection remains by design because the handler must write to PRs immediately after the dispatch event. diff --git a/docs/ci/WORKFLOWS.md b/docs/ci/WORKFLOWS.md index a1da362c9..26dd47337 100644 --- a/docs/ci/WORKFLOWS.md +++ b/docs/ci/WORKFLOWS.md @@ -128,6 +128,7 @@ The gate uses the shared `.github/scripts/detect-changes.js` helper to decide wh * [`maint-65-sync-label-docs.yml`](../../.github/workflows/maint-65-sync-label-docs.yml) synchronizes `docs/LABELS.md` to consumer repositories weekly (Sundays 00:00 UTC) or via manual dispatch. * [`maint-66-monthly-audit.yml`](../../.github/workflows/maint-66-monthly-audit.yml) performs comprehensive monthly workflow health audits, collecting statistics and creating actionable tracking issues. * [`maint-48-docs-drift-audit.yml`](../../.github/workflows/maint-48-docs-drift-audit.yml) seeds a monthly scoped docs-drift audit issue (deduped) for the lane fleet to diff the canonical docs against reality and open fix PRs. +* [`maint-87-docs-drift-fix-agent.yml`](../../.github/workflows/maint-87-docs-drift-fix-agent.yml) runs the deterministic docs-drift check weekly and turns its output into bounded, agent-ready repair batches via `scripts/docs_drift_fix_agent.py`. Complements maint-48: that seeds an LLM semantic diff, this proves mechanical drift (dangling refs, workflow-inventory omissions). Scheduled runs are report-only; explicit `apply` dispatches create idempotent repair issues in a non-cancelling concurrency lane. * [`maint-60-release.yml`](../../.github/workflows/maint-60-release.yml) creates GitHub releases automatically when version tags (`v*`) are pushed. * [`maint-61-release-please.yml`](../../.github/workflows/maint-61-release-please.yml) runs release-please on pushes to `main`, using the Workflows GitHub App token when configured, to maintain the Conventional Commits-driven Release PR, changelog, tags, and GitHub releases from the manifest seeded at `1.1.2`. ## Agents Control Plane diff --git a/docs/ci/WORKFLOW_SYSTEM.md b/docs/ci/WORKFLOW_SYSTEM.md index 2d34cbe94..2655d8b7f 100644 --- a/docs/ci/WORKFLOW_SYSTEM.md +++ b/docs/ci/WORKFLOW_SYSTEM.md @@ -568,6 +568,10 @@ Keep this table handy when you are triaging automation: it confirms which workfl seeds one scoped docs-drift audit issue per month (1st at 07:00 UTC, deduped) so the lane fleet diffs the canonical docs against the current tree and opens fix PRs. Doc-rot is the audit's most pervasive defect class. +- **Maint 87 Docs Drift Fix Agent** – `.github/workflows/maint-87-docs-drift-fix-agent.yml` + runs the deterministic docs-drift detector weekly and uploads bounded repair + plans. Explicit apply dispatches create idempotent issues in a non-cancelling + concurrency lane; scheduled runs remain report-only. - **Maint 45 Cosmetic Repair** – `.github/workflows/maint-45-cosmetic-repair.yml` is a manual workflow. It runs pytest and the guardrail fixers, then opens a labelled PR if changes are needed. diff --git a/renovate-presets/consumer-managed-paths.json b/renovate-presets/consumer-managed-paths.json index 1a2f92a40..38c5ea472 100644 --- a/renovate-presets/consumer-managed-paths.json +++ b/renovate-presets/consumer-managed-paths.json @@ -3,7 +3,7 @@ "description": "GENERATED by scripts/generate_consumer_renovate_ownership.py -- do not edit by hand. Disables Renovate dependency extraction for the paths that maint-68-sync-consumer-repos.yml overwrites from .github/sync-manifest.yml, and only in the consumer repos where that overwrite actually applies. Without this boundary a consumer's Renovate opens PRs against centrally-copied files (Inv-Man-Intake#838, Manager-Database#1347) that the next sync silently reverts. Renovate stays enabled for create-only/skipped paths the consumer owns, and for every canonical source file in stranske/Workflows, which is the sync source rather than a consumer. Regenerate with `python scripts/generate_consumer_renovate_ownership.py`; `--check` fails on drift and runs in scripts/dev_check.sh.", "packageRules": [ { - "description": "Maint 68 overwrites these 211 manifest-managed paths in every registered consumer; Renovate edits there are reverted on the next sync.", + "description": "Maint 68 overwrites these 215 manifest-managed paths in every registered consumer; Renovate edits there are reverted on the next sync.", "matchRepositories": [ "stranske/Collab-Admin", "stranske/Counter_Risk", @@ -143,6 +143,7 @@ ".github/workflows/backplane-conformance.yml", ".github/workflows/list-llm-models.yml", ".github/workflows/maint-76-claude-code-review.yml", + ".github/workflows/maint-87-docs-drift-fix-agent.yml", ".github/workflows/maint-coverage-guard.yml", ".github/workflows/pr-46-dependency-repair-contract.yml", ".github/workflows/reusable-pr-context.yml", @@ -150,6 +151,7 @@ "WORKFLOW_USER_GUIDE.md", "config/model_registry.json", "config/model_selection_policy.json", + "config/source_of_truth_docs.yml", "design-system/PRESENTATION_PATTERNS.md", "design-system/README.md", "design-system/components.css", @@ -175,11 +177,13 @@ "scripts/autopilot_step_timer.py", "scripts/check_agents_md_freshness.py", "scripts/check_deliberate_break.py", + "scripts/check_docs_drift.py", "scripts/check_test_dependencies.sh", "scripts/ci_coverage_delta.py", "scripts/ci_history.py", "scripts/ci_metrics.py", "scripts/coverage_history_append.py", + "scripts/docs_drift_fix_agent.py", "scripts/langchain/_llm_client.py", "scripts/langchain/capability_check.py", "scripts/langchain/checklist_utils.py", diff --git a/scripts/check_docs_drift.py b/scripts/check_docs_drift.py index 91f68d1f7..8c559c7c1 100644 --- a/scripts/check_docs_drift.py +++ b/scripts/check_docs_drift.py @@ -63,14 +63,14 @@ def _workflow_token_context(text: str, token_start: int, token_end: int) -> str: def _is_bare_workflow_reference( text: str, match: re.Match[str], root_workflows: set[str] | None = None ) -> bool: - if match.group(1) in (root_workflows or set()): - return True - token_start, token_end = match.span(1) context = _workflow_token_context(text, token_start, token_end) if NON_ROOT_WORKFLOW_CONTEXT_RE.search(context): return False + if match.group(1) in (root_workflows or set()): + return True + return token_start > 0 and text[token_start - 1] == "`" diff --git a/scripts/docs_drift_fix_agent.py b/scripts/docs_drift_fix_agent.py index 339e74f58..44f1778a1 100644 --- a/scripts/docs_drift_fix_agent.py +++ b/scripts/docs_drift_fix_agent.py @@ -17,11 +17,13 @@ from __future__ import annotations import argparse +import hashlib import json import re +import shlex import subprocess import sys -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import asdict, dataclass from pathlib import Path from typing import Any @@ -113,6 +115,8 @@ def findings_from_deterministic_report(report: dict[str, Any]) -> list[Finding]: def findings_from_scan_json(payload: dict[str, Any], *, repo: str) -> list[Finding]: """Extract stale/contradictory semantic drift from docs-drift-scan JSON.""" + if not isinstance(payload, Mapping): + raise ValueError("scan JSON must contain a top-level mapping") findings: list[Finding] = [] for bucket in payload.get("by_repo") or []: if not isinstance(bucket, dict) or bucket.get("repo") != repo: @@ -178,7 +182,7 @@ def batch_findings( def _docs_arg(docs: Sequence[str] | None) -> str: if not docs: return "" - return " --docs " + " ".join(docs) + return " --docs " + " ".join(shlex.quote(doc) for doc in docs) def verification_commands(docs: Sequence[str] | None = None) -> tuple[str, ...]: @@ -320,7 +324,10 @@ def build_issue_body( def load_scan_json(path: Path | None) -> dict[str, Any]: if path is None: return {"by_repo": []} - return json.loads(path.read_text(encoding="utf-8")) + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, Mapping): + raise ValueError("scan JSON must contain a top-level mapping") + return dict(payload) def default_docs_from_config(repo_root: Path, *, repo: str = DEFAULT_REPO) -> list[str]: @@ -328,7 +335,18 @@ def default_docs_from_config(repo_root: Path, *, repo: str = DEFAULT_REPO) -> li if not config_path.is_file(): return list(check_docs_drift.DEFAULT_DOCS) data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - repo_config = (data.get("repos") or {}).get(repo) or {} + if not isinstance(data, Mapping): + raise ValueError("docs config must contain a top-level mapping") + repos = data.get("repos", {}) + if repos is None: + repos = {} + if not isinstance(repos, Mapping): + raise ValueError("docs config 'repos' must be a mapping") + repo_config = repos.get(repo, {}) + if repo_config is None: + repo_config = {} + if not isinstance(repo_config, Mapping): + raise ValueError(f"docs config entry for {repo!r} must be a mapping") docs = [ str(item.get("path")) for item in repo_config.get("docs") or [] @@ -407,7 +425,54 @@ def write_plan_outputs(plan: dict[str, Any], out_dir: Path) -> None: def apply_issues(plan: dict[str, Any]) -> list[dict[str, Any]]: created: list[dict[str, Any]] = [] + existing_by_marker: dict[str, str] = {} for batch in plan["batches"]: + issue_body = batch["issue_body"] + digest = hashlib.sha256(f"{batch['issue_title']}\0{issue_body}".encode()).hexdigest()[:16] + marker = f"" + list_result = subprocess.run( + [ + "gh", + "issue", + "list", + "--repo", + plan["repo"], + "--state", + "open", + "--label", + "documentation", + "--search", + f'"{marker}" in:body', + "--limit", + "1", + "--json", + "body,url", + ], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + if list_result.returncode != 0: + raise RuntimeError(f"gh issue list failed: {list_result.stderr.strip()}") + matches = json.loads(list_result.stdout or "[]") + if not isinstance(matches, list): + raise ValueError("gh issue list returned a non-list payload") + for row in matches: + if isinstance(row, Mapping) and marker in str(row.get("body") or ""): + existing_by_marker[marker] = str(row.get("url") or "") + break + if marker in existing_by_marker: + created.append( + { + "batch_id": batch["batch_id"], + "disposition": "already-open", + "returncode": 0, + "stdout": existing_by_marker[marker], + "stderr": "", + } + ) + continue result = subprocess.run( [ "gh", @@ -418,7 +483,7 @@ def apply_issues(plan: dict[str, Any]) -> list[dict[str, Any]]: "--title", batch["issue_title"], "--body", - batch["issue_body"], + f"{issue_body.rstrip()}\n\n{marker}\n", "--label", "documentation", ], @@ -430,6 +495,7 @@ def apply_issues(plan: dict[str, Any]) -> list[dict[str, Any]]: created.append( { "batch_id": batch["batch_id"], + "disposition": "created", "returncode": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), @@ -503,7 +569,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(json.dumps(plan, indent=2)) else: print(format_summary(plan, out_dir)) - return 1 if plan["finding_count"] else 0 + return 0 if args.apply else 1 if plan["finding_count"] else 0 except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/templates/consumer-repo/.github/workflows/maint-87-docs-drift-fix-agent.yml b/templates/consumer-repo/.github/workflows/maint-87-docs-drift-fix-agent.yml new file mode 100644 index 000000000..253dfa76a --- /dev/null +++ b/templates/consumer-repo/.github/workflows/maint-87-docs-drift-fix-agent.yml @@ -0,0 +1,113 @@ +name: Maint 87 Docs Drift Fix Agent + +# The missing CALLER for scripts/docs_drift_fix_agent.py. +# +# That script has existed and been selftested for months, but nothing invoked it: it appears in no +# workflow and in no external caller, so it has never produced a repair batch. Maint 48 seeds a +# monthly issue asking a lane agent to diff the docs by hand; this runs the DETERMINISTIC check +# instead and turns its output into bounded, agent-ready repair batches. +# +# The two are complementary, not duplicates. Maint 48 catches semantic rot an LLM must judge +# ("this claim is no longer true"). This catches mechanical drift the checker proves +# (dangling refs, workflow-inventory omissions) and is cheap enough to run weekly. +# +# REPORT-ONLY by default. `--apply` creates one issue per repair batch and is reachable only via +# workflow_dispatch, so a scheduled run can never open issues on its own. The script never edits +# repository files in either mode. + +on: + schedule: + - cron: '0 8 * * 1' # Mondays 08:00 UTC, after the weekly maintenance cluster + workflow_dispatch: + inputs: + apply: + description: 'Create one GitHub issue per repair batch' + type: boolean + default: false + +permissions: + contents: read + issues: write + +concurrency: + group: >- + ${{ github.workflow }}-${{ github.ref }}-${{ + github.event_name == 'workflow_dispatch' && inputs.apply && 'apply' || 'plan' + }} + # Never interrupt issue creation; a report-only run may still supersede an older report. + cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.apply) }} + +jobs: + docs-drift-fix-agent: + name: Build bounded docs-drift repair batches + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.12' + + - name: Install runtime dependency + run: python -m pip install "pyyaml==6.0.3" + + - name: Build repair plan (report-only) + id: plan + run: | + set -euo pipefail + set +e + python3 scripts/docs_drift_fix_agent.py \ + --repo "${GITHUB_REPOSITORY}" \ + --out-dir docs-drift-plan \ + --json > docs-drift-plan.json + agent_status=$? + set -e + if [ "${agent_status}" -gt 1 ]; then + exit "${agent_status}" + fi + cat docs-drift-plan.json + findings=$(python3 -c "import json;print(json.load(open('docs-drift-plan.json'))['finding_count'])") + batches=$(python3 -c "import json;print(json.load(open('docs-drift-plan.json'))['batch_count'])") + echo "findings=${findings}" >> "$GITHUB_OUTPUT" + echo "batches=${batches}" >> "$GITHUB_OUTPUT" + { + echo "### Docs-drift fix agent" + echo "" + echo "- findings: ${findings}" + echo "- repair batches: ${batches}" + echo "" + if [ "${findings}" = "0" ]; then + echo "No deterministic docs drift. This is the healthy state, not a skipped run." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload repair plan + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: docs-drift-plan + path: | + docs-drift-plan.json + docs-drift-plan/ + if-no-files-found: warn + retention-days: 14 + + # Issue creation is dispatch-only AND requires findings, so a scheduled run never opens + # issues and a clean tree never opens an empty one. + - name: Create repair issues (dispatch-only) + if: >- + github.event_name == 'workflow_dispatch' && + inputs.apply && + steps.plan.outputs.findings != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + python3 scripts/docs_drift_fix_agent.py \ + --repo "${GITHUB_REPOSITORY}" \ + --out-dir docs-drift-plan \ + --apply diff --git a/templates/consumer-repo/config/source_of_truth_docs.yml b/templates/consumer-repo/config/source_of_truth_docs.yml new file mode 100644 index 000000000..78eb287ab --- /dev/null +++ b/templates/consumer-repo/config/source_of_truth_docs.yml @@ -0,0 +1,162 @@ +# Source-of-truth docs that the weekly doc-drift scanner classifies for drift +# against current implementation. Issue #2090. +# +# Scope: docs cited from CLAUDE.md / AGENTS.md / README.md as authoritative +# operational entry points. Design proposals, archives, and historical +# analysis docs are intentionally NOT listed -- those are append-only +# narrative, not load-bearing operational documentation. +# +# Schema: +# repos: +# /: +# local_path: +# docs: +# - path: +# focus: short label for the kind of drift to watch for +# ... +# +# The scanner reads each doc, asks claude to compare load-bearing claims +# against the current implementation, and emits one drift record per +# (doc, claim) pair. See scripts/repo_review_docs_drift_scan.py. +# +# Scorecard settings (repo-review human-gated candidate source): +# scorecard: top-level defaults for OpenSSF Scorecard scan +# repos./.scorecard: per-repo overrides (enabled, minimum_score, etc.) + +scorecard: + enabled: true + default_minimum_score: 7.0 + max_findings_per_repo: 5 + source: public_api + include_checks: [] + exclude_checks: [] + +repos: + stranske/Collab-Admin: + local_path: Collab-Admin + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Counter_Risk: + local_path: Counter_Risk + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Inv-Man-Intake: + local_path: Inv-Man-Intake + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Manager-Database: + local_path: Manager-Database + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Pension-Data: + local_path: Pension-Data + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Portable-Alpha-Extension-Model: + local_path: Portable-Alpha-Extension-Model + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Template: + local_path: Template + docs: + - path: README.md + focus: template contract and consumer defaults + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Travel-Plan-Permission: + local_path: Travel-Plan-Permission + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Trend_Model_Project: + local_path: Trend_Model_Project + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/tim-bot: + local_path: tim-bot + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/trip-planner: + local_path: trip-planner + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Workflows-Integration-Tests: + local_path: Workflows-Integration-Tests + docs: + - path: README.md + focus: integration-test scope and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + stranske/Workflows: + local_path: Workflows-steward + scorecard: + enabled: true + minimum_score: 7.0 + workflow: .github/workflows/health-53-scorecard.yml + docs: + - path: README.md + focus: pipeline overview, model versions, entry-point commands + - path: AGENTS.md + focus: agent identifiers and routing labels + - path: docs/INTEGRATION_GUIDE.md + focus: consumer integration commands, version policy + - path: docs/ops/REPO_REVIEW_PROCESS.md + focus: weekly run command, Phase-4 entry point, queue lifecycle + - path: docs/ops/REPO_REVIEW_ROUND1_SCHEMA.md + focus: round-1 output schema and required fields + - path: docs/ops/REPO_REVIEW_ROUND2_PROTOCOL.md + focus: round-2 negotiation protocol and converged.json shape + - path: docs/keepalive/GoalsAndPlumbing.md + focus: keepalive contract, label gates, activation guardrails + - path: docs/keepalive/Agents.md + focus: multi-agent routing labels and runner mapping + - path: docs/AGENTS_POLICY.md + focus: protection layers and protected workflow inventory + - path: docs/LABELS.md + focus: canonical label inventory and trigger semantics + - path: docs/ci/WORKFLOWS.md + focus: CI workflow inventory, autofix contract + - path: docs/MODEL_MANAGEMENT.md + focus: model identifiers in current use, refresh cadence + - path: docs/WORKFLOW_GUIDE.md + focus: workflow authoring guidance and reusable-workflow references + stranske/learning-management-system: + local_path: learning-management-system + docs: + - path: README.md + focus: operational overview and execution entry points + - path: AGENTS.md + focus: agent runtime constraints and repo-specific guardrails + - path: docs/product/project-plan.md + focus: design plan; Phase 1 Minimum Core, Minimum Demo Criterion, Milestone 0-4 deliverables and acceptance criteria, SchedulerEvidenceAdapter, ownership-boundary enforcement, export/import contract + - path: docs/product/early-design-decisions.md + focus: segmented decision queue; especially Segments 2 (mastery), 7 (stack), 8 (sustainability), 9 (privacy), 10 (LLM cost/routing) diff --git a/templates/consumer-repo/scripts/check_docs_drift.py b/templates/consumer-repo/scripts/check_docs_drift.py new file mode 100644 index 000000000..8c559c7c1 --- /dev/null +++ b/templates/consumer-repo/scripts/check_docs_drift.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Detect documentation drift in workflow inventories and repo-path references.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from collections import Counter +from collections.abc import Sequence +from pathlib import Path, PurePosixPath + +DriftRecord = dict[str, str] + +DEFAULT_DOCS = ("docs/ci/WORKFLOWS.md",) +WORKFLOWS_DOC = Path("docs/ci/WORKFLOWS.md") +WORKFLOW_SUFFIXES = (".yml", ".yaml") +REPO_PATH_PREFIXES = ("scripts/", "tests/", "docs/") + +WORKFLOW_TOKEN_RE = re.compile(r"(? set[str]: + workflows_dir = root / ".github" / "workflows" + if not workflows_dir.is_dir(): + return set() + return { + path.name + for path in workflows_dir.iterdir() + if path.is_file() and path.suffix in WORKFLOW_SUFFIXES + } + + +def _is_root_workflow_path(token: str) -> bool: + parts = PurePosixPath(token).parts + for index, part in enumerate(parts[:-1]): + if part == ".github" and index + 1 < len(parts) and parts[index + 1] == "workflows": + return "templates" not in parts and "template" not in parts + return False + + +def _workflow_token_context(text: str, token_start: int, token_end: int) -> str: + line_start = text.rfind("\n", 0, token_start) + 1 + line_end = text.find("\n", token_end) + if line_end == -1: + line_end = len(text) + line = text[line_start:line_end] + relative_start = token_start - line_start + relative_end = token_end - line_start + return line[:relative_start] + line[relative_end:] + + +def _is_bare_workflow_reference( + text: str, match: re.Match[str], root_workflows: set[str] | None = None +) -> bool: + token_start, token_end = match.span(1) + context = _workflow_token_context(text, token_start, token_end) + if NON_ROOT_WORKFLOW_CONTEXT_RE.search(context): + return False + + if match.group(1) in (root_workflows or set()): + return True + + return token_start > 0 and text[token_start - 1] == "`" + + +def _mentioned_workflow_filenames(text: str, root_workflows: set[str] | None = None) -> set[str]: + filenames: set[str] = set() + for match in WORKFLOW_TOKEN_RE.finditer(text): + token = match.group(1) + if "/" in token: + if not _is_root_workflow_path(token): + continue + filename = PurePosixPath(token).name + if filename not in (root_workflows or set()) and NON_ROOT_WORKFLOW_CONTEXT_RE.search( + _workflow_token_context(text, match.start(1), match.end(1)) + ): + continue + elif not _is_bare_workflow_reference(text, match, root_workflows): + continue + if token.startswith("n.") and match.start(1) > 0 and text[match.start(1) - 1] == "\\": + token = token[2:] + filename = PurePosixPath(token).name + if filename.startswith("."): + filename = filename[1:] + if filename.endswith(WORKFLOW_SUFFIXES) and filename not in WORKFLOW_SUFFIXES: + filenames.add(filename) + return filenames + + +def check_workflow_inventory(root: Path) -> list[DriftRecord]: + """Compare .github/workflows files against docs/ci/WORKFLOWS.md mentions.""" + root = Path(root) + workflows_doc = root / WORKFLOWS_DOC + on_disk = _workflow_files_on_disk(root) + doc_text = workflows_doc.read_text(encoding="utf-8") if workflows_doc.is_file() else "" + documented = _mentioned_workflow_filenames(doc_text, on_disk) + + drift: list[DriftRecord] = [] + for filename in sorted(on_disk - documented): + drift.append( + { + "type": "undocumented_workflow", + "path": filename, + "detail": ( + "Exists in .github/workflows/ but is not mentioned in docs/ci/WORKFLOWS.md" + ), + } + ) + for filename in sorted(documented - on_disk): + drift.append( + { + "type": "documented_but_missing", + "path": filename, + "detail": "Mentioned in docs/ci/WORKFLOWS.md but missing from .github/workflows/", + } + ) + return drift + + +def _resolve_doc_path(root: Path, doc: str | Path) -> Path: + doc_path = Path(doc) + if doc_path.is_absolute(): + return doc_path + return root / doc_path + + +def _display_path(path: Path, root: Path) -> str: + return os.path.relpath(path, root).replace(os.sep, "/") + + +def _inline_code_tokens(text: str) -> list[str]: + return [match.group(1) for match in INLINE_CODE_RE.finditer(text)] + + +def _is_repo_path_token(token: str) -> bool: + if token != token.strip() or any(char.isspace() for char in token): + return False + if "\\" in token or any(char in token for char in GLOB_CHARS): + return False + if not token.startswith(REPO_PATH_PREFIXES): + return False + path = PurePosixPath(token) + if path.is_absolute() or ".." in path.parts: + return False + return bool(FILE_EXTENSION_RE.search(token)) + + +def check_dangling_references( + root: Path, docs: Sequence[str | Path] | None = None +) -> list[DriftRecord]: + """Find missing repo-relative file paths cited in inline code spans.""" + root = Path(root) + docs_to_scan = docs if docs is not None else DEFAULT_DOCS + drift: list[DriftRecord] = [] + + for doc in docs_to_scan: + doc_path = _resolve_doc_path(root, doc) + doc_text = doc_path.read_text(encoding="utf-8") + cited_in = _display_path(doc_path, root) + seen_in_doc: set[str] = set() + + for token in _inline_code_tokens(doc_text): + if token in seen_in_doc or not _is_repo_path_token(token): + continue + seen_in_doc.add(token) + + candidate = root.joinpath(*PurePosixPath(token).parts) + if candidate.is_file(): + continue + drift.append( + { + "type": "dangling_reference", + "path": token, + "detail": f"Referenced in {cited_in}", + } + ) + + return sorted(drift, key=lambda record: (record["type"], record["path"])) + + +def check_docs_drift(root: Path, docs: Sequence[str | Path] | None = None) -> list[DriftRecord]: + """Run all docs-drift checks for a repository root.""" + return check_workflow_inventory(root) + check_dangling_references(root, docs) + + +def build_report(drift: Sequence[DriftRecord]) -> dict[str, object]: + """Build the deterministic machine-readable report.""" + sorted_drift = sorted( + (dict(record) for record in drift), + key=lambda record: (record["type"], record["path"]), + ) + by_type = Counter(record["type"] for record in sorted_drift) + return { + "summary": { + "drift": len(sorted_drift), + "by_type": {key: by_type[key] for key in sorted(by_type)}, + }, + "drift": sorted_drift, + } + + +def format_human_report(report: dict[str, object]) -> str: + """Format a stable human summary.""" + summary = report["summary"] + assert isinstance(summary, dict) + drift_count = summary["drift"] + lines = [f"Docs drift: {drift_count}"] + for record in report["drift"]: + assert isinstance(record, dict) + lines.append(f"{record['type']} {record['path']} \u2014 {record.get('detail', '')}") + return "\n".join(lines) + + +def detect_repo_root() -> Path: + """Find the git top-level directory, falling back to this script's repository.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=False, + cwd=Path.cwd(), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except OSError: + result = None + + if result and result.returncode == 0 and result.stdout.strip(): + return Path(result.stdout.strip()) + return Path(__file__).resolve().parent.parent + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Detect drift between workflow docs and repository files." + ) + parser.add_argument( + "--repo-root", + type=Path, + help="Repository root to scan. Defaults to git rev-parse --show-toplevel.", + ) + parser.add_argument( + "--docs", + nargs="+", + metavar="PATH", + help="Docs to scan for dangling repo-path references.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print the deterministic JSON report instead of a human summary.", + ) + parser.add_argument( + "--report", + type=Path, + help="Write the deterministic JSON report to this path as well.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + + try: + root = (args.repo_root if args.repo_root is not None else detect_repo_root()).expanduser() + root = root.resolve() + if not root.exists() or not root.is_dir(): + raise FileNotFoundError(f"repo root not found: {_display_path(root, Path.cwd())}") + + docs = tuple(args.docs) if args.docs is not None else DEFAULT_DOCS + report = build_report(check_docs_drift(root, docs)) + json_report = json.dumps(report, indent=2) + "\n" + + if args.report is not None: + args.report.write_text(json_report, encoding="utf-8") + + if args.json: + sys.stdout.write(json_report) + else: + print(format_human_report(report)) + + summary = report["summary"] + assert isinstance(summary, dict) + return 1 if int(summary["drift"]) else 0 + except OSError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/templates/consumer-repo/scripts/docs_drift_fix_agent.py b/templates/consumer-repo/scripts/docs_drift_fix_agent.py new file mode 100644 index 000000000..44f1778a1 --- /dev/null +++ b/templates/consumer-repo/scripts/docs_drift_fix_agent.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python3 +"""Build bounded docs-drift repair plans from existing drift detectors. + +This is the missing "fix-agent" layer for docs drift: it does not perform a +new semantic scan and it does not edit files. It composes the deterministic +docs-drift check plus an optional weekly ``docs-drift-scan.json`` export into +small repair batches with: + +- an agent prompt for opening a focused fix PR +- an agent-ready GitHub issue body +- a local verification checklist + +Default mode is read-only. ``--apply`` creates one issue per repair batch and +never edits repository files. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shlex +import subprocess +import sys +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts import check_docs_drift # noqa: E402 + +DEFAULT_REPO = "stranske/Workflows" +DEFAULT_MAX_PER_BATCH = 8 +DEFAULT_DOCS_CONFIG = Path("config/source_of_truth_docs.yml") +WORKFLOW_INVENTORY_TEST = ( + "pytest tests/workflows/test_workflow_naming.py::test_inventory_docs_list_all_workflows -q" +) + + +@dataclass(frozen=True) +class Finding: + source: str + kind: str + doc_path: str + target: str + detail: str + classification: str = "" + authoritative_source: str = "" + + +@dataclass(frozen=True) +class RepairBatch: + batch_id: str + findings: tuple[Finding, ...] + + +def detect_repo_root(cwd: Path | None = None) -> Path: + """Find the git root, falling back to the current directory.""" + probe = cwd or Path.cwd() + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=False, + cwd=probe, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + result = None + if result and result.returncode == 0 and result.stdout.strip(): + return Path(result.stdout.strip()).resolve() + return probe.resolve() + + +def _doc_from_detail(detail: str) -> str: + match = re.search(r"Referenced in ([^`]+)$", detail or "") + if match: + return match.group(1).strip() + return "docs/ci/WORKFLOWS.md" + + +def findings_from_deterministic_report(report: dict[str, Any]) -> list[Finding]: + """Map ``check_docs_drift.build_report`` output into repair findings.""" + findings: list[Finding] = [] + for row in report.get("drift") or []: + if not isinstance(row, dict): + continue + kind = str(row.get("type") or "") + path = str(row.get("path") or "") + detail = str(row.get("detail") or "") + doc_path = ( + _doc_from_detail(detail) if kind == "dangling_reference" else "docs/ci/WORKFLOWS.md" + ) + findings.append( + Finding( + source="deterministic", + kind=kind, + doc_path=doc_path, + target=path, + detail=detail, + classification=kind, + authoritative_source=path, + ) + ) + return findings + + +def findings_from_scan_json(payload: dict[str, Any], *, repo: str) -> list[Finding]: + """Extract stale/contradictory semantic drift from docs-drift-scan JSON.""" + if not isinstance(payload, Mapping): + raise ValueError("scan JSON must contain a top-level mapping") + findings: list[Finding] = [] + for bucket in payload.get("by_repo") or []: + if not isinstance(bucket, dict) or bucket.get("repo") != repo: + continue + for row in bucket.get("drift_instances") or []: + if not isinstance(row, dict): + continue + classification = str(row.get("classification") or "") + if classification not in {"stale", "contradictory"}: + continue + claim = str(row.get("claim") or "").strip() + doc_path = str(row.get("doc_path") or "").strip() or "" + findings.append( + Finding( + source="semantic-scan", + kind="semantic_drift", + doc_path=doc_path, + target=claim[:160] or doc_path, + detail=claim, + classification=classification, + authoritative_source=str(row.get("authoritative_source") or "").strip(), + ) + ) + return findings + + +def dedupe_findings(findings: Sequence[Finding]) -> list[Finding]: + """Collapse obvious duplicates while preferring deterministic findings.""" + priority = {"deterministic": 0, "semantic-scan": 1} + ordered = sorted( + findings, key=lambda f: (priority.get(f.source, 9), f.doc_path, f.kind, f.target) + ) + seen: set[tuple[str, str, str]] = set() + out: list[Finding] = [] + for finding in ordered: + key = ( + finding.doc_path.strip().lower(), + finding.kind.strip().lower(), + finding.target.strip().lower(), + ) + if key in seen: + continue + seen.add(key) + out.append(finding) + return sorted(out, key=lambda f: (f.doc_path, f.source, f.kind, f.target)) + + +def batch_findings( + findings: Sequence[Finding], + *, + max_per_batch: int = DEFAULT_MAX_PER_BATCH, +) -> list[RepairBatch]: + if max_per_batch <= 0: + raise ValueError("max_per_batch must be positive") + unique = dedupe_findings(findings) + batches: list[RepairBatch] = [] + for index in range(0, len(unique), max_per_batch): + chunk = tuple(unique[index : index + max_per_batch]) + batches.append(RepairBatch(batch_id=f"docs-drift-{len(batches) + 1:02d}", findings=chunk)) + return batches + + +def _docs_arg(docs: Sequence[str] | None) -> str: + if not docs: + return "" + return " --docs " + " ".join(shlex.quote(doc) for doc in docs) + + +def verification_commands(docs: Sequence[str] | None = None) -> tuple[str, ...]: + """Commands that must pass after a single repair batch.""" + docs_arg = _docs_arg(docs) + return ( + f"python3 scripts/check_docs_drift.py --json{docs_arg}", + WORKFLOW_INVENTORY_TEST, + ) + + +def informational_commands(docs: Sequence[str] | None = None) -> tuple[str, ...]: + """Commands that refresh full-plan context but may still report other batches.""" + docs_arg = _docs_arg(docs) + return (f"python3 scripts/docs_drift_fix_agent.py --repo-root . --json{docs_arg}",) + + +def _finding_line(finding: Finding) -> str: + suffix = f" ({finding.classification})" if finding.classification else "" + source = f"; source={finding.authoritative_source}" if finding.authoritative_source else "" + return ( + f"- `{finding.doc_path}`: {finding.kind}{suffix}; target `{finding.target}`; " + f"{finding.detail}{source}" + ) + + +def build_repair_prompt( + batch: RepairBatch, + *, + repo: str = DEFAULT_REPO, + checks: Sequence[str] | None = None, +) -> str: + finding_lines = "\n".join(_finding_line(finding) for finding in batch.findings) + check_lines = "\n".join(f"- `{cmd}`" for cmd in (checks or verification_commands())) + info_lines = "\n".join(f"- `{cmd}`" for cmd in informational_commands()) + return f"""You are repairing documentation drift in {repo}. + +Goal: open one focused docs-only fix PR for repair batch `{batch.batch_id}`. + +Findings to repair: +{finding_lines} + +Rules: +- Fix the documentation to match the current repository tree unless the finding names a stronger source of truth. +- Keep edits narrow. Do not rewrite whole documents or introduce new design content. +- Do not change workflows, scripts, templates, generated files, or consumer repositories in this batch. +- Cite exact file paths in the PR body and include the verification commands you ran. + +Required verification before opening the PR: +{check_lines} + +Informational full-plan refresh: +{info_lines} + +Deliverable: +- Commit the docs-only changes. +- Push a branch. +- Open a PR titled `[Docs Drift] Repair {batch.batch_id}`. +""" + + +def build_pr_plan(batch: RepairBatch, *, checks: Sequence[str] | None = None) -> str: + doc_paths = sorted({finding.doc_path for finding in batch.findings}) + check_lines = "\n".join(f"- [ ] `{cmd}`" for cmd in (checks or verification_commands())) + findings = "\n".join(_finding_line(finding) for finding in batch.findings) + docs = "\n".join(f"- [ ] `{doc}`" for doc in doc_paths) + return f"""# Docs Drift Repair Plan: {batch.batch_id} + +## Scope +{docs} + +## Findings +{findings} + +## Verification +{check_lines} +""" + + +def build_issue_body( + batch: RepairBatch, + *, + repo: str = DEFAULT_REPO, + checks: Sequence[str] | None = None, +) -> str: + findings = "\n".join(_finding_line(finding) for finding in batch.findings) + docs = sorted({finding.doc_path for finding in batch.findings}) + docs_tasks = "\n".join( + f"- [ ] Update `{doc}` so its cited claims match the current tree." for doc in docs + ) + check_items = "\n".join( + f"- [ ] `{cmd}` passes after the repair." for cmd in (checks or verification_commands()) + ) + info_items = "\n".join( + f"- [ ] `{cmd}` was reviewed for remaining non-batch findings." + for cmd in informational_commands() + ) + evidence = "\n".join( + f"- `{finding.doc_path}` -> `{finding.target}` ({finding.source}/{finding.kind})" + for finding in batch.findings + ) + return f"""## Why +The docs-drift fix-agent found source-of-truth documentation claims that no longer match current repository state in `{repo}`. Stale operational docs mislead agents and humans during workflow maintenance. + +## Scope +Repair only the docs named in this issue for batch `{batch.batch_id}`: + +{findings} + +## Non-Goals +- Do not change workflow YAML, scripts, templates, generated artifacts, or consumer repositories. +- Do not broaden the docs or rewrite unrelated sections. +- Do not resolve findings outside this batch. + +## Tasks +{docs_tasks} +- [ ] Keep each edit tied to one listed finding and preserve unrelated wording. +- [ ] Include the relevant before/after claim in the pull request body. +- [ ] Run the docs-drift and workflow-inventory verification commands. +- [ ] Refresh the full fix-agent plan as informational context. + +## Acceptance Criteria +{check_items} +- [ ] The pull request changes only documentation files for this batch. +- [ ] The PR body lists each repaired finding and the source used to verify it. + +## Informational Checks +These commands may still report findings for other batches and should not block this batch once the required checks pass: + +{info_items} + +## Implementation Notes +Use `scripts/docs_drift_fix_agent.py` output for the repair prompt and plan. Evidence trace: + +{evidence} +""" + + +def load_scan_json(path: Path | None) -> dict[str, Any]: + if path is None: + return {"by_repo": []} + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, Mapping): + raise ValueError("scan JSON must contain a top-level mapping") + return dict(payload) + + +def default_docs_from_config(repo_root: Path, *, repo: str = DEFAULT_REPO) -> list[str]: + config_path = repo_root / DEFAULT_DOCS_CONFIG + if not config_path.is_file(): + return list(check_docs_drift.DEFAULT_DOCS) + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + if not isinstance(data, Mapping): + raise ValueError("docs config must contain a top-level mapping") + repos = data.get("repos", {}) + if repos is None: + repos = {} + if not isinstance(repos, Mapping): + raise ValueError("docs config 'repos' must be a mapping") + repo_config = repos.get(repo, {}) + if repo_config is None: + repo_config = {} + if not isinstance(repo_config, Mapping): + raise ValueError(f"docs config entry for {repo!r} must be a mapping") + docs = [ + str(item.get("path")) + for item in repo_config.get("docs") or [] + if isinstance(item, dict) and item.get("path") + ] + return docs or list(check_docs_drift.DEFAULT_DOCS) + + +def collect_findings( + *, + repo_root: Path, + repo: str, + docs: Sequence[str] | None = None, + scan_json: Path | None = None, +) -> list[Finding]: + docs_to_scan = ( + list(docs) if docs is not None else default_docs_from_config(repo_root, repo=repo) + ) + deterministic = check_docs_drift.build_report( + check_docs_drift.check_docs_drift(repo_root, docs_to_scan) + ) + findings = findings_from_deterministic_report(deterministic) + if scan_json is not None: + findings.extend(findings_from_scan_json(load_scan_json(scan_json), repo=repo)) + return dedupe_findings(findings) + + +def build_plan( + *, + repo_root: Path, + repo: str, + docs: Sequence[str] | None = None, + scan_json: Path | None = None, + max_per_batch: int = DEFAULT_MAX_PER_BATCH, +) -> dict[str, Any]: + docs_to_scan = ( + list(docs) if docs is not None else default_docs_from_config(repo_root, repo=repo) + ) + checks = verification_commands(docs_to_scan) + findings = collect_findings( + repo_root=repo_root, repo=repo, docs=docs_to_scan, scan_json=scan_json + ) + batches = batch_findings(findings, max_per_batch=max_per_batch) + return { + "repo": repo, + "repo_root": str(repo_root), + "finding_count": len(findings), + "batch_count": len(batches), + "checks": list(checks), + "findings": [asdict(finding) for finding in findings], + "batches": [ + { + "batch_id": batch.batch_id, + "findings": [asdict(finding) for finding in batch.findings], + "repair_prompt": build_repair_prompt(batch, repo=repo, checks=checks), + "issue_title": f"[Docs Drift] Repair {batch.batch_id}", + "issue_body": build_issue_body(batch, repo=repo, checks=checks), + "pr_plan": build_pr_plan(batch, checks=checks), + } + for batch in batches + ], + } + + +def write_plan_outputs(plan: dict[str, Any], out_dir: Path) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "plan.json").write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8") + for batch in plan["batches"]: + batch_id = batch["batch_id"] + (out_dir / f"{batch_id}-repair-prompt.md").write_text( + batch["repair_prompt"], encoding="utf-8" + ) + (out_dir / f"{batch_id}-issue-body.md").write_text(batch["issue_body"], encoding="utf-8") + (out_dir / f"{batch_id}-pr-plan.md").write_text(batch["pr_plan"], encoding="utf-8") + + +def apply_issues(plan: dict[str, Any]) -> list[dict[str, Any]]: + created: list[dict[str, Any]] = [] + existing_by_marker: dict[str, str] = {} + for batch in plan["batches"]: + issue_body = batch["issue_body"] + digest = hashlib.sha256(f"{batch['issue_title']}\0{issue_body}".encode()).hexdigest()[:16] + marker = f"" + list_result = subprocess.run( + [ + "gh", + "issue", + "list", + "--repo", + plan["repo"], + "--state", + "open", + "--label", + "documentation", + "--search", + f'"{marker}" in:body', + "--limit", + "1", + "--json", + "body,url", + ], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + if list_result.returncode != 0: + raise RuntimeError(f"gh issue list failed: {list_result.stderr.strip()}") + matches = json.loads(list_result.stdout or "[]") + if not isinstance(matches, list): + raise ValueError("gh issue list returned a non-list payload") + for row in matches: + if isinstance(row, Mapping) and marker in str(row.get("body") or ""): + existing_by_marker[marker] = str(row.get("url") or "") + break + if marker in existing_by_marker: + created.append( + { + "batch_id": batch["batch_id"], + "disposition": "already-open", + "returncode": 0, + "stdout": existing_by_marker[marker], + "stderr": "", + } + ) + continue + result = subprocess.run( + [ + "gh", + "issue", + "create", + "--repo", + plan["repo"], + "--title", + batch["issue_title"], + "--body", + f"{issue_body.rstrip()}\n\n{marker}\n", + "--label", + "documentation", + ], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + created.append( + { + "batch_id": batch["batch_id"], + "disposition": "created", + "returncode": result.returncode, + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + } + ) + if result.returncode != 0: + raise RuntimeError( + f"gh issue create failed for {batch['batch_id']}: {result.stderr.strip()}" + ) + return created + + +def format_summary(plan: dict[str, Any], out_dir: Path | None = None) -> str: + lines = [ + f"Docs drift fix-agent: {plan['finding_count']} finding(s) in {plan['batch_count']} batch(es)" + ] + by_source: dict[str, int] = {} + for finding in plan["findings"]: + by_source[finding["source"]] = by_source.get(finding["source"], 0) + 1 + if by_source: + lines.append(" " + " ".join(f"{key}={by_source[key]}" for key in sorted(by_source))) + for batch in plan["batches"]: + docs = sorted({finding["doc_path"] for finding in batch["findings"]}) + lines.append(f" {batch['batch_id']}: {', '.join(docs)}") + if out_dir is not None: + lines.append(f"Outputs: {out_dir}") + return "\n".join(lines) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build bounded docs-drift repair prompts and issue bodies." + ) + parser.add_argument("--repo-root", type=Path, help="repository root to scan") + parser.add_argument( + "--repo", default=DEFAULT_REPO, help="GitHub repo name for issue/prompt output" + ) + parser.add_argument("--docs", nargs="+", help="repo-relative docs to scan for dangling refs") + parser.add_argument("--scan-json", type=Path, help="optional repo_review docs-drift-scan.json") + parser.add_argument("--out-dir", type=Path, help="write plan and per-batch prompt files") + parser.add_argument("--max-per-batch", type=int, default=DEFAULT_MAX_PER_BATCH) + parser.add_argument("--json", action="store_true", help="print plan JSON") + parser.add_argument( + "--apply", action="store_true", help="create one GitHub issue per repair batch" + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + try: + repo_root = (args.repo_root or detect_repo_root()).expanduser().resolve() + if not repo_root.is_dir(): + raise FileNotFoundError(f"repo root not found: {repo_root}") + scan_json = args.scan_json.expanduser().resolve() if args.scan_json else None + if scan_json is not None and not scan_json.is_file(): + raise FileNotFoundError(f"scan json not found: {scan_json}") + plan = build_plan( + repo_root=repo_root, + repo=args.repo, + docs=args.docs, + scan_json=scan_json, + max_per_batch=args.max_per_batch, + ) + out_dir = args.out_dir.expanduser().resolve() if args.out_dir else None + if out_dir is not None: + write_plan_outputs(plan, out_dir) + if args.apply: + plan["created_issues"] = apply_issues(plan) + if args.json: + print(json.dumps(plan, indent=2)) + else: + print(format_summary(plan, out_dir)) + return 0 if args.apply else 1 if plan["finding_count"] else 0 + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/scripts/test_check_docs_drift.py b/tests/scripts/test_check_docs_drift.py index 1a2d7a34e..20b993c3a 100644 --- a/tests/scripts/test_check_docs_drift.py +++ b/tests/scripts/test_check_docs_drift.py @@ -55,7 +55,7 @@ def test_mentioned_workflow_filenames_counts_root_workflow_paths() -> None: def test_mentioned_workflow_filenames_counts_bare_backtick_references() -> None: - text = "The `health-72-template-sync.yml` workflow validates template sync.\n" + text = "The `health-72-template-sync.yml` workflow validates synchronization.\n" assert _mentioned_workflow_filenames(text, {"health-72-template-sync.yml"}) == { "health-72-template-sync.yml" @@ -85,6 +85,12 @@ def test_mentioned_workflow_filenames_skips_template_consumer_context() -> None: assert _mentioned_workflow_filenames(text, set()) == set() +def test_mentioned_workflow_filenames_skips_template_context_for_root_filename() -> None: + text = "Consumer-template workflow `health-72-template-sync.yml` is copied.\n" + + assert _mentioned_workflow_filenames(text, {"health-72-template-sync.yml"}) == set() + + def test_check_workflow_inventory_reports_undocumented_workflow(tmp_path: Path) -> None: root = _repo( tmp_path, diff --git a/tests/scripts/test_docs_drift_fix_agent.py b/tests/scripts/test_docs_drift_fix_agent.py index 7943c1332..def7440f0 100644 --- a/tests/scripts/test_docs_drift_fix_agent.py +++ b/tests/scripts/test_docs_drift_fix_agent.py @@ -1,9 +1,12 @@ from __future__ import annotations import json +import shlex import sys from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -53,6 +56,19 @@ def test_findings_from_scan_json_filters_accurate_instances() -> None: assert findings[0].classification == "stale" +def test_findings_from_scan_json_rejects_non_mapping_payload() -> None: + with pytest.raises(ValueError, match="top-level mapping"): + fix_agent.findings_from_scan_json([], repo="stranske/Workflows") # type: ignore[arg-type] + + +def test_docs_arg_quotes_shell_sensitive_paths() -> None: + docs = ["docs/plain.md", "docs/my guide.md", "docs/$(unsafe).md", "docs/a'b.md"] + + command = "python3 scripts/check_docs_drift.py" + fix_agent._docs_arg(docs) + + assert shlex.split(command) == ["python3", "scripts/check_docs_drift.py", "--docs", *docs] + + def test_batch_findings_respects_max_per_batch() -> None: findings = [ fix_agent.Finding( @@ -192,12 +208,16 @@ def test_cli_apply_creates_one_issue_per_batch(tmp_path: Path, monkeypatch, caps class Result: returncode = 0 - stdout = "https://github.com/stranske/Workflows/issues/1\n" stderr = "" + def __init__(self, stdout: str) -> None: + self.stdout = stdout + def fake_run(args, **kwargs): # noqa: ANN001 calls.append(list(args)) - return Result() + if list(args)[:3] == ["gh", "issue", "list"]: + return Result("[]") + return Result("https://github.com/stranske/Workflows/issues/1\n") monkeypatch.setattr(fix_agent.subprocess, "run", fake_run) @@ -213,7 +233,125 @@ def fake_run(args, **kwargs): # noqa: ANN001 ] ) - assert exit_code == 1 + assert exit_code == 0 capsys.readouterr() issue_calls = [call for call in calls if call[:3] == ["gh", "issue", "create"]] assert len(issue_calls) == 2 + assert all( + "\n", + "url": "https://github.com/stranske/Workflows/issues/1", + } + ] + ) + ) + + monkeypatch.setattr(fix_agent.subprocess, "run", fake_run) + + result = fix_agent.apply_issues(plan) + + assert result[0]["disposition"] == "already-open" + assert result[0]["stdout"].endswith("/issues/1") + assert [call[:3] for call in calls] == [["gh", "issue", "list"]] + assert "--search" in calls[0] + assert calls[0][calls[0].index("--limit") + 1] == "1" + assert "1000" not in calls[0] + + +def test_apply_issues_queries_exact_marker_instead_of_capped_inventory(monkeypatch) -> None: + plan = { + "repo": "stranske/Workflows", + "batches": [ + { + "batch_id": "docs-drift-01", + "issue_title": "[Docs Drift] Repair docs-drift-01", + "issue_body": "Repair item beyond the first thousand issues.\n", + } + ], + } + digest = fix_agent.hashlib.sha256( + b"[Docs Drift] Repair docs-drift-01\0Repair item beyond the first thousand issues.\n" + ).hexdigest()[:16] + marker = f"" + calls: list[list[str]] = [] + + class Result: + returncode = 0 + stderr = "" + + def __init__(self, stdout: str) -> None: + self.stdout = stdout + + def fake_run(args, **kwargs): # noqa: ANN001 + calls.append(list(args)) + assert list(args)[:3] == ["gh", "issue", "list"] + search = list(args)[list(args).index("--search") + 1] + assert marker in search + return Result( + json.dumps( + [ + { + "body": f"Older issue.\n\n{marker}\n", + "url": "https://github.com/stranske/Workflows/issues/1001", + } + ] + ) + ) + + monkeypatch.setattr(fix_agent.subprocess, "run", fake_run) + + result = fix_agent.apply_issues(plan) + + assert result[0]["disposition"] == "already-open" + assert result[0]["stdout"].endswith("/issues/1001") + assert len(calls) == 1 + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ("- not-a-mapping\n", "top-level mapping"), + ("repos: []\n", "'repos' must be a mapping"), + ("repos:\n stranske/Workflows: []\n", "entry for 'stranske/Workflows'"), + ], +) +def test_default_docs_from_config_rejects_invalid_mapping_shapes( + tmp_path: Path, content: str, message: str +) -> None: + root = tmp_path / "repo" + _write(root / fix_agent.DEFAULT_DOCS_CONFIG, content) + + with pytest.raises(ValueError, match=message): + fix_agent.default_docs_from_config(root) diff --git a/tests/test_check_docs_drift.py b/tests/test_check_docs_drift.py index a204500f9..abea40c53 100644 --- a/tests/test_check_docs_drift.py +++ b/tests/test_check_docs_drift.py @@ -112,12 +112,12 @@ def test_dotted_and_slash_workflow_refs_count_but_template_refs_do_not( assert drift == [] -def test_bare_root_workflow_ref_counts_even_with_template_context(tmp_path: Path) -> None: +def test_bare_root_workflow_ref_counts_in_root_context(tmp_path: Path) -> None: root = _repo( tmp_path, workflows=("health-72-template-sync.yml",), workflows_doc=""" -The `health-72-template-sync.yml` workflow validates consumer template sync. +The `health-72-template-sync.yml` workflow validates synchronization. """, ) @@ -126,6 +126,24 @@ def test_bare_root_workflow_ref_counts_even_with_template_context(tmp_path: Path assert drift == [] +def test_bare_root_filename_in_consumer_template_context_does_not_count( + tmp_path: Path, +) -> None: + root = _repo( + tmp_path, + workflows=("health-72-template-sync.yml",), + workflows_doc=""" +The consumer-template workflow `health-72-template-sync.yml` is copied to consumers. +""", + ) + + drift = check_workflow_inventory(root) + + assert [(record["type"], record["path"]) for record in drift] == [ + ("undocumented_workflow", "health-72-template-sync.yml") + ] + + def test_missing_workflow_inventory_doc_reports_drift(tmp_path: Path) -> None: root = tmp_path _write(root / ".github/workflows/build.yml", "name: Build\n") diff --git a/tests/workflows/test_docs_drift_fix_agent_workflow.py b/tests/workflows/test_docs_drift_fix_agent_workflow.py new file mode 100644 index 000000000..5600ba478 --- /dev/null +++ b/tests/workflows/test_docs_drift_fix_agent_workflow.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/maint-87-docs-drift-fix-agent.yml" +TEMPLATE = ROOT / "templates/consumer-repo/.github/workflows/maint-87-docs-drift-fix-agent.yml" + + +def test_docs_drift_workflow_is_exact_synced_and_pinned() -> None: + source = WORKFLOW.read_text(encoding="utf-8") + + assert TEMPLATE.read_text(encoding="utf-8") == source + workflow = yaml.safe_load(source) + uses = [ + step["uses"] + for job in workflow["jobs"].values() + for step in job["steps"] + if "uses" in step and not step["uses"].startswith("./") + ] + assert uses + assert all(re.fullmatch(r".+@[0-9a-f]{40}", reference) for reference in uses) + assert 'python -m pip install "pyyaml==6.0.3"' in source + + +def test_docs_drift_workflow_preserves_expected_findings_exit() -> None: + source = WORKFLOW.read_text(encoding="utf-8") + + assert "agent_status=$?" in source + assert 'if [ "${agent_status}" -gt 1 ]; then' in source + assert "cat docs-drift-plan.json" in source + + +def test_docs_drift_apply_lane_is_not_cancelled() -> None: + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + concurrency = workflow["concurrency"] + + assert "inputs.apply && 'apply' || 'plan'" in concurrency["group"] + assert concurrency["cancel-in-progress"] == ( + "${{ !(github.event_name == 'workflow_dispatch' && inputs.apply) }}" + ) + steps = workflow["jobs"]["docs-drift-fix-agent"]["steps"] + apply_step = next( + step for step in steps if step["name"] == "Create repair issues (dispatch-only)" + ) + assert "github.event_name == 'workflow_dispatch'" in apply_step["if"] + assert "inputs.apply" in apply_step["if"] + assert "steps.plan.outputs.findings != '0'" in apply_step["if"] diff --git a/tests/workflows/test_workflow_naming.py b/tests/workflows/test_workflow_naming.py index 3c0191e18..45e3d9062 100644 --- a/tests/workflows/test_workflow_naming.py +++ b/tests/workflows/test_workflow_naming.py @@ -272,6 +272,7 @@ def test_workflow_display_names_are_unique(): "maint-46-post-ci.yml": "Maint 46 Post CI", "maint-47-disable-legacy-workflows.yml": "Maint 47 Disable Legacy Workflows", "maint-48-docs-drift-audit.yml": "Maint 48 Docs Drift Audit", + "maint-87-docs-drift-fix-agent.yml": "Maint 87 Docs Drift Fix Agent", "maint-50-tool-version-check.yml": "Maint 50 Tool Version Check", "maint-sync-action-versions.yml": "Maint Sync Action Versions", "maint-sync-env-from-pyproject.yml": "Maint - Sync pyproject from versions.env",