diff --git a/.chezmoidata.toml b/.chezmoidata.toml index 93ae2670..deba5790 100644 --- a/.chezmoidata.toml +++ b/.chezmoidata.toml @@ -29,8 +29,8 @@ # - /configure:mcp command for interactive selection # - MCP Management skill for automated installation based on project needs # - # Note: The update-ai-tools.sh script only installs servers with enabled=true. - # For project-specific needs, manually create .mcp.json in your project root. + # Note: This is a registry only — servers are installed per-project into that + # project's .mcp.json (via /configure:mcp), not from this file directly. [mcp_servers.graphiti-memory] enabled = false diff --git a/.claude/rules/package-registries.md b/.claude/rules/package-registries.md index e7f83ebc..cd99f9f6 100644 --- a/.claude/rules/package-registries.md +++ b/.claude/rules/package-registries.md @@ -18,5 +18,5 @@ Packages are managed through `.chezmoidata/packages.toml` with profile activatio - Registry of available servers in `.chezmoidata.toml` under `[mcp_servers]` - Enable/disable per server — `enabled = true/false` -- Project-specific overrides in per-project `.mcp.json` -- Use `update-ai-tools.sh` for safe updates during active sessions +- Servers are installed per-project into that project's `.mcp.json` +- Use `/configure:mcp` to install servers into a project; `./cleanup-mcp-servers.sh` to remove them (run only when no Claude sessions are active) diff --git a/.claude/rules/shell-completions.md b/.claude/rules/shell-completions.md index 623fd84b..de2b4156 100644 --- a/.claude/rules/shell-completions.md +++ b/.claude/rules/shell-completions.md @@ -3,7 +3,7 @@ ## Overview Shell completions are managed via chezmoi's data-driven approach: -- **Registry**: `.chezmoidata.toml` under `[packages.completion_tools.zsh_completions]` +- **Registry**: `.chezmoidata/completions.toml` under `[packages.completion_tools.zsh_completions]` - **Generator**: `run_onchange_02-generate-completions.sh.tmpl` - **Output**: `~/.zfunc/_` (Zsh completion files) @@ -15,7 +15,7 @@ Shell completions are managed via chezmoi's data-driven approach: # or check tool documentation ``` -2. **Add to registry** in `.chezmoidata.toml`: +2. **Add to registry** in `.chezmoidata/completions.toml`: ```toml [packages.completion_tools.zsh_completions] # ... existing entries ... @@ -41,7 +41,7 @@ Shell completions are managed via chezmoi's data-driven approach: ## How It Works The `run_onchange` script: -1. Detects changes via `.chezmoidata.toml` hash in template header +1. Detects changes via `.chezmoidata/completions.toml` hash in template header 2. Iterates over all entries in `zsh_completions` 3. Skips tools not installed (`command -v` check) 4. Generates `~/.zfunc/_` for each available tool @@ -71,6 +71,6 @@ rm -f ~/.zcompdump && compinit **Force regeneration:** ```bash # Touch the data file to trigger run_onchange -touch ~/.local/share/chezmoi/.chezmoidata.toml +touch ~/.local/share/chezmoi/.chezmoidata/completions.toml chezmoi apply ``` diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 8b646e99..3c3d7841 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -9,7 +9,7 @@ | Lua | `mise run lint:lua` | After modifying Neovim config | | Actions | `mise run lint:actions` | After modifying workflows | | Full | `mise run test` | Before committing | -| Docker | `mise run test:docker` | For environment changes | +| Docker | `mise run docker` | For environment changes | ## Linting Standards diff --git a/.claude/skills/neovim-configuration/SKILL.md b/.claude/skills/neovim-configuration/SKILL.md index 0357fae1..252b412d 100644 --- a/.claude/skills/neovim-configuration/SKILL.md +++ b/.claude/skills/neovim-configuration/SKILL.md @@ -5,8 +5,8 @@ reviewed: 2025-12-16 name: neovim-configuration description: | Modern Neovim configuration expertise including Lua scripting, plugin management with - lazy.nvim, LSP setup with Mason, AI integration with CodeCompanion, and workflow - optimization. Covers keymaps, autocommands, and treesitter configuration. + lazy.nvim, LSP setup with Mason, AI integration with claudecode.nvim/opencode.nvim, + and workflow optimization. Covers keymaps, autocommands, and treesitter configuration. Use when user mentions Neovim, nvim, lazy.nvim, Mason, init.lua, Lua config, nvim plugins, or Neovim customization. allowed-tools: Glob, Grep, Read, Edit, Write @@ -21,7 +21,7 @@ Expert knowledge for modern Neovim configuration with Lua scripting, plugin mana **Modern Neovim Configuration** - Lua-based configuration with lazy.nvim plugin management - LSP setup with Mason for language server management -- AI integration with CodeCompanion and custom prompt strategies +- AI integration with claudecode.nvim and opencode.nvim - Advanced key mapping and workflow optimization ## Key Capabilities @@ -39,9 +39,8 @@ Expert knowledge for modern Neovim configuration with Lua scripting, plugin mana - **Code Navigation**: Go-to-definition, references, and symbol search **AI Integration & Automation** -- **CodeCompanion**: AI-powered code assistance with custom prompts -- **Custom Strategies**: Domain-specific AI workflows for different development contexts -- **Prompt Management**: Custom prompts for Arduino, deployment, debugging, and more +- **claudecode.nvim**: Claude Code integration for AI-powered coding in Neovim +- **opencode.nvim**: OpenCode AI agent integration - **Workflow Integration**: AI assistance integrated into development workflows **Advanced Features** diff --git a/.doc-reference-allow b/.doc-reference-allow new file mode 100644 index 00000000..2d3783cc --- /dev/null +++ b/.doc-reference-allow @@ -0,0 +1,12 @@ +# Allowlist for scripts/check-doc-references.py — one glob per line. +# Docs matching these globs are skipped by the doc-reference gate. + +# Immutable point-in-time records: ADRs intentionally reference paths that were +# accurate when the decision was made and may since have moved/been removed. +docs/adrs/** + +# Global ~/.claude source tree. These docs reference the external +# claude-plugins marketplace and OTHER projects' paths (e.g. an aiq project's +# scripts/aiq.py, docs/source/...), plus runtime ~/.claude structures — none of +# which are files in THIS repo. Repo-relative existence checking does not apply. +exact_dot_claude/** diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77925771..e314761f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,6 +53,17 @@ repos: language: script files: '^(exact_dot_claude/(rules/.*\.md|CLAUDE\.md)|tests/test-claude-context-budget\.sh)$' pass_filenames: false + # Advisory doc-drift nudge: flags docs that reference scripts/paths/links + # which no longer exist. ADVISORY (always exits 0) so it never blocks a + # commit; `mise run lint:docs` is the strict/enforcing variant for CI. + # See scripts/check-doc-references.py and .doc-reference-allow. + - id: check-doc-references + name: check docs for dangling references (advisory) + entry: scripts/check-doc-references.py + language: script + pass_filenames: false + always_run: true + verbose: true - repo: https://github.com/rhysd/actionlint rev: v1.7.7 hooks: diff --git a/CLAUDE.md b/CLAUDE.md index 4fd469bf..7875603d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Managed in [laurigates/claude-plugins](https://github.com/laurigates/claude-plug ## MCP Servers -Managed per-project in `.mcp.json`. Registry of available servers in `.chezmoidata.toml` under `[mcp_servers]`. Use `/configure-mcp` for guided setup. +Managed per-project in `.mcp.json`. Registry of available servers in `.chezmoidata.toml` under `[mcp_servers]`. Use `/configure:mcp` for guided setup. ## Linting @@ -50,9 +50,8 @@ pre-commit run --all-files # All pre-commit hooks ### Secret scanning: ```bash -detect-secrets scan --baseline .secrets.baseline -detect-secrets audit .secrets.baseline -pre-commit run detect-secrets --all-files +gitleaks detect --source . --config .gitleaks.toml # scan the working tree +pre-commit run gitleaks --all-files # via pre-commit ``` ## Key Files & Directories @@ -92,8 +91,6 @@ Blueprint v3.3.0 manages project documentation and rules. ## Sub-documentation - `exact_dot_claude/CLAUDE.md` — Claude Code design and directory structure -- `exact_dot_claude/commands/CLAUDE.md` — Slash commands guide -- `exact_dot_claude/skills/CLAUDE.md` — Skills system - `private_dot_config/CLAUDE.md` — Application configuration - `private_dot_config/nvim/CLAUDE.md` — Neovim configuration - `scripts/CLAUDE.md` — Maintenance scripts diff --git a/README.md b/README.md index 661e3978..ccfb4a4a 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ just plugins-enable ``` Or install individually: + ```bash -claude /plugin install git-plugin@laurigates-claude-plugins +claude plugin install git-plugin@laurigates-claude-plugins ``` ### Bulk Plugin Management @@ -68,37 +69,36 @@ just plugins-list # Show installed plugins and status ## AI Tools & MCP Configuration -AI tools and MCP (Model Context Protocol) servers are configured through the `.chezmoidata.toml` file and automatically installed via the `update-ai-tools.sh` script. +MCP (Model Context Protocol) servers are managed **per-project** via each project's `.mcp.json`. A curated registry of available servers lives in `.chezmoidata.toml` under `[mcp_servers]`, disabled by default to avoid bloating context in every repository. ### MCP Server Configuration -MCP servers for Claude Code are dynamically configured from `.chezmoidata.toml`. To manage servers: +The `[mcp_servers]` registry in `.chezmoidata.toml` records each server's metadata: - **Enable/disable servers**: Set `enabled = true/false` in the `[mcp_servers]` section - **Add new servers**: Create a new `[mcp_servers.name]` section with required fields - **Configure options**: `scope`, `command`, `args`, and optional `transport` Example configuration: + ```toml [mcp_servers.my-server] - enabled = true - scope = "user" + enabled = false + scope = "project" command = "npx" args = ["-y", "my-mcp-package"] transport = "stdio" # optional ``` -**Adding/updating servers** (safe during active Claude sessions): -```bash -./update-ai-tools.sh # or: chezmoi apply update-ai-tools.sh -``` +**Installing servers into a project**: run the `/configure:mcp` command for interactive, project-scoped selection — it writes the chosen servers to that project's `.mcp.json`. + +**Removing servers** (WARNING: disrupts active Claude sessions): -**Cleaning up disabled servers** (WARNING: disrupts active Claude sessions): ```bash ./cleanup-mcp-servers.sh # Run only when no Claude sessions are active ``` -> **Note**: `update-ai-tools.sh` only adds new servers without removing existing ones, making it safe to run during active Claude sessions. Use `cleanup-mcp-servers.sh` only when you need to remove disabled servers and no Claude Code sessions are running. +> **Note**: `cleanup-mcp-servers.sh` removes MCP servers from user and project scope, so run it only when no Claude Code sessions are active. Afterwards, reinstall per-project with `/configure:mcp`. ## Claude Code Configuration @@ -111,6 +111,7 @@ Full guide: See [CLAUDE.md](./CLAUDE.md) ### Skills Auto-discovered skills provide contextual guidance: + - **chezmoi-expert** - Dotfiles management, templates, cross-platform configs - **neovim-configuration** - Lua config, plugin management, LSP setup - **obsidian-bases** - Obsidian Bases database feature for YAML-based views diff --git a/docs/blueprint/README.md b/docs/blueprint/README.md index 782d226c..96425b66 100644 --- a/docs/blueprint/README.md +++ b/docs/blueprint/README.md @@ -4,9 +4,7 @@ Blueprint development structure for this project. ## Contents -- `manifest.json` — Blueprint configuration and task registry (v3.2.0) -- `work-orders/` — Detailed work order documents -- `ai_docs/` — AI-curated documentation +- `manifest.json` — Blueprint configuration and task registry (v3.3.0) ## Related Directories diff --git a/docs/components.md b/docs/components.md index 56ee7845..40d511af 100644 --- a/docs/components.md +++ b/docs/components.md @@ -44,12 +44,8 @@ Configuration is managed via Lua (`private_dot_config/nvim/lua/`). - **Git Integration:** - **[Gitsigns](https://github.com/lewis6991/gitsigns.nvim)**: Shows Git status (added, modified, deleted lines) in the sign column and provides related actions (preview hunk, stage, reset, blame, etc.). - **AI / LLM Integration:** - - **[CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim)**: AI-powered coding plugin supporting Anthropic, Copilot, Gemini, Ollama, OpenAI and other LLMs. - - **Strategies (`strategies.lua`)**: Defines different interaction modes (e.g., chat, workflow). - - **Tools**: Allows the LLM to invoke external commands securely: - - `git diff`, `git log`, `git status`, `git commit`, `git push` - - `arduino-cli compile`, `arduino-cli upload`, `arduino-cli monitor` - - **Prompts (`private_prompts/`)**: Pre-defined prompt templates and workflows (e.g., Arduino code generation). + - **[claudecode.nvim](https://github.com/coder/claudecode.nvim)** (`exact_plugins/claudecode.lua`): Claude Code integration for Neovim. + - **[opencode.nvim](https://github.com/NickvanDyke/opencode.nvim)** (`exact_plugins/opencode.lua`): OpenCode AI agent integration. ## Terminal Utilities diff --git a/docs/homebrew-mise-migration-analysis.md b/docs/homebrew-mise-migration-analysis.md index aaf61059..31b13a98 100644 --- a/docs/homebrew-mise-migration-analysis.md +++ b/docs/homebrew-mise-migration-analysis.md @@ -2,6 +2,12 @@ This document analyzes package duplication between Homebrew and the new mise configuration. +> **Note (point-in-time analysis):** the package/tool registries have since been +> split out of `.chezmoidata.toml` into the `.chezmoidata/` directory — +> `uv_tools` now lives in `.chezmoidata/uv_tools.toml` and packages in +> `.chezmoidata/packages.toml`. References to a `uv_tools` section "in +> `.chezmoidata.toml`" below reflect the layout at the time of writing. + ## 📊 Duplicate Analysis ### Packages Now Managed by mise (Remove from Homebrew) diff --git a/docs/prds/project-overview.md b/docs/prds/project-overview.md index 53800610..131a64cb 100644 --- a/docs/prds/project-overview.md +++ b/docs/prds/project-overview.md @@ -78,8 +78,7 @@ A chezmoi-managed dotfiles repository that declaratively configures the full dev ### Security - No secrets in git — API tokens, credentials via `~/.api_tokens` - Private files use `private_` chezmoi prefix (mode 600) -- gitleaks on every commit via pre-commit hook -- detect-secrets baseline maintained in `.secrets.baseline` +- gitleaks on every commit via pre-commit hook (see ADR 0015) ### Reliability - Smoke test CI validates setup on Ubuntu and macOS @@ -102,9 +101,10 @@ A chezmoi-managed dotfiles repository that declaratively configures the full dev - **Brewfile** with profile-based package selection via `.chezmoidata/profiles.toml` ### Key Configuration Files -- `.chezmoidata.toml` — MCP servers, uv_tools, platform data +- `.chezmoidata.toml` — MCP servers, platform data - `.chezmoidata/packages.toml` — Profile-based Homebrew package registry - `.chezmoidata/profiles.toml` — Profile activation flags +- `.chezmoidata/uv_tools.toml` — Python tools managed by uv - `dot_zshrc.tmpl` — Zsh config (template) - `private_dot_config/mise/config.toml.tmpl` — mise tools and tasks - `private_dot_config/nvim/` — Neovim Lua configuration diff --git a/exact_dot_claude/docs/blueprint-development/IMPLEMENTATION_SUMMARY.md b/exact_dot_claude/docs/blueprint-development/IMPLEMENTATION_SUMMARY.md index 7c742ff1..6a78210f 100644 --- a/exact_dot_claude/docs/blueprint-development/IMPLEMENTATION_SUMMARY.md +++ b/exact_dot_claude/docs/blueprint-development/IMPLEMENTATION_SUMMARY.md @@ -1,5 +1,12 @@ # Blueprint Development - Implementation Summary +> **Superseded:** Blueprint has since moved to the external +> [`blueprint-plugin`](https://github.com/laurigates/claude-plugins) marketplace. +> The in-repo skills (`.claude/skills/blueprint-development/`) and commands +> (`.claude/commands/`) described below **no longer exist in this repo** — those +> capabilities now resolve at runtime from the plugin. This document is retained +> as a historical record of the original in-repo implementation. + This document summarizes the Blueprint Development implementation in your dotfiles repository. ## What Was Created diff --git a/exact_dot_claude/docs/blueprint-development/README.md b/exact_dot_claude/docs/blueprint-development/README.md index de7607bf..39525e25 100644 --- a/exact_dot_claude/docs/blueprint-development/README.md +++ b/exact_dot_claude/docs/blueprint-development/README.md @@ -254,7 +254,9 @@ Use Blueprint Development in CI: ## Commands -See `.claude/commands/` for available commands: +These commands are now provided by the external +[`blueprint-plugin`](https://github.com/laurigates/claude-plugins) marketplace +(they no longer live in this repo's `.claude/commands/`): **PRP Commands:** - `/prp-create` - Create a PRP with systematic research and context curation diff --git a/executable_cleanup-mcp-servers.sh.tmpl b/executable_cleanup-mcp-servers.sh.tmpl index 78ffa06a..b5cb2416 100644 --- a/executable_cleanup-mcp-servers.sh.tmpl +++ b/executable_cleanup-mcp-servers.sh.tmpl @@ -36,7 +36,7 @@ claude mcp remove --scope={{ $config.scope }} {{ $name }} || true {{- end }} echo "" -echo "✅ Cleanup complete. Run update-ai-tools.sh to re-add servers." +echo "✅ Cleanup complete. MCP servers are now managed per-project." echo "" -echo "To re-add servers now:" -echo " ./update-ai-tools.sh" +echo "To reinstall servers into a project, use:" +echo " /configure:mcp (interactive, writes to the project's .mcp.json)" diff --git a/private_dot_config/CLAUDE.md b/private_dot_config/CLAUDE.md index 7e9e60f8..c989d12e 100644 --- a/private_dot_config/CLAUDE.md +++ b/private_dot_config/CLAUDE.md @@ -20,6 +20,5 @@ Application configuration files managed by chezmoi. Uses chezmoi naming conventi | `rofi/` | Application launcher (Linux) | | `sketchybar/` | macOS status bar | | `private_dunst/` | Notification daemon (Linux) | -| `private_i3/` | i3 window manager (Linux) | | `fd/` | fd ignore patterns | | `pet/` | Snippet manager | diff --git a/private_dot_config/mise/config.toml.tmpl b/private_dot_config/mise/config.toml.tmpl index d594544d..a822a55a 100644 --- a/private_dot_config/mise/config.toml.tmpl +++ b/private_dot_config/mise/config.toml.tmpl @@ -278,9 +278,21 @@ else echo " ⚠️ Warning: Brewfile not found" fi +# Documentation references (dangling scripts/paths/links) +if command -v python3 >/dev/null 2>&1; then + echo " ✓ Checking doc references..." + python3 scripts/check-doc-references.py --strict || { + echo " ⚠️ Warning: doc reference check found dangling references" + } +fi + echo "✅ Linting complete!" """ +[tasks."lint:docs"] +description = "Check docs for dangling repo-relative references (scripts/paths/links)" +run = "python3 scripts/check-doc-references.py --strict" + [tasks."lint:shell"] description = "Run shellcheck on shell scripts" run = 'find . -name "*.sh" -not -path "./node_modules/*" -exec shellcheck {} \\;' diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 9a9a5738..8af02d9e 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -13,5 +13,6 @@ Utility scripts for Claude Code infrastructure automation. | `audit-secrets-baseline.py` | Audit secrets baseline file | | `audit-secrets-selective.py` | Selective secrets audit | | `smoke-test-docker.sh` | Docker-based smoke tests for dotfiles | +| `check-doc-references.py` | Flag docs that reference scripts/paths/links no longer in the repo (advisory pre-commit hook; strict via `mise run lint:docs`). See `.doc-reference-allow` for scoping and `tests/test-check-doc-references.sh` for the contract. | All shell scripts support `--help`. Run `shellcheck scripts/*.sh` to lint. diff --git a/scripts/README.md b/scripts/README.md index b06f95b9..34680eb8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -160,17 +160,17 @@ uv run --with detect-secrets python3 scripts/audit-secrets-baseline.py ### Pre-commit Hook -The `.pre-commit-config.yaml` includes detect-secrets: +Secret scanning in pre-commit is handled by gitleaks; `.pre-commit-config.yaml` includes: ```yaml -- repo: https://github.com/Yelp/detect-secrets - rev: v1.5.0 +- repo: https://github.com/gitleaks/gitleaks + rev: v8.30.0 hooks: - - id: detect-secrets - args: ['--baseline', '.secrets.baseline'] - exclude: (package\.lock\.json|lazy-lock\.json) + - id: gitleaks ``` +detect-secrets is no longer a pre-commit hook — it remains available only as a manual/on-demand baseline-audit tool (see the `uv run --with detect-secrets` commands above). + **Pre-commit workflow**: 1. Make changes 2. Run `pre-commit run --all-files` (or let it run on commit) @@ -277,4 +277,4 @@ This repository excludes: - `\.claude/docs/.*` - Claude Code documentation - `lazy-lock\.json` - Neovim plugin lock file (git commit hashes) -See `.secrets.baseline` line 115-117 for the current exclusion pattern. +See `.gitleaks.toml` for the current secret-scan allowlist and exclusion patterns. diff --git a/scripts/check-doc-references.py b/scripts/check-doc-references.py new file mode 100755 index 00000000..d9546956 --- /dev/null +++ b/scripts/check-doc-references.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Check documentation for dangling repo-relative references. + +Catches the mechanical, reproducible slice of "doc drift": a doc that names a +file / path / internal link which no longer exists in the repo. It does NOT +judge prose ("is this model still current?") — that semantic tail is left to a +periodic LLM sweep. See .claude/rules and docs/adrs for the split. + +Precision-first by design: a gate that cries wolf gets bypassed, so v1 flags +only two high-confidence shapes and deliberately under-reaches: + +1. Relative markdown links `[text](path)` whose target does not resolve from the + linking file's directory (skips http(s)/mailto, in-page #anchors, and + template/placeholder targets). +2. Inline-code path tokens `` `like/this.sh` `` that are **anchored to a real + top-level repo entry** (first path segment is an actual tracked top-level + file/dir) and do not resolve from the repo root. + +Deliberately OUT of v1 scope (handled by the periodic LLM sweep instead): +- bare filenames with no slash (`update-ai-tools.sh`) — too many prose examples; +- chezmoi *rendered* target names (`./cleanup-mcp-servers.sh` <- executable_…tmpl); +- slash commands (`/configure:mcp`), domains, `~/` runtime paths, placeholders. + +Fenced code blocks (``` / ~~~) are skipped entirely: they hold examples. + +Structured KEY=VALUE / STATUS= output. Exit code: +- default (advisory): always 0 — the STATUS line is the signal. +- `--strict`: exit 1 when any dangling reference is found (CI / mise task). + +Allowlist: `.doc-reference-allow` at the repo root, one glob per line +(`#` comments ok). Matching docs are skipped — for immutable records (ADRs) +that intentionally reference now-dead paths. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from fnmatch import fnmatch +from pathlib import Path + +# chezmoi source-name attribute prefixes / suffix. A doc that references a +# managed file by its *rendered* name (e.g. hooks/foo.sh) must still resolve +# against its source form (hooks/executable_foo.sh, dot_foo -> .foo, …). +CHEZMOI_PREFIXES = ( + "encrypted_", "private_", "readonly_", "empty_", "executable_", "exact_", + "literal_", "symlink_", "run_once_", "run_onchange_", "run_", "create_", + "modify_", "before_", "after_", "once_", "onchange_", +) + +MD_LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)") +INLINE_CODE = re.compile(r"`([^`]+)`") +PATH_TOKEN = re.compile(r"^[\w./~-]+$") # no spaces / shell metachars +PLACEHOLDER = re.compile(r"\.\.\.|NNNN|xxx|<|>|\{|\bfoo\b|\bbar\b|\bbaz\b|example") +SKIP_PREFIXES = ("http://", "https://", "mailto:", "#", "www.") +DOMAIN_HINTS = (".com/", ".org/", ".io/", ".dev/", ".net/", ".fi/", ".xyz/") + + +def _git(root: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=root, capture_output=True, text=True, check=True, + ).stdout + + +def repo_root() -> Path: + return Path(_git(Path.cwd(), "rev-parse", "--show-toplevel").strip()) + + +def load_allowlist(root: Path) -> list[str]: + f = root / ".doc-reference-allow" + if not f.exists(): + return [] + return [ + ln.strip() for ln in f.read_text().splitlines() + if ln.strip() and not ln.strip().startswith("#") + ] + + +def _rendered_name(src_name: str) -> str: + """Map a chezmoi source basename to its rendered target basename.""" + name = src_name[:-5] if src_name.endswith(".tmpl") else src_name + changed = True + while changed: + changed = False + for p in CHEZMOI_PREFIXES: + if name.startswith(p): + name, changed = name[len(p):], True + if name.startswith("dot_"): + name, changed = "." + name[4:], True + return name + + +def managed_exists(root: Path, rel: str) -> bool: + """True if `rel` exists literally, or as a chezmoi source of that target.""" + p = root / rel + if p.exists(): + return True + parent, base = p.parent, p.name + if not parent.is_dir(): + return False + return any(_rendered_name(entry.name) == base for entry in parent.iterdir()) + + +def strip_fenced_blocks(text: str) -> list[tuple[int, str]]: + """(1-indexed lineno, line) for lines OUTSIDE fenced code blocks.""" + kept, fence = [], None + for i, line in enumerate(text.splitlines(), start=1): + stripped = line.lstrip() + marker = "```" if stripped.startswith("```") else "~~~" if stripped.startswith("~~~") else None + if marker: + fence = None if fence == marker else (fence or marker) + continue + if fence is None: + kept.append((i, line)) + return kept + + +def clean_link_target(target: str) -> str | None: + target = target.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1] + target = target.split()[0] if target else target # drop link title + if not target or target.lower().startswith(SKIP_PREFIXES): + return None + if "{{" in target or "$" in target: # templating + return None + target = target.split("#", 1)[0].split("?", 1)[0] # drop anchor/query + return target or None + + +def link_resolves(target: str, doc_path: Path, root: Path) -> bool: + base = root if target.startswith("/") else doc_path.parent + candidate = base / target.lstrip("/") if target.startswith("/") else base / target + try: + rel = candidate.resolve().relative_to(root) + except (OSError, RuntimeError, ValueError): + return candidate.exists() + return managed_exists(root, str(rel)) + + +def token_is_candidate(tok: str) -> bool: + if not PATH_TOKEN.match(tok) or tok.startswith(("/", "~/")): + return False + if tok.lower().startswith(SKIP_PREFIXES) or PLACEHOLDER.search(tok): + return False + if any(d in tok for d in DOMAIN_HINTS): + return False + return "/" in tok # a path (incl. ./name) + + +def token_resolves(tok: str, root: Path, top_level: set[str]) -> bool: + had_dot = tok.startswith("./") + t = tok[2:] if had_dot else tok + if "/" not in t: + # single segment: only an explicit `./name` invocation names a repo-root + # file; a bare filename without `./` is out of v1 scope (prose examples). + return managed_exists(root, t) if had_dot else True + if t.split("/", 1)[0] not in top_level: # not anchored to a real repo entry + return True + return managed_exists(root, t.rstrip("/")) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--strict", action="store_true", + help="exit 1 when dangling references are found") + ap.add_argument("files", nargs="*", + help="markdown files to check (default: all tracked *.md)") + args = ap.parse_args() + + root = repo_root() + allow = load_allowlist(root) + tracked = [ln for ln in _git(root, "ls-files").splitlines() if ln] + top_level = {p.split("/", 1)[0] for p in tracked} + + if args.files: + md_files = [os.path.relpath(os.path.abspath(f), root) + for f in args.files if f.endswith(".md")] + else: + md_files = [f for f in tracked if f.endswith(".md")] + + findings: list[tuple[str, int, str, str]] = [] + checked = 0 + for rel in sorted(md_files): + if any(fnmatch(rel, pat) for pat in allow): + continue + doc = root / rel + if not doc.exists(): + continue + checked += 1 + for lineno, line in strip_fenced_blocks( + doc.read_text(encoding="utf-8", errors="replace") + ): + for m in MD_LINK.finditer(line): + target = clean_link_target(m.group(1)) + if not target or target.lower().startswith(SKIP_PREFIXES): + continue + if "/" not in target and "." not in target: # not a filesystem path + continue + if PLACEHOLDER.search(target): + continue + if not link_resolves(target, doc, root): + findings.append((rel, lineno, "link", target)) + for m in INLINE_CODE.finditer(line): + tok = m.group(1) + if token_is_candidate(tok) and not token_resolves(tok, root, top_level): + findings.append((rel, lineno, "ref", tok)) + + print("=== DOC REFERENCE CHECK ===") + print(f"DOCS_CHECKED={checked}") + print(f"ALLOWLISTED_PATTERNS={len(allow)}") + print(f"DANGLING_COUNT={len(findings)}") + print(f"STATUS={'FAIL' if findings else 'OK'}") + for rel, lineno, kind, tok in findings: + print(f"DANGLING {rel}:{lineno} [{kind}] {tok}") + print("=== END DOC REFERENCE CHECK ===") + + return 1 if (findings and args.strict) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test-check-doc-references.sh b/scripts/tests/test-check-doc-references.sh new file mode 100755 index 00000000..46a8dcea --- /dev/null +++ b/scripts/tests/test-check-doc-references.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Regression test for scripts/check-doc-references.py. +# +# Pins the precision/recall contract: the gate must FLAG dead repo-relative +# references and broken links, and must NOT flag chezmoi-managed source names, +# slash commands, domains, placeholders, or valid links. Run from anywhere in +# the repo; uses a temp fixture that references REAL repo paths so the +# chezmoi-aware and top-level-anchor logic exercises the live tree. +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +CHECK="$ROOT/scripts/check-doc-references.py" +FIX="$(mktemp -d)/fixture.md" +trap 'rm -rf "$(dirname "$FIX")"' EXIT + +cat > "$FIX" <<'MD' +# Fixture + +Dead root script: `./update-ai-tools.sh` +Dead full path: `exact_dot_claude/commands/CLAUDE.md` +Broken link: [gone](../does/not/exist.md) + +Managed root script: `./cleanup-mcp-servers.sh` +Managed hook by rendered name: `exact_dot_claude/hooks/chezmoi-workflow-nudge.sh` +Slash command: `/configure:mcp` +Domain: `github.com/laurigates/dotfiles` +Placeholder: `docs/adrs/NNNN-title.md` +Live path: `scripts/check-doc-references.py` +MD + +out="$(python3 "$CHECK" "$FIX")" +echo "$out" + +fail=0 +assert_flagged() { + if ! grep -q "\[.*\] $1\$" <<<"$out"; then + echo "FAIL: expected flagged but was not: $1"; fail=1 + fi +} +assert_clean() { + if grep -q " $1\$" <<<"$out"; then + echo "FAIL: expected NOT flagged but was: $1"; fail=1 + fi +} + +assert_flagged "./update-ai-tools.sh" +assert_flagged "exact_dot_claude/commands/CLAUDE.md" +assert_flagged "../does/not/exist.md" + +assert_clean "./cleanup-mcp-servers.sh" +assert_clean "exact_dot_claude/hooks/chezmoi-workflow-nudge.sh" +assert_clean "/configure:mcp" +assert_clean "github.com/laurigates/dotfiles" +assert_clean "docs/adrs/NNNN-title.md" +assert_clean "scripts/check-doc-references.py" + +# And the live tree must be clean (STATUS=OK) — the fixes + allowlist hold. +tree_status="$(python3 "$CHECK" | grep '^STATUS=')" +if [ "$tree_status" != "STATUS=OK" ]; then + echo "FAIL: live tree not clean: $tree_status"; fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "PASS: check-doc-references regression test" +else + echo "FAILED"; exit 1 +fi