On this page
Check command -- validates directive resolution, measures documentation coverage, runs SEO lint rules, and detects stale or drifted descriptions.
#selfdoc.check
The check module validates documentation quality across multiple dimensions. Its main entry point, check_docs(), scans every Markdown template in docs/, parses directives, and attempts to resolve each one against the project's source code -- reporting per-directive OK/FAILED status. It then computes coverage: what fraction of public symbols in the source are referenced by at least one directive. Beyond directives, the module runs a suite of SEO lint rules (heading structure, title length, missing descriptions, alt text quality, link targets, color contrast) and detects stale or drifted descriptions via content hashing against stored baselines.
Results are returned as a CheckResult dataclass containing DirectiveResult entries, optional CoverageStats, and a list of LintResult diagnostics. Each lint has a code (e.g., SEO001, STALE001, DRIFT001), severity, file, and line number. The accept_baselines() function allows advancing the stored content hash for pages where a human has confirmed the description is still accurate despite source changes. Developers interact with this module through selfdoc check on the CLI; it also runs automatically during rlsbl release run as a pre-release gate.
#selfdoc.check
Check command -- validates directive resolution, measures documentation coverage, runs SEO lint rules, and detects stale or drifted descriptions.
Scans docs/ templates for directives, attempts to resolve each one, and reports per-directive status (OK or FAILED). For all supported languages, computes coverage: how many public/exported symbols are referenced by directives vs. the total in source files.
#_machine_owned_keys
def _machine_owned_keys(all_docs, dir_path, cli_structure, locale_prefix)Return the locale-prefixed page keys whose description is machine-owned.
These pages are exempt from the STALE001/DRIFT001 baseline hold: their description is a machine placeholder (recognized by the ownership predicate via template match or the recorded seed_hash), so holding the baseline would deadlock -- they cannot be hand-fixed. Hand-described generated pages (text NOT machine-classified) are absent from this set and therefore receive full staleness protection.
#_example_suffix
def _example_suffix(lang)Return the scratch-file suffix for a fenced-block lang.
#_example_output_tail
def _example_output_tail(proc)Collapse a failing validator's output into one message-sized line.
#_validate_example_block
def _validate_example_block(tok, rel_path, command_template, cwd)Execute one validate-marked block; return an EXAMPLE002 or None.
The block's raw text is written to a scratch file whose suffix names the language, {file} in command_template is replaced with that path, and the result runs through the effects chokepoint. Under --dry-run the run is recorded rather than executed, so there is no verdict to report and the block yields no lint.
#DirectiveResult
Result of validating a single directive.
#ResolvedDirective
A successfully resolved directive with its output content.
#CoverageStats
Coverage of public symbols by directives.
#CheckResult
Aggregate result of check_docs().
#_validate_directives
def _validate_directives(docs_dict, resolver, valid_names, file_prefix='', collect_resolved=False)Validate directives across a set of documentation templates.
Parses directives from each template, attempts resolution, and records per-directive OK/FAILED results.
Args:
docs_dict: Dict from resolve_all_docs mapping rel_path to
(frontmatter, resolved, raw_content, fm_line_count).
resolver: Directive resolver callable.valid_names: Set of valid directive names for parse-time validation.file_prefix: String prepended to rel_path in results (e.g. "[0.1.0] ").collect_resolved: If True, collect successfully resolved directives
with attrs into a list for coverage tracking.
Returns:
- (directive_results, resolved_directives) where directive_results is
- a list of DirectiveResult and resolved_directives is a list of
- ResolvedDirective (empty if collect_resolved is False).
#_resolve_root_templates
def _resolve_root_templates(config, base_dir='.')Read root-file templates and return a dict in resolve_all_docs format.
Each root template listed in config["root_files"] is read, its frontmatter parsed and stripped, and the raw body is kept for directive validation. The returned dict maps the template path (e.g. "docs/_README.md") to the same (frontmatter, resolved, raw, fm_lines) tuple that resolve_all_docs produces -- except resolved is set to the raw body (no resolution is performed here; _validate_directives does its own resolution).
Root templates that do not exist on disk are silently skipped (they will be caught by gen's own validation at gen time).
#_posts_dir
def _posts_dir(config, dir_path)Return (posts_dir_rel, posts_dir_abs) for a project with posts.
The absolute path is None when the project has no posts directory on disk. One resolution for both post surfaces of the check -- the validation hook and the lint slice -- so neither can look somewhere the other does not, and both look where the build looks.
#_post_lint_docs
def _post_lint_docs(config, dir_path, resolver, valid_names)Resolve the project's published posts into the lint rules' slice.
A post is a page on the site, so every rule that holds a documentation page to a standard holds a post to it too -- but no path used to reach them. check never injected posts into the docs tree, and the build's lint pass runs after the injected files have been removed, so a post could carry any defect and both surfaces reported nothing.
The conversion is not repeated here: post_docs_payloads is the one place a post becomes a docs page (the build's injection and the in-memory render path both go through it), and this hands it the same published set the build would. Two things are then corrected, because a diagnostic has to name something a reader can open:
- the key is the post's own path, relative to the project root, not the
blog/<slug>.md address the docs tree would hold it at;
- the frontmatter line count is the SOURCE file's, not the rebuilt
frontmatter's. The conversion injects, drops and reorders keys, so its line count differs from the file on disk while the body below it is byte-identical -- taking the source's count makes every reported line the post file's real line.
Drafts are excluded, matching the build: an unpublished draft is not on the site, so the check does not judge it. The generated listing page is excluded too -- it has no source file, so a diagnostic about it would name nothing anyone can fix.
Only ever called on a post set that post validation (POST001-POST007) accepted: discovery raises on an invalid post, and the caller reports that as its own lint rather than asking this to resolve a set that does not exist.
Returns a dict in resolve_all_docs shape, empty when the project has no posts.
#check_docs
def check_docs(dir_path='.', config=None, dry_run=False, version_filter=None, version_override=None)Validate all directives in docs templates and report coverage.
Scans docs/ for .md templates, parses directives, attempts to resolve each one, and computes coverage for Python projects.
Args:
dir_path: Project root directory.config: Pre-loaded config dict (if None, loads from selfdoc.json).dry_run: If True, report staleness without writing hashes to disk.version_filter: When set, skip multi-version validation (VER001).
Used by build --version to check only a single version.
version_override: Version that version-bearing generated content is
expected to embed (VER004), overriding the version detected from the project manifest. Release orchestrators pass the about-to-be-released version here, matching the value they pass to selfdoc gen --version-override.
Returns:
- CheckResult with per-directive results and optional coverage stats.
#AcceptError
Raised when 'selfdoc baseline accept' cannot accept a named page.
#compute_staleness_state
def compute_staleness_state(dir_path='.', config=None)Compute current page hashes and the pages frozen in an error state.
Runs the same content/description/source-docstring/schema hashing that check_docs uses for STALE001/DRIFT001 detection, but never writes .selfdoc/hashes/hashes.json.
Returns:
- Tuple
(current_hashes, stored_hashes, error_pages)where:
- current_hashes maps each page identifier (locale-prefixed when locales are configured, matching hashes.json keys) to its full current hash dict. - stored_hashes is the loaded baseline (hashes.json contents). - error_pages maps a page identifier to the lint code of its outstanding error: "STALE001" or "DRIFT001".
#accept_baselines
def accept_baselines(pages, dir_path='.', config=None)Advance the stored baseline for each named page to its current hashes.
A deliberate, auditable human action meaning "reviewed: the page content changed but the existing frontmatter description is still accurate." Each named page must currently be frozen in a STALE001/DRIFT001 error state; accepting advances its baseline exactly as if the description had been rewritten, so the next selfdoc check passes for that page.
Args:
pages: List of page identifiers exactly as shown inselfdoc check
output (e.g. "en/cli-index.md").
dir_path: Project root directory.config: Pre-loaded config dict (loaded from selfdoc.json if None).
Returns:
- List of
(page, code)tuples for the accepted pages, where code - is the error that was cleared.
Raises:
AcceptError: if any named page does not exist, has no baseline, or
is not currently stale/drifted. Nothing is written when any named page is invalid (all-or-nothing).
#_check_version_consistency
def _check_version_consistency(config, dir_path)Check version consistency between config and project manifest.
VER002: config["version"] differs from detected project version. VER003: versions array last entry doesn't match config["version"].
#_check_version_match
def _check_version_match(config, dir_path, version_override=None)Check that version-bearing generated content is not stale (VER004).
Root files generated from a template that interpolates var key="project.version" carry a RESOLVED version literal on disk. Generation runs before the version bump in a release, so without selfdoc gen --version-override those committed files end up one release behind -- silently. This check turns that lag into a hard failure by requiring the generated file to embed the expected version.
The expected version is version_override when given (the about-to-be-released version, matching what the orchestrator passes to gen), otherwise the version detected from the project manifest.
#_has_version_var_directive
def _has_version_var_directive(template)True when template interpolates the project version via a var directive.
#_check_manifest_freshness
def _check_manifest_freshness(config, dir_path)Check manifest pages/posts against files on disk (STALE002).
#counts_as_statistic
def counts_as_statistic(word)Return True when a prose token is a concrete numeric data point.
SEO008 measures how many quantities a page offers a citing model. A digit alone does not make a quantity: release versions and calendar years appear in almost every documentation page and say nothing about magnitude, count or proportion. Both are refused here, so a page whose only digits are 0.36.0 and 2026 reads as having no statistics -- which is the truth.
Args:
word: A whitespace-delimited token from prose content, with any
markdown decoration still attached.
Returns:
- True for genuine quantities (
42,3.5,87%,12ms), - False for tokens carrying no digit, version-shaped tokens and
- bare years.
#_authored_data_documents
def _authored_data_documents(docs_dir, output_dir)Every authored data document in the docs tree, as absolute paths.
A page whose whole body is a directive -- the CV, the curated project listing -- keeps its prose in a TOML document beside the templates. Those documents are where a reader edits, so they are where a misspelling in the rendered page is reported. Found by walking rather than asked of the directive, because a directive that reads a fixed document (projects-cards) declares no attributes at all.
The build output is skipped: it holds copies of the same documents, and reporting a word at its line in a generated copy would send a reader to a file the next build overwrites.
#_directive_data_files
def _directive_data_files(page_directives, docs_documents, project_root)Documents the content rendered onto one page could have come out of.
The documents in the docs tree, plus any existing file a directive on the page names in path. A path that names a module or a directory (ref, list-tree) is not a document and contributes nothing. Every entry is absolute, so a document reached both ways is held once and never reported twice.
#_locate_word
def _locate_word(word, data_files, project_root)Every (file, line, column) word occupies in data_files.
Whole-word matches only, so ok inside token is not one. The paths come back relative to the project root, which is how every other diagnostic names a file.
#_spell_rendered_directives
def _spell_rendered_directives(rel_path, body_content, resolved, raw_misspellings, page_directives, docs_documents, project_root, vocab, accepted)SPELL001 over prose a directive rendered out of an authored document.
A page's own prose is scanned from the raw body, where every reported column is a real column in the file. Prose a directive rendered has no position in that file at all -- a marker stands in for it -- so the resolved body is scanned instead and each finding is reported against the document it was written in, at the word's own line and column there.
A word the resolved body carries but no authored document holds came out of source code: a module name, a symbol, a type. Identifiers are not prose, and the file to fix would be code rather than a document, so those are not this check's findings.
#_run_lints
def _run_lints(all_docs, docs_dir, resolver, config, resolved_directives=None)Run lint checks on documentation templates.
Args:
all_docs: Dict from resolve_all_docs mapping rel_path to
(frontmatter, resolved, raw_content, fm_line_count).
docs_dir: Absolute path to the docs directory.resolver: Directive resolver callable.config: Project configuration dict.resolved_directives: List of ResolvedDirective objects from
directive validation, or None.
Returns a list of LintResult diagnostics covering SEO best practices: multiple H1s, heading level gaps, empty alt text, title length, missing base_url, and missing description.
#_parse_hex_color
def _parse_hex_color(hex_color)Parse a #RRGGBB hex color to (R, G, B) tuple of 0-255 ints.
#_relative_luminance
def _relative_luminance(rgb)Compute WCAG 2.1 relative luminance from an (R, G, B) tuple.
#_contrast_ratio
def _contrast_ratio(rgb1, rgb2)Compute WCAG 2.1 contrast ratio between two RGB colors.
#_extract_css_vars
def _extract_css_vars(css_block)Extract CSS custom properties from a block of CSS text.
Returns a dict mapping property names (e.g. '--bg') to values.
#theme_css_path
def theme_css_path(theme_name)Return the path of the stylesheet the build emits for theme_name.
Resolved through the theme registry rather than built from this module's own directory: the themes live in selfdoc_core and the selfdoc.themes shim points at them, so this is the one file the build reads and therefore the one the contrast lint must measure.
#_check_contrast
def _check_contrast(lints, config, base_dir)Check WCAG 2.1 contrast ratios for theme colors (SEO012).
Parses the theme CSS for custom properties and verifies critical foreground/background pairs meet minimum contrast ratios.
#_check_pairs
def _check_pairs(lints, css_vars, pairs, mode_prefix, css_file='theme CSS')Check contrast ratio for each pair and emit SEO012 if below threshold.
#_is_skeleton_page
def _is_skeleton_page(frontmatter)Return True if the page is a skeleton auto-generated page.
A page is "skeleton" when it has generated: true AND seeded: true (indicating the description was auto-generated and hasn't been hand-edited). A generated page whose description has been customized (seeded removed) counts as documented.
#_compute_coverage
def _compute_coverage(config, base_dir, resolved_directives, source_entries, all_docs=None)Count public symbols in source files vs. those documented by directives.
Multi-language: iterates over all source entries, using each entry's extractor to discover public symbols and resolve file paths. Two-tier tracking:
- "referenced": symbol appears in ANY directive's resolved output
- "documented": symbol appears on a non-skeleton page (hand-written or
generated with a customized description)
Files whose module path matches a gen.exclude pattern are skipped so that intentionally-internal modules do not drag down coverage.
Args:
source_entries: List of SourceEntry objects (each with path, language,
extractor). Replaces the old single-extractor parameter.
all_docs: Dict from resolve_all_docs mapping rel_path to
(frontmatter, resolved, raw_content, fm_line_count). When provided, enables two-tier skeleton page detection.
#_color
def _color(text, code)Wrap text in ANSI escape codes when color output is enabled.
#filter_lints
def filter_lints(lints, ignore_codes)Return lints excluding those whose code is in ignore_codes.
Args:
lints: List of LintResult objects.ignore_codes: Set or collection of code strings to suppress.
Returns:
- Filtered list of LintResult objects.
#check_result_exit_code
def check_result_exit_code(result, config=None)Compute the process exit code for a whole CheckResult.
Thin adapter over :func:selfdoc_core.lints.check_exit_code -- the one implementation of the verdict rules -- for callers holding a full CheckResult. Reduced entry points (the post-build lint pass, the posts-only check) call the core function directly with just their lints.
Args:
result: CheckResult to inspect (lints already filtered).config: Project configuration, read forcoverage_threshold.
Returns:
- 1 if any directive failed, any lint is an error, or documented
- coverage is below the configured threshold; 0 otherwise.
#serialize_check_result
def serialize_check_result(result, exit_code)Build the JSON payload emitted by selfdoc check --format json.
This is the single definition of the machine-readable check contract: the CLI and its tests both call it, so the schema in schemas/check-output.schema.json has exactly one producer to stay in sync with.
Args:
result: CheckResult to serialize (lints already filtered).exit_code: Exit code the run will terminate with, from
check_exit_code().
Returns:
- JSON-serializable dict conforming to check-output.schema.json.
#print_results
def print_results(result)Print check results to stdout in a human-readable format.
Args:
result: CheckResult to print.