
Doc Freshness
- 2 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Scans a project for documentation drift, stale references, broken links, and code-doc inconsistencies, and reports findings without auto-fixing.
About
Detects documentation drift across a project by scanning for code-doc drift, cross-document conflicts, and stale references like broken links and deleted files. A developer uses it to audit doc health and catch outdated documentation before it misleads readers.
- Three drift types: code-doc, cross-doc, and stale references
- Reports findings without auto-fixing; script-driven link checks
Doc Freshness by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,292 of 1,877 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill doc-freshnessAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Scans a project for documentation drift, stale references, broken links, and code-doc inconsistencies, and reports findings without auto-fixing.
Files
Documentation Freshness
Detect documentation drift across any project. Reports findings without auto-fixing.
Context Discovery
Priority 1: Configuration
Read .arkhe.yaml from project root. Extract doc-freshness: section for custom patterns, exclusions, and doc-code mappings. Extract doc-health: section for output_dir (used by report mode, default: docs/health).
Priority 2: Project Identity
Read CLAUDE.md and README.md to understand project structure, tech stack, and conventions.
Priority 3: Documentation Inventory
Run the scanner to discover all documentation files and perform mechanical checks:
python3 ${CLAUDE_SKILL_DIR}/scripts/scan_freshness.py <project-root>Use --links-only for fast mode (links and file refs only, no git staleness).
Arguments
Parse from $ARGUMENTS:
| Mode | Description |
|---|---|
scan | Full freshness analysis (all three drift types) |
check <path> | Focused analysis on one file or directory |
links | Broken links and stale references only (script-driven, fast) |
drift <path> | Code-doc drift for a specific doc or doc-code pair |
cross-doc | Cross-document consistency check |
report | Persist structured freshness report to {output_dir}/ |
claude-md | CLAUDE.md structural drift (plugin counts, components, versions) |
onboard | Suggest/apply tracking frontmatter to docs that need it |
setup | Scaffold GitHub Actions workflow for docs-health-action CI |
| _(none)_ | Full scan (same as scan) |
Scanning Tiers
Documents are automatically classified into scanning tiers:
| Tier | Detection | Checks Performed |
|---|---|---|
| Basic | All .md files | Broken links, git age classification, backtick-path verification |
| Deep | YAML frontmatter with last_updated or version | Basic + version drift, last_updated accuracy, cross-doc consistency |
Tier is auto-detected per file. No configuration needed. The scanner JSON output includes "tier" per doc and "tier_counts" in the summary.
Mode Execution
scan (default)
1. Run scan_freshness.py for mechanical checks (links, versions, git staleness) 2. Present script findings (broken links table, version mismatches, staleness scores) 3. For stale/very_stale docs: use Grep/Read to check code-doc alignment on key references 4. For docs covering the same topic: cross-check for consistency 5. Produce the freshness report
links
1. Run scan_freshness.py --links-only 2. Present broken links table directly from JSON output 3. Group findings by severity: broken links first, then file_ref warnings
check <path>
1. If path is a file, run link checker and version checker on that file only 2. If path is a directory, scan all .md files within it 3. Use Grep to check file path references and function names against the codebase 4. Report findings for the targeted scope
drift <path>
1. Read the specified doc 2. Extract: function/method names, API endpoints, file paths, config keys, class names 3. Grep/Read the corresponding code to verify each reference still exists and is accurate 4. Report mismatches with evidence (doc line vs code location)
cross-doc
1. Run scan_freshness.py to get doc inventory with headings 2. Identify docs with overlapping topics (shared heading keywords) 3. Read overlapping docs and compare factual claims 4. Flag contradictions (e.g., different version requirements, conflicting setup steps)
report
Same as scan but write output to {output_dir}/{YYYY-MM-DD}-freshness.md.
claude-md
1. Run claude_md_checker.py to compare CLAUDE.md claims against filesystem 2. Present findings by category: plugin counts, component inventories, versions, file paths 3. Highlight undocumented components (CRITICAL) and version mismatches (WARNING)
onboard
1. Run frontmatter_onboard.py to find docs without tracking frontmatter 2. Uses whitelist of known-maintained docs (READMEs, custom docs) 3. Generates minimal title + last_updated frontmatter from git history 4. On user approval, apply with --apply flag
setup
Scaffold a GitHub Actions workflow that runs joaquimscosta/docs-health-action@v1 on every PR.
1. Check if .github/workflows/ exists — create if needed 2. Check if a docs-health workflow already exists — warn and offer to overwrite/skip 3. Ask user which checks to enable (use AskUserQuestion with multiSelect):
links(broken internal links and anchors)versions(version references vs ground truth files)staleness(git-based documentation age)claude-md(CLAUDE.md structural drift — Claude Code projects only)cross-doc(cross-document version conflicts)frontmatter(missing tracking frontmatter)
4. Ask user for failure policy: errors (default), warnings, or none (advisory) 5. Generate .github/workflows/docs-health.yml from template 6. Show the generated file and confirm before writing
See WORKFLOW.md § setup for the full template.
Automation Integration
SessionStart Hook: Critical-doc fast scan on session start (5-second timeout)
/doc:health --critical-only
# Scans: README.md, CLAUDE.md only (root-level critical docs)PostToolUse Hook (after /commit): Post-commit doc-impact checks
/doc:health drift
# Checks if modified code files have corresponding documentationUser-Driven (`/loop`): Periodic freshness monitoring
/loop 1h /doc:health links # Hourly broken-link checks
/loop 4h /doc:health scan # Full scans every 4 hoursSeverity Levels
| Severity | Meaning |
|---|---|
| CRITICAL | Broken link to deleted file, doc references removed API/function |
| WARNING | Version mismatch, function signature changed, doc stale >30 days |
| INFO | Minor inconsistency, doc aging (7-30 days), cosmetic drift |
Output Rules
- Evidence-based: every finding backed by file path and line number
- Tabular: summary table first, detailed findings below
- Actionable: each finding includes what needs updating
- Detection only: NEVER auto-fix documentation
Lane Discipline
- Do NOT update or rewrite documentation — detection only
- Do NOT produce roadmap status, architecture analysis, or user stories
- Do NOT write source code
References
- WORKFLOW.md — Detection algorithms and convention tables
- EXAMPLES.md — Usage examples for each mode
- TROUBLESHOOTING.md — Common issues and fixes
Doc-Freshness Examples
Example 1: Full Scan on a Web App
Command: scan (or no arguments)
## Documentation Freshness Report
_Scanned 12 docs on 2026-03-21_
| Category | Count | Critical | Warning | Info |
|----------|-------|----------|---------|------|
| Broken Links | 5 | 3 | 2 | — |
| Version Mismatches | 2 | 1 | 1 | — |
| Stale Docs | 3 | — | 2 | 1 |
| Code-Doc Drift | 4 | 2 | 2 | 0 |
| Cross-Doc Conflicts | 1 | — | 1 | 0 |
### Findings
| # | Severity | Type | File:Line | Finding | Evidence |
|---|----------|------|-----------|---------|----------|
| 1 | CRITICAL | broken-link | README.md:45 | Link to deleted file | `docs/api-v1.md` does not exist |
| 2 | CRITICAL | broken-link | docs/setup.md:23 | Link to deleted file | `docs/old-config.md` does not exist |
| 3 | CRITICAL | version | docs/setup.md:7 | Node major version mismatch | Doc says "Node 18", .nvmrc says "20" |
| 4 | CRITICAL | drift | docs/api.md:34 | Function removed | `createUser(name, email)` not found in codebase |
| 5 | CRITICAL | drift | docs/api.md:56 | Endpoint removed | `DELETE /api/v1/sessions` not found in routes |
| 6 | WARNING | version | README.md:3 | Minor version mismatch | Doc says "3.11", pyproject.toml says "3.12" |
| 7 | WARNING | stale | docs/architecture.md | 42 days since last update | Code changed 3 days ago |
| 8 | WARNING | stale | CONTRIBUTING.md | 60 days since last update | CI config changed 5 days ago |
| 9 | WARNING | drift | docs/api.md:12 | Signature changed | `getUsers(page)` is now `getUsers(page, limit)` |
| 10 | WARNING | drift | docs/config.md:8 | Config key renamed | `DB_HOST` is now `DATABASE_URL` |
| 11 | WARNING | cross-doc | README.md:15 vs docs/setup.md:7 | Conflicting Node version | "18" vs "20" |
| 12 | WARNING | broken-link | docs/api.md:78 | Anchor not found | `#authentication` heading removed from docs/auth.md |Example 2: Links Mode (Fast)
Command: links
## Link Check Results
_Checked 12 docs, found 5 issues_
### Broken Links (3 CRITICAL)
| Doc:Line | Link Text | Target | Reason |
|----------|-----------|--------|--------|
| README.md:45 | "API docs" | docs/api-v1.md | File deleted |
| docs/setup.md:23 | "config reference" | docs/old-config.md | File deleted |
| docs/api.md:78 | "auth section" | docs/auth.md#authentication | Heading removed |
### Stale File References (2 WARNING)
| Doc:Line | Path Referenced | Reason |
|----------|----------------|--------|
| docs/deploy.md:15 | `scripts/deploy.sh` | File not found at project root or doc directory |
| CONTRIBUTING.md:30 | `config/eslint.json` | File not found (renamed to `eslint.config.js`?) |Example 3: Drift Mode (Deep Code-Doc Analysis)
Command: drift docs/api-reference.md
## Code-Doc Drift Analysis: docs/api-reference.md
### References Checked
| # | Reference Type | Doc Says | Code Shows | Status |
|---|---------------|----------|------------|--------|
| 1 | Function | `createUser(name, email)` | `createUser(data: CreateUserDto)` at src/services/UserService.ts:45 | CHANGED |
| 2 | Endpoint | `POST /api/users` | Found at src/routes/users.ts:12 | OK |
| 3 | Endpoint | `DELETE /api/sessions` | Not found in any route file | REMOVED |
| 4 | Response shape | `{ id, name, email }` | Returns `{ id, name, email, createdAt }` at src/mappers/UserMapper.ts:8 | CHANGED |
| 5 | Config key | `API_PORT` | Found in .env.example:3 | OK |
| 6 | Class | `UserValidator` | Not found (renamed to `UserSchema`?) | REMOVED |
### Summary
- 6 references checked
- 2 OK, 2 changed (WARNING), 2 removed (CRITICAL)Example 4: Cross-Doc Mode
Command: cross-doc
## Cross-Document Consistency Check
### Topic Overlap Detected
| Topic | Docs |
|-------|------|
| Installation/Setup | README.md, docs/getting-started.md, CONTRIBUTING.md |
| API Authentication | docs/api.md, docs/auth.md |
### Conflicts Found
| # | Topic | Doc A | Doc B | Conflict |
|---|-------|-------|-------|----------|
| 1 | Node version | README.md:3 says "Node 18+" | docs/getting-started.md:5 says "Node 20+" | Version requirement mismatch |
| 2 | Default port | README.md:15 says "port 3000" | docker-compose.yml maps to 8080 | Port number mismatch |
| 3 | Auth method | docs/api.md:20 says "Bearer token" | docs/auth.md:8 says "API key header" | Authentication method conflict |
### Consistent Topics
- Installation steps: README.md and docs/getting-started.md agree on `npm install` workflow
- Database setup: docs/getting-started.md and CONTRIBUTING.md both reference Docker ComposeExample 5: Check Single File
Command: check CONTRIBUTING.md
## Freshness Check: CONTRIBUTING.md
### Staleness
- Last updated: 2026-01-15 (65 days ago)
- Latest code change: 2026-03-19 (2 days ago)
- Drift score: **stale**
### Link Issues (1 found)
| Line | Target | Status |
|------|--------|--------|
| 30 | `config/eslint.json` | WARNING: File not found |
### Code-Doc Drift (2 found)
| Line | Reference | Finding |
|------|-----------|---------|
| 15 | "Run `npm run lint`" | Script exists in package.json — OK |
| 22 | "Tests use Jest" | package.json shows vitest, not jest — **CRITICAL** |
| 45 | "CI runs on GitHub Actions" | .github/workflows/ci.yml exists — OK |
### Version References
- None foundExample 6: Zero-Config vs Config-Driven
Zero-Config (Auto-Discovery)
The skill works out of the box. It: 1. Discovers docs via default patterns (README.md, docs/**/*.md, etc.) 2. Maps docs to code via naming conventions 3. Checks versions against auto-detected ground truth files
Config-Driven (Precise Mappings)
For projects needing explicit control, add to .arkhe.yaml:
doc-freshness:
doc_patterns:
- "docs/**/*.md"
- "guides/**/*.md"
exclude:
- "docs/archive/**"
- "docs/drafts/**"
mappings:
- doc: docs/api-reference.md
code: src/api/**/*.ts
- doc: docs/database.md
code: src/models/**/*.tsThis produces more precise drift analysis since the doc-code relationships are explicit rather than guessed.
---
Example 7: CLAUDE.md Structural Drift
Command: /doc:health claude-md
What it does: Compares CLAUDE.md claims (plugin counts, component inventories, versions, file paths) against filesystem ground truth.
Example output:
### CLAUDE.md Drift Analysis
| # | Severity | Category | Plugin | Finding |
|---|----------|----------|--------|---------|
| 1 | CRITICAL | agents | design-intent | agent 'design-reviewer' exists on disk but not in CLAUDE.md |
| 2 | CRITICAL | agents | design-intent | agent 'ui-architect' exists on disk but not in CLAUDE.md |
| 3 | CRITICAL | agents | design-intent | agent 'ui-explorer' exists on disk but not in CLAUDE.md |
| 4 | WARNING | version | doc | CLAUDE.md says 1.10.0, plugin.json says 1.11.0 |
**Summary**: 144 checks, 140 ok, 3 undocumented, 1 version driftStandalone script:
python3 plugins/doc/skills/doc-freshness/scripts/claude_md_checker.py .Categories checked:
plugin_count— Number of plugins in CLAUDE.md vs marketplace.jsonagents— Agent names per plugin vs agents/*.md filescommands— Command names per plugin vs commands/*.md filesskills— Skill names per plugin vs skills/*/SKILL.md frontmatterversion— Version strings vs plugin.json filesfile_path— Backtick-quoted paths vs filesystem existence
---
Example 8: Frontmatter Onboarding
Command: /doc:health onboard
What it does: Finds maintained docs without tracking frontmatter and suggests minimal title + last_updated fields using git history.
Suggest mode (default — no file changes):
python3 plugins/doc/skills/doc-freshness/scripts/frontmatter_onboard.py .Example output:
23 candidates found for frontmatter onboarding:
| # | File | Title | Last Updated |
|---|------|-------|-------------|
| 1 | README.md | Arkhe Claude Plugins | 2026-03-23 |
| 2 | CLAUDE.md | CLAUDE.md | 2026-03-26 |
| 3 | INSTALLATION.md | Installation Guide | 2026-03-26 |
| 4 | plugins/core/README.md | Core Plugin | 2026-03-24 |
| ... | ... | ... | ... |Apply mode (prepends frontmatter to files):
python3 plugins/doc/skills/doc-freshness/scripts/frontmatter_onboard.py --apply .Generated frontmatter (minimal 2-field format for general docs):
---
title: "Installation Guide"
last_updated: 2026-03-26
---Note: Research docs keep their full 5-field template (title, version, status, created, last_updated). The onboard script only targets general docs that have no frontmatter at all.
Example 8: Setup CI Workflow
Command: /doc:health setup
Interaction:
> Which documentation health checks should run on every PR?
[x] links — Broken internal links and missing anchors
[x] versions — Version references vs ground truth
[x] staleness — Git-based documentation age scoring
[ ] claude-md — CLAUDE.md structural drift
[ ] cross-doc — Cross-document version conflicts
[ ] frontmatter — Missing tracking frontmatter
> When should the workflow fail?
(x) errors — Fail on broken links and critical mismatches
( ) warnings — Also fail on staleness and minor version drift
( ) none — Advisory only, never failGenerated file (.github/workflows/docs-health.yml):
name: Documentation Health
on:
pull_request:
paths:
- '**/*.md'
- 'package.json'
- '.nvmrc'
- '.python-version'
- 'pyproject.toml'
- 'go.mod'
- 'Cargo.toml'
permissions:
contents: read
pull-requests: write
jobs:
docs-health:
name: Documentation Health
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run documentation health checks
uses: joaquimscosta/docs-health-action@v1
with:
checks: 'links,versions,staleness'
fail-on: 'errors'Output:
Workflow created at .github/workflows/docs-health.yml
Next steps:
1. Commit the workflow file: git add .github/workflows/docs-health.yml
2. Push to trigger on your next PR
3. The action will post a comment on PRs with documentation issues
To customize further, see: https://github.com/joaquimscosta/docs-health-action#!/usr/bin/env python3
"""
CLAUDE.md Drift Checker
Parses CLAUDE.md structural claims (plugin counts, component inventories,
versions, file paths) and compares them against filesystem ground truth.
Uses only standard library (no external dependencies). Python 3.8+.
Usage:
python3 claude_md_checker.py <project_root>
Output:
JSON with findings grouped by category and summary.
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import extract_frontmatter, read_file_safe, read_json_safe
# ---------------------------------------------------------------------------
# Plugin name normalization
# ---------------------------------------------------------------------------
# Edge cases where lower().replace(" ", "-") doesn't match directory name
_NAME_OVERRIDES: Dict[str, str] = {
"google stitch": "google-stitch",
"design intent": "design-intent",
"spring boot": "spring-boot",
}
def _normalize_plugin_name(heading: str) -> str:
"""Convert heading text to plugin directory name.
'Core' -> 'core', 'Design Intent' -> 'design-intent'.
"""
lower = heading.strip().lower()
return _NAME_OVERRIDES.get(lower, lower.replace(" ", "-"))
# ---------------------------------------------------------------------------
# Ground truth collection
# ---------------------------------------------------------------------------
def _scan_marketplace(project_root: Path) -> List[str]:
"""Get plugin names from marketplace.json."""
mp = read_json_safe(project_root / ".claude-plugin" / "marketplace.json")
if not mp or "plugins" not in mp:
return []
return [p.get("name", "") for p in mp["plugins"] if p.get("name")]
def _scan_plugin_versions(project_root: Path) -> Dict[str, str]:
"""Get actual versions from each plugin's plugin.json."""
versions: Dict[str, str] = {}
plugins_dir = project_root / "plugins"
if not plugins_dir.is_dir():
return versions
for entry in sorted(plugins_dir.iterdir()):
pj = entry / ".claude-plugin" / "plugin.json"
data = read_json_safe(pj)
if data and "version" in data:
versions[entry.name] = data["version"]
return versions
def _scan_plugin_agents(project_root: Path) -> Dict[str, List[str]]:
"""Get agent names from each plugin's agents/ directory."""
result: Dict[str, List[str]] = {}
plugins_dir = project_root / "plugins"
if not plugins_dir.is_dir():
return result
for entry in sorted(plugins_dir.iterdir()):
agents_dir = entry / "agents"
if not agents_dir.is_dir():
continue
names = []
for md in sorted(agents_dir.glob("*.md")):
content = read_file_safe(md)
if content:
fm = extract_frontmatter(content)
name = fm.get("name") if fm else None
names.append(name or md.stem)
else:
names.append(md.stem)
if names:
result[entry.name] = names
return result
def _scan_plugin_commands(project_root: Path) -> Dict[str, List[str]]:
"""Get command names from each plugin's commands/ directory."""
result: Dict[str, List[str]] = {}
plugins_dir = project_root / "plugins"
if not plugins_dir.is_dir():
return result
for entry in sorted(plugins_dir.iterdir()):
cmds_dir = entry / "commands"
if not cmds_dir.is_dir():
continue
names = [md.stem for md in sorted(cmds_dir.glob("*.md"))]
if names:
result[entry.name] = names
return result
def _scan_plugin_skills(project_root: Path) -> Dict[str, List[str]]:
"""Get skill names from each plugin's skills/ directory."""
result: Dict[str, List[str]] = {}
plugins_dir = project_root / "plugins"
if not plugins_dir.is_dir():
return result
for entry in sorted(plugins_dir.iterdir()):
skills_dir = entry / "skills"
if not skills_dir.is_dir():
continue
names = []
for skill_subdir in sorted(skills_dir.iterdir()):
if not skill_subdir.is_dir():
continue
skill_md = skill_subdir / "SKILL.md"
content = read_file_safe(skill_md)
if content:
fm = extract_frontmatter(content)
name = fm.get("name") if fm else None
names.append(name or skill_subdir.name)
else:
names.append(skill_subdir.name)
if names:
result[entry.name] = names
return result
def _collect_ground_truth(project_root: Path) -> dict:
"""Collect all filesystem ground truth."""
return {
"marketplace_plugins": _scan_marketplace(project_root),
"versions": _scan_plugin_versions(project_root),
"agents": _scan_plugin_agents(project_root),
"commands": _scan_plugin_commands(project_root),
"skills": _scan_plugin_skills(project_root),
}
# ---------------------------------------------------------------------------
# CLAUDE.md claim parsing
# ---------------------------------------------------------------------------
_PLUGIN_HEADING_RE = re.compile(r"^###\s+(.+?)\s+Plugin\s*$", re.MULTILINE)
_AGENTS_LINE_RE = re.compile(
r"^\s*-\s+\*\*Agents\*\*:\s*(.+)$", re.MULTILINE
)
_COMMANDS_LINE_RE = re.compile(
r"^\s*-\s+\*\*Commands\*\*:\s*(.+)$", re.MULTILINE
)
_SKILLS_LINE_RE = re.compile(
r"^\s*-\s+\*\*Skills\*\*:\s*(.+)$", re.MULTILINE
)
_BACKTICK_NAME_RE = re.compile(r"`([^`]+)`")
_SLASH_CMD_RE = re.compile(r"`/([a-z][-a-z0-9]*)`")
_VERSION_LINE_RE = re.compile(
r"Plugin versions?:\s*(.+)", re.IGNORECASE
)
_VERSION_ENTRY_RE = re.compile(r"([a-z][-a-z0-9]*)\s+(\d+\.\d+\.\d+)")
def _extract_names_from_line(line: str) -> List[str]:
"""Extract backtick-quoted names from a component line.
Filters out flags (--deep), paths (docs/foo.md), and parenthetical hints.
"""
names = _BACKTICK_NAME_RE.findall(line)
return [
n for n in names
if not n.startswith("-") and "/" not in n and "." not in n
]
def _extract_commands_from_line(line: str) -> List[str]:
"""Extract /command names from a component line."""
return _SLASH_CMD_RE.findall(line)
def _parse_plugin_sections(content: str) -> Dict[str, dict]:
"""Parse all ### {Name} Plugin sections from CLAUDE.md.
Only parses sections within the '## Available Plugins' area to avoid
matching headings like '### Creating a New Plugin' in other sections.
Returns dict mapping normalized plugin name to claimed components.
"""
# Restrict to "Available Plugins" section
avail_start = content.find("## Available Plugins")
if avail_start == -1:
return {}
# Find the next ## heading after Available Plugins
next_section = re.search(r"^## (?!Available Plugins)", content[avail_start + 1:], re.MULTILINE)
avail_end = (avail_start + 1 + next_section.start()) if next_section else len(content)
avail_content = content[avail_start:avail_end]
sections: Dict[str, dict] = {}
headings = list(_PLUGIN_HEADING_RE.finditer(avail_content))
for i, match in enumerate(headings):
heading_text = match.group(1)
plugin_name = _normalize_plugin_name(heading_text)
# Extract section text up to next heading or end
start = match.end()
end = headings[i + 1].start() if i + 1 < len(headings) else len(avail_content)
section_text = avail_content[start:end]
section: dict = {"heading": heading_text, "agents": [], "commands": [], "skills": []}
# Parse agents
agents_match = _AGENTS_LINE_RE.search(section_text)
if agents_match:
section["agents"] = _extract_names_from_line(agents_match.group(1))
# Parse commands
cmds_match = _COMMANDS_LINE_RE.search(section_text)
if cmds_match:
section["commands"] = _extract_commands_from_line(cmds_match.group(1))
# Parse skills
skills_match = _SKILLS_LINE_RE.search(section_text)
if skills_match:
section["skills"] = _extract_names_from_line(skills_match.group(1))
sections[plugin_name] = section
return sections
def _parse_version_line(content: str) -> Dict[str, str]:
"""Parse the 'Plugin versions: core 2.1.0, ...' line."""
match = _VERSION_LINE_RE.search(content)
if not match:
return {}
return dict(_VERSION_ENTRY_RE.findall(match.group(1)))
# ---------------------------------------------------------------------------
# Drift checks
# ---------------------------------------------------------------------------
def _finding(
category: str,
status: str,
severity: str,
detail: str,
plugin: str = "",
claimed: str = "",
actual: str = "",
) -> dict:
"""Create a finding dict."""
f = {
"category": category,
"status": status,
"severity": severity,
"detail": detail,
}
if plugin:
f["plugin"] = plugin
if claimed:
f["claimed"] = claimed
if actual:
f["actual"] = actual
return f
def _check_plugin_count(
sections: Dict[str, dict], ground_truth: dict
) -> List[dict]:
"""Check if documented plugin count matches marketplace."""
findings = []
claimed_count = len(sections)
actual_count = len(ground_truth["marketplace_plugins"])
if claimed_count != actual_count:
findings.append(_finding(
category="plugin_count",
status="drift",
severity="WARNING",
detail=f"CLAUDE.md documents {claimed_count} plugins, marketplace has {actual_count}",
claimed=str(claimed_count),
actual=str(actual_count),
))
else:
findings.append(_finding(
category="plugin_count",
status="ok",
severity="INFO",
detail=f"Plugin count matches: {actual_count}",
claimed=str(claimed_count),
actual=str(actual_count),
))
return findings
def _check_component_inventories(
sections: Dict[str, dict], ground_truth: dict
) -> List[dict]:
"""Check agents, commands, skills per plugin."""
findings = []
for component_type in ("agents", "commands", "skills"):
actual_all = ground_truth[component_type]
for plugin_name, section in sections.items():
claimed_names = set(section.get(component_type, []))
actual_names = set(actual_all.get(plugin_name, []))
# Names in CLAUDE.md but not on disk
for name in sorted(claimed_names - actual_names):
findings.append(_finding(
category=component_type,
status="phantom",
severity="CRITICAL",
detail=f"{component_type[:-1]} '{name}' documented but not found on disk",
plugin=plugin_name,
claimed=name,
))
# Names on disk but not in CLAUDE.md
for name in sorted(actual_names - claimed_names):
findings.append(_finding(
category=component_type,
status="undocumented",
severity="CRITICAL",
detail=f"{component_type[:-1]} '{name}' exists on disk but not in CLAUDE.md",
plugin=plugin_name,
actual=name,
))
# Matching names
for name in sorted(claimed_names & actual_names):
findings.append(_finding(
category=component_type,
status="ok",
severity="INFO",
detail=f"{component_type[:-1]} '{name}' matches",
plugin=plugin_name,
))
return findings
def _check_plugin_versions(
content: str, ground_truth: dict
) -> List[dict]:
"""Check plugin version claims against plugin.json files."""
findings = []
claimed_versions = _parse_version_line(content)
actual_versions = ground_truth["versions"]
all_plugins = set(claimed_versions) | set(actual_versions)
for plugin in sorted(all_plugins):
claimed = claimed_versions.get(plugin)
actual = actual_versions.get(plugin)
if claimed and actual:
if claimed == actual:
findings.append(_finding(
category="version",
status="ok",
severity="INFO",
detail=f"{plugin} version matches: {actual}",
plugin=plugin,
claimed=claimed,
actual=actual,
))
else:
findings.append(_finding(
category="version",
status="drift",
severity="WARNING",
detail=f"{plugin} version mismatch: CLAUDE.md says {claimed}, plugin.json says {actual}",
plugin=plugin,
claimed=claimed,
actual=actual,
))
elif claimed and not actual:
findings.append(_finding(
category="version",
status="phantom",
severity="WARNING",
detail=f"{plugin} version in CLAUDE.md but plugin not found",
plugin=plugin,
claimed=claimed,
))
elif actual and not claimed:
findings.append(_finding(
category="version",
status="undocumented",
severity="WARNING",
detail=f"{plugin} has version {actual} but not listed in CLAUDE.md versions line",
plugin=plugin,
actual=actual,
))
return findings
def _check_file_claims(content: str, project_root: Path) -> List[dict]:
"""Check backtick-quoted file paths in CLAUDE.md exist on disk."""
findings = []
# Match paths that contain / and have an extension (relative file references)
path_re = re.compile(r"`([a-zA-Z0-9_./-]+/[a-zA-Z0-9_./-]+\.\w+)`")
for match in path_re.finditer(content):
path_str = match.group(1)
# Skip URLs, anchors, glob patterns
if path_str.startswith("http") or "#" in path_str or "*" in path_str:
continue
# Skip slash commands and skill references
if path_str.startswith("/plugin") or path_str.startswith("/doc:"):
continue
# Skip paths starting with ./ (context-dependent)
if path_str.startswith("./"):
continue
target = project_root / path_str
if target.exists():
findings.append(_finding(
category="file_path",
status="ok",
severity="INFO",
detail=f"Path exists: {path_str}",
claimed=path_str,
))
else:
findings.append(_finding(
category="file_path",
status="missing",
severity="WARNING",
detail=f"Path referenced in CLAUDE.md does not exist: {path_str}",
claimed=path_str,
))
return findings
# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------
def check_claude_md(project_root: Path) -> dict:
"""Run all CLAUDE.md drift checks.
Args:
project_root: Path to the project root.
Returns:
Complete check results as a dict.
"""
claude_md = project_root / "CLAUDE.md"
content = read_file_safe(claude_md)
if not content:
return {
"claude_md_path": "CLAUDE.md",
"error": "CLAUDE.md not found or empty",
"findings": [],
"summary": {"total_checks": 0},
}
# Collect ground truth
truth = _collect_ground_truth(project_root)
# Parse CLAUDE.md claims
sections = _parse_plugin_sections(content)
# Run all checks
findings: List[dict] = []
findings.extend(_check_plugin_count(sections, truth))
findings.extend(_check_component_inventories(sections, truth))
findings.extend(_check_plugin_versions(content, truth))
findings.extend(_check_file_claims(content, project_root))
# Build summary
status_counts: Dict[str, int] = {}
category_counts: Dict[str, Dict[str, int]] = {}
for f in findings:
st = f["status"]
cat = f["category"]
status_counts[st] = status_counts.get(st, 0) + 1
if cat not in category_counts:
category_counts[cat] = {}
category_counts[cat][st] = category_counts[cat].get(st, 0) + 1
return {
"claude_md_path": "CLAUDE.md",
"findings": findings,
"summary": {
"total_checks": len(findings),
**status_counts,
"by_category": category_counts,
},
}
def main() -> None:
parser = argparse.ArgumentParser(
description="Check CLAUDE.md claims against filesystem ground truth."
)
parser.add_argument(
"project_root",
help="Path to the project root directory",
)
args = parser.parse_args()
root = Path(args.project_root).resolve()
if not root.is_dir():
print(f"Error: {root} is not a directory", file=sys.stderr)
sys.exit(1)
result = check_claude_md(root)
print(json.dumps(result, indent=2, default=str))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Frontmatter Onboarding Tool
Discovers markdown files that would benefit from tracking frontmatter
and suggests (or applies) minimal frontmatter using git history.
Uses only standard library (no external dependencies). Python 3.8+.
Usage:
python3 frontmatter_onboard.py <project_root> # Suggest mode
python3 frontmatter_onboard.py --apply <project_root> # Apply frontmatter
Output:
JSON with candidates and suggested frontmatter.
"""
import argparse
import json
import re
import sys
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import extract_frontmatter, git_last_modified, read_file_safe
# ---------------------------------------------------------------------------
# Whitelist: Only these locations contain docs we own and maintain
# ---------------------------------------------------------------------------
CANDIDATE_PATTERNS: List[str] = [
# Root-level project docs
"README.md",
"CLAUDE.md",
"INSTALLATION.md",
"CONTRIBUTING.md",
"RELEASING.md",
# Plugin READMEs
"plugins/*/README.md",
# Custom docs (not synced reference copies)
"docs/CLAUDE_CODE_GUIDE.md",
"docs/SKILL_DEVELOPMENT_BEST_PRACTICES.md",
"docs/PLAYWRIGHT_CLI.md",
"docs/README.md",
"docs/research/README.md",
]
# ---------------------------------------------------------------------------
# Candidate discovery
# ---------------------------------------------------------------------------
def _find_candidates(project_root: Path) -> List[Path]:
"""Find markdown files that need frontmatter onboarding.
Uses whitelist patterns. Skips files that already have frontmatter.
"""
candidates: List[Path] = []
for pattern in CANDIDATE_PATTERNS:
for md in sorted(project_root.glob(pattern)):
if not md.is_file():
continue
content = read_file_safe(md)
if not content:
continue
# Skip files that already have any frontmatter
if extract_frontmatter(content) is not None:
continue
candidates.append(md)
return candidates
# ---------------------------------------------------------------------------
# Frontmatter generation
# ---------------------------------------------------------------------------
def _extract_title(content: str) -> Optional[str]:
"""Extract the first heading from markdown content.
Handles both markdown # headings and HTML <h1> tags.
"""
# Try HTML <h1> first (some READMEs use this)
html_match = re.search(r"<h1[^>]*>(.+?)</h1>", content, re.IGNORECASE | re.DOTALL)
if html_match:
title = html_match.group(1).strip()
title = re.sub(r"<[^>]+>", "", title) # Strip nested HTML tags
return title
# Fall back to markdown # heading
md_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
if md_match:
title = md_match.group(1).strip()
title = re.sub(r"\*\*(.+?)\*\*", r"\1", title)
title = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", title)
return title
return None
def _git_last_date(path: Path, project_root: Path) -> str:
"""Get the last commit date (YYYY-MM-DD) for a file via git log."""
rel = str(path.relative_to(project_root))
iso_date = git_last_modified(rel, str(project_root))
if iso_date:
return iso_date[:10]
return date.today().isoformat()
def _generate_frontmatter(path: Path, project_root: Path) -> Dict[str, str]:
"""Generate minimal 2-field frontmatter for a doc."""
content = read_file_safe(path) or ""
title = _extract_title(content) or path.stem
last_updated = _git_last_date(path, project_root)
return {
"title": title,
"last_updated": last_updated,
}
def _format_frontmatter(fm: Dict[str, str]) -> str:
"""Format frontmatter dict as YAML block."""
lines = ["---"]
lines.append(f'title: "{fm["title"]}"')
lines.append(f'last_updated: {fm["last_updated"]}')
lines.append("---")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Apply frontmatter
# ---------------------------------------------------------------------------
def _apply_frontmatter(path: Path, fm: Dict[str, str]) -> bool:
"""Prepend frontmatter to a file. Returns True on success."""
content = read_file_safe(path)
if content is None:
return False
# Double-check: don't add if frontmatter already exists
if extract_frontmatter(content) is not None:
return False
fm_block = _format_frontmatter(fm)
new_content = fm_block + "\n\n" + content
try:
path.write_text(new_content, encoding="utf-8")
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------
def suggest_onboarding(project_root: Path) -> dict:
"""Find candidates and generate suggested frontmatter.
Returns dict with candidates list and summary.
"""
candidates = _find_candidates(project_root)
results = []
for path in candidates:
rel = str(path.relative_to(project_root))
fm = _generate_frontmatter(path, project_root)
results.append({
"path": rel,
"suggested_frontmatter": fm,
})
return {
"candidates": results,
"summary": {
"total_scanned": len(results) + _count_skipped(project_root),
"already_has_frontmatter": _count_skipped(project_root),
"candidates": len(results),
},
}
def _count_skipped(project_root: Path) -> int:
"""Count files matching whitelist that already have frontmatter."""
count = 0
for pattern in CANDIDATE_PATTERNS:
for md in project_root.glob(pattern):
if not md.is_file():
continue
content = read_file_safe(md)
if content and extract_frontmatter(content) is not None:
count += 1
return count
def apply_onboarding(project_root: Path) -> dict:
"""Apply frontmatter to all candidates.
Returns dict with results for each file.
"""
candidates = _find_candidates(project_root)
results = []
applied = 0
for path in candidates:
rel = str(path.relative_to(project_root))
fm = _generate_frontmatter(path, project_root)
success = _apply_frontmatter(path, fm)
results.append({
"path": rel,
"frontmatter": fm,
"applied": success,
})
if success:
applied += 1
return {
"results": results,
"summary": {
"candidates": len(candidates),
"applied": applied,
"failed": len(candidates) - applied,
},
}
def main() -> None:
parser = argparse.ArgumentParser(
description="Suggest or apply frontmatter to docs that need tracking."
)
parser.add_argument(
"project_root",
help="Path to the project root directory",
)
parser.add_argument(
"--apply",
action="store_true",
help="Apply suggested frontmatter to candidate files",
)
args = parser.parse_args()
root = Path(args.project_root).resolve()
if not root.is_dir():
print(f"Error: {root} is not a directory", file=sys.stderr)
sys.exit(1)
if args.apply:
result = apply_onboarding(root)
else:
result = suggest_onboarding(root)
print(json.dumps(result, indent=2, default=str))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Broken link and file reference detection for doc-freshness.
Parses markdown files for internal links, anchor references, and
backtick-quoted file paths. Verifies targets exist.
Uses only standard library (no external dependencies). Python 3.8+.
"""
import sys
from pathlib import Path
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
extract_backtick_paths,
extract_headings,
heading_to_slug,
parse_markdown_links,
read_file_safe,
resolve_relative_path,
)
def check_links(
doc_path: Path, project_root: Path, content: Optional[str] = None,
check_backtick_paths: bool = False,
) -> List[Dict[str, object]]:
"""Check all internal links in a markdown file.
Returns list of findings with keys:
doc, line, target, status ('ok'|'broken'|'warning'), reason, type.
"""
if content is None:
content = read_file_safe(doc_path)
if content is None:
return [{
"doc": str(doc_path.relative_to(project_root)),
"line": 0,
"target": "(file)",
"status": "broken",
"reason": "Cannot read file",
"type": "file",
}]
findings: List[Dict[str, object]] = []
rel_doc = str(doc_path.relative_to(project_root))
# Check markdown links
links = parse_markdown_links(content)
self_heading_slugs = {str(h["slug"]) for h in extract_headings(content)}
for link in links:
target = str(link["target"])
line = link["line"]
# Skip external URLs
if target.startswith(("http://", "https://", "mailto:", "ftp://", "data:")):
continue
# Skip pure anchor links within same file
if target.startswith("#"):
slug = target[1:]
if slug not in self_heading_slugs:
findings.append({
"doc": rel_doc,
"line": line,
"target": target,
"status": "broken",
"reason": f"Anchor '{slug}' not found in this file",
"type": "anchor",
})
continue
# Handle target#anchor
has_anchor = "#" in target
anchor = ""
if has_anchor:
target_path_str, anchor = target.rsplit("#", 1)
else:
target_path_str = target
# Resolve the target path (with project root boundary check)
resolved = resolve_relative_path(doc_path, target_path_str, project_root)
if not resolved.exists():
findings.append({
"doc": rel_doc,
"line": line,
"target": target,
"status": "broken",
"reason": "Target file does not exist",
"type": "link",
})
elif has_anchor and anchor:
# Verify anchor in target file
target_content = read_file_safe(resolved)
if target_content:
headings = extract_headings(target_content)
heading_slugs = {str(h["slug"]) for h in headings}
if anchor not in heading_slugs:
findings.append({
"doc": rel_doc,
"line": line,
"target": target,
"status": "broken",
"reason": f"Anchor '#{anchor}' not found in {target_path_str}",
"type": "anchor",
})
# Check backtick-quoted file paths (opt-in, off by default)
if not check_backtick_paths:
return findings
backtick_paths = extract_backtick_paths(content)
for ref in backtick_paths:
ref_path = str(ref["path"])
line = ref["line"]
# Resolve relative to project root (backtick paths are typically root-relative)
resolved = project_root / ref_path
if not resolved.exists():
# Also try relative to the doc's directory
resolved_from_doc = doc_path.parent / ref_path
if not resolved_from_doc.exists():
findings.append({
"doc": rel_doc,
"line": line,
"target": ref_path,
"status": "warning",
"reason": "Referenced file path does not exist",
"type": "file_ref",
})
return findings
def check_all_links(
doc_paths: List[Path], project_root: Path,
check_backtick_paths: bool = False,
) -> Dict[str, object]:
"""Check links across all discovered documentation files.
Returns dict with:
findings: list of all findings (broken/warning only)
summary: {total_checked, broken, warnings, ok}
"""
all_findings: List[Dict[str, object]] = []
total_links = 0
broken = 0
warnings = 0
for doc_path in doc_paths:
# Count total links in this doc for accurate summary
content = read_file_safe(doc_path)
if content:
total_links += len(parse_markdown_links(content))
if check_backtick_paths:
total_links += len(extract_backtick_paths(content))
findings = check_links(doc_path, project_root, content, check_backtick_paths)
for f in findings:
if f["status"] == "broken":
broken += 1
elif f["status"] == "warning":
warnings += 1
all_findings.extend(findings)
return {
"findings": all_findings,
"summary": {
"total_checked": total_links,
"broken": broken,
"warnings": warnings,
"ok": total_links - broken - warnings,
},
}
if __name__ == "__main__":
import json
if len(sys.argv) < 3:
print("Usage: link_checker.py <project-root> <doc-file> [<doc-file> ...]",
file=sys.stderr)
sys.exit(1)
root = Path(sys.argv[1]).resolve()
doc_files = [Path(f).resolve() for f in sys.argv[2:]]
result = check_all_links(doc_files, root)
print(json.dumps(result, indent=2, default=str))
#!/usr/bin/env python3
"""
Documentation Freshness Scanner (Orchestrator)
Discovers documentation files, runs link checks, version checks,
and git staleness analysis. Outputs a unified JSON report.
Uses only standard library (no external dependencies). Python 3.8+.
Usage:
python3 scan_freshness.py <project_root>
python3 scan_freshness.py --links-only <project_root>
python3 scan_freshness.py --critical-only <project_root>
python3 scan_freshness.py --config .arkhe.yaml <project_root>
Output:
JSON with docs inventory, broken links, version mismatches,
staleness metrics, and summary.
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
detect_doc_tier,
discover_markdown_files,
extract_headings,
git_is_available,
git_last_modified,
parse_markdown_links,
read_file_safe,
read_yaml_section,
DEFAULT_DOC_PATTERNS,
DEFAULT_EXCLUDE_PATTERNS,
)
from link_checker import check_all_links
from version_checker import check_all_versions, collect_ground_truth
from claude_md_checker import check_claude_md
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
def load_config(root: Path, config_path: Optional[str] = None) -> dict:
"""Load doc-freshness configuration from .arkhe.yaml."""
if config_path:
path = Path(config_path)
if not path.is_absolute():
path = root / path
else:
path = root / ".arkhe.yaml"
if not path.exists():
return {}
section = read_yaml_section(path, "doc-freshness")
return section or {}
# ---------------------------------------------------------------------------
# Git staleness analysis
# ---------------------------------------------------------------------------
def compute_staleness(
doc_paths: List[Path], project_root: Path
) -> List[Dict[str, object]]:
"""Compute git-based staleness for each documentation file.
Compares each doc's last commit date against the most recent
code commit in the project.
Returns list of dicts with:
doc, doc_date, doc_age_days, latest_code_date, code_age_days, drift_score.
"""
if not git_is_available(str(project_root)):
return []
now = datetime.now(timezone.utc).replace(tzinfo=None)
staleness: List[Dict[str, object]] = []
# Get latest code commit date (any file that isn't .md)
import subprocess
try:
result = subprocess.run(
["git", "log", "-1", "--format=%aI", "--diff-filter=ACMR",
"--", "*.py", "*.ts", "*.tsx", "*.js", "*.jsx",
"*.java", "*.kt", "*.go", "*.rs", "*.rb",
"*.yaml", "*.yml", "*.json", "*.toml"],
capture_output=True, text=True, timeout=10,
cwd=str(project_root),
)
latest_code_date_str = result.stdout.strip() if result.returncode == 0 else None
except (subprocess.TimeoutExpired, FileNotFoundError):
latest_code_date_str = None
latest_code_date = _parse_git_date(latest_code_date_str)
for doc_path in doc_paths:
rel_doc = str(doc_path.relative_to(project_root))
doc_date_str = git_last_modified(rel_doc, str(project_root))
doc_date = _parse_git_date(doc_date_str)
doc_age_days = (now - doc_date).days if doc_date else None
code_age_days = (now - latest_code_date).days if latest_code_date else None
# Compute drift score
drift_score = "unknown"
if doc_age_days is not None and code_age_days is not None:
gap = doc_age_days - code_age_days
if gap <= 7:
drift_score = "fresh"
elif gap <= 30:
drift_score = "aging"
elif gap <= 90:
drift_score = "stale"
else:
drift_score = "very_stale"
staleness.append({
"doc": rel_doc,
"doc_date": doc_date_str,
"doc_age_days": doc_age_days,
"latest_code_date": latest_code_date_str,
"code_age_days": code_age_days,
"drift_score": drift_score,
})
return staleness
def _parse_git_date(date_str: Optional[str]) -> Optional[datetime]:
"""Parse a git date string to a UTC-naive datetime for age calculations.
Accepts both %ai format ('2026-03-15 10:30:00 -0400') and
%aI format ('2026-03-15T10:30:00-04:00').
"""
if not date_str:
return None
try:
# Try ISO 8601 with timezone first (%aI format)
# Python 3.7+ supports %z for ±HH:MM
cleaned = date_str.strip()
if "T" in cleaned:
# %aI format: 2026-03-15T10:30:00-04:00
dt = datetime.fromisoformat(cleaned)
else:
# %ai format: 2026-03-15 10:30:00 -0400
# Convert -0400 to -04:00 for fromisoformat
parts = cleaned.rsplit(" ", 1)
if len(parts) == 2 and (parts[1].startswith("+") or parts[1].startswith("-")):
tz = parts[1]
if len(tz) == 5: # -0400
tz = tz[:3] + ":" + tz[3:]
cleaned = parts[0].replace(" ", "T") + tz
dt = datetime.fromisoformat(cleaned)
# Convert to UTC-naive for consistent age calculations
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
except (ValueError, IndexError, TypeError):
# Fallback: parse just date/time, ignore timezone
try:
return datetime.strptime(date_str[:19], "%Y-%m-%d %H:%M:%S")
except (ValueError, IndexError):
return None
# ---------------------------------------------------------------------------
# Document inventory
# ---------------------------------------------------------------------------
def build_inventory(
doc_paths: List[Path], project_root: Path
) -> List[Dict[str, object]]:
"""Build an inventory of documentation files with their metadata."""
inventory: List[Dict[str, object]] = []
for doc_path in doc_paths:
rel_doc = str(doc_path.relative_to(project_root))
content = read_file_safe(doc_path)
tier = detect_doc_tier(doc_path)
if content is None:
inventory.append({
"path": rel_doc,
"tier": tier,
"headings": [],
"link_count": 0,
"line_count": 0,
"error": "Cannot read file",
})
continue
headings = extract_headings(content)
links = parse_markdown_links(content)
inventory.append({
"path": rel_doc,
"tier": tier,
"headings": [h["text"] for h in headings],
"link_count": len(links),
"line_count": len(content.splitlines()),
})
return inventory
# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------
def scan(
project_root: Path,
links_only: bool = False,
config_path: Optional[str] = None,
critical_only: bool = False,
) -> dict:
"""Run the full freshness scan.
Args:
project_root: Path to the project root.
links_only: If True, only run link checks (fast mode).
config_path: Optional path to config file.
critical_only: If True, only scan critical docs (README.md, CLAUDE.md, plugin docs).
Returns:
Complete scan results as a dict.
"""
# Load configuration
config = load_config(project_root, config_path)
doc_patterns = config.get("doc_patterns", DEFAULT_DOC_PATTERNS)
exclude = config.get("exclude", DEFAULT_EXCLUDE_PATTERNS)
# Ensure patterns are lists
if isinstance(doc_patterns, str):
doc_patterns = [doc_patterns]
if isinstance(exclude, str):
exclude = [exclude]
# Discover documentation files
doc_paths = discover_markdown_files(project_root, doc_patterns, exclude)
# Filter to critical docs if requested
if critical_only:
critical_paths = {
"README.md",
"CLAUDE.md",
}
doc_paths = [
p for p in doc_paths
if str(p.relative_to(project_root)) in critical_paths
]
if not doc_paths:
return {
"error": "No documentation files found",
"config": {
"doc_patterns": doc_patterns,
"exclude": exclude,
},
"docs": [],
"broken_links": {"findings": [], "summary": {}},
"version_mismatches": {"findings": [], "summary": {}},
"staleness": [],
"summary": {
"total_docs": 0,
"broken_links": 0,
"version_mismatches": 0,
"stale_docs": 0,
},
}
# Build inventory
inventory = build_inventory(doc_paths, project_root)
# Run link checks (always)
link_results = check_all_links(doc_paths, project_root)
# Run additional checks unless links-only mode
version_results: Dict[str, object] = {"findings": [], "summary": {}}
staleness: List[Dict[str, object]] = []
claude_md_results: Dict[str, object] = {"findings": [], "summary": {}}
if not links_only:
version_results = check_all_versions(doc_paths, project_root)
staleness = compute_staleness(doc_paths, project_root)
# CLAUDE.md structural drift check
if (project_root / "CLAUDE.md").exists():
claude_md_results = check_claude_md(project_root)
# Build summary
broken_count = link_results["summary"].get("broken", 0)
version_mismatch_count = version_results.get("summary", {})
if isinstance(version_mismatch_count, dict):
version_mismatch_count = version_mismatch_count.get("mismatches", 0)
else:
version_mismatch_count = 0
stale_count = sum(
1 for s in staleness
if s.get("drift_score") in ("stale", "very_stale")
)
# Count docs per tier
tier_counts = {"basic": 0, "deep": 0}
for doc in inventory:
tier = doc.get("tier", "basic")
tier_counts[tier] = tier_counts.get(tier, 0) + 1
return {
"scan_date": datetime.now().isoformat(),
"project_root": str(project_root),
"config": {
"doc_patterns": doc_patterns,
"exclude": exclude,
"links_only": links_only,
"critical_only": critical_only,
},
"docs": inventory,
"broken_links": link_results,
"version_mismatches": version_results,
"staleness": staleness,
"claude_md_drift": claude_md_results,
"summary": {
"total_docs": len(doc_paths),
"tier_counts": tier_counts,
"broken_links": broken_count,
"version_mismatches": version_mismatch_count,
"stale_docs": stale_count,
"claude_md_drift": len([
f for f in claude_md_results.get("findings", [])
if f.get("status") != "ok"
]),
},
}
def main() -> None:
parser = argparse.ArgumentParser(
description="Scan project documentation for freshness issues."
)
parser.add_argument(
"project_root",
help="Path to the project root directory",
)
parser.add_argument(
"--links-only",
action="store_true",
help="Only check for broken links (fast mode)",
)
parser.add_argument(
"--critical-only",
action="store_true",
help="Only scan critical docs (README.md, CLAUDE.md, plugin docs) — fast mode for SessionStart hook",
)
parser.add_argument(
"--config",
help="Path to .arkhe.yaml config file (default: <project_root>/.arkhe.yaml)",
)
args = parser.parse_args()
root = Path(args.project_root).resolve()
if not root.is_dir():
print(f"Error: {root} is not a directory", file=sys.stderr)
sys.exit(1)
result = scan(root, links_only=args.links_only, config_path=args.config, critical_only=args.critical_only)
print(json.dumps(result, indent=2, default=str))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Shared utilities for doc-freshness scanners.
Common helpers for markdown parsing, git operations, and file handling.
Uses only standard library (no external dependencies). Python 3.8+.
"""
import re
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
# Directories to skip when walking project trees
SKIP_DIRS: Set[str] = {
"build", ".gradle", "node_modules", ".git", "target", "out", ".idea",
"__pycache__", ".venv", "venv", "env", ".tox", ".nox", ".mypy_cache",
".pytest_cache", ".ruff_cache", "dist", ".next", ".turbo", "coverage",
"vendor", ".bundle", "_site", ".docusaurus",
}
# Default glob patterns for discovering documentation files
DEFAULT_DOC_PATTERNS: List[str] = [
"README.md", "CLAUDE.md", "CONTRIBUTING.md", "CHANGELOG.md",
"INSTALL.md", "INSTALLATION.md", "SETUP.md", "LICENSE.md",
"docs/**/*.md", "wiki/**/*.md", "plan/**/*.md",
".github/**/*.md",
]
# Default patterns to exclude
DEFAULT_EXCLUDE_PATTERNS: List[str] = [
"node_modules/**", ".git/**", "vendor/**", ".venv/**", "venv/**",
"dist/**", "build/**", "target/**", "coverage/**",
]
def extract_frontmatter(content: str) -> Optional[Dict[str, str]]:
"""Extract YAML frontmatter from markdown content.
Parses the block between opening and closing '---' delimiters.
Returns flat key-value dict, or None if no frontmatter found.
"""
lines = content.splitlines()
if not lines or lines[0].strip() != "---":
return None
for i, line in enumerate(lines[1:50], start=1):
if line.strip() == "---":
result: Dict[str, str] = {}
for fm_line in lines[1:i]:
fm_stripped = fm_line.strip()
if not fm_stripped or fm_stripped.startswith("#"):
continue
if ":" in fm_stripped:
key, _, value = fm_stripped.partition(":")
value = value.strip().strip('"').strip("'")
if value:
result[key.strip()] = value
return result if result else None
return None
def detect_doc_tier(path: Path) -> str:
"""Detect whether a doc qualifies for deep or basic scanning.
Deep tier: has YAML frontmatter with last_updated or version fields.
Basic tier: everything else.
"""
content = read_file_safe(path)
if not content:
return "basic"
fm = extract_frontmatter(content)
if fm and ("last_updated" in fm or "version" in fm):
return "deep"
return "basic"
def read_file_safe(path: Path) -> Optional[str]:
"""Read a file's text content, returning None on any error."""
try:
return path.read_text(encoding="utf-8")
except Exception:
return None
def read_json_safe(path: Path) -> Optional[dict]:
"""Read and parse a JSON file, returning None on any error."""
import json
content = read_file_safe(path)
if content is None:
return None
try:
return json.loads(content)
except (ValueError, TypeError):
return None
def read_yaml_section(path: Path, section: str) -> Optional[dict]:
"""Read a YAML file and extract a top-level section.
Uses a simple line-based parser (no PyYAML dependency).
Only handles flat key-value and simple list structures.
"""
content = read_file_safe(path)
if content is None:
return None
lines = content.splitlines()
in_section = False
result: dict = {}
current_key = None
current_list: List[str] = []
base_indent = 0
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
indent = len(line) - len(line.lstrip())
if indent == 0 and stripped.endswith(":"):
if in_section:
if current_key and current_list:
result[current_key] = current_list
break
if stripped == f"{section}:":
in_section = True
base_indent = 2
continue
if not in_section:
continue
if indent < base_indent and not stripped.startswith("-"):
if current_key and current_list:
result[current_key] = current_list
break
if ":" in stripped and not stripped.startswith("-"):
if current_key and current_list:
result[current_key] = current_list
current_list = []
key, _, value = stripped.partition(":")
current_key = key.strip()
value = value.strip().strip('"').strip("'")
if value:
result[current_key] = value
current_key = None
elif stripped.startswith("- "):
item = stripped[2:].strip().strip('"').strip("'")
current_list.append(item)
if in_section and current_key and current_list:
result[current_key] = current_list
return result if result else None
# ---------------------------------------------------------------------------
# Markdown parsing
# ---------------------------------------------------------------------------
# Matches fenced code block delimiters (backtick or tilde, 3+ chars)
_FENCE_RE = re.compile(r'^(`{3,}|~{3,})')
def _is_fence_line(stripped: str) -> bool:
"""Check if a stripped line is a fenced code block delimiter."""
return bool(_FENCE_RE.match(stripped))
# Matches [text](target) and [text](target "title")
_INLINE_LINK_RE = re.compile(
r'\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)'
)
# Matches [text]: target (reference-style links)
_REF_LINK_RE = re.compile(
r'^\[([^\]]+)\]:\s+(\S+)', re.MULTILINE
)
# Matches <img src="..."> and <img src='...'>
_IMG_SRC_RE = re.compile(
r'<img\s[^>]*src=["\']([^"\']+)["\']', re.IGNORECASE
)
# Matches backtick-quoted file paths like `src/foo.ts` or `docs/bar.md`
_BACKTICK_PATH_RE = re.compile(
r'`([a-zA-Z0-9_./-]+\.[a-zA-Z0-9]+)`'
)
# Patterns that look like file paths but are not
_NON_FILE_PREFIXES = re.compile(
r'^(?:'
r'(?:feature|fix|release|hotfix|bugfix|chore|dependabot)/' # git branches
r'|roles/' # IAM roles
r')',
re.IGNORECASE,
)
# Known code file extensions (for owner/repo disambiguation)
_CODE_EXTENSIONS = {
"md", "py", "js", "ts", "tsx", "jsx", "go", "rs", "java",
"kt", "kts", "rb", "php", "c", "h", "cpp", "hpp", "cs", "swift",
"yaml", "yml", "json", "toml", "xml", "html", "css", "scss",
"sql", "sh", "bash", "zsh", "ps1", "bat", "cmd",
"txt", "cfg", "ini", "conf", "env", "lock", "sum",
"vue", "svelte", "astro", "mdx", "png", "jpg", "svg", "gif",
"gradle", "properties", "sq",
}
def _is_likely_file_path(path: str) -> bool:
"""Heuristic: does this backtick-quoted string look like a file path?
Returns False for git branches, IAM roles, runtime URLs, and owner/repo refs.
"""
if _NON_FILE_PREFIXES.match(path):
return False
# Skip owner/repo patterns: exactly 2 segments with non-code extension
# e.g. "deznode/skola.dev" (TLD), but keep "src/main.py" (code extension)
segments = path.split("/")
if len(segments) == 2 and "." not in segments[0] and "." in segments[1]:
ext = segments[1].rsplit(".", 1)[-1].lower()
if ext not in _CODE_EXTENSIONS:
return False
return True
def parse_markdown_links(content: str) -> List[Dict[str, object]]:
"""Extract all links from markdown content.
Returns list of dicts with keys: text, target, line, type.
Types: 'inline', 'reference', 'image'.
Skips links inside fenced code blocks.
"""
links: List[Dict[str, object]] = []
lines = content.splitlines()
in_code_block = False
for line_num, line in enumerate(lines, start=1):
stripped = line.strip()
if _is_fence_line(stripped):
in_code_block = not in_code_block
continue
if in_code_block:
continue
for match in _INLINE_LINK_RE.finditer(line):
links.append({
"text": match.group(1),
"target": match.group(2),
"line": line_num,
"type": "inline",
})
for match in _IMG_SRC_RE.finditer(line):
links.append({
"text": "(image)",
"target": match.group(1),
"line": line_num,
"type": "image",
})
# Reference-style links on this line
ref_match = _REF_LINK_RE.match(line)
if ref_match:
links.append({
"text": ref_match.group(1),
"target": ref_match.group(2),
"line": line_num,
"type": "reference",
})
return links
def extract_backtick_paths(content: str) -> List[Dict[str, object]]:
"""Extract backtick-quoted file paths from markdown content.
Filters for paths that look like actual file references (contain / and extension).
"""
paths: List[Dict[str, object]] = []
lines = content.splitlines()
in_code_block = False
for line_num, line in enumerate(lines, start=1):
stripped = line.strip()
if _is_fence_line(stripped):
in_code_block = not in_code_block
continue
if in_code_block:
continue
for match in _BACKTICK_PATH_RE.finditer(line):
path = match.group(1)
# Only include paths with a directory separator that look like files
if "/" in path and _is_likely_file_path(path):
paths.append({
"path": path,
"line": line_num,
})
return paths
def extract_headings(content: str) -> List[Dict[str, object]]:
"""Extract markdown headings from content.
Returns list of dicts with keys: level, text, slug, line.
"""
headings: List[Dict[str, object]] = []
lines = content.splitlines()
in_code_block = False
for line_num, line in enumerate(lines, start=1):
stripped = line.strip()
if _is_fence_line(stripped):
in_code_block = not in_code_block
continue
if in_code_block:
continue
match = re.match(r'^(#{1,6})\s+(.+?)(?:\s*#*\s*)?$', stripped)
if match:
level = len(match.group(1))
text = match.group(2).strip()
slug = heading_to_slug(text)
headings.append({
"level": level,
"text": text,
"slug": slug,
"line": line_num,
})
return headings
def heading_to_slug(text: str) -> str:
"""Convert a heading to a GitHub-compatible anchor slug.
Rules: lowercase, spaces→hyphens, strip non-ASCII and non-alphanumeric
except hyphens. Matches GitHub's anchor generation algorithm.
"""
slug = text.lower()
slug = re.sub(r'[^a-z0-9\s-]', '', slug)
slug = re.sub(r'[\s]+', '-', slug)
slug = slug.strip('-')
return slug
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def git_last_modified(file_path: str, project_root: str) -> Optional[str]:
"""Get the last commit date for a file via git log.
Returns strict ISO 8601 date string (e.g., '2026-03-15T10:30:00-04:00') or None.
"""
try:
result = subprocess.run(
["git", "log", "-1", "--format=%aI", "--", file_path],
capture_output=True, text=True, timeout=10,
cwd=project_root,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return None
def git_is_available(project_root: str) -> bool:
"""Check if git is available and the directory is a git repo."""
try:
result = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, timeout=5,
cwd=project_root,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def resolve_relative_path(
from_file: Path, link_target: str, project_root: Optional[Path] = None
) -> Path:
"""Resolve a relative link target from a document's location.
If project_root is provided, returns Path("") for paths that
escape the project root (path traversal protection).
"""
# Strip anchor fragments
target = link_target.split("#")[0]
if not target:
return from_file # Same-file anchor
resolved = (from_file.parent / target).resolve()
if project_root is not None:
try:
resolved.relative_to(project_root.resolve())
except ValueError:
return Path("") # Escapes project root
return resolved
def discover_markdown_files(
root: Path,
patterns: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
) -> List[Path]:
"""Discover markdown files in a project using glob patterns.
Args:
root: Project root directory.
patterns: Glob patterns to search. Defaults to DEFAULT_DOC_PATTERNS.
exclude: Glob patterns to exclude. Defaults to DEFAULT_EXCLUDE_PATTERNS.
Returns:
Sorted list of unique markdown file paths.
"""
if patterns is None:
patterns = DEFAULT_DOC_PATTERNS
if exclude is None:
exclude = DEFAULT_EXCLUDE_PATTERNS
# Build exclusion set
excluded: Set[Path] = set()
for pattern in exclude:
excluded.update(root.glob(pattern))
# Discover files
found: Set[Path] = set()
for pattern in patterns:
for path in root.glob(pattern):
if path.is_file() and path not in excluded:
# Check not inside a skip directory
parts = path.relative_to(root).parts
if not any(part in SKIP_DIRS for part in parts):
found.add(path)
return sorted(found)
# ---------------------------------------------------------------------------
# Severity helpers (shared across formatters)
# ---------------------------------------------------------------------------
# Severity ordering (higher = more severe)
SEVERITY_ORDER: Dict[str, int] = {
"critical": 3,
"error": 3, # Treat "error" same as "critical"
"warning": 2,
"info": 1,
}
def normalize_severity(raw: str) -> str:
"""Normalize severity string to one of: error, warning, info.
Maps 'critical', 'ERROR', 'broken', 'mismatch' etc. to canonical levels.
"""
lower = raw.lower().strip()
if lower in ("critical", "error", "broken", "mismatch"):
return "error"
if lower in ("warning", "warn", "minor_mismatch", "outdated_frontmatter"):
return "warning"
return "info"
def severity_meets_threshold(severity: str, threshold: str) -> bool:
"""Check if a severity meets the minimum threshold."""
sev_rank = SEVERITY_ORDER.get(severity, 0)
thr_rank = SEVERITY_ORDER.get(threshold, 0)
return sev_rank >= thr_rank
#!/usr/bin/env bash
set -euo pipefail
# ── Configuration ──────────────────────────────────────────────────────
VERBATIM_FILES=(shared.py link_checker.py version_checker.py scan_freshness.py)
MANUAL_FILES=(claude_md_checker.py frontmatter_onboard.py)
# ── Paths ──────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_SCRIPTS="$SCRIPT_DIR"
ACTION_REPO=""
YES_FLAG=false
for arg in "$@"; do
case "$arg" in
-y|--yes) YES_FLAG=true ;;
-h|--help)
echo "Usage: $(basename "$0") <path-to-docs-health-action> [-y|--yes]"
echo ""
echo "Syncs checker scripts from docs-health-action into this plugin."
echo ""
echo " Verbatim-copies: ${VERBATIM_FILES[*]}"
echo " Manual review: ${MANUAL_FILES[*]}"
echo ""
echo "Options:"
echo " -y, --yes Skip confirmation prompts"
echo " -h, --help Show this help"
exit 0
;;
-*) echo "Unknown flag: $arg"; exit 1 ;;
*) ACTION_REPO="$arg" ;;
esac
done
if [[ -z "$ACTION_REPO" ]]; then
echo "Usage: $(basename "$0") <path-to-docs-health-action> [-y|--yes]"
echo ""
echo "Syncs checker scripts from docs-health-action into this plugin."
echo "Example: $(basename "$0") ~/Projects/docs-health-action"
exit 1
fi
ACTION_SCRIPTS="$ACTION_REPO/scripts"
# ── Validate ───────────────────────────────────────────────────────────
if [[ ! -d "$ACTION_SCRIPTS" ]]; then
echo "ERROR: Cannot find action scripts at: $ACTION_SCRIPTS"
echo " Make sure the path points to the docs-health-action repo root."
exit 1
fi
echo "=== Sync from docs-health-action ==="
echo "Source: $(cd "$ACTION_SCRIPTS" && pwd)"
echo "Destination: $PLUGIN_SCRIPTS"
echo ""
# Validate all source files exist
missing=false
for f in "${VERBATIM_FILES[@]}" "${MANUAL_FILES[@]}"; do
if [[ ! -f "$ACTION_SCRIPTS/$f" ]]; then
echo "ERROR: Missing in action repo: $ACTION_SCRIPTS/$f"
missing=true
fi
done
if $missing; then exit 1; fi
# ── Helpers ────────────────────────────────────────────────────────────
confirm() {
if $YES_FLAG; then return 0; fi
local prompt="$1"
read -r -p "$prompt [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
# ── Verbatim copies ───────────────────────────────────────────────────
echo "── Verbatim files ──"
copied=0
skipped_identical=0
skipped_declined=0
for f in "${VERBATIM_FILES[@]}"; do
src="$ACTION_SCRIPTS/$f"
dst="$PLUGIN_SCRIPTS/$f"
if [[ ! -f "$dst" ]]; then
echo " [new] $f — does not exist in plugin yet"
if confirm " Copy $f?"; then
cp "$src" "$dst"
echo " [copied] $f"
copied=$((copied + 1))
else
echo " [skipped] $f"
skipped_declined=$((skipped_declined + 1))
fi
continue
fi
if diff -q "$src" "$dst" >/dev/null 2>&1; then
echo " [ok] $f — already identical"
skipped_identical=$((skipped_identical + 1))
continue
fi
changes=$(diff "$src" "$dst" | grep -c '^[<>]' || true)
echo " [changed] $f — $changes lines differ"
if confirm " Overwrite $f?"; then
cp "$src" "$dst"
echo " [copied] $f"
((copied++))
else
echo " [skipped] $f"
((skipped_declined++))
fi
done
# ── Manual review ─────────────────────────────────────────────────────
echo ""
echo "── Files requiring manual review ──"
echo "(These have plugin-specific customizations — do NOT copy blindly)"
echo ""
needs_review=0
for f in "${MANUAL_FILES[@]}"; do
src="$ACTION_SCRIPTS/$f"
dst="$PLUGIN_SCRIPTS/$f"
if [[ ! -f "$dst" ]]; then
echo " [new] $f — does not exist in plugin (copy manually if needed)"
needs_review=$((needs_review + 1))
continue
fi
if diff -q "$src" "$dst" >/dev/null 2>&1; then
echo " [ok] $f — already in sync"
continue
fi
((needs_review++))
echo "┌─── $f: action (a) vs plugin (b) ───"
diff -u "$src" "$dst" || true
echo "└────────────────────────────────────────────"
echo ""
case "$f" in
claude_md_checker.py)
echo " PRESERVE in plugin:"
echo " - Arkhe-specific _NAME_OVERRIDES dict values"
echo " MERGE from action:"
echo " - has_plugins guard (prevents crash without plugins/ dir)"
echo " - Any new check logic or bug fixes"
;;
frontmatter_onboard.py)
echo " PRESERVE in plugin:"
echo " - Arkhe-specific CANDIDATE_PATTERNS whitelist"
echo " MERGE from action:"
echo " - patterns parameter on public functions (API flexibility)"
echo " - _count_skipped() single-call optimization (efficiency bug fix)"
echo " - Any new check logic or bug fixes"
;;
esac
echo ""
done
# ── Summary ───────────────────────────────────────────────────────────
echo ""
echo "=== Summary ==="
echo " Copied: $copied"
echo " Already identical: $skipped_identical"
echo " Declined: $skipped_declined"
echo " Need manual merge: $needs_review"
if ((needs_review > 0)); then
echo ""
echo "Review the diffs above and manually merge changes for the flagged files."
fi
#!/usr/bin/env python3
"""
Version staleness detection for doc-freshness.
Extracts version references from markdown files and compares them
against ground truth sources (package.json, .nvmrc, pyproject.toml, etc.).
Uses only standard library (no external dependencies). Python 3.8+.
"""
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import extract_frontmatter, git_last_modified, read_file_safe, read_json_safe
from shared import _is_fence_line
# ---------------------------------------------------------------------------
# Ground truth extractors
# ---------------------------------------------------------------------------
def _extract_from_package_json(root: Path) -> Dict[str, str]:
"""Extract version info from package.json."""
versions: Dict[str, str] = {}
data = read_json_safe(root / "package.json")
if data:
if "version" in data:
versions["package-version"] = data["version"]
engines = data.get("engines", {})
if "node" in engines:
# Extract numeric version from range like ">=18.0.0"
match = re.search(r'(\d+(?:\.\d+)*)', engines["node"])
if match:
versions["node"] = match.group(1)
if "npm" in engines:
match = re.search(r'(\d+(?:\.\d+)*)', engines["npm"])
if match:
versions["npm"] = match.group(1)
return versions
def _extract_from_nvmrc(root: Path) -> Dict[str, str]:
"""Extract Node version from .nvmrc."""
content = read_file_safe(root / ".nvmrc")
if content:
version = content.strip().lstrip("v")
if re.match(r'\d+', version):
return {"node": version}
return {}
def _extract_from_python_version(root: Path) -> Dict[str, str]:
"""Extract Python version from .python-version."""
content = read_file_safe(root / ".python-version")
if content:
version = content.strip()
if re.match(r'\d+\.\d+', version):
return {"python": version}
return {}
def _extract_from_pyproject(root: Path) -> Dict[str, str]:
"""Extract Python version requirement from pyproject.toml."""
content = read_file_safe(root / "pyproject.toml")
if content:
versions: Dict[str, str] = {}
# requires-python = ">=3.8"
match = re.search(r'requires-python\s*=\s*"([^"]+)"', content)
if match:
ver_match = re.search(r'(\d+\.\d+)', match.group(1))
if ver_match:
versions["python"] = ver_match.group(1)
# version = "1.2.3"
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if match:
versions["package-version"] = match.group(1)
return versions
return {}
def _extract_from_go_mod(root: Path) -> Dict[str, str]:
"""Extract Go version from go.mod."""
content = read_file_safe(root / "go.mod")
if content:
match = re.search(r'^go\s+(\d+\.\d+)', content, re.MULTILINE)
if match:
return {"go": match.group(1)}
return {}
def _extract_from_tool_versions(root: Path) -> Dict[str, str]:
"""Extract versions from .tool-versions (asdf)."""
content = read_file_safe(root / ".tool-versions")
if content:
versions: Dict[str, str] = {}
for line in content.splitlines():
parts = line.strip().split()
if len(parts) >= 2:
tool = parts[0].lower()
version = parts[1]
if tool in ("nodejs", "node"):
versions["node"] = version
elif tool == "python":
versions["python"] = version
elif tool == "golang":
versions["go"] = version
elif tool == "java":
versions["java"] = version
elif tool == "ruby":
versions["ruby"] = version
else:
versions[tool] = version
return versions
return {}
def _extract_from_gradle(root: Path) -> Dict[str, str]:
"""Extract Java version from build.gradle.kts or build.gradle."""
for filename in ("build.gradle.kts", "build.gradle"):
content = read_file_safe(root / filename)
if content:
versions: Dict[str, str] = {}
# Java toolchain: jvmToolchain(21) or languageVersion.set(JavaLanguageVersion.of(21))
match = re.search(r'jvmToolchain\((\d+)\)', content)
if match:
versions["java"] = match.group(1)
else:
match = re.search(r'JavaLanguageVersion\.of\((\d+)\)', content)
if match:
versions["java"] = match.group(1)
# sourceCompatibility / targetCompatibility
if "java" not in versions:
match = re.search(
r'(?:source|target)Compatibility\s*=\s*["\']?(\d+)["\']?',
content,
)
if match:
versions["java"] = match.group(1)
return versions
return {}
def _extract_from_pom(root: Path) -> Dict[str, str]:
"""Extract Java version from pom.xml."""
content = read_file_safe(root / "pom.xml")
if content:
# Use capturing group for tag name, backreference for closing tag
match = re.search(
r'<(java\.version|maven\.compiler\.(?:source|target))>(\d+)</\1>',
content,
)
if match:
return {"java": match.group(2)}
return {}
def collect_ground_truth(root: Path) -> Dict[str, str]:
"""Collect all version ground truth from project files.
Returns dict mapping tool/language name to version string.
Later sources override earlier ones (more specific wins).
"""
truth: Dict[str, str] = {}
# Order matters: more specific sources override generic ones
extractors = [
_extract_from_tool_versions,
_extract_from_package_json,
_extract_from_nvmrc,
_extract_from_python_version,
_extract_from_pyproject,
_extract_from_go_mod,
_extract_from_gradle,
_extract_from_pom,
]
for extractor in extractors:
truth.update(extractor(root))
return truth
# ---------------------------------------------------------------------------
# Version extraction from markdown
# ---------------------------------------------------------------------------
# Common patterns for version references in docs
_VERSION_PATTERNS: List[Tuple[str, re.Pattern]] = [
("node", re.compile(
r'(?:Node(?:\.js)?|node)\s*(?:>=?\s*)?v?(\d+(?:\.\d+)*)', re.IGNORECASE
)),
("python", re.compile(
r'(?:Python|python)\s*(?:>=?\s*)?(\d+\.\d+(?:\.\d+)?)', re.IGNORECASE
)),
("java", re.compile(
r'(?:Java|JDK|java)\s*(?:>=?\s*)?(\d+)(?:\.\d+)?', re.IGNORECASE
)),
("go", re.compile(
r'(?:Go|golang)\s*(?:>=?\s*)?v?(\d+\.\d+(?:\.\d+)?)', re.IGNORECASE
)),
("ruby", re.compile(
r'(?:Ruby|ruby)\s*(?:>=?\s*)?(\d+\.\d+(?:\.\d+)?)', re.IGNORECASE
)),
("rust", re.compile(
r'(?:Rust|rust)\s*(?:>=?\s*)?(\d+\.\d+(?:\.\d+)?)', re.IGNORECASE
)),
]
def extract_doc_versions(
content: str,
) -> List[Dict[str, object]]:
"""Extract version references from markdown content.
Returns list of dicts with keys: name, value, line.
"""
found: List[Dict[str, object]] = []
lines = content.splitlines()
in_code_block = False
for line_num, line in enumerate(lines, start=1):
stripped = line.strip()
if _is_fence_line(stripped):
in_code_block = not in_code_block
continue
if in_code_block:
continue
for name, pattern in _VERSION_PATTERNS:
for match in pattern.finditer(line):
found.append({
"name": name,
"value": match.group(1),
"line": line_num,
})
return found
def check_versions(
doc_path: Path, project_root: Path, ground_truth: Dict[str, str],
content: Optional[str] = None,
) -> List[Dict[str, object]]:
"""Check version references in a doc against ground truth.
Returns list of findings with keys:
doc, line, name, doc_value, actual, source, status.
"""
if content is None:
content = read_file_safe(doc_path)
if content is None:
return []
findings: List[Dict[str, object]] = []
rel_doc = str(doc_path.relative_to(project_root))
doc_versions = extract_doc_versions(content)
for ref in doc_versions:
name = str(ref["name"])
if name not in ground_truth:
continue
doc_value = str(ref["value"])
actual = ground_truth[name]
# Compare major version at minimum
doc_major = doc_value.split(".")[0]
actual_major = actual.split(".")[0]
if doc_major != actual_major:
findings.append({
"doc": rel_doc,
"line": ref["line"],
"name": name,
"doc_value": doc_value,
"actual": actual,
"status": "mismatch",
})
elif doc_value != actual and len(doc_value.split(".")) > 1:
# Minor/patch mismatch
findings.append({
"doc": rel_doc,
"line": ref["line"],
"name": name,
"doc_value": doc_value,
"actual": actual,
"status": "minor_mismatch",
})
return findings
def check_all_versions(
doc_paths: List[Path], project_root: Path
) -> Dict[str, object]:
"""Check version references across all documentation files.
Returns dict with:
ground_truth: collected version info
findings: list of mismatches
summary: {total_refs, mismatches, minor_mismatches}
"""
truth = collect_ground_truth(project_root)
all_findings: List[Dict[str, object]] = []
total_refs = 0
mismatches = 0
minor = 0
for doc_path in doc_paths:
content = read_file_safe(doc_path)
if content:
total_refs += len(extract_doc_versions(content))
findings = check_versions(doc_path, project_root, truth, content)
for f in findings:
if f["status"] == "mismatch":
mismatches += 1
elif f["status"] == "minor_mismatch":
minor += 1
all_findings.extend(findings)
return {
"ground_truth": truth,
"findings": all_findings,
"summary": {
"total_refs": total_refs,
"mismatches": mismatches,
"minor_mismatches": minor,
},
}
def check_last_updated(
doc_path: Path, project_root: Path
) -> Optional[Dict[str, object]]:
"""Check if a doc's last_updated frontmatter matches its git history.
Only applies to deep-tier docs (those with last_updated in frontmatter).
Returns a finding dict if the dates differ by >7 days, or None.
"""
content = read_file_safe(doc_path)
if content is None:
return None
fm = extract_frontmatter(content)
if not fm or "last_updated" not in fm:
return None
date_match = re.match(r'(\d{4}-\d{2}-\d{2})', fm["last_updated"])
if not date_match:
return None
from datetime import datetime
try:
fm_date = datetime.strptime(date_match.group(1), "%Y-%m-%d")
except ValueError:
return None
rel_doc = str(doc_path.relative_to(project_root))
iso_date = git_last_modified(rel_doc, str(project_root))
if not iso_date:
return None
try:
git_date_str = iso_date[:10]
git_date = datetime.strptime(git_date_str, "%Y-%m-%d")
except ValueError:
return None
diff_days = abs((git_date - fm_date).days)
if diff_days > 7:
return {
"doc": rel_doc,
"frontmatter_date": date_match.group(1),
"git_date": git_date_str,
"diff_days": diff_days,
"status": "outdated_frontmatter",
}
return None
if __name__ == "__main__":
import json
if len(sys.argv) < 3:
print("Usage: version_checker.py <project-root> <doc-file> [<doc-file> ...]",
file=sys.stderr)
sys.exit(1)
root = Path(sys.argv[1]).resolve()
doc_files = [Path(f).resolve() for f in sys.argv[2:]]
result = check_all_versions(doc_files, root)
print(json.dumps(result, indent=2, default=str))
Doc-Freshness Troubleshooting
No Documentation Files Found
Symptom: "No documentation files found" or empty results.
Cause: Docs are in non-standard locations not covered by default patterns.
Fix: Add custom patterns to .arkhe.yaml:
doc-freshness:
doc_patterns:
- "guides/**/*.md"
- "manual/**/*.md"
- "wiki/**/*.rst"Too Many Broken Link False Positives
Symptom: Many broken links reported for paths that are intentional (code examples, templates).
Cause: Backtick-quoted paths in prose are treated as file references even when they're illustrative.
Fix: The checker only flags backtick paths containing / (directory separators). If you still get false positives, exclude specific docs:
doc-freshness:
exclude:
- "docs/templates/**"
- "docs/examples/**"Script Permission Denied
Symptom: Permission denied when running scan_freshness.py.
Fix:
chmod +x plugins/doc/skills/doc-freshness/scripts/*.pyPython Version Error
Symptom: Syntax errors or f-string failures.
Cause: Python < 3.8 being used.
Fix: Ensure Python 3.8+ is available:
python3 --versionCode Mapping Guesses Wrong
Symptom: Drift analysis compares doc against unrelated code files.
Cause: Convention-based mapping inferred the wrong code counterpart.
Fix: Add explicit mappings:
doc-freshness:
mappings:
- doc: docs/payments.md
code: src/billing/**/*.tsGit Staleness Unreliable
Symptom: Recently updated docs still flagged as stale.
Cause: The doc was touched (e.g., whitespace fix) but not meaningfully updated. Or the doc has no git history (new file not yet committed).
Mitigation: Staleness uses commit dates as a heuristic. Combine with drift mode for semantic accuracy — a stale date doesn't necessarily mean wrong content.
Large Repository Slow
Symptom: Full scan takes too long on a monorepo.
Cause: Too many markdown files or deep directory trees.
Fix Options: 1. Use check <path> for targeted analysis 2. Use links mode for fast mechanical checks only 3. Add exclusions to reduce scope:
doc-freshness:
exclude:
- "packages/legacy/**"
- "docs/archive/**"
- "vendor/**"Version Checker Finds No Versions
Symptom: "0 version mismatches" even though docs reference versions.
Cause: The version patterns only match common formats like "Node.js 18", "Python 3.8+", "Java 21". Uncommon formats or tool-specific versions aren't detected.
Fix: The checker handles Node.js, Python, Java, Go, Ruby, and Rust version references. For other tools, the code-doc drift analysis (drift mode) can catch specific version references via Grep.
Cross-Doc Mode Finds No Overlaps
Symptom: "No overlapping topics detected" even though multiple docs cover the same subject.
Cause: The topic clustering uses heading keywords. If docs use very different heading text for the same topic, overlap won't be detected.
Fix: Use check <path> on specific docs you suspect conflict, or provide more context in the cross-doc invocation about which topics to compare.
No Git Repository
Symptom: Staleness section shows "unknown" for all docs.
Cause: Not a git repository, or git is not installed.
Impact: Link checking and version checking still work normally. Only git-based staleness is unavailable. Use drift mode for semantic freshness checking instead.
Doc-Freshness Workflow
Detailed detection algorithms, convention tables, and output templates.
Context Discovery Protocol
Priority-based context discovery:
1. Configuration: Read .arkhe.yaml → doc-freshness: section 2. Project Identity: Read CLAUDE.md and README.md 3. Documentation Inventory: Run scan_freshness.py to discover all docs 4. Code Mapping: Map docs to code via conventions (below) or config mappings
Johnny Decimal Detection
If .jd-config.json exists or docs/[0-9][0-9]-*/ directories are found:
- Glob
{jd_root}/[0-9][0-9]-*/**/*.mdto discover all J.D. docs - Deprioritize
90-*(archive) — flag but don't report as stale
Convention-Based Code Mapping
When no explicit mappings config exists, map docs to code by convention:
| Doc Pattern | Likely Code Counterpart |
|---|---|
README.md | Project root, src/, install commands |
docs/api-reference.md, docs/api/*.md | src/api/**, src/routes/**, src/controllers/** |
docs/architecture.md | Module structure, src/*/ directory names |
docs/setup.md, docs/getting-started.md | package.json scripts, Makefile, docker-compose.yml |
CONTRIBUTING.md | .github/, CI config, linter config |
docs/deployment.md | Dockerfile, CI/CD config, infra/ |
CHANGELOG.md | package.json version, git tags |
docs/{module-name}.md | src/{module-name}/** |
docs/{module-name}/*.md | src/{module-name}/** |
Tech-Stack-Aware Mapping
| Ecosystem | Additional Code Patterns |
|---|---|
| Java/Kotlin | src/main/java/**, src/main/kotlin/**, build.gradle.kts |
| JavaScript/TypeScript | src/**/*.ts, src/**/*.tsx, app/**, pages/** |
| Python | src/**/*.py, app/**/*.py, {package}/**/*.py |
| Go | cmd/**/*.go, internal/**/*.go, pkg/**/*.go |
| Rust | src/**/*.rs, crates/**/*.rs |
Detection Algorithms
Algorithm 0: Tier Detection (Script-Driven)
Before running checks, classify each doc into a scanning tier:
1. Read first 50 lines for YAML frontmatter (--- delimiters) 2. If frontmatter contains last_updated: or version: field → deep tier 3. Otherwise → basic tier
Basic tier runs: link checking, backtick-path verification, git staleness. Deep tier runs: all basic checks + version checking, last_updated accuracy vs git date, cross-doc consistency.
Algorithm 1: Stale References (Script-Driven)
The scan_freshness.py script handles these deterministic checks:
Link Checking
1. Parse all [text](target) and [text]: target markdown links 2. Skip links inside fenced code blocks 3. For relative links: resolve path from doc's directory, verify target exists 4. For anchor links (#heading): verify heading exists in target file using slug matching 5. For backtick-quoted paths (` src/foo.ts `): check existence from project root and doc directory
Version Checking
1. Extract version references from markdown (e.g., "Node.js 18", "Python 3.8+") 2. Collect ground truth from: package.json, .nvmrc, .python-version, pyproject.toml, build.gradle.kts, pom.xml, go.mod, .tool-versions 3. Compare major versions (CRITICAL mismatch) and minor versions (WARNING)
Git Staleness
1. For each doc: get last commit date via git log -1 --format="%ai" 2. Get latest code commit date across all source files 3. Compute age gap and classify:
- fresh: doc updated within 7 days of latest code change
- aging: 7-30 day gap
- stale: 30-90 day gap
- very_stale: 90+ day gap
Algorithm 2: Code-Doc Drift (Claude-Driven)
For docs flagged as stale or when drift mode is used:
1. Extract references from the doc:
- Function/method names: grep for
functionName(,def function_name, etc. - API endpoints: grep for
GET /api/,POST /api/, etc. - File paths: resolve backtick-quoted paths
- Config keys: grep for referenced config values
- Class/type names: grep for
class ClassName,interface TypeName, etc.
2. Verify each reference against the codebase:
- Does the function/class still exist? → Grep for it
- Has its signature changed? → Read the definition, compare with doc
- Does the file path still exist? → Check filesystem
- Does the API endpoint still exist? → Grep routes/controllers
3. Report mismatches with:
- Doc file and line number
- What the doc says
- What the code actually shows (with file:line reference)
- Severity (CRITICAL if removed, WARNING if changed)
Algorithm 3: Cross-Doc Drift (Claude-Driven)
For cross-doc mode or as part of full scan:
1. Topic clustering: Group docs by shared heading keywords
- Extract headings from each doc (script provides this)
- Find docs with overlapping topics (e.g., both have "Installation" sections)
2. Claim comparison: For overlapping topics:
- Read the relevant sections from each doc
- Extract factual claims (version requirements, setup steps, configuration values)
- Compare claims across docs
3. Conflict detection: Flag contradictions like:
- Different version requirements (e.g., "Node 18" vs "Node 20")
- Different setup steps for the same thing
- Conflicting configuration instructions
- Inconsistent terminology for the same concept
Mode Workflows
scan Mode (Full Analysis)
Step 1: Run scan_freshness.py <project-root>
Step 2: Parse JSON output
Step 3: Present summary table (docs, broken links, version mismatches, stale docs)
Step 4: Present broken links grouped by severity
Step 5: Present version mismatches
Step 6: For each stale/very_stale doc, run Algorithm 2 (code-doc drift)
Step 7: Run Algorithm 3 (cross-doc drift) on docs with overlapping topics
Step 8: Present final report with all findingslinks Mode (Fast)
Step 1: Run scan_freshness.py --links-only <project-root>
Step 2: Parse JSON output
Step 3: Present broken links table (CRITICAL broken links, then WARNING file refs)
Step 4: Present summary countscheck <path> Mode (Focused)
Step 1: If path is directory, discover .md files within it
Step 2: Run link checker on targeted files
Step 3: Run version checker on targeted files
Step 4: For each targeted doc, run Algorithm 2 (code-doc drift)
Step 5: Present findings for targeted scope onlydrift <path> Mode (Deep Code-Doc)
Step 1: Read the specified doc
Step 2: Run Algorithm 2 (code-doc drift) thoroughly
Step 3: For each reference extracted, verify against codebase
Step 4: Present detailed mismatch report with evidencecross-doc Mode
Step 1: Run scan_freshness.py to get doc inventory
Step 2: Cluster docs by topic
Step 3: Run Algorithm 3 on overlapping pairs
Step 4: Present conflicts with doc referencesreport Mode (Persist)
Step 1: Run full scan (same as scan mode)
Step 2: Format as markdown report
Step 3: Write to {output_dir}/{YYYY-MM-DD}-freshness.md
Step 4: Confirm file writtensetup
Scaffold a GitHub Actions workflow for automated documentation health checks using joaquimscosta/docs-health-action.
Step 1: Check existing setup
ls .github/workflows/docs-health.yml 2>/dev/null- If file exists: "A docs-health workflow already exists at
.github/workflows/docs-health.yml. Overwrite / skip?" - If
.github/workflows/doesn't exist: create it withmkdir -p
Step 2: Gather preferences
Use AskUserQuestion with two questions:
Question 1 (multiSelect): "Which documentation health checks should run on every PR?"
links— Broken internal links and missing anchors (Recommended)versions— Version references vs ground truth (.nvmrc, package.json, etc.)staleness— Git-based documentation age scoringclaude-md— CLAUDE.md structural drift (Claude Code projects only)cross-doc— Cross-document version conflictsfrontmatter— Missing tracking frontmatter
Default selection: links, versions, staleness.
Question 2 (single): "When should the workflow fail?"
errors— Fail on broken links and critical mismatches (Recommended)warnings— Also fail on staleness and minor version driftnone— Advisory only, never fail
Step 3: Generate workflow file
Use the template below, substituting {checks} and {fail_on} from user selections:
name: Documentation Health
on:
pull_request:
paths:
- '**/*.md'
- 'package.json'
- '.nvmrc'
- '.python-version'
- 'pyproject.toml'
- 'go.mod'
- 'Cargo.toml'
permissions:
contents: read
pull-requests: write
jobs:
docs-health:
name: Documentation Health
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run documentation health checks
uses: joaquimscosta/docs-health-action@v1
with:
checks: '{checks}'
fail-on: '{fail_on}'If staleness is NOT in the selected checks, remove fetch-depth: 0 (shallow clone is faster).
If claude-md is in the selected checks and a CLAUDE.md exists, add 'CLAUDE.md' to the paths filter.
Step 4: Present and confirm
Show the generated file content and ask: "Write to .github/workflows/docs-health.yml? (y/N)"
On confirmation, write the file.
Step 5: Post-setup guidance
After writing, print:
Workflow created at .github/workflows/docs-health.yml
Next steps:
1. Commit the workflow file: git add .github/workflows/docs-health.yml
2. Push to trigger on your next PR
3. The action will post a comment on PRs with documentation issues
To customize further, see: https://github.com/joaquimscosta/docs-health-action---
Output Templates
Summary Table
## Documentation Freshness Report
_Scanned {N} docs on {date}_
| Category | Count | Critical | Warning | Info |
|----------|-------|----------|---------|------|
| Broken Links | {n} | {n} | {n} | — |
| Version Mismatches | {n} | {n} | {n} | — |
| Stale Docs | {n} | — | {n} | {n} |
| Code-Doc Drift | {n} | {n} | {n} | {n} |
| Cross-Doc Conflicts | {n} | — | {n} | {n} |Findings Table
### Findings
| # | Severity | Type | File:Line | Finding | Evidence |
|---|----------|------|-----------|---------|----------|
| 1 | CRITICAL | broken-link | README.md:45 | Link to deleted file | `docs/api.md` does not exist |
| 2 | WARNING | version | docs/setup.md:7 | Node version mismatch | Doc says 18, .nvmrc says 20 |
| 3 | WARNING | stale | docs/architecture.md | 45 days since last update | Code changed 2 days ago |
| 4 | CRITICAL | drift | docs/api.md:23 | Function removed | `createUser()` not found in codebase |
| 5 | WARNING | cross-doc | README.md:15 vs docs/setup.md:7 | Conflicting Node version | 18 vs 20 |Config File Reference
Optional configuration in .arkhe.yaml:
doc-freshness:
# Custom doc patterns (overrides defaults)
doc_patterns:
- "docs/**/*.md"
- "wiki/**/*.md"
- "README.md"
# Files/patterns to exclude from scanning
exclude:
- "docs/archive/**"
- "CHANGELOG.md"
# Explicit doc-to-code mappings (supplements convention-based discovery)
mappings:
- doc: docs/api-reference.md
code: src/api/**/*.ts
- doc: docs/auth.md
code: src/auth/**/*.ts
# Version references to track against ground truth
versions:
- name: "Node.js"
pattern: "node.*?(\\d+\\.\\d+)"
source: ".nvmrc"Hook Integration
The doc-freshness skill integrates with Claude Code hooks for proactive freshness monitoring.
SessionStart Hook (Critical-Doc Fast Scan)
Trigger: Session start (synchronous, 5-second timeout)
Configuration (in .claude/settings.local.json):
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "prompt",
"prompt": "Check critical documentation freshness: README.md, CLAUDE.md. Use: /doc:health --critical-only\n\nSurface only if issues found (broken links or stale docs).",
"timeout": 5
}
]
}
]
}
}Behavior: Scans critical root-level docs only (README.md, CLAUDE.md) to keep execution < 1 second. Surfaces alerts only if issues found.
PostToolUse Hook (Post-Commit Doc-Impact Checks)
Trigger: After /commit command completes (async, non-blocking, 30-second timeout)
Configuration (in .claude/settings.local.json):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "prompt",
"prompt": "After commit completed, check if modified code files lack corresponding documentation: /doc:health drift\n\nReport findings only if doc-code misalignment detected.",
"timeout": 30
}
]
}
]
}
}Behavior: Runs asynchronously after commit succeeds. Checks if modified source files have corresponding documentation updates. Non-blocking — doesn't interrupt workflow.
User-Driven Periodic Monitoring (/loop)
Pattern: /loop <interval> /doc:health <mode>
Examples:
/loop 1h /doc:health links # Hourly broken-link checks
/loop 4h /doc:health scan # Full scans every 4 hours
/loop 30m /doc:health drift # Rapid post-commit checksConfiguration: No setup needed — user initiates based on session needs.
When no config is present, the skill uses convention-based discovery and default patterns.