
Doc Drift Detector
- 82 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Documentation Drift Detector is a Claude skill with four Python CLI tools that detect doc-vs-code drift, validate Python API docs via AST, score staleness, and audit markdown link integrity.
About
Documentation Drift Detector finds where documentation has fallen out of sync with code. It maps docs to code directories, compares git modification histories, extracts Python function signatures via AST to validate API docs, checks every markdown link and anchor, and scores freshness on a 0-100 scale. A developer uses it when docs go stale, before a release, or to enforce doc gates in CI. All four CLI tools use the Python standard library only.
- Maps docs to code and compares git histories to detect drift, then classifies each issue by severity and fix type
- AST-based Python API doc validation catches undocumented items, phantom docs, and parameter mismatches
- Scores staleness 0-100 and audits markdown links/anchors, with non-zero exit codes for CI gates
Doc Drift Detector by the numbers
- 82 all-time installs (skills.sh)
- Ranked #683 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
doc-drift-detector capabilities & compatibility
- Capabilities
- database schema designer
- Works with
- github
- Use cases
- documentation · ci cd · code review
- Platforms
- macOS · Windows · Linux
- Pricing
- Free
What doc-drift-detector says it does
Detect documentation drift against code changes, score staleness, validate API docs via AST parsing, and audit link integrity.
All four CLI tools use the Python standard library only.
All tools: Python 3.8+ stdlib only, `--json` and `--help`, non-zero exit codes for CI, any OS.
npx skills add https://github.com/borghei/claude-skills --skill doc-drift-detectorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 82 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Detect documentation drift against code, validate API docs, and gate doc freshness in CI.
Who is it for?
Teams that need to catch stale docs, validate API documentation, or gate PRs on doc freshness in CI.
Skip if: Non-Python API validation or rewriting docs; the AST validator only parses Python and tools do not generate replacement text.
When should I use this skill?
Docs have fallen out of sync with code, preparing a release, running CI doc gates, or auditing doc accuracy.
What you get
A drift report classifying each issue by severity and fix type, a 0-100 staleness score, and validated links that can fail CI.
- drift analysis report
- 0-100 staleness score
- API-doc validation results
By the numbers
- 4 CLI tools
- 0-100 weighted staleness score across five dimensions
- 5 drift categories (structural, factual, referential, temporal, semantic)
Files
Documentation Drift Detector
The agent detects documentation drift by mapping code directories to their docs, comparing git modification histories, extracting Python function signatures via AST, validating every markdown link and anchor, and scoring freshness on a weighted 0-100 scale. All four CLI tools use the Python standard library only.
Core Capabilities
- Full drift analysis — map docs to code, compare git histories, detect renamed files, version drift, broken references, and structural gaps; classify each issue by category, severity, and fix type.
- API doc validation — AST-based extraction of Python signatures/classes compared against markdown API docs (undocumented items, phantom docs, parameter mismatches, deprecations).
- Staleness scoring — weighted 0-100 freshness score across five dimensions with CI threshold gates and README-focused mode.
- Link integrity audit — validate local files, anchors, cross-document anchors, images, case-sensitivity, and duplicate anchors; optional external URL checks.
- Drift classification — structural, factual, referential, temporal, semantic categories, each tagged
[AUTO]/[SEMI]/[MANUAL]for fix routing. - CI/CD integration — non-zero exit codes, JSON output, GitHub Actions and pre-commit recipes for ongoing monitoring.
When to Use
- Docs have fallen out of sync with code — run full drift analysis.
- Preparing a release — gate on aggregate staleness score.
- Running CI doc gates — fail PRs on high/critical drift or broken links.
- Auditing API doc accuracy against Python source.
- Checking README health and link integrity after refactors.
Tools
| Tool | Purpose | Command |
|---|---|---|
drift_analyzer.py | Full drift analysis between code and docs | python scripts/drift_analyzer.py <repo> --min-severity high --json |
doc_staleness_scorer.py | Score documentation freshness 0-100 | python scripts/doc_staleness_scorer.py <repo> --threshold 60 |
api_doc_validator.py | Validate API docs against Python source (AST) | python scripts/api_doc_validator.py <src> <docs> --recursive |
link_checker.py | Audit all markdown links and anchors | python scripts/link_checker.py <repo> --broken-only |
All tools: Python 3.8+ stdlib only, --json and --help, non-zero exit codes for CI, any OS.
References
Load the reference that matches the task — keep this file lean and pull detail on demand:
- [references/workflows-and-tool-reference.md](references/workflows-and-tool-reference.md) — quick start, the 5 core workflows (full analysis, API validation, README health, link audit, CI monitoring) with output examples, GitHub Actions + pre-commit recipes, and the complete per-tool parameter/output/exit-code reference. Read when running tools or wiring CI.
- [references/scoring-categories-and-troubleshooting.md](references/scoring-categories-and-troubleshooting.md) — the staleness scoring model and weights, the five drift categories, auto-fix vs manual-fix classification, detailed integration points, anti-patterns, troubleshooting table, and success criteria. Read when interpreting results or triaging drift.
- [references/documentation_standards.md](references/documentation_standards.md) — README structure, API docs, changelogs, ADRs, docs-as-code standards.
- [references/drift_prevention_guide.md](references/drift_prevention_guide.md) — coupling strategies, CI gates, review checklists, and prevention patterns.
Assets
| Asset | Description |
|---|---|
| Drift Report Template | Template for drift analysis reports |
| Sample Drift Data | Sample JSON for testing and demonstration |
Scope & Limitations
Covers:
- Detection of documentation drift against git history for any git repository
- AST-based validation of Python API documentation (function signatures, class definitions, parameters, return types)
- Internal link validation including local files, markdown anchors, cross-document anchors, images, and case-sensitivity checks
- Multi-dimensional staleness scoring with configurable weights and CI/CD threshold enforcement
Does NOT cover:
- Non-Python source code API validation -- the AST-based validator only parses Python; for TypeScript, Go, Rust, or Java APIs, use language-specific doc generators and pair with the link checker
- External URL uptime monitoring --
--check-externalperforms one-shot HEAD requests but does not provide continuous monitoring; use the senior-devops skill for uptime dashboards - Automatic documentation rewriting -- tools classify issues as
[AUTO],[SEMI], or[MANUAL]but do not generate replacement text; use the code-reviewer skill for AI-assisted doc suggestions - Content quality or readability assessment -- staleness scoring measures freshness and structural completeness, not prose quality; see the standards/communication library for writing guidelines
Integration Points
| Skill | Integration | Data Flow |
|---|---|---|
| code-reviewer | Include drift report in PR review comments | drift_analyzer.py --json output feeds into review checklists as a documentation health section |
| senior-devops | Add staleness gate to CI/CD pipelines | doc_staleness_scorer.py --threshold 50 returns exit code 1 on failure, blocking deploys |
| senior-qa | Documentation quality as part of QA acceptance | link_checker.py --json output merges into QA dashboards alongside test coverage metrics |
| senior-fullstack | Validate generated project docs post-scaffold | Run api_doc_validator.py against scaffolded docs/ directory to confirm generated API docs match source |
| senior-secops | Audit security documentation currency | drift_analyzer.py --scope security/ detects when security docs fall behind policy changes |
| senior-architect | Architecture decision record (ADR) freshness | doc_staleness_scorer.py --required-sections "Status,Context,Decision,Consequences" validates ADR completeness |
Documentation Drift Report
Repository: {{repository_path}} Scan Date: {{scan_date}} Generated By: doc-drift-detector v2.0.0
---
Summary
| Metric | Value |
|---|---|
| Documentation files scanned | {{total_docs}} |
| Files with drift detected | {{drifted_docs}} |
| Overall staleness score | {{overall_score}}/100 |
| Broken links found | {{broken_links}} |
| API mismatches found | {{api_mismatches}} |
Staleness Scores
| File | Score | Last Updated | Status |
|---|---|---|---|
| {{file_name}} | {{score}}/100 | {{last_updated}} | {{status}} |
Drift Issues
High Severity
| File | Issue | Category | Fix Type |
|---|---|---|---|
| {{file}} | {{description}} | {{category}} | {{fix_type}} |
Medium Severity
| File | Issue | Category | Fix Type |
|---|---|---|---|
| {{file}} | {{description}} | {{category}} | {{fix_type}} |
Low Severity
| File | Issue | Category | Fix Type |
|---|---|---|---|
| {{file}} | {{description}} | {{category}} | {{fix_type}} |
Auto-Fixable Issues
The following issues can be fixed automatically:
- [ ] {{auto_fix_description}} in
{{file}}
Manual Review Required
The following issues require human judgment:
- [ ] {{manual_fix_description}} in
{{file}}
Recommendations
1. {{recommendation}}
---
Score Breakdown
| Dimension | Weight | Score | Weighted |
|---|---|---|---|
| Last Updated | 20% | {{updated_score}} | {{updated_weighted}} |
| Code-Doc Alignment | 30% | {{alignment_score}} | {{alignment_weighted}} |
| Link Health | 15% | {{link_score}} | {{link_weighted}} |
| Completeness | 20% | {{completeness_score}} | {{completeness_weighted}} |
| Accuracy | 15% | {{accuracy_score}} | {{accuracy_weighted}} |
| Total | 100% | {{total_score}} |
---
Generated by [doc-drift-detector](../SKILL.md)
{
"repository": "/path/to/example-project",
"scan_date": "2026-03-18T14:30:00Z",
"tool_version": "2.0.0",
"summary": {
"total_docs": 8,
"drifted_docs": 3,
"overall_score": 62,
"broken_links": 5,
"api_mismatches": 7
},
"staleness_scores": [
{"file": "README.md", "score": 78, "last_updated": "2026-03-01", "status": "good"},
{"file": "docs/api.md", "score": 34, "last_updated": "2025-12-15", "status": "critical"},
{"file": "docs/architecture.md", "score": 85, "last_updated": "2026-02-20", "status": "good"},
{"file": "CONTRIBUTING.md", "score": 92, "last_updated": "2026-03-10", "status": "excellent"},
{"file": "CHANGELOG.md", "score": 45, "last_updated": "2026-01-30", "status": "stale"},
{"file": "docs/deployment.md", "score": 58, "last_updated": "2026-02-01", "status": "stale"},
{"file": "docs/testing.md", "score": 71, "last_updated": "2026-02-15", "status": "good"},
{"file": "docs/security.md", "score": 88, "last_updated": "2026-03-05", "status": "good"}
],
"drift_issues": [
{
"file": "docs/api.md",
"severity": "high",
"category": "factual",
"description": "Function create_user() signature changed: added 'role' parameter",
"fix_type": "semi",
"source_file": "src/handlers/users.py",
"source_line": 45
},
{
"file": "README.md",
"severity": "medium",
"category": "temporal",
"description": "Version string says 1.8.0, current version is 2.0.0",
"fix_type": "auto",
"source_file": "pyproject.toml",
"source_line": 3
},
{
"file": "docs/api.md",
"severity": "high",
"category": "structural",
"description": "Module 'src/handlers/webhooks.py' has no documentation section",
"fix_type": "manual",
"source_file": "src/handlers/webhooks.py",
"source_line": null
},
{
"file": "CHANGELOG.md",
"severity": "medium",
"category": "structural",
"description": "No entries for version 2.0.0 (released 2026-03-15)",
"fix_type": "semi",
"source_file": null,
"source_line": null
},
{
"file": "docs/deployment.md",
"severity": "low",
"category": "referential",
"description": "Link to 'scripts/deploy.sh' broken - file moved to 'bin/deploy.sh'",
"fix_type": "auto",
"source_file": "bin/deploy.sh",
"source_line": null
}
],
"score_breakdown": {
"last_updated": {"weight": 0.20, "score": 65, "weighted": 13.0},
"code_doc_alignment": {"weight": 0.30, "score": 48, "weighted": 14.4},
"link_health": {"weight": 0.15, "score": 72, "weighted": 10.8},
"completeness": {"weight": 0.20, "score": 70, "weighted": 14.0},
"accuracy": {"weight": 0.15, "score": 65, "weighted": 9.75}
}
}
Documentation Standards Reference
Expert knowledge for writing, maintaining, and evaluating technical documentation across all common formats.
---
README Structure Best Practices
A well-structured README is the front door to any project. Follow this ordering for maximum clarity:
Essential Sections (in order)
1. Title and Description -- One sentence explaining what the project does. No jargon in the first paragraph. 2. Badges -- Build status, version, license, coverage. Keep to 4-6 maximum. 3. Table of Contents -- Required for READMEs longer than 100 lines. 4. Installation -- Copy-pasteable commands. Cover all supported platforms. Include prerequisites. 5. Quick Start / Usage -- The shortest path from install to working example. Under 10 lines of code. 6. API Reference -- Or link to full API docs. Include the most-used functions inline. 7. Configuration -- Environment variables, config files, CLI flags. Use tables. 8. Examples -- Real-world use cases beyond the quick start. Link to example directory if extensive. 9. Architecture -- High-level diagram or description for contributors. Can link to ARCHITECTURE.md. 10. Contributing -- Or link to CONTRIBUTING.md. Include setup instructions for development. 11. License -- State the license and link to LICENSE file. 12. Changelog -- Or link to CHANGELOG.md.
README Anti-Patterns
- Wall of text with no headings
- Installation instructions that assume specific OS
- Examples that reference files not in the repo
- Badges that point to broken CI pipelines
- "TODO" placeholders left in published README
- Version numbers hardcoded in multiple places
- Screenshots from 3 versions ago
---
API Documentation Patterns
Function Documentation
Every public function should document:
- Purpose -- One sentence on what it does
- Parameters -- Name, type, description, default value, whether required
- Return value -- Type and description
- Exceptions -- What errors it can raise and when
- Example -- At least one usage example
- Since -- Version when the function was introduced
Class Documentation
- Purpose -- What the class represents
- Constructor parameters -- Same detail as function parameters
- Public methods -- Each documented as a function
- Properties -- Type and description
- Usage example -- Instantiation through common operations
Module Documentation
- Overview -- What the module provides
- Public API listing -- All exported classes, functions, constants
- Dependency notes -- What this module requires
- Usage patterns -- Common import and usage patterns
API Doc Formats
| Format | Best For | Tooling |
|---|---|---|
| Docstrings (Python) | Python libraries | Sphinx, pdoc, mkdocstrings |
| JSDoc | JavaScript/TypeScript | TypeDoc, documentation.js |
| OpenAPI/Swagger | REST APIs | Swagger UI, Redoc |
| GraphQL SDL | GraphQL APIs | GraphiQL, Apollo Studio |
| gRPC Proto | gRPC services | protoc-gen-doc |
---
Changelog Conventions
Follow Keep a Changelog format:
Structure
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.2.0] - 2026-03-18
### Added
- New drift detection algorithm for renamed files
### Changed
- Improved staleness scoring weights
### Deprecated
- Old `--verbose` flag (use `--log-level` instead)
### Removed
- Python 3.7 support
### Fixed
- False positives in anchor validation
### Security
- Updated dependency to patch CVE-2026-XXXXChangelog Rules
- Every user-facing change gets an entry
- Group by type (Added, Changed, Deprecated, Removed, Fixed, Security)
- Most recent version first
- Include dates in ISO 8601 format
- Link version headers to git comparison URLs
- Keep an
[Unreleased]section for in-progress changes - Write for the user, not the developer ("Added search feature" not "Implemented ElasticSearch integration in SearchService")
---
Architecture Decision Records (ADRs)
ADR Format
# ADR-NNN: Title
## Status
Proposed | Accepted | Deprecated | Superseded by ADR-NNN
## Context
What is the issue that we are seeing that is motivating this decision?
## Decision
What is the change that we are proposing and/or doing?
## Consequences
What becomes easier or harder to do because of this change?ADR Best Practices
- Number sequentially, never reuse numbers
- Keep each ADR focused on a single decision
- Record the date of the decision
- Link to related ADRs
- Update status when superseded (do not delete old ADRs)
- Store in
docs/adr/ordocs/decisions/ - Include ADR index in project documentation
---
Documentation-as-Code Principles
Core Principles
1. Docs live with code -- Same repository, same branch, same PR 2. Docs are reviewed -- Documentation changes go through code review 3. Docs are tested -- Link checking, spell checking, build verification 4. Docs are versioned -- Tagged with releases, branches for versions 5. Docs are automated -- Generated where possible, validated in CI
File Organization
project/
├── README.md # Entry point
├── CONTRIBUTING.md # How to contribute
├── CHANGELOG.md # Version history
├── LICENSE # License text
├── docs/
│ ├── getting-started.md # Expanded setup guide
│ ├── architecture.md # System design
│ ├── api/ # API reference
│ ├── guides/ # How-to guides
│ ├── tutorials/ # Step-by-step tutorials
│ └── adr/ # Architecture decisions
└── src/ # Source with inline docsThe Four Types of Documentation
Following the Diataxis framework:
| Type | Purpose | Approach |
|---|---|---|
| Tutorials | Learning-oriented | Step-by-step lessons, hands-on |
| How-to Guides | Task-oriented | Practical steps to achieve a goal |
| Reference | Information-oriented | Accurate, complete technical description |
| Explanation | Understanding-oriented | Clarification, background, reasoning |
Each type serves a different need. A healthy project has all four.
---
Last Updated: 2026-03-18
Drift Prevention Guide
Strategies and patterns for preventing documentation from falling out of sync with code.
---
Documentation-Code Coupling Strategies
1. Proximity Coupling
Keep documentation physically close to the code it describes. The closer the docs are to the code, the more likely they are to be updated together.
- Module-level README files in each package directory
- Inline docstrings updated as part of function changes
- Architecture docs in the same directory as the system they describe
2. Reference Coupling
Documentation references specific code artifacts (function names, file paths, line numbers). When those artifacts change, tools can detect the broken references.
- Use exact function names in docs rather than paraphrasing
- Reference file paths relative to the repo root
- Include code snippets via file inclusion rather than copy-paste
3. Generation Coupling
Some documentation is generated directly from code, making drift impossible for the generated portions.
- API reference from docstrings (Sphinx, TypeDoc)
- CLI help text from argparse/click definitions
- Configuration docs from schema definitions
- Database schema docs from migration files
4. Process Coupling
Team processes that ensure docs are updated alongside code changes.
- PR templates with a "Documentation" checkbox
- Required doc review for any PR touching public API
- Documentation ownership assigned per module
---
Automated Documentation Generation
What to Generate
| Source | Generated Doc | Tool |
|---|---|---|
| Python docstrings | API reference | Sphinx, pdoc, mkdocstrings |
| TypeScript types | API reference | TypeDoc |
| OpenAPI spec | REST API docs | Swagger UI, Redoc |
| CLI argparse | Command reference | argparse --help, click |
| Database schema | ERD / schema docs | SchemaSpy, dbdocs |
| Git log | Changelog draft | git-cliff, conventional-changelog |
What NOT to Generate
- Tutorials (require narrative flow)
- Architecture overviews (require judgment)
- Getting started guides (require empathy for beginners)
- Migration guides (require understanding of breaking changes)
- Security advisories (require careful wording)
---
CI/CD Documentation Gates
Gate 1: Link Validation (Every PR)
- name: Check documentation links
run: python link_checker.py . --broken-only
# Fails PR if any internal links are brokenGate 2: Staleness Check (Every PR touching code)
- name: Check doc freshness
run: python doc_staleness_scorer.py . --threshold 50
# Fails if documentation score drops below 50Gate 3: API Validation (PRs touching src/)
- name: Validate API docs
run: python api_doc_validator.py src/ docs/api.md
# Fails if documented API diverges from sourceGate 4: Full Drift Report (Release branches)
- name: Full drift analysis
run: python drift_analyzer.py . --json > drift-report.json
# Generates report as release artifactRecommended Pipeline
- PR checks: Gates 1 + 2 (fast, blocks merge)
- Nightly: Gates 1 + 2 + 3 (thorough, alerts team)
- Release: All gates + full report (comprehensive, blocks release)
---
Review Checklist for Documentation Updates
For Every Code PR
- [ ] Do any doc files reference changed functions, classes, or files?
- [ ] Are there new public functions/classes that need documentation?
- [ ] Were any documented functions removed or renamed?
- [ ] Do code examples in docs still work with the changes?
- [ ] Are version strings still accurate?
For Documentation PRs
- [ ] All links resolve (local files, anchors, cross-document)
- [ ] Code examples are syntactically correct
- [ ] Screenshots and diagrams reflect current UI/architecture
- [ ] Table of contents matches actual headings
- [ ] No placeholder or TODO text remains
- [ ] Dates and version numbers are current
For Release PRs
- [ ] CHANGELOG updated with all user-facing changes
- [ ] README version strings match release version
- [ ] Migration guide written for breaking changes
- [ ] API docs regenerated from latest source
- [ ] "Unreleased" section in CHANGELOG moved to new version
---
Common Drift Patterns and Prevention
Pattern 1: The Renamed Function
Drift: Function renamed in code, docs still reference old name. Prevention: Search docs for old function name as part of rename refactoring. Use IDE "find all references" including markdown files.
Pattern 2: The Moved File
Drift: File moved to new directory, docs link to old path. Prevention: Run link checker after any file move. Configure IDE to update markdown references on move.
Pattern 3: The Outdated Version
Drift: Version bumped in package manifest but not in README/docs. Prevention: Use a single source of truth for version. Reference it dynamically or add version check to CI.
Pattern 4: The Stale Screenshot
Drift: UI changed but screenshots in docs show old design. Prevention: Tag screenshots with the version they depict. Automated screenshot generation in CI for critical flows.
Pattern 5: The Accumulated Options
Drift: New CLI flags or config options added over time but never documented. Prevention: Generate configuration/CLI docs from source. Add "doc update" to definition of done for new options.
Pattern 6: The Orphaned Section
Drift: Feature removed but its documentation section remains. Prevention: Include feature removal in the PR that removes the code. Search docs for feature name during removal.
Pattern 7: The Divergent Example
Drift: Code example in docs worked with v1 API but not v2. Prevention: Extract code examples into testable files. Run example tests in CI. Or use doc-testing tools (doctest, mdx-js).
---
Last Updated: 2026-03-18
Scoring, Drift Categories, Integration & Troubleshooting
Read this when interpreting staleness scores, classifying drift, deciding what to auto-fix vs fix by hand, wiring the skill into pipelines/release gates, or diagnosing tool issues against success-criteria targets.
Staleness Scoring
Documentation freshness is scored on a 0-100 scale where 100 = perfectly current. The score is a weighted combination of five dimensions:
| Dimension | Weight | What It Measures |
|---|---|---|
| Last Updated | 20% | How recently the doc file was modified relative to its associated code |
| Code-Doc Alignment | 30% | Whether documented items (functions, classes, files) still exist and match |
| Link Health | 15% | Percentage of links that resolve correctly |
| Completeness | 20% | Whether expected sections are present and non-empty |
| Accuracy | 15% | Whether version strings, file paths, and other verifiable facts are correct |
Score interpretation:
| Score | Label | Action |
|---|---|---|
| 90-100 | Excellent | No action needed |
| 70-89 | Good | Minor updates recommended |
| 50-69 | Stale | Updates needed before next release |
| 30-49 | Critical | Immediate attention required |
| 0-29 | Abandoned | Full rewrite likely needed |
Customization:
# Override default weights
python scripts/doc_staleness_scorer.py /path/to/repo \
--weight-updated 0.25 \
--weight-alignment 0.25 \
--weight-links 0.15 \
--weight-completeness 0.20 \
--weight-accuracy 0.15
# Set staleness thresholds
python scripts/doc_staleness_scorer.py /path/to/repo --threshold 60Drift Categories
Every detected drift instance is classified into one or more categories:
Structural Drift
Missing or misorganized sections. A README lacks an Installation section. An API doc is missing an entire module. A CHANGELOG has no entries for the latest version.
Detection: Compare actual document headings against expected headings for that document type.
Factual Drift
Incorrect information. A function signature in the docs has the wrong parameters. An installation command references a removed package. A configuration example uses deprecated options.
Detection: Cross-reference documented facts against code analysis (AST parsing, file existence, git tags).
Referential Drift
Broken references. A link points to a file that was moved. An anchor references a heading that was renamed. An image path is wrong.
Detection: Link checker validates every reference against the filesystem and document structure.
Temporal Drift
Outdated time-sensitive content. Version strings are old. "Last updated" dates are stale. "Coming soon" items that shipped months ago. Roadmap items past their target date.
Detection: Extract version strings and dates, compare against git tags, package manifests, and current date.
Semantic Drift
Technically accurate but misleading. A description says "simple REST API" when the project now has GraphQL, gRPC, and WebSocket endpoints. The architecture overview omits a major new subsystem.
Detection: Compare document topic coverage against code directory structure and file counts. Flag when code complexity has grown significantly but documentation scope has not.
Auto-Fix vs Manual-Fix Classification
Not all drift can be fixed programmatically. The tools classify each issue:
Auto-Fixable (safe to automate)
- Version string updates -- replace old version with current from package manifest
- Date updates -- update "last modified" timestamps
- Broken local links -- suggest correct path when file was moved (git log tracks renames)
- Missing table of contents entries -- generate from actual headings
- Removed file references -- flag for deletion or suggest replacement
Manual-Fix Required (needs human judgment)
- Architectural description changes -- requires understanding intent
- API usage examples -- new examples need domain context
- Migration guides -- require understanding of breaking changes
- Getting started rewrites -- narrative flow needs human touch
- Security documentation updates -- compliance implications require review
Semi-Automated (template + human review)
- New function documentation -- generate skeleton from AST, human fills description
- Changelog entries -- generate from git commits, human edits for clarity
- README section additions -- provide template, human adds content
The drift report marks each issue with [AUTO], [MANUAL], or [SEMI] tags.
Integration Points (detailed)
With CI/CD Pipelines
All tools return non-zero exit codes when issues are found:
- Exit 0: No issues (or all within threshold)
- Exit 1: Issues found exceeding threshold
- Exit 2: Tool error (invalid arguments, missing files)
With Code Review
Add drift analysis to PR checks. When a PR modifies code in src/, automatically check whether docs in docs/ need updates. The drift analyzer can scope its analysis to only changed directories.
With Documentation Generators
Pair with tools like Sphinx, MkDocs, or mdBook. Run API validation after doc generation to ensure the generated docs match source. Run link checker on the built output.
With Release Processes
Add staleness scoring to release checklists. Block releases if documentation score falls below threshold. Generate drift reports as release artifacts.
With Other Skills
- code-reviewer -- include doc drift in PR review reports
- senior-devops -- integrate into deployment pipelines
- senior-qa -- documentation quality as part of QA checklist
Anti-Patterns
- Ignoring drift until release -- run drift analysis in CI on every PR, not as a release-day scramble
- Treating all drift as equal -- factual drift (wrong function signatures) is critical; temporal drift (stale dates) is cosmetic; prioritize by category
- Manual-only doc updates -- use
[AUTO]fixes for version strings and broken links; reserve human effort for semantic and architectural drift - Shallow clone in CI --
fetch-depth: 1breaks git history comparison; always usefetch-depth: 0for drift analysis - Skipping link checks on internal docs -- cross-document anchor references break silently on refactors; run
link_checker.pyon every markdown change
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
drift_analyzer.py reports zero docs found | Repository has non-standard doc extensions or docs are in ignored directories (e.g., node_modules, dist) | Use --doc-patterns "*.md,*.rst,*.txt" to explicitly specify extensions |
| Staleness scores are unexpectedly low | Docs reference files that were reorganized or moved to new directories | Run link_checker.py first to identify broken references, fix them, then re-score |
| API validator finds no source signatures | Source path points to a non-Python directory or all functions are _-prefixed private | Verify source_path contains .py files; add --include-private if the API surface uses private names |
| Link checker flags valid anchors as broken | Heading text contains special characters, inline code, or emoji that alter the slug | Compare the expected slug (lowercase, special chars stripped, spaces to hyphens) against the actual heading text |
| Git history comparison shows no changes | Shallow clone lacks full commit history (common in CI) | Clone with fetch-depth: 0 or pass --scope to narrow the analysis window |
| External URL checks hang or time out | Target servers are slow or block automated HEAD requests | Omit --check-external for local-only validation, or run external checks in a separate non-blocking job |
Drift report marks everything as [MANUAL] | Most detected drift is semantic or architectural, not auto-fixable | This is expected for large refactors; focus on [AUTO] and [SEMI] items first, then triage [MANUAL] items by severity |
Success Criteria
- Zero stale docs older than 90 days -- every documentation file has been updated within the last 90 days relative to its associated code changes
- Aggregate staleness score above 80/100 -- the repository-wide freshness score stays in the "Good" or "Excellent" range
- Link integrity above 99% -- fewer than 1% of internal links (file references, anchors, cross-document links) are broken
- API doc coverage above 95% -- at least 95% of public functions and classes have corresponding entries in API documentation
- Zero high-severity drift issues in CI -- pull requests with high or critical drift are blocked before merge
- Version string accuracy at 100% -- every version reference in documentation matches the current release tag or package manifest
- Drift report turnaround under 60 seconds -- full drift analysis completes in under one minute for repositories with up to 500 documentation files
Workflows & Tool Reference
Read this when running a drift analysis end-to-end, wiring tools into CI, or looking up the exact flags, parameters, output formats, and exit codes for each CLI tool.
Quick Start
# 1. Run full drift analysis on a repository
python scripts/drift_analyzer.py /path/to/repo
# 2. Score documentation freshness
python scripts/doc_staleness_scorer.py /path/to/repo
# 3. Validate API docs against Python source
python scripts/api_doc_validator.py /path/to/repo/src /path/to/repo/docs/api.md
# 4. Check all markdown links
python scripts/link_checker.py /path/to/repo
# JSON output for any tool
python scripts/drift_analyzer.py /path/to/repo --json
# Set failure threshold for CI
python scripts/doc_staleness_scorer.py /path/to/repo --threshold 60All tools support --help for full usage details.
Core Workflows
Workflow 1: Full Drift Analysis
Scan all documentation against code changes since each doc was last updated. This is the primary entry point for understanding the overall drift state of a repository.
# Basic analysis
python scripts/drift_analyzer.py /path/to/repo
# Analyze with custom doc patterns
python scripts/drift_analyzer.py /path/to/repo --doc-patterns "*.md,*.rst,*.txt"
# JSON output for tooling
python scripts/drift_analyzer.py /path/to/repo --json
# Only show high-severity drift
python scripts/drift_analyzer.py /path/to/repo --min-severity high
# Analyze specific directory
python scripts/drift_analyzer.py /path/to/repo --scope src/What it does:
1. Discovers all documentation files in the repo 2. For each doc, identifies the code directories it describes (via path proximity and content references) 3. Compares the doc's last-modified date against the git history of its associated code 4. Identifies specific changes (renamed files, moved directories, changed function signatures) 5. Classifies each drift instance by category and severity 6. Generates an actionable report with specific file:line references
Output example:
Documentation Drift Report
==========================
Repository: /path/to/repo
Scan date: 2026-03-18
Docs found: 12
Drifted: 5
HIGH SEVERITY:
docs/api.md (last updated: 2026-01-15)
- 23 code files changed since doc update
- 4 functions renamed in src/handlers/
- 2 new modules undocumented
Category: Factual + Structural
Recommendation: Manual update required
MEDIUM SEVERITY:
README.md (last updated: 2026-02-28)
- Installation section references removed dependency
- Version string outdated (says 1.8.0, current 2.0.0)
Category: Factual + Temporal
Recommendation: Auto-fixable (version), Manual (installation)Workflow 2: API Documentation Validation
Check that API documentation accurately reflects the actual function signatures, class definitions, and module structure in your Python source code.
# Validate API docs against source
python scripts/api_doc_validator.py /path/to/src /path/to/docs/api.md
# Scan entire docs directory
python scripts/api_doc_validator.py /path/to/src /path/to/docs/ --recursive
# JSON output
python scripts/api_doc_validator.py /path/to/src /path/to/docs/api.md --json
# Include private methods in validation
python scripts/api_doc_validator.py /path/to/src /path/to/docs/ --include-privateWhat it detects:
- Functions/classes present in code but missing from docs
- Functions/classes documented but no longer in code (removed or renamed)
- Parameter mismatches (missing params, wrong types, wrong defaults)
- Deprecated items still documented as current
- Return type mismatches
- Module-level docstring drift
How it works:
The tool uses Python's ast module to parse source files and extract function signatures, class definitions, decorators, and docstrings. It then parses the markdown documentation looking for function/class references, parameter lists, and code blocks. Mismatches are reported with exact locations in both source and documentation.
Workflow 3: README Health Check
Validate README sections against the actual project state. This combines drift analysis, link checking, and completeness scoring into a single README-focused report.
# Check README health
python scripts/doc_staleness_scorer.py /path/to/repo --readme-focus
# Check with custom sections
python scripts/doc_staleness_scorer.py /path/to/repo --required-sections "Installation,Usage,API,Contributing,License"Validates:
- Required sections are present (Installation, Usage, API Reference, Contributing, License)
- Version strings match package version (package.json, setup.py, pyproject.toml)
- File references in README actually exist
- Badge URLs are well-formed
- Code examples reference existing files/functions
- Table of contents matches actual headings
Workflow 4: Link Integrity Audit
Check every link in every markdown file -- local file references, anchors, cross-document links, and optionally external URLs.
# Check all markdown links
python scripts/link_checker.py /path/to/repo
# Include external URL checks (slower, makes HTTP requests)
python scripts/link_checker.py /path/to/repo --check-external
# Check specific file
python scripts/link_checker.py /path/to/repo/README.md
# JSON output
python scripts/link_checker.py /path/to/repo --json
# Only show broken links
python scripts/link_checker.py /path/to/repo --broken-onlyWhat it checks:
- Local file references (
[link](path/to/file.md)) -- does the file exist? - Anchor references (
[link](#section-name)) -- does the heading exist? - Cross-document anchors (
[link](other.md#section)) -- does the file and heading exist? - Relative path correctness (catches
../errors) - Case sensitivity issues (common on Linux but silent on macOS)
- Image references -- do referenced images exist?
- Duplicate anchors that would cause ambiguous links
Workflow 5: Continuous Doc Monitoring
Integrate documentation drift detection into your CI/CD pipeline for ongoing monitoring.
GitHub Actions example:
name: Documentation Drift Check
on:
pull_request:
branches: [main, dev]
push:
branches: [main]
jobs:
doc-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for git log analysis
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run drift analysis
run: python engineering/doc-drift-detector/scripts/drift_analyzer.py . --json > drift-report.json
- name: Check staleness score
run: python engineering/doc-drift-detector/scripts/doc_staleness_scorer.py . --threshold 50
- name: Validate API docs
run: python engineering/doc-drift-detector/scripts/api_doc_validator.py src/ docs/api.md
- name: Check links
run: python engineering/doc-drift-detector/scripts/link_checker.py .
- name: Upload drift report
if: always()
uses: actions/upload-artifact@v4
with:
name: drift-report
path: drift-report.jsonPre-commit hook:
#!/bin/bash
# .git/hooks/pre-commit
# Fail commit if docs are severely stale
python engineering/doc-drift-detector/scripts/doc_staleness_scorer.py . --threshold 30 --quiet
if [ $? -ne 0 ]; then
echo "Documentation is critically stale. Update docs before committing."
exit 1
fiTools Summary
| Tool | Purpose | Lines | Key Feature |
|---|---|---|---|
drift_analyzer.py | Full drift analysis between code and docs | ~550 | Git history comparison with code-to-doc mapping |
doc_staleness_scorer.py | Score documentation freshness 0-100 | ~450 | Weighted multi-dimensional scoring |
api_doc_validator.py | Validate API docs against Python source | ~400 | AST-based signature extraction and comparison |
link_checker.py | Audit all markdown links and anchors | ~400 | Local file, anchor, and cross-document validation |
All tools:
- Python 3.8+ standard library only
- Support
--jsonfor machine-readable output - Support
--helpfor usage details - Use non-zero exit codes on failure (CI/CD compatible)
- Work on any OS (Windows, macOS, Linux)
Tool Reference
drift_analyzer.py
Purpose: Scan a git repository for documentation that has fallen out of sync with code. Maps documentation files to their associated code directories, compares git modification dates, detects renamed files, version string drift, broken references, and structural gaps. Classifies every issue by category, severity, and fix type.
Usage:
python scripts/drift_analyzer.py <repo_path> [options]Parameters:
| Flag | Type | Default | Description |
|---|---|---|---|
repo_path | positional | (required) | Path to the git repository to analyze |
--json | flag | off | Output the full drift report as JSON |
--min-severity | choice | low | Minimum severity to include in report. Choices: critical, high, medium, low, info |
--scope | string | "" (all) | Limit code analysis to a subdirectory (e.g., src/) |
--doc-patterns | string | *.md,*.rst,*.txt,*.adoc | Comma-separated file patterns for documentation discovery |
Example:
python scripts/drift_analyzer.py /path/to/repo --min-severity medium --scope src/ --jsonOutput Formats:
- Human-readable (default): Grouped by severity with
[AUTO]/[SEMI]/[MANUAL]fix-type tags, category labels, and a fix-type summary - JSON (
--json): Structured object withrepository,scan_date,summary(counts by severity, category, fix type), andissuesarray
Exit Codes: 0 = no high/critical issues, 1 = high or critical issues found, 2 = tool error (invalid path, not a git repo)
doc_staleness_scorer.py
Purpose: Score documentation freshness on a weighted 0-100 scale across five dimensions: last updated, code-doc alignment, link health, completeness, and accuracy. Supports CI/CD threshold gates and README-focused analysis.
Usage:
python scripts/doc_staleness_scorer.py <repo_path> [options]Parameters:
| Flag | Type | Default | Description |
|---|---|---|---|
repo_path | positional | (required) | Path to the git repository to score |
--json | flag | off | Output the full scoring report as JSON |
--threshold | float | (none) | Fail with exit code 1 if aggregate score falls below this value |
--readme-focus | flag | off | Only score README files (filenames starting with readme) |
--required-sections | string | Installation,Usage,API,Contributing,License | Comma-separated section names for completeness scoring |
--quiet | flag | off | Only print the aggregate score number (no report) |
--weight-updated | float | 0.20 | Weight for the "last updated" dimension |
--weight-alignment | float | 0.30 | Weight for the "code-doc alignment" dimension |
--weight-links | float | 0.15 | Weight for the "link health" dimension |
--weight-completeness | float | 0.20 | Weight for the "completeness" dimension |
--weight-accuracy | float | 0.15 | Weight for the "accuracy" dimension |
Example:
python scripts/doc_staleness_scorer.py /path/to/repo --threshold 60 --readme-focus --quietOutput Formats:
- Human-readable (default): Aggregate score with label, per-file score table sorted worst-first, and dimension breakdown with ASCII bars for the bottom 5 files
- JSON (
--json): Structured object withaggregate_score,aggregate_label,total_documents, anddocumentsarray (each withtotal_score,label, and per-dimension scores/details) - Quiet (
--quiet): Single line with the aggregate score (e.g.,72.3)
Exit Codes: 0 = score above threshold (or no threshold set), 1 = score below threshold, 2 = tool error
api_doc_validator.py
Purpose: Extract function and class signatures from Python source files using the ast module and compare them against API documentation in markdown files. Detects undocumented items, phantom documentation for removed code, parameter mismatches, and deprecated items.
Usage:
python scripts/api_doc_validator.py <source_path> <doc_path> [options]Parameters:
| Flag | Type | Default | Description |
|---|---|---|---|
source_path | positional | (required) | Path to a Python source file or directory |
doc_path | positional | (required) | Path to API documentation file (.md) or directory |
--json | flag | off | Output the validation report as JSON |
--recursive | flag | off | Recursively scan the doc directory for markdown files |
--include-private | flag | off | Include _-prefixed private functions and classes in validation |
Example:
python scripts/api_doc_validator.py /path/to/src /path/to/docs/ --recursive --include-private --jsonOutput Formats:
- Human-readable (default): Summary counts (source signatures, documented items, issues), then issues grouped by severity with type tags, source/doc file locations, and a summary-by-type table
- JSON (
--json): Structured object withsummary(counts by type and severity) andissuesarray (each withtype,severity,name, file/line references, anddescription)
Exit Codes: 0 = no high-severity issues, 1 = high-severity issues found (e.g., documented items missing from source), 2 = tool error
link_checker.py
Purpose: Scan markdown files for every link type (local files, anchors, cross-document anchors, images, HTML links, reference-style links) and validate them against the filesystem and document headings. Optionally validates external URLs via HTTP HEAD requests. Also detects duplicate heading anchors.
Usage:
python scripts/link_checker.py <path> [options]Parameters:
| Flag | Type | Default | Description |
|---|---|---|---|
path | positional | (required) | File or directory to check (single .md file or directory for recursive scan) |
--json | flag | off | Output the link check report as JSON |
--broken-only | flag | off | Only show broken links in the report (omit valid links from output) |
--check-external | flag | off | Also validate external URLs via HTTP HEAD requests (slower, makes network requests) |
Example:
python scripts/link_checker.py /path/to/repo --broken-only --jsonOutput Formats:
- Human-readable (default): Summary counts (total, valid, broken, skipped, duplicate anchors), broken links grouped by source file with line numbers and error messages, duplicate anchor list, and link-type breakdown table
- JSON (
--json): Structured object withsummary(counts),broken_linksarray (each with source file, line, text, target, type, error),duplicate_anchorsmap, and optionallyall_links(when--broken-onlyis not set)
Exit Codes: 0 = no broken links and no duplicate anchors, 1 = broken links or duplicate anchors found, 2 = tool error
#!/usr/bin/env python3
"""
API Documentation Validator
Extracts function and class signatures from Python source files using the ast module
and compares them against API documentation in markdown files.
Detects:
- Functions/classes in code but missing from docs
- Functions/classes documented but removed from code
- Parameter mismatches (missing, extra, wrong defaults)
- Deprecated items still documented as current
- Return type annotation mismatches
Usage:
python api_doc_validator.py /path/to/src /path/to/docs/api.md
python api_doc_validator.py /path/to/src /path/to/docs/ --recursive
python api_doc_validator.py /path/to/src /path/to/docs/api.md --json
python api_doc_validator.py /path/to/src /path/to/docs/ --include-private
"""
import argparse
import ast
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
# --- AST Source Extraction ---
class SourceSignature:
"""Represents an extracted function or class signature."""
def __init__(
self,
name: str,
kind: str, # "function", "method", "class"
file_path: str,
line_number: int,
parameters: List[Dict[str, Any]],
return_annotation: Optional[str] = None,
decorators: Optional[List[str]] = None,
docstring: Optional[str] = None,
is_private: bool = False,
parent_class: Optional[str] = None,
):
self.name = name
self.kind = kind
self.file_path = file_path
self.line_number = line_number
self.parameters = parameters
self.return_annotation = return_annotation
self.decorators = decorators or []
self.docstring = docstring
self.is_private = is_private
self.parent_class = parent_class
@property
def qualified_name(self) -> str:
if self.parent_class:
return f"{self.parent_class}.{self.name}"
return self.name
@property
def is_deprecated(self) -> bool:
return any("deprecated" in d.lower() for d in self.decorators)
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"qualified_name": self.qualified_name,
"kind": self.kind,
"file": self.file_path,
"line": self.line_number,
"parameters": self.parameters,
"return_annotation": self.return_annotation,
"decorators": self.decorators,
"is_private": self.is_private,
"is_deprecated": self.is_deprecated,
}
def _annotation_to_str(node: Optional[ast.expr]) -> Optional[str]:
"""Convert an AST annotation node to a string representation."""
if node is None:
return None
try:
return ast.unparse(node)
except AttributeError:
# Python < 3.9 fallback
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Constant):
return repr(node.value)
elif isinstance(node, ast.Attribute):
return f"{_annotation_to_str(node.value)}.{node.attr}"
elif isinstance(node, ast.Subscript):
return f"{_annotation_to_str(node.value)}[{_annotation_to_str(node.slice)}]"
return str(type(node).__name__)
def _extract_decorator_names(decorator_list: List[ast.expr]) -> List[str]:
"""Extract decorator names from AST decorator list."""
names = []
for dec in decorator_list:
if isinstance(dec, ast.Name):
names.append(dec.id)
elif isinstance(dec, ast.Attribute):
names.append(f"{_annotation_to_str(dec.value)}.{dec.attr}")
elif isinstance(dec, ast.Call):
if isinstance(dec.func, ast.Name):
names.append(dec.func.id)
elif isinstance(dec.func, ast.Attribute):
names.append(f"{_annotation_to_str(dec.func.value)}.{dec.func.attr}")
return names
def _extract_parameters(func_node: ast.FunctionDef) -> List[Dict[str, Any]]:
"""Extract parameter information from a function definition."""
params = []
args = func_node.args
# Calculate defaults offset
num_args = len(args.args)
num_defaults = len(args.defaults)
default_offset = num_args - num_defaults
for i, arg in enumerate(args.args):
if arg.arg == "self" or arg.arg == "cls":
continue
param: Dict[str, Any] = {
"name": arg.arg,
"annotation": _annotation_to_str(arg.annotation),
"has_default": False,
"default": None,
}
# Check if this arg has a default
default_idx = i - default_offset
if default_idx >= 0 and default_idx < len(args.defaults):
param["has_default"] = True
try:
param["default"] = ast.unparse(args.defaults[default_idx])
except AttributeError:
param["default"] = "..."
params.append(param)
# *args
if args.vararg:
params.append({
"name": f"*{args.vararg.arg}",
"annotation": _annotation_to_str(args.vararg.annotation),
"has_default": False,
"default": None,
})
# keyword-only args
for i, arg in enumerate(args.kwonlyargs):
param = {
"name": arg.arg,
"annotation": _annotation_to_str(arg.annotation),
"has_default": False,
"default": None,
}
if i < len(args.kw_defaults) and args.kw_defaults[i] is not None:
param["has_default"] = True
try:
param["default"] = ast.unparse(args.kw_defaults[i])
except AttributeError:
param["default"] = "..."
params.append(param)
# **kwargs
if args.kwarg:
params.append({
"name": f"**{args.kwarg.arg}",
"annotation": _annotation_to_str(args.kwarg.annotation),
"has_default": False,
"default": None,
})
return params
def extract_signatures(source_path: str, include_private: bool = False) -> List[SourceSignature]:
"""Extract all function and class signatures from a Python file."""
signatures = []
try:
with open(source_path, "r", encoding="utf-8", errors="ignore") as f:
source = f.read()
tree = ast.parse(source, filename=source_path)
except (SyntaxError, OSError, IOError):
return signatures
rel_path = source_path # Will be made relative by caller
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
is_private = node.name.startswith("_")
if is_private and not include_private:
continue
# Determine if it's a method (inside a class)
parent_class = None
kind = "function"
for parent in ast.walk(tree):
if isinstance(parent, ast.ClassDef):
for item in parent.body:
if item is node:
parent_class = parent.name
kind = "method"
break
docstring = ast.get_docstring(node)
decorators = _extract_decorator_names(node.decorator_list)
parameters = _extract_parameters(node)
return_annotation = _annotation_to_str(node.returns)
sig = SourceSignature(
name=node.name,
kind=kind,
file_path=rel_path,
line_number=node.lineno,
parameters=parameters,
return_annotation=return_annotation,
decorators=decorators,
docstring=docstring,
is_private=is_private,
parent_class=parent_class,
)
signatures.append(sig)
elif isinstance(node, ast.ClassDef):
is_private = node.name.startswith("_")
if is_private and not include_private:
continue
docstring = ast.get_docstring(node)
decorators = _extract_decorator_names(node.decorator_list)
# Extract __init__ params as the class params
init_params = []
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == "__init__":
init_params = _extract_parameters(item)
break
sig = SourceSignature(
name=node.name,
kind="class",
file_path=rel_path,
line_number=node.lineno,
parameters=init_params,
decorators=decorators,
docstring=docstring,
is_private=is_private,
)
signatures.append(sig)
return signatures
def extract_all_signatures(
source_dir: str, include_private: bool = False
) -> Dict[str, List[SourceSignature]]:
"""Extract signatures from all Python files in a directory."""
all_sigs: Dict[str, List[SourceSignature]] = {}
skip_dirs = {"__pycache__", ".venv", "venv", ".git", "node_modules", ".tox"}
for root, dirs, files in os.walk(source_dir):
dirs[:] = [d for d in dirs if d not in skip_dirs]
for f in files:
if f.endswith(".py"):
full_path = os.path.join(root, f)
rel_path = os.path.relpath(full_path, source_dir)
sigs = extract_signatures(full_path, include_private)
# Update file paths to be relative
for s in sigs:
s.file_path = rel_path
if sigs:
all_sigs[rel_path] = sigs
return all_sigs
# --- Documentation Parsing ---
def extract_documented_items(doc_path: str) -> Dict[str, Dict[str, Any]]:
"""Extract function/class references from markdown documentation."""
items: Dict[str, Dict[str, Any]] = {}
try:
with open(doc_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return items
lines = content.splitlines()
# Pattern 1: Function headings like ### `function_name()` or ### function_name
heading_func = re.compile(r'^#{1,6}\s+`?(\w+(?:\.\w+)*)\(`?.*\)?`?\s*$')
# Pattern 2: Function in backticks with params like `function_name(param1, param2)`
inline_func = re.compile(r'`(\w+(?:\.\w+)*)\(([^)]*)\)`')
# Pattern 3: Class headings like ### class ClassName or ### ClassName
heading_class = re.compile(r'^#{1,6}\s+(?:class\s+)?`?(\w+)`?\s*$')
# Pattern 4: Parameter lists like - `param_name` (type): description
param_pattern = re.compile(r'^\s*[-*]\s+`(\w+)`\s*(?:\(([^)]+)\))?\s*(?::|--)?\s*(.*)')
current_item = None
current_params: List[Dict[str, Any]] = []
for i, line in enumerate(lines):
# Check for function heading
match = heading_func.match(line)
if match:
if current_item and current_item in items:
items[current_item]["parameters"] = current_params
func_name = match.group(1)
items[func_name] = {
"name": func_name,
"line": i + 1,
"kind": "function",
"file": doc_path,
"parameters": [],
}
current_item = func_name
current_params = []
continue
# Check for inline function definitions
for match in inline_func.finditer(line):
func_name = match.group(1)
param_str = match.group(2)
if func_name not in items:
params = []
if param_str:
for p in param_str.split(","):
p = p.strip()
if p and p not in ("self", "cls", "..."):
# Handle type annotations like param: type
parts = p.split(":")
param_name = parts[0].strip().split("=")[0].strip()
if param_name and param_name.replace("*", "").isidentifier():
params.append({"name": param_name})
items[func_name] = {
"name": func_name,
"line": i + 1,
"kind": "function",
"file": doc_path,
"parameters": params,
}
# Check for parameter list items
param_match = param_pattern.match(line)
if param_match and current_item:
param_name = param_match.group(1)
param_type = param_match.group(2)
current_params.append({
"name": param_name,
"annotation": param_type,
})
# Finalize last item
if current_item and current_item in items:
items[current_item]["parameters"] = current_params
return items
def extract_all_documented_items(
doc_path: str, recursive: bool = False
) -> Dict[str, Dict[str, Any]]:
"""Extract documented items from one or more markdown files."""
all_items: Dict[str, Dict[str, Any]] = {}
if os.path.isfile(doc_path):
return extract_documented_items(doc_path)
if os.path.isdir(doc_path) and recursive:
for root, dirs, files in os.walk(doc_path):
dirs[:] = [d for d in dirs if d not in {".git", "node_modules"}]
for f in files:
if f.endswith((".md", ".rst")):
full_path = os.path.join(root, f)
items = extract_documented_items(full_path)
all_items.update(items)
elif os.path.isdir(doc_path):
# Non-recursive: just top-level files
for f in os.listdir(doc_path):
if f.endswith((".md", ".rst")):
full_path = os.path.join(doc_path, f)
items = extract_documented_items(full_path)
all_items.update(items)
return all_items
# --- Validation ---
def validate_api_docs(
source_sigs: Dict[str, List[SourceSignature]],
documented_items: Dict[str, Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Compare source signatures against documented items and find mismatches."""
issues = []
# Build lookup of all source signatures by name and qualified name
source_by_name: Dict[str, SourceSignature] = {}
for file_sigs in source_sigs.values():
for sig in file_sigs:
source_by_name[sig.name] = sig
source_by_name[sig.qualified_name] = sig
documented_names = set(documented_items.keys())
source_names = set(source_by_name.keys())
# 1. Documented but not in source (removed or renamed)
for name in documented_names:
if name not in source_names:
doc_item = documented_items[name]
issues.append({
"type": "documented_not_in_source",
"severity": "high",
"name": name,
"doc_file": doc_item.get("file", "unknown"),
"doc_line": doc_item.get("line", 0),
"description": f"'{name}' is documented but not found in source code (removed or renamed)",
})
# 2. In source but not documented
for name, sig in source_by_name.items():
# Skip qualified names that are also present as simple names
if "." in name and name.split(".")[-1] in source_by_name:
# Only report on the simple name to avoid duplicates
continue
if name not in documented_names and sig.qualified_name not in documented_names:
if not sig.is_private:
issues.append({
"type": "undocumented",
"severity": "medium",
"name": sig.qualified_name,
"source_file": sig.file_path,
"source_line": sig.line_number,
"description": f"'{sig.qualified_name}' ({sig.kind}) exists in source but is not documented",
})
# 3. Parameter mismatches
for name in documented_names & source_names:
sig = source_by_name[name]
doc_item = documented_items[name]
doc_params = doc_item.get("parameters", [])
source_params = sig.parameters
if not doc_params and not source_params:
continue
doc_param_names = {p["name"] for p in doc_params if "name" in p}
source_param_names = {
p["name"].lstrip("*") for p in source_params if "name" in p
}
# Parameters in source but not in docs
missing_in_docs = source_param_names - doc_param_names
for param in missing_in_docs:
if param not in ("self", "cls"):
issues.append({
"type": "missing_param_in_docs",
"severity": "medium",
"name": name,
"parameter": param,
"source_file": sig.file_path,
"source_line": sig.line_number,
"description": f"Parameter '{param}' of '{name}' exists in source but not in docs",
})
# Parameters in docs but not in source
extra_in_docs = doc_param_names - source_param_names
for param in extra_in_docs:
issues.append({
"type": "extra_param_in_docs",
"severity": "medium",
"name": name,
"parameter": param,
"doc_file": doc_item.get("file", "unknown"),
"doc_line": doc_item.get("line", 0),
"description": f"Parameter '{param}' documented for '{name}' but not in source",
})
# 4. Deprecated items still documented without deprecation notice
for name in documented_names & source_names:
sig = source_by_name[name]
if sig.is_deprecated:
doc_item = documented_items[name]
# Check if doc mentions "deprecated"
# This is a simple heuristic; we check the item's doc context
issues.append({
"type": "deprecated_still_documented",
"severity": "low",
"name": name,
"source_file": sig.file_path,
"source_line": sig.line_number,
"description": f"'{name}' has @deprecated decorator but may still be documented as current",
})
return issues
# --- Report ---
def generate_report(issues: List[Dict[str, Any]], source_count: int, doc_count: int, as_json: bool = False) -> str:
"""Generate a validation report."""
report_data = {
"summary": {
"source_signatures": source_count,
"documented_items": doc_count,
"total_issues": len(issues),
"by_type": {},
"by_severity": {},
},
"issues": issues,
}
for issue in issues:
itype = issue.get("type", "unknown")
sev = issue.get("severity", "unknown")
report_data["summary"]["by_type"][itype] = report_data["summary"]["by_type"].get(itype, 0) + 1
report_data["summary"]["by_severity"][sev] = report_data["summary"]["by_severity"].get(sev, 0) + 1
if as_json:
return json.dumps(report_data, indent=2, default=str)
lines = []
lines.append("API Documentation Validation Report")
lines.append("=" * 60)
lines.append(f"Source signatures found: {source_count}")
lines.append(f"Documented items found: {doc_count}")
lines.append(f"Issues found: {len(issues)}")
lines.append("")
if not issues:
lines.append("No issues found. API documentation matches source code.")
return "\n".join(lines)
# Group by severity
severity_order = ["high", "medium", "low"]
for severity in severity_order:
sev_issues = [i for i in issues if i.get("severity") == severity]
if not sev_issues:
continue
lines.append(f"{severity.upper()} ({len(sev_issues)} issues):")
lines.append("-" * 40)
for issue in sev_issues:
lines.append(f" [{issue.get('type', 'unknown')}] {issue['description']}")
if "source_file" in issue:
lines.append(f" Source: {issue['source_file']}:{issue.get('source_line', '?')}")
if "doc_file" in issue:
lines.append(f" Doc: {issue['doc_file']}:{issue.get('doc_line', '?')}")
lines.append("")
lines.append("")
# Summary by type
lines.append("SUMMARY BY TYPE:")
lines.append("-" * 40)
type_labels = {
"undocumented": "Undocumented items",
"documented_not_in_source": "Documented but not in source",
"missing_param_in_docs": "Missing parameters in docs",
"extra_param_in_docs": "Extra parameters in docs",
"deprecated_still_documented": "Deprecated items still documented",
}
for itype, count in sorted(report_data["summary"]["by_type"].items()):
label = type_labels.get(itype, itype)
lines.append(f" {label}: {count}")
return "\n".join(lines)
# --- Main ---
def main():
parser = argparse.ArgumentParser(
description="Validate API documentation against Python source code",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("source_path", help="Path to Python source directory")
parser.add_argument("doc_path", help="Path to API documentation (file or directory)")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--recursive", action="store_true", help="Recursively scan doc directory")
parser.add_argument("--include-private", action="store_true", help="Include private (_prefixed) items")
args = parser.parse_args()
source_path = os.path.abspath(args.source_path)
doc_path = os.path.abspath(args.doc_path)
if not os.path.exists(source_path):
print(f"Error: Source path '{source_path}' does not exist", file=sys.stderr)
sys.exit(2)
if not os.path.exists(doc_path):
print(f"Error: Doc path '{doc_path}' does not exist", file=sys.stderr)
sys.exit(2)
# Extract source signatures
if os.path.isfile(source_path) and source_path.endswith(".py"):
sigs = extract_signatures(source_path, args.include_private)
source_sigs = {os.path.basename(source_path): sigs}
elif os.path.isdir(source_path):
source_sigs = extract_all_signatures(source_path, args.include_private)
else:
print(f"Error: Source path must be a Python file or directory", file=sys.stderr)
sys.exit(2)
# Extract documented items
documented_items = extract_all_documented_items(doc_path, recursive=args.recursive)
# Count totals
source_count = sum(len(sigs) for sigs in source_sigs.values())
doc_count = len(documented_items)
# Validate
issues = validate_api_docs(source_sigs, documented_items)
# Report
report = generate_report(issues, source_count, doc_count, as_json=args.json)
print(report)
# Exit code
has_high = any(i.get("severity") == "high" for i in issues)
sys.exit(1 if has_high else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Documentation Staleness Scorer
Scores documentation freshness on a 0-100 scale using five weighted dimensions:
- Last Updated (20%): How recently the doc was modified
- Code-Doc Alignment (30%): Whether documented items still match code
- Link Health (15%): Percentage of links that resolve
- Completeness (20%): Whether expected sections exist
- Accuracy (15%): Version strings, file paths, verifiable facts
Usage:
python doc_staleness_scorer.py /path/to/repo
python doc_staleness_scorer.py /path/to/repo --json
python doc_staleness_scorer.py /path/to/repo --threshold 60
python doc_staleness_scorer.py /path/to/repo --readme-focus
python doc_staleness_scorer.py /path/to/repo --required-sections "Installation,Usage,API"
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# --- Constants ---
DOC_EXTENSIONS = {".md", ".rst", ".txt", ".adoc"}
CODE_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java",
".c", ".cpp", ".h", ".hpp", ".rb", ".php",
}
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox", "dist", "build"}
DEFAULT_WEIGHTS = {
"last_updated": 0.20,
"code_doc_alignment": 0.30,
"link_health": 0.15,
"completeness": 0.20,
"accuracy": 0.15,
}
DEFAULT_README_SECTIONS = [
"installation", "usage", "api", "contributing", "license",
]
SCORE_LABELS = {
(90, 101): "excellent",
(70, 90): "good",
(50, 70): "stale",
(30, 50): "critical",
(0, 30): "abandoned",
}
def get_label(score: float) -> str:
for (low, high), label in SCORE_LABELS.items():
if low <= score < high:
return label
return "unknown"
# --- Git Helpers ---
def run_git(repo_path: str, args: List[str], default: str = "") -> str:
try:
result = subprocess.run(
["git", "-C", repo_path] + args,
capture_output=True, text=True, timeout=30,
)
return result.stdout.strip() if result.returncode == 0 else default
except (subprocess.TimeoutExpired, FileNotFoundError):
return default
def get_file_last_commit_date(repo_path: str, file_path: str) -> Optional[datetime]:
output = run_git(repo_path, ["log", "-1", "--format=%aI", "--", file_path])
if output:
try:
return datetime.fromisoformat(output)
except ValueError:
pass
return None
def get_code_changes_since(repo_path: str, since_date: str, directory: str = "") -> int:
args = ["log", "--since", since_date, "--oneline", "--", directory or "."]
output = run_git(repo_path, args)
return len([l for l in output.splitlines() if l.strip()]) if output else 0
def get_latest_tag(repo_path: str) -> Optional[str]:
output = run_git(repo_path, ["describe", "--tags", "--abbrev=0"])
return output.lstrip("v") if output else None
# --- File Discovery ---
def find_doc_files(repo_path: str) -> List[str]:
doc_files = []
for root, dirs, files in os.walk(repo_path):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
if Path(f).suffix.lower() in DOC_EXTENSIONS:
doc_files.append(os.path.relpath(os.path.join(root, f), repo_path))
return sorted(doc_files)
# --- Scoring Dimensions ---
def score_last_updated(repo_path: str, doc_path: str) -> Tuple[float, Dict[str, Any]]:
"""Score based on how recently the doc was modified. 0-100."""
last_modified = get_file_last_commit_date(repo_path, doc_path)
details = {}
if not last_modified:
details["reason"] = "No git history found"
return 50.0, details
now = datetime.now(timezone.utc)
if last_modified.tzinfo is None:
last_modified = last_modified.replace(tzinfo=timezone.utc)
days_ago = (now - last_modified).days
details["last_modified"] = last_modified.strftime("%Y-%m-%d")
details["days_ago"] = days_ago
# Score: 100 if updated today, decays over time
if days_ago <= 7:
score = 100.0
elif days_ago <= 30:
score = 90.0 - (days_ago - 7) * 0.4
elif days_ago <= 90:
score = 80.0 - (days_ago - 30) * 0.5
elif days_ago <= 180:
score = 50.0 - (days_ago - 90) * 0.3
elif days_ago <= 365:
score = 25.0 - (days_ago - 180) * 0.1
else:
score = max(0.0, 10.0 - (days_ago - 365) * 0.02)
return max(0.0, min(100.0, score)), details
def score_code_doc_alignment(repo_path: str, doc_path: str) -> Tuple[float, Dict[str, Any]]:
"""Score based on whether documented items still exist in code. 0-100."""
full_path = os.path.join(repo_path, doc_path)
details = {"referenced_files": 0, "existing_files": 0, "referenced_functions": 0}
try:
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return 50.0, details
# Extract file references
file_refs = set()
# Markdown links
for match in re.finditer(r'\[([^\]]*)\]\(([^)]+)\)', content):
target = match.group(2).split("#")[0]
if not target.startswith(("http://", "https://", "mailto:")) and target:
file_refs.add(target)
# Backtick file references
for match in re.finditer(r'`([^\s`]+\.\w{1,5})`', content):
candidate = match.group(1)
if Path(candidate).suffix.lower() in CODE_EXTENSIONS | DOC_EXTENSIONS:
file_refs.add(candidate)
if not file_refs:
# No explicit references; check if associated code dir has changed
doc_dir = os.path.dirname(doc_path)
last_mod = get_file_last_commit_date(repo_path, doc_path)
if last_mod:
since = last_mod.strftime("%Y-%m-%d")
changes = get_code_changes_since(repo_path, since, doc_dir)
details["code_changes_since_update"] = changes
if changes == 0:
return 100.0, details
elif changes < 5:
return 80.0, details
elif changes < 20:
return 60.0, details
else:
return 40.0, details
return 70.0, details
# Check which referenced files exist
doc_dir = os.path.dirname(doc_path)
existing = 0
for ref in file_refs:
# Try relative to doc location
resolved = os.path.normpath(os.path.join(repo_path, doc_dir, ref))
if os.path.exists(resolved):
existing += 1
continue
# Try relative to repo root
resolved_root = os.path.normpath(os.path.join(repo_path, ref))
if os.path.exists(resolved_root):
existing += 1
details["referenced_files"] = len(file_refs)
details["existing_files"] = existing
if len(file_refs) == 0:
return 70.0, details
ratio = existing / len(file_refs)
return ratio * 100.0, details
def score_link_health(repo_path: str, doc_path: str) -> Tuple[float, Dict[str, Any]]:
"""Score based on percentage of valid internal links. 0-100."""
full_path = os.path.join(repo_path, doc_path)
details = {"total_links": 0, "valid_links": 0, "broken_links": []}
try:
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return 100.0, details
doc_dir = os.path.dirname(doc_path)
# Extract markdown links
links = []
for match in re.finditer(r'\[([^\]]*)\]\(([^)]+)\)', content):
target = match.group(2)
if not target.startswith(("http://", "https://", "mailto:")):
links.append(target)
if not links:
return 100.0, details
details["total_links"] = len(links)
valid = 0
for link in links:
anchor = None
if "#" in link:
file_part, anchor = link.split("#", 1)
else:
file_part = link
if not file_part:
# Anchor-only link within same doc
if anchor:
headings = _extract_headings(content)
slug = _slugify(anchor)
if slug in headings:
valid += 1
else:
details["broken_links"].append(f"#{anchor}")
else:
valid += 1
continue
# Check file existence
resolved = os.path.normpath(os.path.join(repo_path, doc_dir, file_part))
if not os.path.exists(resolved):
resolved = os.path.normpath(os.path.join(repo_path, file_part))
if os.path.exists(resolved):
if anchor and resolved.endswith((".md", ".rst")):
# Validate anchor in target file
try:
with open(resolved, "r", encoding="utf-8", errors="ignore") as f:
target_content = f.read()
headings = _extract_headings(target_content)
if _slugify(anchor) in headings:
valid += 1
else:
details["broken_links"].append(link)
except (OSError, IOError):
valid += 1 # Give benefit of doubt
else:
valid += 1
else:
details["broken_links"].append(link)
details["valid_links"] = valid
if len(links) == 0:
return 100.0, details
return (valid / len(links)) * 100.0, details
def score_completeness(repo_path: str, doc_path: str, required_sections: List[str]) -> Tuple[float, Dict[str, Any]]:
"""Score based on whether expected sections are present. 0-100."""
full_path = os.path.join(repo_path, doc_path)
details = {"expected_sections": required_sections, "found_sections": [], "missing_sections": []}
try:
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return 0.0, details
# Extract headings
headings_lower = set()
for line in content.splitlines():
match = re.match(r'^#{1,4}\s+(.+)', line)
if match:
headings_lower.add(match.group(1).strip().lower())
# Also check heading words
heading_words = set()
for h in headings_lower:
heading_words.update(h.split())
found = []
missing = []
for section in required_sections:
section_lower = section.lower()
if section_lower in headings_lower or section_lower in heading_words:
found.append(section)
elif any(section_lower in h for h in headings_lower):
found.append(section)
else:
missing.append(section)
details["found_sections"] = found
details["missing_sections"] = missing
if not required_sections:
return 100.0, details
# Also score based on content length (very short docs are incomplete)
content_lines = len([l for l in content.splitlines() if l.strip()])
length_penalty = 0
if content_lines < 10:
length_penalty = 30
elif content_lines < 30:
length_penalty = 15
section_score = (len(found) / len(required_sections)) * 100.0
return max(0.0, section_score - length_penalty), details
def score_accuracy(repo_path: str, doc_path: str) -> Tuple[float, Dict[str, Any]]:
"""Score based on accuracy of verifiable facts (versions, dates, paths). 0-100."""
full_path = os.path.join(repo_path, doc_path)
details = {"checks": [], "issues": []}
try:
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return 50.0, details
checks_passed = 0
checks_total = 0
# Check 1: Version strings match latest tag
git_version = get_latest_tag(repo_path)
if git_version:
version_refs = re.findall(r'(?:v|version[:\s]*)(\d+\.\d+(?:\.\d+)?)', content, re.IGNORECASE)
if version_refs:
checks_total += 1
if any(v == git_version for v in version_refs):
checks_passed += 1
details["checks"].append("version_match: PASS")
else:
details["checks"].append(f"version_match: FAIL (doc has {version_refs}, git has {git_version})")
details["issues"].append(f"Version mismatch: doc={version_refs}, git={git_version}")
# Check 2: Package version from manifests
for manifest in ["package.json", "pyproject.toml", "setup.py", "Cargo.toml"]:
manifest_path = os.path.join(repo_path, manifest)
if os.path.exists(manifest_path):
manifest_version = _extract_version_from_manifest(manifest_path, manifest)
if manifest_version:
version_refs = re.findall(r'(?:v|version[:\s]*)(\d+\.\d+(?:\.\d+)?)', content, re.IGNORECASE)
if version_refs:
checks_total += 1
if manifest_version in version_refs:
checks_passed += 1
details["checks"].append(f"manifest_version ({manifest}): PASS")
else:
details["checks"].append(
f"manifest_version ({manifest}): FAIL "
f"(doc={version_refs}, manifest={manifest_version})"
)
details["issues"].append(
f"Manifest version mismatch: {manifest} has {manifest_version}"
)
# Check 3: Referenced file paths exist
file_refs = re.findall(r'`([^\s`]+/[^\s`]+\.\w{1,5})`', content)
if file_refs:
doc_dir = os.path.dirname(doc_path)
existing_count = 0
for ref in file_refs:
resolved = os.path.normpath(os.path.join(repo_path, doc_dir, ref))
if os.path.exists(resolved):
existing_count += 1
else:
resolved_root = os.path.normpath(os.path.join(repo_path, ref))
if os.path.exists(resolved_root):
existing_count += 1
checks_total += 1
if len(file_refs) > 0 and existing_count == len(file_refs):
checks_passed += 1
details["checks"].append(f"file_paths: PASS ({existing_count}/{len(file_refs)})")
else:
ratio = existing_count / len(file_refs) if file_refs else 0
# Partial credit
checks_passed += ratio
details["checks"].append(f"file_paths: PARTIAL ({existing_count}/{len(file_refs)})")
details["issues"].append(f"{len(file_refs) - existing_count} referenced file paths not found")
# Check 4: Dates are not in the future and not suspiciously old
date_pattern = re.compile(r'(\d{4}-\d{2}-\d{2})')
dates_found = date_pattern.findall(content)
if dates_found:
checks_total += 1
now = datetime.now(timezone.utc)
all_ok = True
for date_str in dates_found:
try:
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
if d > now:
details["issues"].append(f"Future date found: {date_str}")
all_ok = False
except ValueError:
pass
if all_ok:
checks_passed += 1
details["checks"].append("dates: PASS")
else:
details["checks"].append("dates: FAIL")
if checks_total == 0:
return 75.0, details # No verifiable facts found, assume reasonable
return (checks_passed / checks_total) * 100.0, details
# --- Helpers ---
def _extract_headings(content: str) -> set:
"""Extract heading slugs from markdown content."""
slugs = set()
for line in content.splitlines():
match = re.match(r'^#{1,6}\s+(.+)', line)
if match:
slugs.add(_slugify(match.group(1).strip()))
return slugs
def _slugify(text: str) -> str:
"""Convert heading text to anchor slug (GitHub-style)."""
text = text.lower()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[\s]+', '-', text)
text = text.strip('-')
return text
def _extract_version_from_manifest(path: str, filename: str) -> Optional[str]:
"""Extract version string from a package manifest."""
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return None
if filename == "package.json":
match = re.search(r'"version"\s*:\s*"([^"]+)"', content)
return match.group(1) if match else None
elif filename == "pyproject.toml":
match = re.search(r'version\s*=\s*"([^"]+)"', content)
return match.group(1) if match else None
elif filename == "setup.py":
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
return match.group(1) if match else None
elif filename == "Cargo.toml":
match = re.search(r'version\s*=\s*"([^"]+)"', content)
return match.group(1) if match else None
return None
# --- Scoring Engine ---
def score_document(
repo_path: str,
doc_path: str,
weights: Dict[str, float],
required_sections: List[str],
) -> Dict[str, Any]:
"""Score a single documentation file across all dimensions."""
updated_score, updated_details = score_last_updated(repo_path, doc_path)
alignment_score, alignment_details = score_code_doc_alignment(repo_path, doc_path)
link_score, link_details = score_link_health(repo_path, doc_path)
completeness_score, completeness_details = score_completeness(repo_path, doc_path, required_sections)
accuracy_score, accuracy_details = score_accuracy(repo_path, doc_path)
weighted_total = (
updated_score * weights["last_updated"]
+ alignment_score * weights["code_doc_alignment"]
+ link_score * weights["link_health"]
+ completeness_score * weights["completeness"]
+ accuracy_score * weights["accuracy"]
)
return {
"file": doc_path,
"total_score": round(weighted_total, 1),
"label": get_label(weighted_total),
"dimensions": {
"last_updated": {
"score": round(updated_score, 1),
"weight": weights["last_updated"],
"weighted": round(updated_score * weights["last_updated"], 1),
"details": updated_details,
},
"code_doc_alignment": {
"score": round(alignment_score, 1),
"weight": weights["code_doc_alignment"],
"weighted": round(alignment_score * weights["code_doc_alignment"], 1),
"details": alignment_details,
},
"link_health": {
"score": round(link_score, 1),
"weight": weights["link_health"],
"weighted": round(link_score * weights["link_health"], 1),
"details": link_details,
},
"completeness": {
"score": round(completeness_score, 1),
"weight": weights["completeness"],
"weighted": round(completeness_score * weights["completeness"], 1),
"details": completeness_details,
},
"accuracy": {
"score": round(accuracy_score, 1),
"weight": weights["accuracy"],
"weighted": round(accuracy_score * weights["accuracy"], 1),
"details": accuracy_details,
},
},
}
# --- Report ---
def generate_report(scores: List[Dict[str, Any]], as_json: bool = False) -> str:
"""Generate a staleness report from scored documents."""
if not scores:
if as_json:
return json.dumps({"documents": [], "aggregate_score": 0}, indent=2)
return "No documentation files found to score."
aggregate = sum(s["total_score"] for s in scores) / len(scores)
report_data = {
"aggregate_score": round(aggregate, 1),
"aggregate_label": get_label(aggregate),
"total_documents": len(scores),
"documents": scores,
}
if as_json:
return json.dumps(report_data, indent=2, default=str)
# Human-readable
lines = []
lines.append("Documentation Staleness Report")
lines.append("=" * 60)
lines.append(f"Aggregate Score: {aggregate:.1f}/100 ({get_label(aggregate)})")
lines.append(f"Documents Scored: {len(scores)}")
lines.append("")
# Sort by score ascending (worst first)
sorted_scores = sorted(scores, key=lambda s: s["total_score"])
lines.append(f"{'File':<45} {'Score':>6} {'Label':>12}")
lines.append("-" * 65)
for s in sorted_scores:
lines.append(f"{s['file']:<45} {s['total_score']:>5.1f} {s['label']:>12}")
lines.append("")
lines.append("DIMENSION BREAKDOWN (worst scoring files):")
lines.append("-" * 60)
# Show detailed breakdown for bottom 5
for s in sorted_scores[:5]:
lines.append(f"\n {s['file']} (score: {s['total_score']:.1f})")
for dim_name, dim_data in s["dimensions"].items():
bar = _score_bar(dim_data["score"])
lines.append(
f" {dim_name:<25} {dim_data['score']:>5.1f} "
f"(x{dim_data['weight']:.2f} = {dim_data['weighted']:>5.1f}) {bar}"
)
lines.append("")
return "\n".join(lines)
def _score_bar(score: float, width: int = 20) -> str:
"""Generate a simple ASCII bar for a score."""
filled = int(score / 100 * width)
return "[" + "#" * filled + "." * (width - filled) + "]"
# --- Main ---
def main():
parser = argparse.ArgumentParser(
description="Score documentation freshness on a 0-100 scale",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("repo_path", help="Path to the git repository")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument(
"--threshold", type=float, default=None,
help="Fail (exit 1) if aggregate score is below this value",
)
parser.add_argument("--readme-focus", action="store_true", help="Only score README files")
parser.add_argument(
"--required-sections", default=None,
help="Comma-separated required sections for completeness scoring",
)
parser.add_argument("--quiet", action="store_true", help="Only output score number")
parser.add_argument("--weight-updated", type=float, default=None)
parser.add_argument("--weight-alignment", type=float, default=None)
parser.add_argument("--weight-links", type=float, default=None)
parser.add_argument("--weight-completeness", type=float, default=None)
parser.add_argument("--weight-accuracy", type=float, default=None)
args = parser.parse_args()
repo_path = os.path.abspath(args.repo_path)
if not os.path.isdir(repo_path):
print(f"Error: {repo_path} is not a directory", file=sys.stderr)
sys.exit(2)
# Build weights
weights = dict(DEFAULT_WEIGHTS)
if args.weight_updated is not None:
weights["last_updated"] = args.weight_updated
if args.weight_alignment is not None:
weights["code_doc_alignment"] = args.weight_alignment
if args.weight_links is not None:
weights["link_health"] = args.weight_links
if args.weight_completeness is not None:
weights["completeness"] = args.weight_completeness
if args.weight_accuracy is not None:
weights["accuracy"] = args.weight_accuracy
# Normalize weights to sum to 1.0
total_weight = sum(weights.values())
if total_weight > 0:
weights = {k: v / total_weight for k, v in weights.items()}
# Required sections
required_sections = DEFAULT_README_SECTIONS
if args.required_sections:
required_sections = [s.strip() for s in args.required_sections.split(",")]
# Find docs
doc_files = find_doc_files(repo_path)
if args.readme_focus:
doc_files = [d for d in doc_files if os.path.basename(d).lower().startswith("readme")]
if not doc_files:
if args.quiet:
print("0")
elif args.json:
print(json.dumps({"error": "No documentation files found"}, indent=2))
else:
print("No documentation files found.")
sys.exit(0)
# Score each document
scores = []
for doc in doc_files:
score = score_document(repo_path, doc, weights, required_sections)
scores.append(score)
aggregate = sum(s["total_score"] for s in scores) / len(scores) if scores else 0
if args.quiet:
print(f"{aggregate:.1f}")
else:
report = generate_report(scores, as_json=args.json)
print(report)
# Threshold check
if args.threshold is not None and aggregate < args.threshold:
if not args.quiet:
print(
f"\nFAILED: Aggregate score {aggregate:.1f} is below threshold {args.threshold}",
file=sys.stderr,
)
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Documentation Drift Analyzer
Compares git log of code changes against documentation file modification dates
to identify documentation that has fallen out of sync with the codebase.
Features:
- Maps code directories to their documentation files
- Detects renamed functions/files, moved directories, changed APIs
- Classifies drift by category and severity
- Outputs actionable drift reports with specific mismatches
Usage:
python drift_analyzer.py /path/to/repo
python drift_analyzer.py /path/to/repo --json
python drift_analyzer.py /path/to/repo --min-severity high
python drift_analyzer.py /path/to/repo --scope src/
python drift_analyzer.py /path/to/repo --doc-patterns "*.md,*.rst"
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
# --- Constants ---
DOC_EXTENSIONS = {".md", ".rst", ".txt", ".adoc"}
CODE_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java",
".c", ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt",
".scala", ".sh", ".bash", ".zsh", ".yaml", ".yml", ".toml",
".json", ".xml", ".css", ".scss", ".html",
}
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
DRIFT_CATEGORIES = {"structural", "factual", "referential", "temporal", "semantic"}
# --- Git Helpers ---
def run_git(repo_path: str, args: List[str], default: str = "") -> str:
"""Run a git command and return stdout. Returns default on failure."""
try:
result = subprocess.run(
["git", "-C", repo_path] + args,
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
return result.stdout.strip()
return default
except (subprocess.TimeoutExpired, FileNotFoundError):
return default
def get_file_last_modified(repo_path: str, file_path: str) -> Optional[datetime]:
"""Get the last git commit date for a file."""
output = run_git(repo_path, [
"log", "-1", "--format=%aI", "--", file_path
])
if output:
try:
return datetime.fromisoformat(output)
except ValueError:
pass
return None
def get_files_changed_since(repo_path: str, since_date: str, scope: str = "") -> List[Dict[str, str]]:
"""Get files changed since a given date, optionally scoped to a directory."""
args = ["log", "--since", since_date, "--name-status", "--pretty=format:", "--diff-filter=ACDMRT"]
if scope:
args += ["--", scope]
output = run_git(repo_path, args)
changes = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) >= 2:
status = parts[0][0] # First char: A, C, D, M, R, T
filepath = parts[-1]
changes.append({"status": status, "file": filepath})
return changes
def get_renamed_files(repo_path: str, since_date: str) -> List[Tuple[str, str]]:
"""Get files that were renamed since a given date."""
output = run_git(repo_path, [
"log", "--since", since_date, "--name-status", "--pretty=format:",
"--diff-filter=R", "-M"
])
renames = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) >= 3 and parts[0].startswith("R"):
renames.append((parts[1], parts[2]))
return renames
def get_current_version_from_git(repo_path: str) -> Optional[str]:
"""Get the latest git tag as a version string."""
output = run_git(repo_path, ["describe", "--tags", "--abbrev=0"])
if output:
return output.lstrip("v")
return None
# --- File Discovery ---
def find_doc_files(repo_path: str, patterns: Optional[List[str]] = None) -> List[str]:
"""Find all documentation files in the repository."""
doc_files = []
repo = Path(repo_path)
skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox", "dist", "build"}
if patterns:
extensions = set()
for p in patterns:
if p.startswith("*."):
extensions.add(p[1:])
elif p.startswith("."):
extensions.add(p)
else:
extensions.add("." + p)
else:
extensions = DOC_EXTENSIONS
for root, dirs, files in os.walk(repo_path):
dirs[:] = [d for d in dirs if d not in skip_dirs]
for f in files:
if Path(f).suffix.lower() in extensions:
rel_path = os.path.relpath(os.path.join(root, f), repo_path)
doc_files.append(rel_path)
return sorted(doc_files)
def find_code_files(repo_path: str, scope: str = "") -> List[str]:
"""Find all code files in the repository."""
code_files = []
search_path = os.path.join(repo_path, scope) if scope else repo_path
skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox", "dist", "build"}
if not os.path.isdir(search_path):
return code_files
for root, dirs, files in os.walk(search_path):
dirs[:] = [d for d in dirs if d not in skip_dirs]
for f in files:
if Path(f).suffix.lower() in CODE_EXTENSIONS:
rel_path = os.path.relpath(os.path.join(root, f), repo_path)
code_files.append(rel_path)
return sorted(code_files)
# --- Code-to-Doc Mapping ---
def map_docs_to_code(repo_path: str, doc_files: List[str], code_files: List[str]) -> Dict[str, List[str]]:
"""Map documentation files to the code directories they likely describe."""
mapping = defaultdict(list)
# Strategy 1: Directory proximity -- docs describe code in same or parent directory
doc_dirs = {}
for doc in doc_files:
doc_dir = os.path.dirname(doc)
doc_dirs[doc] = doc_dir
code_dirs = set()
for code in code_files:
code_dirs.add(os.path.dirname(code))
for doc, doc_dir in doc_dirs.items():
# Check if there are code files in the same directory
if doc_dir in code_dirs:
mapping[doc].append(doc_dir)
# Check parent directory
parent = os.path.dirname(doc_dir)
if parent in code_dirs:
mapping[doc].append(parent)
# Strategy 2: Content references -- docs that reference code file paths
for doc in doc_files:
doc_full = os.path.join(repo_path, doc)
try:
with open(doc_full, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
continue
for code in code_files:
# Check if the code file path is mentioned in the doc
code_basename = os.path.basename(code)
if code_basename in content:
code_dir = os.path.dirname(code)
if code_dir and code_dir not in mapping[doc]:
mapping[doc].append(code_dir)
# Strategy 3: Naming convention -- README in src/auth/ describes src/auth/
for doc in doc_files:
doc_lower = os.path.basename(doc).lower()
if doc_lower in ("readme.md", "readme.rst", "readme.txt", "index.md"):
doc_dir = os.path.dirname(doc)
if doc_dir not in mapping[doc]:
mapping[doc].append(doc_dir)
return dict(mapping)
# --- Drift Detection ---
def extract_references_from_doc(repo_path: str, doc_path: str) -> Dict[str, Set[str]]:
"""Extract file references, function names, and version strings from a doc."""
full_path = os.path.join(repo_path, doc_path)
refs: Dict[str, Set[str]] = {
"files": set(),
"functions": set(),
"versions": set(),
"links": set(),
}
try:
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return refs
# File references in markdown links
link_pattern = re.compile(r'\[([^\]]*)\]\(([^)]+)\)')
for match in link_pattern.finditer(content):
target = match.group(2)
if not target.startswith(("http://", "https://", "mailto:", "#")):
refs["files"].add(target.split("#")[0])
refs["links"].add(target)
# Function/method references in backticks
func_pattern = re.compile(r'`(\w+(?:\.\w+)*)\(`')
for match in func_pattern.finditer(content):
refs["functions"].add(match.group(1))
# Version strings
version_pattern = re.compile(r'(?:v|version[:\s]*)(\d+\.\d+(?:\.\d+)?)', re.IGNORECASE)
for match in version_pattern.finditer(content):
refs["versions"].add(match.group(1))
# Code block file references
code_ref_pattern = re.compile(r'`([^\s`]+\.\w{1,5})`')
for match in code_ref_pattern.finditer(content):
candidate = match.group(1)
if Path(candidate).suffix.lower() in CODE_EXTENSIONS:
refs["files"].add(candidate)
return refs
def detect_drift_for_doc(
repo_path: str,
doc_path: str,
associated_code_dirs: List[str],
renames: List[Tuple[str, str]],
current_version: Optional[str],
) -> List[Dict[str, Any]]:
"""Detect drift issues for a single documentation file."""
issues = []
doc_modified = get_file_last_modified(repo_path, doc_path)
if not doc_modified:
issues.append({
"file": doc_path,
"severity": "info",
"category": "temporal",
"description": "Documentation file has no git history (untracked or new)",
"fix_type": "manual",
})
return issues
since_date = doc_modified.strftime("%Y-%m-%d")
# Check code changes since doc was last updated
total_code_changes = 0
changed_dirs = set()
for code_dir in associated_code_dirs:
changes = get_files_changed_since(repo_path, since_date, code_dir)
code_changes = [c for c in changes if Path(c["file"]).suffix.lower() in CODE_EXTENSIONS]
total_code_changes += len(code_changes)
if code_changes:
changed_dirs.add(code_dir)
if total_code_changes > 20:
issues.append({
"file": doc_path,
"severity": "high",
"category": "factual",
"description": f"{total_code_changes} code files changed in associated directories since doc was last updated",
"fix_type": "manual",
"details": {"changed_dirs": list(changed_dirs), "change_count": total_code_changes},
})
elif total_code_changes > 5:
issues.append({
"file": doc_path,
"severity": "medium",
"category": "factual",
"description": f"{total_code_changes} code files changed since doc update",
"fix_type": "semi",
"details": {"changed_dirs": list(changed_dirs), "change_count": total_code_changes},
})
elif total_code_changes > 0:
issues.append({
"file": doc_path,
"severity": "low",
"category": "factual",
"description": f"{total_code_changes} code files changed since doc update",
"fix_type": "semi",
"details": {"changed_dirs": list(changed_dirs), "change_count": total_code_changes},
})
# Check for renamed files referenced in the doc
refs = extract_references_from_doc(repo_path, doc_path)
for old_name, new_name in renames:
old_base = os.path.basename(old_name)
if old_base in {os.path.basename(r) for r in refs["files"]}:
issues.append({
"file": doc_path,
"severity": "high",
"category": "referential",
"description": f"References '{old_base}' which was renamed to '{os.path.basename(new_name)}'",
"fix_type": "auto",
"details": {"old_path": old_name, "new_path": new_name},
})
# Check for broken file references
for ref_file in refs["files"]:
# Resolve relative to doc's directory
doc_dir = os.path.dirname(doc_path)
resolved = os.path.normpath(os.path.join(repo_path, doc_dir, ref_file))
if not os.path.exists(resolved):
# Also try from repo root
resolved_root = os.path.normpath(os.path.join(repo_path, ref_file))
if not os.path.exists(resolved_root):
issues.append({
"file": doc_path,
"severity": "medium",
"category": "referential",
"description": f"References non-existent file: {ref_file}",
"fix_type": "auto",
})
# Check version string drift
if current_version and refs["versions"]:
for doc_version in refs["versions"]:
if doc_version != current_version and _version_is_older(doc_version, current_version):
issues.append({
"file": doc_path,
"severity": "medium",
"category": "temporal",
"description": f"References version {doc_version}, current is {current_version}",
"fix_type": "auto",
"details": {"doc_version": doc_version, "current_version": current_version},
})
# Check temporal staleness (days since update)
now = datetime.now(timezone.utc)
# Normalize both datetimes to UTC-aware for safe comparison
if doc_modified.tzinfo is None:
doc_modified_utc = doc_modified.replace(tzinfo=timezone.utc)
else:
doc_modified_utc = doc_modified.astimezone(timezone.utc)
days_since = (now - doc_modified_utc).days
if days_since > 180:
issues.append({
"file": doc_path,
"severity": "medium",
"category": "temporal",
"description": f"Documentation not updated in {days_since} days",
"fix_type": "manual",
"details": {"days_since_update": days_since, "last_updated": since_date},
})
elif days_since > 365:
issues.append({
"file": doc_path,
"severity": "high",
"category": "temporal",
"description": f"Documentation not updated in {days_since} days",
"fix_type": "manual",
"details": {"days_since_update": days_since, "last_updated": since_date},
})
# Check structural completeness for README files
if os.path.basename(doc_path).lower().startswith("readme"):
structural_issues = check_readme_structure(repo_path, doc_path)
issues.extend(structural_issues)
return issues
def check_readme_structure(repo_path: str, doc_path: str) -> List[Dict[str, Any]]:
"""Check if a README has expected sections."""
issues = []
full_path = os.path.join(repo_path, doc_path)
try:
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except (OSError, IOError):
return issues
headings = set()
for line in content.splitlines():
match = re.match(r'^#{1,3}\s+(.+)', line)
if match:
headings.add(match.group(1).strip().lower())
expected = {"installation", "usage", "license"}
heading_words = set()
for h in headings:
heading_words.update(h.split())
for section in expected:
if section not in heading_words and section not in headings:
# Check partial matches
found = any(section in h for h in headings)
if not found:
issues.append({
"file": doc_path,
"severity": "low",
"category": "structural",
"description": f"README missing recommended section: {section.title()}",
"fix_type": "manual",
})
return issues
def _parse_version_part(part: str) -> int:
"""Extract numeric value from a version part, stripping pre-release tags."""
# Strip pre-release suffixes like alpha, beta, rc
cleaned = re.sub(r'[^0-9].*$', '', part)
try:
return int(cleaned) if cleaned else 0
except ValueError:
return 0
def _version_is_older(v1: str, v2: str) -> bool:
"""Check if v1 is older than v2 using semantic version comparison.
Handles versions like '1.10.0', '2.0.0-alpha', '1.2.3-rc.1'.
"""
try:
parts1 = [_parse_version_part(x) for x in v1.split(".")]
parts2 = [_parse_version_part(x) for x in v2.split(".")]
# Pad to same length
max_len = max(len(parts1), len(parts2))
while len(parts1) < max_len:
parts1.append(0)
while len(parts2) < max_len:
parts2.append(0)
return tuple(parts1) < tuple(parts2)
except (ValueError, AttributeError, TypeError):
return False
# --- Semantic Drift Detection ---
def detect_semantic_drift(repo_path: str, doc_path: str, associated_code_dirs: List[str]) -> List[Dict[str, Any]]:
"""Detect when documentation scope hasn't kept up with code growth."""
issues = []
full_path = os.path.join(repo_path, doc_path)
try:
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
doc_content = f.read()
except (OSError, IOError):
return issues
doc_lines = len(doc_content.splitlines())
# Count code files and directories in associated dirs
total_code_files = 0
code_subdirs = set()
for code_dir in associated_code_dirs:
full_code_dir = os.path.join(repo_path, code_dir) if code_dir else repo_path
if not os.path.isdir(full_code_dir):
continue
for root, dirs, files in os.walk(full_code_dir):
dirs[:] = [d for d in dirs if d not in {".git", "node_modules", "__pycache__", ".venv"}]
for f in files:
if Path(f).suffix.lower() in CODE_EXTENSIONS:
total_code_files += 1
for d in dirs:
code_subdirs.add(os.path.relpath(os.path.join(root, d), repo_path))
# Heuristic: if code has many subdirectories but doc is small, flag semantic drift
if len(code_subdirs) > 10 and doc_lines < 50:
issues.append({
"file": doc_path,
"severity": "medium",
"category": "semantic",
"description": (
f"Documentation is {doc_lines} lines but describes code with "
f"{len(code_subdirs)} subdirectories and {total_code_files} files"
),
"fix_type": "manual",
"details": {
"doc_lines": doc_lines,
"code_subdirs": len(code_subdirs),
"code_files": total_code_files,
},
})
# Check if major code directories are mentioned in the doc
for subdir in code_subdirs:
dirname = os.path.basename(subdir)
if len(dirname) > 2 and dirname not in doc_content:
# Only flag top-level subdirectories of the associated code dir
parts = subdir.split(os.sep)
if len(parts) <= 2:
issues.append({
"file": doc_path,
"severity": "low",
"category": "semantic",
"description": f"Code directory '{subdir}' not mentioned in documentation",
"fix_type": "manual",
})
return issues
# --- Report Generation ---
def generate_report(
repo_path: str,
all_issues: List[Dict[str, Any]],
doc_files: List[str],
as_json: bool = False,
) -> str:
"""Generate a drift report."""
# Sort by severity
all_issues.sort(key=lambda x: SEVERITY_ORDER.get(x.get("severity", "info"), 99))
drifted_files = set(i["file"] for i in all_issues if i["severity"] != "info")
report_data = {
"repository": os.path.abspath(repo_path),
"scan_date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"summary": {
"total_docs": len(doc_files),
"drifted_docs": len(drifted_files),
"total_issues": len(all_issues),
"by_severity": {},
"by_category": {},
"by_fix_type": {},
},
"issues": all_issues,
}
for issue in all_issues:
sev = issue.get("severity", "info")
cat = issue.get("category", "unknown")
fix = issue.get("fix_type", "manual")
report_data["summary"]["by_severity"][sev] = report_data["summary"]["by_severity"].get(sev, 0) + 1
report_data["summary"]["by_category"][cat] = report_data["summary"]["by_category"].get(cat, 0) + 1
report_data["summary"]["by_fix_type"][fix] = report_data["summary"]["by_fix_type"].get(fix, 0) + 1
if as_json:
return json.dumps(report_data, indent=2, default=str)
# Human-readable report
lines = []
lines.append("Documentation Drift Report")
lines.append("=" * 60)
lines.append(f"Repository: {os.path.abspath(repo_path)}")
lines.append(f"Scan date: {report_data['scan_date']}")
lines.append(f"Docs found: {len(doc_files)}")
lines.append(f"Drifted: {len(drifted_files)}")
lines.append(f"Issues: {len(all_issues)}")
lines.append("")
severity_groups = defaultdict(list)
for issue in all_issues:
severity_groups[issue["severity"]].append(issue)
for severity in ["critical", "high", "medium", "low", "info"]:
group = severity_groups.get(severity, [])
if not group:
continue
lines.append(f"{severity.upper()} SEVERITY ({len(group)} issues):")
lines.append("-" * 40)
for issue in group:
fix_tag = {"auto": "[AUTO]", "semi": "[SEMI]", "manual": "[MANUAL]"}.get(
issue.get("fix_type", "manual"), "[MANUAL]"
)
lines.append(f" {issue['file']}")
lines.append(f" {fix_tag} {issue['description']}")
lines.append(f" Category: {issue.get('category', 'unknown')}")
lines.append("")
lines.append("")
# Summary
lines.append("FIX TYPE SUMMARY:")
lines.append("-" * 40)
for fix_type, count in sorted(report_data["summary"]["by_fix_type"].items()):
label = {"auto": "Auto-fixable", "semi": "Semi-automated", "manual": "Manual review"}.get(fix_type, fix_type)
lines.append(f" {label}: {count}")
lines.append("")
if not all_issues:
lines.append("No documentation drift detected. All docs appear current.")
return "\n".join(lines)
# --- Main ---
def main():
parser = argparse.ArgumentParser(
description="Analyze documentation drift in a git repository",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s /path/to/repo
%(prog)s /path/to/repo --json
%(prog)s /path/to/repo --min-severity high
%(prog)s /path/to/repo --scope src/
%(prog)s /path/to/repo --doc-patterns "*.md,*.rst"
""",
)
parser.add_argument("repo_path", help="Path to the git repository")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument(
"--min-severity",
choices=["critical", "high", "medium", "low", "info"],
default="low",
help="Minimum severity to report (default: low)",
)
parser.add_argument("--scope", default="", help="Limit code analysis to a subdirectory")
parser.add_argument(
"--doc-patterns",
default=None,
help="Comma-separated doc file patterns (default: *.md,*.rst,*.txt,*.adoc)",
)
args = parser.parse_args()
repo_path = os.path.abspath(args.repo_path)
if not os.path.isdir(repo_path):
print(f"Error: {repo_path} is not a directory", file=sys.stderr)
sys.exit(2)
# Verify it's a git repo
if not os.path.isdir(os.path.join(repo_path, ".git")):
print(f"Error: {repo_path} is not a git repository", file=sys.stderr)
sys.exit(2)
# Parse doc patterns
patterns = None
if args.doc_patterns:
patterns = [p.strip() for p in args.doc_patterns.split(",")]
# Discovery
doc_files = find_doc_files(repo_path, patterns)
code_files = find_code_files(repo_path, args.scope)
if not doc_files:
if args.json:
print(json.dumps({"error": "No documentation files found"}, indent=2))
else:
print("No documentation files found in the repository.")
sys.exit(0)
# Map docs to code
doc_code_map = map_docs_to_code(repo_path, doc_files, code_files)
# Get renames from last 90 days
renames = get_renamed_files(repo_path, "90 days ago")
# Get current version
current_version = get_current_version_from_git(repo_path)
# Detect drift for each doc
all_issues = []
for doc in doc_files:
associated_dirs = doc_code_map.get(doc, [])
if not associated_dirs:
# Default: associate with repo root
associated_dirs = [""]
issues = detect_drift_for_doc(repo_path, doc, associated_dirs, renames, current_version)
semantic_issues = detect_semantic_drift(repo_path, doc, associated_dirs)
issues.extend(semantic_issues)
all_issues.extend(issues)
# Filter by severity
min_sev = SEVERITY_ORDER.get(args.min_severity, 3)
filtered = [i for i in all_issues if SEVERITY_ORDER.get(i.get("severity", "info"), 99) <= min_sev]
# Report
report = generate_report(repo_path, filtered, doc_files, as_json=args.json)
print(report)
# Exit code: 1 if high/critical issues found, 0 otherwise
has_serious = any(
i["severity"] in ("critical", "high") for i in filtered
)
sys.exit(1 if has_serious else 0)
if __name__ == "__main__":
main()
Related skills
FAQ
What tools does it include?
Four Python CLI tools: drift_analyzer.py, doc_staleness_scorer.py, api_doc_validator.py, and link_checker.py, all Python 3.8+ stdlib only.
Can it run in CI?
Yes. All tools support --json output and non-zero exit codes, with GitHub Actions and pre-commit recipes for gating PRs on drift or broken links.