
Doc Claim Validator
- 43 installs
- 28 repo stars
- Updated June 29, 2026
- nickcrew/claude-ctx-plugin
Helps with ai & agent building tasks.
About
doc-claim-validator is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- doc-claim-validator
- AI & Agent Building
- AI-coding skill
Doc Claim Validator by the numbers
- 43 all-time installs (skills.sh)
- Ranked #7,884 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nickcrew/claude-ctx-plugin --skill doc-claim-validatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 43 |
|---|---|
| repo stars | ★ 28 |
| Last updated | June 29, 2026 |
| Repository | nickcrew/claude-ctx-plugin ↗ |
What it does
Helps with ai & agent building tasks.
Files
Documentation Claim Validator
Verify that what documentation says is actually true by extracting testable claims and checking them against the codebase. Complements doc-maintenance (which handles structural health) by handling semantic accuracy.
When to Use
- After significant code changes (refactors, renames, API changes)
- Before releases — catch docs that describe removed or changed behavior
- When onboarding devs report "the docs are wrong"
- As a periodic trust audit on project documentation
- After running
doc-maintenanceto go deeper than structural checks
Quick Reference
| Resource | Purpose | Load when |
|---|---|---|
scripts/extract_claims.py | Deterministic claim extraction from markdown | Always (Phase 1) |
scripts/verify_claims.py | Automated verification against codebase | Always (Phase 2) |
references/claim-taxonomy.md | Full taxonomy of claim types with examples | Triaging unclear claims |
---
Workflow Overview
Phase 1: Extract → Pull verifiable claims from docs (deterministic script)
Phase 2: Verify → Check claims against codebase (automated + AI)
Phase 3: Report → Classify failures by severity and type
Phase 4: Remediate → Fix or flag broken claims---
Phase 1: Extract Claims
Run the extraction script to parse all markdown files and pull out verifiable assertions:
python3 skills/doc-claim-validator/scripts/extract_claims.py [--json] [--root PATH] [--scope docs|manual|all]The script extracts these claim types from markdown:
| Type | What it captures | Example in docs |
|---|---|---|
file_path | Inline code matching file path patterns | ` src/auth/login.ts ` |
command | Code blocks or inline code with shell commands | ` npm run build ` |
code_ref | Function, class, method references in inline code | ` authenticate() ` |
import | Import/require statements in code blocks | import { Router } from 'express' |
config | Configuration keys, env vars, settings | ` MAX_RETRIES=3 ` |
url | External links (http/https) | [docs](https://example.com) |
architectural | Verb-anchored prose claims about technology, integrations, or architectural patterns | "Uses Redis for caching", "follows the actor model", "delegated to Auth0" |
dependency | Package/library name claims | "Uses Redis for caching" |
behavioral | Assertions about what code does | "The system retries 3 times" |
The first 7 types are extracted deterministically. The script uses verb-anchored regex for architectural (rules like uses X, built with X, follows the X pattern, delegated to X, via X, depends on X) — this catches anchorless prose claims that previously slipped through.
The last 2 (dependency, behavioral) require AI analysis and are handled in Phase 2. behavioral in particular is not regex-extracted because behavioral claims are free-form prose ("the cache invalidates when the user logs out") that doesn't pattern-match cleanly — the behavioral verifier discovers and verifies them in one pass.
Output: A structured list of claims with source file, line number, claim type, and the literal text of the claim.
---
Phase 2: Verify Claims
Step 2a — Automated verification
Run the verification script on the extracted claims:
python3 skills/doc-claim-validator/scripts/verify_claims.py [--json] [--root PATH] [--claims-file PATH] [--check-staleness]Pass --check-staleness to enable git-based drift analysis (see below).
The script checks each claim type differently:
| Claim type | Verification method | Pass condition |
|---|---|---|
file_path | os.path.exists() | File exists at referenced path |
command | shutil.which() + script check | Binary exists or script file exists |
code_ref | grep -r for function/class name | Symbol found in codebase |
import | Check module exists in project or deps | Module resolvable |
config | Grep for config key in source | Key found in config files or code |
url | HTTP HEAD request (optional, off by default) | Returns 2xx/3xx |
Pass --check-urls to enable URL verification (slow, requires network).
Step 2b — AI-assisted verification
After the automated pass, dispatch agents to verify claims the script cannot. Three of four verifiers run on general-purpose + sonnet — behavioral, architectural, and code-example verification all require multi-file reasoning that haiku's excerpt-read pattern strains under. The dependency verifier stays on Explore + haiku because it's pure pattern matching against manifest files.
Dispatch strategy: per-docfile batching
For behavioral and architectural verifiers, dispatch one sonnet call per markdown file containing claims of that type, with all claims from that file batched into a single prompt. This keeps each call's context budget on a small number of related claims (cross-referencing within the doc improves verification) while keeping total call count tied to doc-set size rather than claim count. For a project with ~50 docs and ~150 architectural claims, expect ~10–20 sonnet calls (only docs with claims trigger calls), not 150.
For release audits where precision matters more than cost, run with per-claim dispatch — one sonnet call per claim, each with the full doc as context. Higher cost, higher precision.
Verifiers
Verifier 1 — Dependency claim verifier (subagent_type: "Explore", model: "haiku"): Read package.json, requirements.txt, go.mod, Cargo.toml, or equivalent dependency manifests. Cross-reference any doc claims about libraries, frameworks, or services used. Report claims that reference dependencies not in the project. Stays on haiku because pattern-matching against manifests doesn't benefit from sonnet's reasoning.
Verifier 2 — Behavioral claim verifier (subagent_type: "general-purpose", model: "sonnet", per-docfile): For each markdown file in scope, dispatch a sonnet agent with the file content. The agent (a) discovers behavioral claims in the file ("retries 3 times", "caches for 5 minutes", "validates input before processing", "the cache invalidates when the user logs out"), (b) finds the relevant code via grep / codanna / Read, (c) verifies whether the claim matches the implementation. Report each claim with confirmed / contradicted / unverifiable / conditional status. Sonnet is needed because behavioral verification often requires tracing across multiple files (handler → middleware → config) and distinguishing happy-path from error-path behavior.
Verifier 3 — Architectural claim verifier (subagent_type: "general-purpose", model: "sonnet", per-docfile): For each markdown file with extracted architectural claims, dispatch a sonnet agent with the file content and the list of pre-extracted claims. The agent verifies each claim by:
- For
uses/built/depends/viaframes: check the named technology in
dependency manifests, config files, and source imports.
- For
delegatedframes: check for SDK imports or HTTP integrations matching
the named service.
- For
follows/uses_patternframes: check directory structure, class names,
and code organization for the named architectural pattern (e.g., CQRS: separate command/query handlers + event store; hexagonal: adapters/ports dirs; saga: orchestrator class with named transitions).
Report each claim with confirmed / contradicted / unverifiable / conditional status. Sonnet is needed because architectural patterns aren't 1:1 with any single file — verification requires reading enough of the codebase to recognize the pattern.
Verifier 4 — Code example verifier (subagent_type: "general-purpose", model: "sonnet", per-docfile): For code blocks in docs that show usage examples, verify the function signatures, parameter names, return types, and import paths match the current codebase. Report examples that would fail if copy-pasted. Sonnet is needed because signature checking requires reading the current implementation and comparing — haiku's excerpt reads aren't sufficient.
Launch verifiers 1, 2, 3, 4 in parallel. Within verifiers 2/3/4, the per-docfile dispatches run sequentially (or in small parallel batches if cost permits).
Step 2c — Git staleness scoring
For claims that pass existence checks, compute a drift score to surface likely-stale claims:
python3 skills/doc-claim-validator/scripts/verify_claims.py --check-stalenessFor each passing claim, the script: 1. Gets the doc file's last git modification timestamp 2. Gets the target file(s) last git modification timestamp 3. Counts how many commits touched the target after the doc was last edited 4. Assigns a drift score: low (1-3 commits), medium (4-9), high (10+)
High-drift claims are the best candidates for AI review — the target changed heavily but the doc didn't, so the doc is probably describing outdated behavior.
The staleness report is appended as a ranked table, sorted by score descending.
---
Phase 3: Report
Merge automated and AI findings into a single report. Classify each failed claim:
Severity
| Level | Meaning | Example |
|---|---|---|
| P0 | User-facing doc claims something that would break if followed | Tutorial shows deleted API endpoint |
| P1 | Dev doc references nonexistent code construct | README references auth.validate() which was renamed |
| P2 | Behavioral claim no longer accurate | "Retries 3 times" but retry logic was removed |
| P3 | Dependency/import claim outdated | "Uses Express" but migrated to Fastify |
| P4 | Minor inaccuracy, cosmetic | Config key renamed but behavior unchanged |
Failure Categories
| Category | Description |
|---|---|
missing_target | Referenced file, function, or symbol doesn't exist |
wrong_signature | Function exists but signature differs from doc |
stale_behavior | Behavioral claim doesn't match implementation |
dead_dependency | Doc references a dependency not in the project |
phantom_pattern | Architectural claim ("uses CQRS", "follows actor model") not evidenced in the codebase |
wrong_integration | Doc names a service/SDK ("delegated to Auth0") that isn't actually integrated |
broken_example | Code example would fail if executed |
dead_url | External link returns 4xx/5xx |
phantom_config | Config option referenced in docs doesn't exist in code |
---
Phase 4: Remediate
For each failed claim, decide the action:
| Action | When | How |
|---|---|---|
| Update doc | Code is correct, doc is stale | Edit doc to match code |
| Flag for review | Unclear if code or doc is wrong | Create issue for human review |
| Remove claim | Referenced feature was deleted | Remove or rewrite section |
| Update example | Code example is outdated | Rewrite example against current code |
Route remediation to the appropriate agent per doc-maintenance conventions:
reference-builderfor API/CLI reference docstechnical-writerfor architecture and developer docslearning-guidefor user-facing tutorials and guides
---
Integration with doc-maintenance
This skill is designed to run after doc-maintenance:
doc-maintenance → Structural health (links, orphans, folders, staleness)
doc-claim-validator → Semantic accuracy (do claims match reality?)The two skills share the same severity scale and remediation agent routing. Results from both can be combined into a single documentation health report.
---
Anti-Patterns
- Do not auto-fix behavioral claims — they require human judgment about intent
- Do not treat every inline code reference as a file path (`
true` is not a file) - Do not validate claims in archived docs (
docs/archive/) — they're historical - Do not fail on optional/conditional features — mark as "conditional" instead
- Do not check URLs by default — it's slow and flaky; opt-in only
- Do not validate code blocks marked with
<!-- no-verify -->comment
---
Bundled Resources
Scripts
scripts/extract_claims.py— Deterministic claim extraction from markdown filesscripts/verify_claims.py— Automated verification of extracted claims against codebase
References
references/claim-taxonomy.md— Full taxonomy of claim types with extraction patterns and examples
Claim Taxonomy
Complete classification of verifiable claims found in documentation.
Mechanically Verifiable (Scripts)
These claims can be checked deterministically without AI.
file_path — File Path References
Inline code that matches filesystem path patterns.
Extraction pattern: Backtick-wrapped text containing / and a file extension.
Examples:
- `
src/auth/login.ts→ checkos.path.exists()` - `
scripts/deploy.sh` → check file exists - `
docs/architecture/overview.md` → check file exists
Verification: Resolve path relative to project root, then relative to the doc file's directory.
False positive filters:
- URL-like paths (
http://,https://) - Anchor-only references (
#section) - Abstract examples (
path/to/file)
---
command — Shell Commands
Commands in code blocks (with $ prefix or shell language hint) and inline code matching known command prefixes.
Extraction pattern: Lines starting with $ in bash/sh blocks, or inline code matching npm|pip|python3?|cargo|go|make|docker|git|cortex|bd|claude ....
Examples:
- `
npm run build→ checkpackage.json` scripts - `
python3 scripts/audit.py` → check script exists $ cortex review→ checkcortexbinary on PATH or inbin/
Verification: 1. Extract base command (first word) 2. If path-like (./scripts/foo.sh): check file exists 3. If known system command: pass 4. Check shutil.which() 5. Check package.json scripts for npm run X 6. Check bin/ directory
---
code_ref — Code Symbol References
Function calls, class names, and method references in inline code.
Extraction pattern:
- Function calls:
word(...)pattern - Class references:
PascalCaseword - Method references:
object.methodorobject.method()
Examples:
- `
authenticate()→ grep fordef authenticate/function authenticate` - `
UserService→ grep forclass UserService` - `
router.get()→ grep forrouter` usage
Verification: grep -r the symbol name across source directories. A match in any source file counts as verified.
Limitations: Cannot verify that the signature or behavior matches — only that the symbol exists. Signature checking requires AI verification (Phase 2b).
---
import — Import/Require Statements
Import declarations in code blocks.
Extraction pattern:
import X from 'Y'(ES modules)const X = require('Y')(CommonJS)from X import Y(Python)import X(Python/Go/Java)
Examples:
import { Router } from 'express'→ checkexpressinpackage.jsondependenciesfrom pathlib import Path→ check Python stdlibimport "github.com/foo/bar"→ checkgo.mod
Verification: 1. Extract module name 2. If relative path: check file exists in project 3. If package name: check dependency manifests 4. If stdlib: check against known stdlib modules
---
config — Configuration Keys
Environment variables and configuration option references.
Extraction pattern:
ALL_CAPS_UNDERSCOREpattern (3+ chars)${VAR_NAME}referencesKEY=valueassignments
Examples:
- `
MAX_RETRIES` → grep source code for this key - `
DATABASE_URL` → grep for env var usage - `
NODE_ENV=production→ grep forNODE_ENV`
Verification: Grep source code (excluding docs) for the config key. Found in source means the option exists; not found means it may be phantom.
---
url — External URLs
HTTP/HTTPS links in markdown.
Extraction pattern: Standard markdown [text](https://...) links.
Verification: HTTP HEAD request (opt-in only, --check-urls flag). Not enabled by default because:
- Network dependency makes CI flaky
- Rate limiting causes false failures
- Slow on large doc sets
---
architectural — Architectural Prose Claims
Verb-anchored prose claims about technology choices, integrations, or architectural patterns. These are extracted by extract_claims.py using heuristic regex patterns; AI verification confirms or denies each.
Extraction patterns (verb-anchored, post-filtered):
| Frame | Pattern | Captures |
|---|---|---|
uses | `uses\ | using\ |
built | built/implemented/powered/deployed/hosted with/using/on/via X | Capitalized target |
delegated | delegated/delegates to X | Capitalized brand-like target |
via | via X | Capitalized service/protocol name |
depends | depends on X | Capitalized target |
follows | follows/implements/adopts the X pattern/architecture/model/approach | Lowercase OK (saga, actor model) |
uses_pattern | `uses/using/adopting (a\ | the) X pattern/architecture/...` |
The follows and uses_pattern frames allow lowercase targets because real architectural patterns are often lowercase ("actor model", "saga", "publish-subscribe", "event sourcing"). Other frames require at least one uppercase letter in the target to filter pronoun noise.
Examples that match:
- "Uses Redis for caching" →
Redisviauses - "Built with React and TypeScript" →
React,TypeScriptviabuilt - "Deployed on AWS Lambda" →
AWS Lambdaviabuilt - "Authentication is delegated to Auth0" →
Auth0viadelegated - "We use a CQRS pattern for the order service" →
CQRSviauses - "follows the actor model" →
actorviafollows - "We adopt the saga pattern" →
sagaviafollows - "Implements a publish-subscribe approach" →
publish-subscribeviafollows
Examples that don't match (correctly):
- "Use clear, descriptive commit messages" → no capitalized target → filtered
- "the system uses memory efficiently" → "memory" lowercase, not in
pattern-suffix frame → filtered
- "Master Architecture" heading alone → no verb anchor → filtered (the old
standalone <X> pattern rule was removed because it caught every heading)
Verification: AI agent (sonnet, per-docfile) reads dependency manifests for uses/built/depends/via claims, scans for SDK imports or HTTP integrations for delegated claims, and checks directory structure / class names / code organization for follows/uses_pattern claims.
Failure category: phantom_pattern (when the claimed pattern has no evidence in the codebase) or wrong_integration (when the named service isn't actually integrated).
---
AI-Discovered + Verified (Subagents)
These claims require understanding code semantics. The dependency and behavioral verifiers both discover and verify in one agent pass — the extraction script doesn't pre-extract them.
dependency — Technology/Library Claims
Prose claims about what technologies the project uses, where the agent reads manifests directly.
Examples:
- "Uses Redis for caching"
- "Built with React and TypeScript"
- "Deployed on AWS Lambda"
Verification: Haiku Explore agent reads dependency manifests (package.json, requirements.txt, go.mod, etc.) and cross-references doc claims. Stays on haiku because pattern-matching against manifests doesn't benefit from sonnet's reasoning.
Note: This verifier overlaps with architectural (which extracts uses X patterns regex-first and verifies per-docfile). The two agents complement: the dependency verifier scans manifests and looks back to docs; the architectural verifier starts from extracted claims and looks forward to code. Run both — they catch different misses.
---
behavioral — Code Behavior Claims
Assertions about what the code does, how it works, or what happens in specific scenarios. Not regex-extracted — too free-form for reliable patterns. The sonnet behavioral verifier reads each docfile, identifies behavioral claims, and verifies them in one pass.
Examples:
- "The system retries failed requests 3 times"
- "Passwords are hashed with bcrypt before storage"
- "Requests are rate-limited to 100/minute"
- "The cache expires after 5 minutes"
- "The cache invalidates when the user logs out"
Verification: Sonnet general-purpose agent (per-docfile dispatch). Reads the doc file, identifies behavioral claims, finds the relevant code via grep or codanna, and checks whether the claimed behavior matches. Reports each claim with status:
- Confirmed: Code does what the doc says
- Contradicted: Code does something different
- Unverifiable: Cannot locate relevant code (logs the behavioral claim and
what was searched for, so a human can decide whether the claim is real but hidden, or vacuous)
- Conditional: True under some conditions, not others — note the
conditions
Why sonnet, not haiku: behavioral verification often requires tracing across multiple files (handler → middleware → config) and distinguishing happy-path from error-path behavior. Haiku's Explore excerpt reads strain on multi-hop traces; sonnet's general-purpose reads full files.
---
example_code — Code Examples
Code blocks that demonstrate usage patterns.
Examples:
- Tutorial showing how to call an API
- Quick-start code snippet
- Configuration example
Verification: Sonnet general-purpose agent (per-docfile). Checks: 1. Do the function/method names exist? 2. Do the parameter names and types match current signatures? 3. Do the import paths resolve? 4. Would this code produce the described output?
Sonnet is required because signature comparison needs full-file reads of the current implementation, not excerpts.
#!/usr/bin/env python3
"""
Documentation Claim Extractor
Parses markdown files and extracts verifiable claims:
- File path references (inline code matching path patterns)
- Shell commands (code blocks with shell indicators)
- Code references (function, class, method names in inline code)
- Import statements (from code blocks)
- Configuration references (env vars, config keys)
- URL references (external links)
- Architectural prose claims (uses/built-with/delegated-to/<X> pattern)
Behavioral claims are NOT extracted by this script — they're free-form prose
that doesn't pattern-match cleanly. The behavioral verifier (Phase 2b) reads
docs directly and discovers + verifies behavioral claims in one pass.
Usage:
python3 skills/doc-claim-validator/scripts/extract_claims.py [OPTIONS]
Options:
--json Output as JSON instead of markdown
--root PATH Project root directory (default: git root or cwd)
--scope SCOPE Which docs to scan: docs, manual, all (default: all)
--verbose Show extraction details per file
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional
# --- Configuration ---
# Directories to scan per scope
SCOPE_DIRS = {
"docs": ["docs"],
"manual": ["manual"],
"all": ["docs", "manual"],
}
# Always include README.md at project root
ALWAYS_INCLUDE = ["README.md", "CONTRIBUTING.md"]
# Skip directories
SKIP_DIRS = {"node_modules", ".git", "__pycache__", "archive"}
# --- Patterns ---
# Inline code: `something`
INLINE_CODE_RE = re.compile(r"(?<!`)`([^`\n]+?)`(?!`)")
# Fenced code blocks: ```lang\n...\n```
FENCED_BLOCK_RE = re.compile(
r"^```(\w*)\s*\n(.*?)^```", re.MULTILINE | re.DOTALL
)
# Markdown links: [text](url)
MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
# File path patterns (inline code that looks like a file path)
FILE_PATH_RE = re.compile(
r"^(?:\.{0,2}/)?(?:[\w@.-]+/)*[\w@.-]+\.\w+$"
)
# Shell command indicators
SHELL_LANGS = {"bash", "sh", "shell", "zsh", "console", "terminal", ""}
SHELL_PREFIX_RE = re.compile(r"^\s*\$\s+(.+)$", re.MULTILINE)
COMMAND_RE = re.compile(
r"^(?:npm|npx|yarn|pnpm|pip|python3?|node|cargo|go|make|docker|"
r"git|curl|wget|brew|apt|cortex|bd|claude)\s+.+"
)
# Code reference patterns (function calls, class names, methods)
FUNC_CALL_RE = re.compile(r"^[\w.]+\(.*\)$")
CLASS_REF_RE = re.compile(r"^[A-Z][\w]*(?:\.\w+)*$")
METHOD_REF_RE = re.compile(r"^[\w]+\.[\w]+(?:\(.*\))?$")
# Import/require patterns in code blocks
IMPORT_RE = re.compile(
r"^(?:import\s+.*?from\s+['\"](.+?)['\"]|"
r"(?:const|let|var)\s+.*?=\s*require\(['\"](.+?)['\"]\)|"
r"from\s+([\w.]+)\s+import|"
r"import\s+([\w.]+))",
re.MULTILINE,
)
# Config/env var patterns
CONFIG_RE = re.compile(r"^[A-Z][A-Z0-9_]{2,}(?:=.+)?$")
ENV_VAR_RE = re.compile(r"\$\{?([A-Z][A-Z0-9_]{2,})\}?")
# Architectural prose claims — heuristic regex for patterns that don't have
# code anchors but make verifiable assertions about technology, architecture,
# or integration choices.
#
# Each tuple is (pattern, frame_label). The frame_label captures the rhetorical
# shape of the claim so the verifier knows what to check (e.g., "uses" → look
# for the named technology in dependencies; "pattern" → look for the named
# architectural pattern in directory structure / class names).
#
# All patterns require the captured target to start with a capital letter or
# be a hyphenated multi-word term. This filters most pronoun / generic-noun
# false positives ("uses memory") while catching real targets ("uses Redis,"
# "delegated to Auth0").
ARCHITECTURAL_PATTERNS = [
# "uses X" / "using X" / "leverages X" — scoped IGNORECASE on the verb
# via (?i:...) keeps [A-Z] in the target group case-strict.
(
re.compile(
r"\b(?i:uses?|using|leverages?|leveraging)\s+"
r"(?:the\s+|a\s+|an\s+)?"
r"([A-Z][\w.+-]+(?:\s+(?:[A-Z][\w.+-]+|API|SDK))?)\b"
),
"uses",
),
# "built/implemented/powered/deployed/hosted (with|using|on|via|by|in|for) X"
(
re.compile(
r"\b(?i:built|implemented|powered|deployed|hosted|running)\s+"
r"(?i:with|using|on|via|by|in|for)\s+"
r"(?:the\s+|a\s+|an\s+)?"
r"([A-Z][\w.+-]+(?:\s+[A-Z][\w.+-]+)?)\b"
),
"built",
),
# "delegated to X" / "delegates to X"
(
re.compile(
r"\b(?i:delegated|delegates?)\s+(?:to|via)\s+"
r"(?:the\s+|a\s+|an\s+)?"
r"([A-Z][\w.+-]+\d?)\b"
),
"delegated",
),
# "follows the X pattern/architecture/model" — lowercase target allowed
# because many real patterns are lowercase ("actor model", "saga",
# "event sourcing"). The verb anchor "follows" makes this specific
# enough to avoid heading false positives.
(
re.compile(
r"\b(?i:follows?|implements?|adopts?)\s+(?:the\s+|a\s+|an\s+)?"
r"([\w-]+(?:[\s-][\w-]+)?)\s+"
r"(?i:pattern|architecture|model|approach)\b"
),
"follows",
),
# "(uses|use) (a|the) X pattern" — verb-anchored so heading
# noise ("Master Architecture") is excluded. Lowercase target allowed
# because the "X pattern" suffix is specific enough.
(
re.compile(
r"\b(?i:uses?|using|adopting)\s+(?:the\s+|a\s+|an\s+)"
r"([\w-]+(?:[\s-][\w-]+)?)\s+"
r"(?i:pattern|architecture|model|approach)\b"
),
"uses_pattern",
),
# "via X" — only when X is capitalized service/protocol name
(
re.compile(
r"\bvia\s+(?:the\s+)?"
r"([A-Z][\w.-]*(?:\s+(?i:API|service|gateway|protocol))?)\b"
),
"via",
),
# "depends on X"
(
re.compile(
r"\b(?i:depends?)\s+(?i:on|upon)\s+"
r"(?:the\s+|a\s+|an\s+)?"
r"([A-Z][\w.+-]+)\b"
),
"depends",
),
]
# Words that frequently appear in architectural patterns but aren't real
# targets — filtered post-extraction.
ARCHITECTURAL_STOPWORDS = {
"the", "a", "an", "this", "that", "these", "those",
"we", "you", "they", "i", "it", "us",
"all", "any", "some", "every", "each",
"same", "different", "other", "new", "old",
"default", "standard", "custom", "main",
"api", "sdk", "service", # too generic alone
}
# No-verify marker
NO_VERIFY_RE = re.compile(r"<!--\s*no-verify\s*-->")
# Common false positives to skip
FALSE_POSITIVES = {
"true", "false", "null", "undefined", "none", "nil",
"string", "number", "boolean", "object", "array",
"int", "float", "str", "bool", "dict", "list", "tuple",
"void", "any", "never", "unknown",
"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS",
"TODO", "FIXME", "NOTE", "WARNING", "HACK",
"OK", "ERROR", "SUCCESS", "FAIL",
}
# --- Data Structures ---
@dataclass
class Claim:
claim_type: str # file_path, command, code_ref, import, config, url
source_file: str # markdown file containing the claim
line_number: int # line in the source file
literal: str # exact text of the claim
context: str = "" # surrounding text for disambiguation
details: dict = field(default_factory=dict)
# --- Utilities ---
def get_project_root(root_override=None):
"""Determine project root from git or override."""
if root_override:
return Path(root_override).resolve()
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
return Path(result.stdout.strip())
except (subprocess.CalledProcessError, FileNotFoundError):
return Path.cwd()
def find_markdown_files(root, scope="all"):
"""Find markdown files based on scope."""
files = []
dirs = SCOPE_DIRS.get(scope, SCOPE_DIRS["all"])
for d in dirs:
search_root = root / d
if search_root.exists():
for md in search_root.rglob("*.md"):
# Skip archive and other excluded dirs
if any(part in SKIP_DIRS for part in md.relative_to(root).parts):
continue
files.append(md)
# Always include root-level files
for name in ALWAYS_INCLUDE:
f = root / name
if f.exists():
files.append(f)
return sorted(set(files))
def is_in_no_verify_block(content, line_num):
"""Check if a line is preceded by a no-verify comment."""
lines = content.splitlines()
# Look back up to 5 lines for a no-verify marker
start = max(0, line_num - 6)
preceding = "\n".join(lines[start:line_num - 1])
return bool(NO_VERIFY_RE.search(preceding))
# --- Extractors ---
def extract_inline_code_claims(filepath, content, root):
"""Extract claims from inline code spans."""
claims = []
rel_path = str(filepath.relative_to(root))
for i, line in enumerate(content.splitlines(), 1):
if is_in_no_verify_block(content, i):
continue
for match in INLINE_CODE_RE.finditer(line):
code = match.group(1).strip()
# Skip false positives
if code.lower() in {fp.lower() for fp in FALSE_POSITIVES}:
continue
if len(code) < 2:
continue
# Classify the inline code
if FILE_PATH_RE.match(code) and "/" in code:
claims.append(Claim(
claim_type="file_path",
source_file=rel_path,
line_number=i,
literal=code,
context=line.strip(),
))
elif CONFIG_RE.match(code):
claims.append(Claim(
claim_type="config",
source_file=rel_path,
line_number=i,
literal=code.split("=")[0],
context=line.strip(),
))
elif FUNC_CALL_RE.match(code):
# Strip parens for the function name
func_name = code.split("(")[0]
if func_name and not func_name[0].isdigit():
claims.append(Claim(
claim_type="code_ref",
source_file=rel_path,
line_number=i,
literal=func_name,
context=line.strip(),
details={"kind": "function_call", "full": code},
))
elif METHOD_REF_RE.match(code) and "." in code:
claims.append(Claim(
claim_type="code_ref",
source_file=rel_path,
line_number=i,
literal=code.rstrip(")").split("(")[0],
context=line.strip(),
details={"kind": "method_ref", "full": code},
))
elif COMMAND_RE.match(code):
claims.append(Claim(
claim_type="command",
source_file=rel_path,
line_number=i,
literal=code,
context=line.strip(),
))
return claims
def extract_code_block_claims(filepath, content, root):
"""Extract claims from fenced code blocks."""
claims = []
rel_path = str(filepath.relative_to(root))
for match in FENCED_BLOCK_RE.finditer(content):
lang = match.group(1).lower()
block = match.group(2)
block_start = content[:match.start()].count("\n") + 1
# Check for no-verify
if is_in_no_verify_block(content, block_start):
continue
# Shell commands
if lang in SHELL_LANGS:
for line_match in SHELL_PREFIX_RE.finditer(block):
cmd = line_match.group(1).strip()
line_in_block = block[:line_match.start()].count("\n")
if cmd and not cmd.startswith("#"):
claims.append(Claim(
claim_type="command",
source_file=rel_path,
line_number=block_start + line_in_block + 1,
literal=cmd,
context=f"```{lang}``` block",
details={"block_lang": lang},
))
# Also catch commands without $ prefix
for j, line in enumerate(block.splitlines()):
stripped = line.strip()
if stripped and COMMAND_RE.match(stripped) and not stripped.startswith("#"):
claims.append(Claim(
claim_type="command",
source_file=rel_path,
line_number=block_start + j + 1,
literal=stripped,
context=f"```{lang}``` block",
details={"block_lang": lang},
))
# Import statements in any code block
for imp_match in IMPORT_RE.finditer(block):
module = imp_match.group(1) or imp_match.group(2) or imp_match.group(3) or imp_match.group(4)
if module:
line_in_block = block[:imp_match.start()].count("\n")
claims.append(Claim(
claim_type="import",
source_file=rel_path,
line_number=block_start + line_in_block + 1,
literal=module,
context=imp_match.group(0).strip(),
details={"block_lang": lang},
))
return claims
def extract_url_claims(filepath, content, root):
"""Extract external URL claims from markdown links."""
claims = []
rel_path = str(filepath.relative_to(root))
for i, line in enumerate(content.splitlines(), 1):
if is_in_no_verify_block(content, i):
continue
for match in MD_LINK_RE.finditer(line):
url = match.group(2)
if url.startswith(("http://", "https://")):
claims.append(Claim(
claim_type="url",
source_file=rel_path,
line_number=i,
literal=url,
context=match.group(0),
details={"link_text": match.group(1)},
))
return claims
def extract_architectural_claims(filepath, content, root):
"""Extract prose claims about technology, architecture, or integrations.
Heuristic regex pass. False positives are expected and acceptable — the
verifier marks unverifiable claims rather than wrongly confirming them.
Without this pass, anchorless prose claims have no extraction path and
no agent target.
"""
claims = []
rel_path = str(filepath.relative_to(root))
# Skip code blocks — patterns inside fenced blocks are usually examples,
# not architectural claims about the project.
content_outside_blocks = FENCED_BLOCK_RE.sub("", content)
for i, line in enumerate(content_outside_blocks.splitlines(), 1):
if is_in_no_verify_block(content, i):
continue
# Skip lines that are mostly inline code (likely API documentation)
# to reduce noise.
if line.count("`") >= 4:
continue
for pattern, frame in ARCHITECTURAL_PATTERNS:
for match in pattern.finditer(line):
target = match.group(1).strip()
if not target:
continue
# Filter common false positives
lower = target.lower()
if lower in ARCHITECTURAL_STOPWORDS:
continue
# Multi-word: at least one substantive word
words = target.split()
if all(w.lower() in ARCHITECTURAL_STOPWORDS for w in words):
continue
if len(target) < 2:
continue
# Frames where lowercase targets are intentional (real
# architectural patterns are often lowercase: actor model,
# saga, publish-subscribe, event sourcing). The "pattern"
# suffix in the regex makes these specific enough.
lowercase_ok_frames = {"follows", "uses_pattern"}
if frame not in lowercase_ok_frames:
# For uses/built/delegated/via/depends, require at least
# one uppercase letter — passes acronyms (gRPC, JWT),
# brands (Redis, Auth0), multi-word names (AWS Lambda),
# filters lowercase pronouns ("clear", "memory") pulled
# in by sentence-start verbs.
if not any(ch.isupper() for ch in target):
continue
claims.append(Claim(
claim_type="architectural",
source_file=rel_path,
line_number=i,
literal=target,
context=line.strip(),
details={"frame": frame, "matched_text": match.group(0)},
))
return claims
def extract_env_var_claims(filepath, content, root):
"""Extract environment variable references."""
claims = []
rel_path = str(filepath.relative_to(root))
for i, line in enumerate(content.splitlines(), 1):
if is_in_no_verify_block(content, i):
continue
for match in ENV_VAR_RE.finditer(line):
var_name = match.group(1)
if var_name not in FALSE_POSITIVES and len(var_name) > 3:
claims.append(Claim(
claim_type="config",
source_file=rel_path,
line_number=i,
literal=var_name,
context=line.strip(),
details={"kind": "env_var"},
))
return claims
# --- Deduplication ---
def deduplicate_claims(claims):
"""Remove duplicate claims (same type + literal + source file)."""
seen = set()
unique = []
for c in claims:
key = (c.claim_type, c.literal, c.source_file)
if key not in seen:
seen.add(key)
unique.append(c)
return unique
# --- Report Generation ---
def generate_markdown_report(claims, root):
"""Generate a markdown-formatted extraction report."""
lines = [
"# Documentation Claim Extraction Report",
"",
f"**Project root:** `{root}`",
f"**Total claims extracted:** {len(claims)}",
"",
]
if not claims:
lines.append("No verifiable claims found in documentation.")
return "\n".join(lines)
# Summary by type
by_type = defaultdict(list)
for c in claims:
by_type[c.claim_type].append(c)
lines.append("## Summary by Type")
lines.append("")
lines.append("| Type | Count |")
lines.append("|------|-------|")
for ct in ["file_path", "command", "code_ref", "import", "config", "url", "architectural"]:
if ct in by_type:
lines.append(f"| {ct} | {len(by_type[ct])} |")
lines.append("")
# Claims by type
for ct in ["file_path", "command", "code_ref", "import", "config", "url", "architectural"]:
if ct not in by_type:
continue
lines.append(f"## {ct} ({len(by_type[ct])})")
lines.append("")
if ct == "architectural":
lines.append("| File | Line | Frame | Claim | Context |")
lines.append("|------|------|-------|-------|---------|")
for c in by_type[ct]:
escaped = c.literal.replace("|", "\\|")
ctx = c.context[:80].replace("|", "\\|") if c.context else ""
frame = c.details.get("frame", "—") if c.details else "—"
lines.append(
f"| `{c.source_file}` | {c.line_number} | {frame} | "
f"`{escaped}` | {ctx} |"
)
else:
lines.append("| File | Line | Claim |")
lines.append("|------|------|-------|")
for c in by_type[ct]:
escaped = c.literal.replace("|", "\\|")
lines.append(f"| `{c.source_file}` | {c.line_number} | `{escaped}` |")
lines.append("")
return "\n".join(lines)
def generate_json_report(claims, root):
"""Generate a JSON-formatted extraction report."""
report = {
"project_root": str(root),
"total_claims": len(claims),
"claims": [asdict(c) for c in claims],
}
return json.dumps(report, indent=2)
# --- Main ---
def main():
parser = argparse.ArgumentParser(description="Extract verifiable claims from docs")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--root", type=str, default=None, help="Project root path")
parser.add_argument(
"--scope",
choices=["docs", "manual", "all"],
default="all",
help="Which docs to scan (default: all)",
)
parser.add_argument("--verbose", action="store_true", help="Show per-file details")
args = parser.parse_args()
root = get_project_root(args.root)
md_files = find_markdown_files(root, args.scope)
print(f"Scanning {len(md_files)} markdown files in {root}...", file=sys.stderr)
all_claims = []
for md_file in md_files:
try:
content = md_file.read_text(encoding="utf-8", errors="replace")
except OSError as e:
print(f"Warning: Could not read {md_file}: {e}", file=sys.stderr)
continue
file_claims = []
file_claims.extend(extract_inline_code_claims(md_file, content, root))
file_claims.extend(extract_code_block_claims(md_file, content, root))
file_claims.extend(extract_url_claims(md_file, content, root))
file_claims.extend(extract_env_var_claims(md_file, content, root))
file_claims.extend(extract_architectural_claims(md_file, content, root))
if args.verbose and file_claims:
rel = str(md_file.relative_to(root))
print(f" {rel}: {len(file_claims)} claims", file=sys.stderr)
all_claims.extend(file_claims)
# Deduplicate
all_claims = deduplicate_claims(all_claims)
# Sort by source file, then line number
all_claims.sort(key=lambda c: (c.source_file, c.line_number))
# Output
if args.json:
print(generate_json_report(all_claims, root))
else:
print(generate_markdown_report(all_claims, root))
print(f"\nExtracted {len(all_claims)} unique claims.", file=sys.stderr)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Documentation Claim Verifier
Takes claims extracted by extract_claims.py and verifies them against
the actual project state.
Verification methods per claim type:
- file_path: os.path.exists()
- command: shutil.which() + script existence check
- code_ref: grep for symbol in codebase
- import: check module in project or dependency manifests
- config: grep for config key in source files
- url: HTTP HEAD request (opt-in with --check-urls)
Usage:
python3 skills/doc-claim-validator/scripts/verify_claims.py [OPTIONS]
Options:
--json Output as JSON instead of markdown
--root PATH Project root directory (default: git root or cwd)
--claims-file PATH Read claims from JSON file (default: run extractor)
--check-urls Enable URL verification (slow, requires network)
--scope SCOPE Passed to extractor if no claims-file: docs, manual, all
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from collections import defaultdict
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Dict, Optional, Tuple
# --- Configuration ---
# Source directories to search for code references
SOURCE_DIRS = [
"src", "lib", "app", "bin", "scripts", "tools", "pkg", "cmd",
"skills", "agents", "hooks", "codex", "catskills",
]
# File extensions to search for code references
CODE_EXTENSIONS = {
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".rb",
".java", ".kt", ".swift", ".c", ".cpp", ".h", ".hpp",
".sh", ".bash", ".zsh", ".yaml", ".yml", ".toml", ".json",
}
# Dependency manifest files
DEP_MANIFESTS = [
"package.json", "requirements.txt", "Pipfile", "pyproject.toml",
"go.mod", "Cargo.toml", "Gemfile", "pom.xml", "build.gradle",
"composer.json",
]
# Config file patterns
CONFIG_PATTERNS = [
"*.env", "*.env.*", ".env*",
"config.*", "*.config.*", "settings.*",
"*.yaml", "*.yml", "*.toml", "*.ini", "*.cfg",
]
# Known system commands that don't need local verification
KNOWN_COMMANDS = {
"npm", "npx", "yarn", "pnpm", "pip", "pip3", "python", "python3",
"node", "cargo", "go", "make", "docker", "docker-compose",
"git", "curl", "wget", "brew", "apt", "apt-get",
"cat", "ls", "mkdir", "cp", "mv", "rm", "echo", "grep", "find",
"sed", "awk", "sort", "uniq", "head", "tail", "wc",
}
# --- Data Structures ---
@dataclass
class VerificationResult:
claim_type: str
source_file: str
line_number: int
literal: str
status: str # pass, fail, skip, warn
reason: str
severity: str = "" # P0-P4, set during classification
category: str = "" # missing_target, wrong_signature, etc.
details: dict = field(default_factory=dict)
# --- Utilities ---
def get_project_root(root_override=None):
"""Determine project root from git or override."""
if root_override:
return Path(root_override).resolve()
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
return Path(result.stdout.strip())
except (subprocess.CalledProcessError, FileNotFoundError):
return Path.cwd()
def grep_codebase(pattern, root, file_extensions=None):
"""Search codebase for a pattern. Returns list of (file, line_num, line)."""
cmd = ["grep", "-rn", "--include=*.py", "--include=*.js", "--include=*.ts",
"--include=*.tsx", "--include=*.jsx", "--include=*.go", "--include=*.rs",
"--include=*.sh", "--include=*.yaml", "--include=*.yml", "--include=*.toml",
"--include=*.json", "--include=*.md",
"-l", pattern]
# Only search source directories that exist
search_paths = []
for d in SOURCE_DIRS:
p = root / d
if p.exists():
search_paths.append(str(p))
if not search_paths:
# Fall back to root
search_paths = [str(root)]
cmd.extend(search_paths)
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=root,
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip().splitlines()
return []
except (subprocess.TimeoutExpired, FileNotFoundError):
return []
# --- Git Staleness ---
# Cache for git timestamps to avoid repeated calls
_git_timestamp_cache: Dict[str, Optional[int]] = {}
def git_last_modified_ts(filepath, root):
"""Get Unix timestamp of last git modification. Cached."""
key = str(filepath)
if key in _git_timestamp_cache:
return _git_timestamp_cache[key]
try:
rel = str(Path(filepath).relative_to(root))
except ValueError:
rel = str(filepath)
try:
result = subprocess.run(
["git", "log", "-1", "--format=%ct", "--", rel],
capture_output=True,
text=True,
cwd=root,
timeout=5,
)
ts_str = result.stdout.strip()
ts = int(ts_str) if ts_str else None
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
ts = None
_git_timestamp_cache[key] = ts
return ts
def git_commit_count_since(filepath, since_ts, root):
"""Count commits touching filepath after a given timestamp."""
try:
rel = str(Path(filepath).relative_to(root))
except ValueError:
rel = str(filepath)
try:
result = subprocess.run(
["git", "log", "--oneline", f"--since={since_ts}", "--", rel],
capture_output=True,
text=True,
cwd=root,
timeout=5,
)
lines = [l for l in result.stdout.strip().splitlines() if l]
return len(lines)
except (subprocess.TimeoutExpired, FileNotFoundError):
return 0
def compute_staleness(claim, target_files, root):
"""Compute staleness score for a claim that passed existence checks.
Returns (score, detail_dict) where:
score = 0 -> no evidence of staleness
score = 1-3 -> low drift (target changed 1-3 times since doc edit)
score = 4-9 -> medium drift
score = 10+ -> high drift (target changed heavily, doc didn't)
The score is the total number of commits touching any target file
after the doc file's last modification.
"""
doc_ts = git_last_modified_ts(root / claim["source_file"], root)
if doc_ts is None:
return 0, {}
total_commits = 0
drift_details = {}
for tf in target_files:
target_ts = git_last_modified_ts(root / tf, root)
if target_ts is None:
continue
# Only interesting if target was modified after the doc
if target_ts > doc_ts:
commits = git_commit_count_since(root / tf, doc_ts, root)
if commits > 0:
total_commits += commits
days_ahead = (target_ts - doc_ts) // 86400
drift_details[tf] = {
"commits_since_doc": commits,
"days_ahead": days_ahead,
}
return total_commits, drift_details
def staleness_label(score):
"""Convert numeric staleness score to human label."""
if score == 0:
return None
if score <= 3:
return "low"
if score <= 9:
return "medium"
return "high"
def read_dep_manifests(root):
"""Read dependency manifest files and extract package names."""
deps = set()
# package.json
pkg_json = root / "package.json"
if pkg_json.exists():
try:
data = json.loads(pkg_json.read_text())
for key in ("dependencies", "devDependencies", "peerDependencies"):
if key in data:
deps.update(data[key].keys())
except (json.JSONDecodeError, OSError):
pass
# requirements.txt
req_txt = root / "requirements.txt"
if req_txt.exists():
try:
for line in req_txt.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#"):
# Strip version specifiers
pkg = re.split(r"[>=<!\[;]", line)[0].strip()
if pkg:
deps.add(pkg.lower())
except OSError:
pass
# pyproject.toml (basic parsing)
pyproject = root / "pyproject.toml"
if pyproject.exists():
try:
content = pyproject.read_text()
# Look for dependencies list
in_deps = False
for line in content.splitlines():
if "dependencies" in line and "=" in line:
in_deps = True
continue
if in_deps:
if line.strip().startswith("]"):
in_deps = False
elif line.strip().startswith('"'):
pkg = re.split(r"[>=<!\[;]", line.strip().strip('",'))[0].strip()
if pkg:
deps.add(pkg.lower())
except OSError:
pass
# go.mod
go_mod = root / "go.mod"
if go_mod.exists():
try:
for line in go_mod.read_text().splitlines():
line = line.strip()
if line and not line.startswith(("module ", "go ", "//")):
parts = line.split()
if parts:
deps.add(parts[0])
except OSError:
pass
# Cargo.toml
cargo = root / "Cargo.toml"
if cargo.exists():
try:
in_deps = False
for line in cargo.read_text().splitlines():
if re.match(r"\[.*dependencies.*\]", line):
in_deps = True
continue
if in_deps:
if line.startswith("["):
in_deps = False
elif "=" in line:
pkg = line.split("=")[0].strip()
if pkg:
deps.add(pkg)
except OSError:
pass
return deps
# --- Verifiers ---
def verify_file_path(claim, root):
"""Verify that a referenced file path exists."""
literal = claim["literal"]
# Try as-is from project root
target = root / literal
if target.exists():
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"File exists: {literal}",
)
# Try relative to the source file's directory
source_dir = (root / claim["source_file"]).parent
target = source_dir / literal
if target.exists():
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"File exists relative to doc: {literal}",
)
# Determine severity based on doc location
is_user_facing = claim["source_file"].startswith("manual/")
severity = "P0" if is_user_facing else "P1"
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="fail",
reason=f"File not found: {literal}",
severity=severity,
category="missing_target",
)
def verify_command(claim, root):
"""Verify that a referenced command exists."""
literal = claim["literal"]
# Extract the base command (first word)
base_cmd = literal.split()[0] if literal.split() else literal
# Strip leading ./ or paths
if "/" in base_cmd:
# It's a script path — check if it exists
script_path = root / base_cmd.lstrip("./")
if script_path.exists():
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Script exists: {base_cmd}",
)
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="fail",
reason=f"Script not found: {base_cmd}",
severity="P1",
category="missing_target",
)
# Known system commands
if base_cmd in KNOWN_COMMANDS:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Known system command: {base_cmd}",
)
# Check if command exists on PATH
if shutil.which(base_cmd):
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Command found on PATH: {base_cmd}",
)
# Check if it's a project script (package.json scripts, Makefile targets, etc.)
pkg_json = root / "package.json"
if pkg_json.exists():
try:
data = json.loads(pkg_json.read_text())
scripts = data.get("scripts", {})
# Check for "npm run X" pattern
if base_cmd == "npm" and len(literal.split()) >= 3:
subcmd = literal.split()[1]
script_name = literal.split()[2] if subcmd == "run" else subcmd
if script_name in scripts:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"npm script exists: {script_name}",
)
except (json.JSONDecodeError, OSError):
pass
# Check bin/ directory
bin_path = root / "bin" / base_cmd
if bin_path.exists():
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Found in bin/: {base_cmd}",
)
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="warn",
reason=f"Command not found locally: {base_cmd} (may be installed separately)",
severity="P3",
category="missing_target",
)
def verify_code_ref(claim, root):
"""Verify that a referenced code symbol exists in the codebase."""
literal = claim["literal"]
# Search for the symbol
matches = grep_codebase(re.escape(literal), root)
if matches:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Symbol found in {len(matches)} file(s)",
details={"found_in": matches[:5]},
)
is_user_facing = claim["source_file"].startswith("manual/")
severity = "P0" if is_user_facing else "P1"
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="fail",
reason=f"Symbol not found in codebase: {literal}",
severity=severity,
category="missing_target",
)
def verify_import(claim, root, deps):
"""Verify that an imported module exists."""
literal = claim["literal"]
# Check if it's a relative import (project module)
if literal.startswith(".") or literal.startswith("/"):
# Resolve relative to project
target = root / literal.lstrip("./")
# Try with common extensions
for ext in ["", ".py", ".js", ".ts", "/index.js", "/index.ts"]:
if (root / (literal.lstrip("./") + ext)).exists():
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Local module found: {literal}",
)
# Check if it's a known dependency
# Normalize: @scope/pkg -> @scope/pkg, lodash/fp -> lodash
pkg_name = literal.split("/")[0]
if literal.startswith("@") and "/" in literal:
pkg_name = "/".join(literal.split("/")[:2])
if pkg_name.lower() in {d.lower() for d in deps}:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Package found in dependencies: {pkg_name}",
)
# Check Python stdlib (basic heuristic)
python_stdlib = {
"os", "sys", "re", "json", "pathlib", "subprocess", "argparse",
"collections", "dataclasses", "typing", "functools", "itertools",
"datetime", "time", "math", "random", "hashlib", "base64",
"io", "logging", "unittest", "asyncio", "abc", "enum",
}
if literal.split(".")[0] in python_stdlib:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Python stdlib module: {literal}",
)
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="warn",
reason=f"Module not found in project deps: {literal}",
severity="P3",
category="dead_dependency",
)
def verify_config(claim, root):
"""Verify that a config key exists in the project."""
literal = claim["literal"]
# Search for the config key in source files
matches = grep_codebase(re.escape(literal), root)
if matches:
# Filter out matches that are only in docs (the claim itself)
code_matches = [m for m in matches if not m.startswith(("docs/", "manual/"))]
if code_matches:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"Config key found in {len(code_matches)} source file(s)",
details={"found_in": code_matches[:5]},
)
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="warn",
reason=f"Config key not found in source code: {literal}",
severity="P3",
category="phantom_config",
)
def verify_url(claim):
"""Verify that a URL is reachable (opt-in)."""
literal = claim["literal"]
try:
import urllib.request
req = urllib.request.Request(literal, method="HEAD")
req.add_header("User-Agent", "doc-claim-validator/1.0")
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status < 400:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="pass",
reason=f"URL reachable (HTTP {resp.status})",
)
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="fail",
reason=f"URL returned HTTP {resp.status}",
severity="P2",
category="dead_url",
)
except Exception as e:
return VerificationResult(
claim_type=claim["claim_type"],
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=literal,
status="fail",
reason=f"URL unreachable: {type(e).__name__}",
severity="P2",
category="dead_url",
)
# --- Report Generation ---
def generate_markdown_report(results, root):
"""Generate a markdown-formatted verification report."""
passed = [r for r in results if r.status == "pass"]
failed = [r for r in results if r.status == "fail"]
warned = [r for r in results if r.status == "warn"]
skipped = [r for r in results if r.status == "skip"]
lines = [
"# Documentation Claim Verification Report",
"",
f"**Project root:** `{root}`",
f"**Total claims verified:** {len(results)}",
f"**Passed:** {len(passed)} | **Failed:** {len(failed)} | **Warnings:** {len(warned)} | **Skipped:** {len(skipped)}",
"",
]
if not failed and not warned:
lines.append("All claims verified successfully.")
return "\n".join(lines)
# Failures by severity
if failed:
by_severity = defaultdict(list)
for r in failed:
by_severity[r.severity or "unclassified"].append(r)
lines.append("## Failures")
lines.append("")
for sev in ["P0", "P1", "P2", "P3", "P4", "unclassified"]:
if sev not in by_severity:
continue
lines.append(f"### {sev}")
lines.append("")
lines.append("| Source | Line | Type | Claim | Reason |")
lines.append("|--------|------|------|-------|--------|")
for r in by_severity[sev]:
escaped = r.literal.replace("|", "\\|")[:60]
reason = r.reason.replace("|", "\\|")
lines.append(
f"| `{r.source_file}` | {r.line_number} | {r.claim_type} | `{escaped}` | {reason} |"
)
lines.append("")
# Warnings
if warned:
lines.append("## Warnings")
lines.append("")
lines.append("| Source | Line | Type | Claim | Reason |")
lines.append("|--------|------|------|-------|--------|")
for r in warned:
escaped = r.literal.replace("|", "\\|")[:60]
reason = r.reason.replace("|", "\\|")
lines.append(
f"| `{r.source_file}` | {r.line_number} | {r.claim_type} | `{escaped}` | {reason} |"
)
lines.append("")
return "\n".join(lines)
def generate_staleness_report(stale_results):
"""Generate a dedicated staleness section for the markdown report."""
if not stale_results:
return ""
lines = [
"",
"## Likely Stale Claims (Git Drift Analysis)",
"",
"Claims that passed existence checks but whose targets changed significantly",
"after the doc was last edited. Higher scores = more likely outdated.",
"",
"| Score | Drift | Source | Line | Type | Claim | Details |",
"|-------|-------|--------|------|------|-------|---------|",
]
for r in stale_results:
score = r.details.get("staleness_score", 0)
drift = r.details.get("drift", "?")
targets = r.details.get("targets", {})
# Build a compact details string
detail_parts = []
for tf, info in targets.items():
detail_parts.append(
f"{tf}: {info['commits_since_doc']} commits, {info['days_ahead']}d ahead"
)
detail_str = "; ".join(detail_parts[:2])
if len(detail_parts) > 2:
detail_str += f" (+{len(detail_parts) - 2} more)"
escaped = r.literal.replace("|", "\\|")[:50]
detail_str = detail_str.replace("|", "\\|")
lines.append(
f"| **{score}** | {drift} | `{r.source_file}` | {r.line_number} | {r.claim_type} | `{escaped}` | {detail_str} |"
)
lines.append("")
return "\n".join(lines)
def generate_json_report(results, root):
"""Generate a JSON-formatted verification report."""
report = {
"project_root": str(root),
"total_verified": len(results),
"summary": {
"passed": sum(1 for r in results if r.status == "pass"),
"failed": sum(1 for r in results if r.status == "fail"),
"warnings": sum(1 for r in results if r.status == "warn"),
"skipped": sum(1 for r in results if r.status == "skip"),
},
"results": [asdict(r) for r in results if r.status != "pass"],
}
return json.dumps(report, indent=2)
# --- Main ---
def main():
parser = argparse.ArgumentParser(description="Verify documentation claims")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--root", type=str, default=None, help="Project root path")
parser.add_argument("--claims-file", type=str, default=None, help="JSON claims file from extractor")
parser.add_argument("--check-urls", action="store_true", help="Enable URL verification")
parser.add_argument("--check-staleness", action="store_true", help="Enable git-based staleness scoring")
parser.add_argument(
"--scope",
choices=["docs", "manual", "all"],
default="all",
help="Passed to extractor if no claims-file",
)
args = parser.parse_args()
root = get_project_root(args.root)
# Load claims
if args.claims_file:
with open(args.claims_file) as f:
data = json.load(f)
claims = data["claims"]
else:
# Run extractor inline
print("Running claim extractor...", file=sys.stderr)
extractor_path = Path(__file__).parent / "extract_claims.py"
result = subprocess.run(
[sys.executable, str(extractor_path), "--json", "--root", str(root), "--scope", args.scope],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Extractor failed: {result.stderr}", file=sys.stderr)
sys.exit(1)
data = json.loads(result.stdout)
claims = data["claims"]
print(f"Verifying {len(claims)} claims...", file=sys.stderr)
# Read dependency manifests once
deps = read_dep_manifests(root)
# Verify each claim
results = []
for claim in claims:
ct = claim["claim_type"]
if ct == "file_path":
results.append(verify_file_path(claim, root))
elif ct == "command":
results.append(verify_command(claim, root))
elif ct == "code_ref":
results.append(verify_code_ref(claim, root))
elif ct == "import":
results.append(verify_import(claim, root, deps))
elif ct == "config":
results.append(verify_config(claim, root))
elif ct == "url":
if args.check_urls:
results.append(verify_url(claim))
else:
results.append(VerificationResult(
claim_type=ct,
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=claim["literal"],
status="skip",
reason="URL verification disabled (use --check-urls)",
))
else:
results.append(VerificationResult(
claim_type=ct,
source_file=claim["source_file"],
line_number=claim["line_number"],
literal=claim["literal"],
status="skip",
reason=f"No automated verifier for claim type: {ct}",
))
# --- Staleness scoring pass ---
stale_results = []
if args.check_staleness:
print("Running git staleness analysis...", file=sys.stderr)
passed_results = [r for r in results if r.status == "pass"]
# Build a map from (source_file, claim) -> target files for staleness
claim_lookup = {
(c["source_file"], c["line_number"], c["literal"]): c
for c in claims
}
for r in passed_results:
claim = claim_lookup.get((r.source_file, r.line_number, r.literal))
if not claim:
continue
# Determine target files to check for drift
target_files = []
ct = r.claim_type
if ct == "file_path":
# The literal is the file path
candidate = r.literal
if (root / candidate).exists():
target_files.append(candidate)
else:
# Try relative to source file
source_dir = Path(r.source_file).parent
rel = str(source_dir / candidate)
if (root / rel).exists():
target_files.append(rel)
elif ct == "code_ref" and r.details.get("found_in"):
# Use the files where the symbol was found
for f in r.details["found_in"][:3]:
# grep -l output is relative paths
target_files.append(f)
elif ct == "config" and r.details.get("found_in"):
for f in r.details["found_in"][:3]:
target_files.append(f)
elif ct == "command":
# For script-path commands, check the script file
literal = r.literal
base_cmd = literal.split()[0]
if "/" in base_cmd:
script = base_cmd.lstrip("./")
if (root / script).exists():
target_files.append(script)
if not target_files:
continue
score, drift_details = compute_staleness(claim, target_files, root)
label = staleness_label(score)
if label:
stale_results.append(VerificationResult(
claim_type=r.claim_type,
source_file=r.source_file,
line_number=r.line_number,
literal=r.literal,
status="stale",
reason=f"Target changed {score}x since doc was last edited (drift: {label})",
severity="P2" if label == "high" else "P3" if label == "medium" else "P4",
category="likely_stale",
details={"staleness_score": score, "drift": label, "targets": drift_details},
))
if stale_results:
# Sort stale results by score descending
stale_results.sort(key=lambda r: -r.details.get("staleness_score", 0))
print(f" Found {len(stale_results)} likely stale claims.", file=sys.stderr)
# Sort: failures first, then stale, then by severity
severity_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3, "P4": 4, "": 9}
status_order = {"fail": 0, "warn": 1, "stale": 2, "skip": 3, "pass": 4}
all_results = results + stale_results
all_results.sort(key=lambda r: (
status_order.get(r.status, 9),
severity_order.get(r.severity, 9),
r.source_file,
r.line_number,
))
# Output
if args.json:
print(generate_json_report(all_results, root))
else:
report = generate_markdown_report(all_results, root)
if stale_results:
report += generate_staleness_report(stale_results)
print(report)
# Summary to stderr
failed = sum(1 for r in all_results if r.status == "fail")
warned = sum(1 for r in all_results if r.status == "warn")
passed = sum(1 for r in all_results if r.status == "pass")
stale = sum(1 for r in all_results if r.status == "stale")
parts = [f"{passed} passed", f"{failed} failed", f"{warned} warnings"]
if stale:
parts.append(f"{stale} likely stale")
print(f"\nVerification complete: {', '.join(parts)}.", file=sys.stderr)
# Exit code: non-zero if P0 or P1 failures
has_critical = any(r.status == "fail" and r.severity in ("P0", "P1") for r in all_results)
sys.exit(1 if has_critical else 0)
if __name__ == "__main__":
main()