Skip to content

Checks

Status: current (2026-09-20)

Each check is exposed as a CLI subcommand (jk-standards <name>), and the doc-facing ones also ship as pre-commit hooks. All checks emit GitHub-Actions ::error annotations and exit non-zero on violations.

This page is generated from site/src/generated/checks.json, which is itself emitted from the Python registry — the list below is what actually ships in v0.19.0: 19 entries, 18 of which run without a git base ref and are wired as pre-commit hooks. The remaining 1 run only in CI where a base ref is available.

Every doc under the configured roots must carry YAML front-matter class: <value> from the configured vocabulary (default generated, gated, archived). A doc with no front-matter fails [verified: test_checks::test_missing_class_flagged], as does an unknown class value [verified: test_checks::test_invalid_class_flagged].

The class is the contract the other checks key off: gated docs are linted and drift-mapped, archived docs are exempt, generated docs are freshness-checked.

Applies only to class: gated docs; archived docs are exempt [verified: test_checks::test_archived_docs_exempt_from_status_prose].

Rule 1 — a Status: line must carry a (YYYY-MM-DD) anchor. Undated status fails [verified: test_checks::test_undated_status_flagged]; a dated one passes [verified: test_checks::test_dated_status_passes].

Rule 2 — progress-tracking phrases (the not-yet-implemented family, phase tracking, TODO-count claims) are rejected outright [verified: test_checks::test_forbidden_phrase_flagged]. That state belongs in the changelog, issues, or generated dashboards. Extra project-specific phrases can be added via status_prose.forbidden_extra.

Rule 3 — the accuracy arm. Rule 1 proves a Status: anchor is present; this arm proves it is not stale. Diff-scoped like doc-drift, it runs only when a git base ref resolves (--base, or GITHUB_BASE_REF in CI). For each gated doc in the change range it compares the anchor date against the doc’s last-touched commit date in that range and flags the doc when the commit is newer than the anchor beyond a tolerance window [verified: test_checks::test_status_prose_accuracy_flags_stale_anchor_in_range]. A doc whose only change since the anchor is the Status: line itself is never flagged — re-stamping the date is not a substantive edit, and treating it as one would make the re-stamp immediately re-trip the check [verified: test_checks::test_status_prose_accuracy_ignores_status_only_edit]. The window is status_prose.date_tolerance_days (default 0 — flag any commit strictly newer than the anchor); a wider window forgives edits within N days [verified: test_checks::test_status_prose_accuracy_within_tolerance_not_flagged]. When no base ref resolves — a local run with no --base and no GITHUB_BASE_REF — the accuracy arm skips cleanly and never fails, while the presence arm still runs [verified: test_checks::test_status_prose_accuracy_skips_without_base_ref_presence_green]. A summary line records whether the arm ran (naming the base ref and changed-file count) or skipped, so CI-vs-local behaviour is legible.

The worktree arm. The range arm compares an anchor against the doc’s last commit, so a pre-commit run has nothing to compare and stays silent — the violation used to surface only in CI, one commit too late. The worktree arm closes that gap: a gated doc with an uncommitted substantive edit is compared against today, with no base ref required [verified: test_checks::test_worktree_arm_flags_dirty_substantive_edit_without_base]. A Status-only working-tree edit is never substantive [verified: test_checks::test_worktree_arm_ignores_status_only_dirty_edit], a clean tree stays silent [verified: test_checks::test_worktree_arm_silent_on_clean_tree], an anchor already at today passes [verified: test_checks::test_worktree_arm_passes_todays_anchor], and the tolerance window applies unchanged [verified: test_checks::test_worktree_arm_respects_tolerance_window]. Untracked docs stay outside governance (they are invisible to CI checkouts too) [verified: test_checks::test_worktree_arm_ignores_untracked_docs].

Relatedly, a doc-drift failure whose mapped doc carries a dated anchor now says so up front — fixing the drift by editing the doc is itself an edit that stales the anchor, and the warning turns that two-round-trip chain into one [verified: test_doc_drift::test_failure_notes_status_anchor_chain] [verified: test_doc_drift::test_failure_has_no_anchor_note_for_unanchored_doc].

Enduring docs and source comments must cite symbols or region names, not line numbers — references like engine.cpp:42 rot on the next edit and are flagged [verified: test_checks::test_file_line_ref_in_doc_flagged].

Docs are scanned on every line. Configured source roots are scanned only inside comments — the same reference in a string literal is code, not commentary, and is ignored [verified: test_checks::test_source_comment_scanned_code_ignored].

Escape hatch: a [file-line-ok] marker on the line [verified: test_checks::test_file_line_ok_marker_exempts], for consciously-pinned references such as commit-SHA permalinks. Dated review records belong in exempt_dirs — their line refs are frozen by the review’s date anchor.

A numeral (Arabic or spelled out) adjacent to a configured inventory trigger phrase is flagged [verified: test_checks::test_hardcoded_count_flagged] — the fact belongs in a generated source of truth, interpolated as {'{counts.x}'}, which is never flagged [verified: test_checks::test_counts_template_passes].

Triggers are project-supplied and should be inventory-scoped phrases, not bare nouns, so compositional prose never matches. With no triggers configured the check is skipped.

Exemptions: archived docs; fenced code blocks [verified: test_checks::test_code_fence_exempt]; markdown table rows; and counts-ok markers on the line, the preceding line, or whole-file before the first heading [verified: test_checks::test_counts_ok_marker_exempts].

Opt-in markers make prose claims machine-checkable:

  • A verified: marker must cite an entry in the scraped test index — an unresolved citation fails [verified: test_checks::test_unresolved_citation_flagged], a resolving one passes [verified: test_checks::test_resolved_citation_passes].
  • The ⚠-unverified marker is allowed but counted and warned — the honest-state metric [verified: test_checks::test_unverified_marker_is_warning_not_error].

Citation formats by index source: Suite.TestName for gtest [verified: test_checks::test_gtest_index_resolves], filestem/slug for js test files, filestem::test_name for pytest.

For each configured (doc, command) pair the check snapshots the tracked content, runs the generator, diffs, and restores the snapshot — a stale doc is flagged and the working tree is left untouched [verified: test_checks::test_stale_generated_doc_flagged_and_restored]; a fresh doc passes [verified: test_checks::test_fresh_generated_doc_passes].

Diff-scoped: needs a git base ref (--base, or GITHUB_BASE_REF in CI). Reads the drift map; if the change touches a mapping’s sources without touching its doc, the check fails [verified: test_doc_drift::test_source_change_without_doc_flagged]. Touching the doc in the same range satisfies the mapping [verified: test_doc_drift::test_source_change_with_doc_passes], unmapped changes pass untouched [verified: test_doc_drift::test_unmapped_change_passes], and a Docs-Not-Affected: <reason> commit trailer bypasses with the justification recorded in history [verified: test_doc_drift::test_trailer_bypasses].

The trailer is a range-level assertion: one trailer satisfies every triggered mapping in the PR.

Manifests listed in doc_drift.deps_only_manifests are excluded from triggering mappings when every changed line is a dependency pin — a "name": "value" entry whose value is version shaped, meaning an optional range operator followed by a digit-led version [verified: test_doc_drift::test_deps_only_bump_does_not_trigger] — because a Dependabot bump is not a taxonomy change. Position in the file is deliberately not consulted: a diff is a fragment, and a real bump’s hunk usually opens after the "dependencies": { line, so any rule requiring that line in context rejects the very case the exemption exists to allow [verified: test_doc_drift::test_deps_only_accepts_mid_block_hunk]. Two blocks bumped in one diff, with neither opening line in context, are accepted for the same reason [verified: test_doc_drift::test_deps_only_accepts_both_blocks_without_either_opening_line], and diff metadata lines are not mistaken for changes [verified: test_doc_drift::test_deps_only_ignores_diff_metadata_lines].

Judging the value’s shape rather than the line’s position is also what keeps the exemption narrow. A scripts-block edit still triggers [verified: test_doc_drift::test_scripts_block_change_still_triggers], including a single-token value such as "test": "vitest" that is entry-shaped but not version shaped [verified: test_doc_drift::test_deps_only_rejects_single_token_script_value]; so does a package rename [verified: test_doc_drift::test_deps_only_rejects_package_rename], a multi-token script value [verified: test_doc_drift::test_deps_only_rejects_scripts_edit], and a bump mixed with a script edit in the same diff [verified: test_doc_drift::test_deps_only_rejects_mixed_bump_and_scripts_edit]. An empty diff is not an exemption [verified: test_doc_drift::test_deps_only_rejects_empty_diff], and manifests not listed get none at all [verified: test_doc_drift::test_unlisted_manifest_still_triggers_on_deps_bump].

A dependency pinned to something not version shaped — a git URL, an npm: alias, a file: path — is not recognised, so its bump still triggers the mapping. That is the same conservative direction the check already fails in, and it is preferred to widening the accept rule until a scripts edit slips through.

The map also carries a cannot_drift registry: docs that legitimately have no touch-correlation source, each recording a required, non-empty reason so a deliberate exemption is distinguishable from an accidental omission (the distinction doc-completeness keys off). A valid entry parses [verified: test_doc_drift::test_cannot_drift_valid_entry_parses]; an entry missing its reason [verified: test_doc_drift::test_cannot_drift_missing_reason_rejected], with a blank reason [verified: test_doc_drift::test_cannot_drift_empty_reason_rejected], or missing its doc key [verified: test_doc_drift::test_cannot_drift_missing_doc_rejected] is a config error surfaced as exit 2 [verified: test_doc_drift::test_cannot_drift_invalid_entry_cli_exit_2]. The worked example is site/src/content/docs/reference/skills.mdx: it renders its catalog by importing site/src/generated/skills.json at build time — nothing on the page is hand-maintained — so a skill add or rename updates the generated JSON (freshness-gated by generated-freshness and the emit --check) and the page reflects it with no source edit to mirror. Mapping a touch-correlation rule at a build-time-generated page would only manufacture a false positive on every skill change, so it is declared cannot_drift instead.

Every doc iter_docs enumerates under a configured doc_root must be accounted for in the drift map — either as a mapping’s doc: target or as a cannot_drift entry. A page that is neither is an accidental omission, and this check names it: it emits ::error file=<doc>,line=1:: for each unregistered doc and fails [verified: test_doc_completeness::test_unregistered_doc_fails_naming_it], while a doc that is mapped [verified: test_doc_completeness::test_mapped_only_passes] or declared un-driftable [verified: test_doc_completeness::test_cannot_drift_only_passes] passes. The remediation names both escape routes — add a mappings entry or a cannot_drift entry — and deliberately does not name the docs already accounted for. On success it prints doc-completeness: all N doc(s) mapped or declared [verified: test_doc_completeness::test_success_emits_summary].

Beyond that forward pass the check also runs a reverse existence pass: every registered doc — whether a mapping’s doc: target or a cannot_drift entry — must still exist on disk. A registry entry naming a path that no longer exists is an orphan, and the check names it and its registry, because the two registries go stale in distinct ways. A stale cannot_drift entry silently pre-exempts any future doc later created at that path from the completeness gate [verified: test_doc_completeness::test_cannot_drift_orphan_is_reported]; a stale mapping becomes an unsatisfiable gate — the doc can never be produced, so its only escape is a Docs-Not-Affected trailer on every affecting commit [verified: test_doc_completeness::test_mappings_orphan_is_reported_with_unsatisfiable_gate_framing]. The pass tests plain filesystem existence, not iter_docs membership, so an entry legitimately naming a doc outside the configured doc_roots is tolerated [verified: test_doc_completeness::test_entry_naming_file_outside_doc_roots_is_tolerated], and it runs unconditionally — a fact about the filesystem, independent of the git fail-open branch [verified: test_doc_completeness::test_orphan_reported_when_git_tracking_fails_open]. The success summary gains a count of how many registry entries were existence-checked, so a green run still proves the reverse pass ran [verified: test_doc_completeness::test_all_entries_existing_reports_zero_orphans_and_names_checked_count].

A doc whose front-matter class is in doc_completeness.exempt_classes (default archived) is skipped before the mapped/declared test — an archived page is deliberately frozen, so requiring it to be mapped or declared is noise, and it passes even when it is neither [verified: test_doc_completeness::test_unmapped_archived_doc_passes]. The exemption keys off the doc’s own front-matter class, not cfg.generated: a gated orphan in the same tree still fails [verified: test_doc_completeness::test_unmapped_gated_doc_still_fails_alongside_archived], and pointing exempt_classes at a different class exempts only that one [verified: test_doc_completeness::test_custom_exempt_classes_exempts_named_class]. The success summary notes how many docs were exempted by class [verified: test_doc_completeness::test_summary_notes_exempt_count].

Unlike doc-drift it needs no git base ref: the working tree and the map are its only inputs, so it runs unconditionally as a static check under jk-standards all and as a pre-commit hook. It honors the same doc_roots, extensions, and exempt_dirs as every other doc check — a file outside the configured extensions is never enumerated [verified: test_doc_completeness::test_multiple_doc_roots_and_extensions] and an exempt_dirs path is skipped [verified: test_doc_completeness::test_exempt_dirs_excluded].

It reuses doc-drift’s cannot_drift parser, so a malformed registry — an entry missing its required reason — surfaces as the same config error: (exit 2) rather than a traceback [verified: test_doc_completeness::test_malformed_cannot_drift_cli_exit_2], and a mappings entry missing its doc key fails the same way [verified: test_doc_completeness::test_mapping_missing_doc_key_cli_exit_2]. A missing drift map is itself a failure [verified: test_doc_completeness::test_missing_drift_map_fails].

The doc-drift family catches a doc that lies about code; this check catches the opposite gap — code that no doc, and no docstring, describes at all. It walks the configured Python source roots and enumerates each module’s public documentable units (the module itself, its top-level public classes and functions, and those classes’ public methods), then asks of every unit whether ANY of three independent OR-signals holds:

  • docstring — the unit carries a non-empty docstring.
  • drift — the unit’s file matches a sources: glob in the drift map, so a change to it is already touch-correlated to a doc.
  • mention — the unit’s bare symbol name appears as a whole word in one of the configured doc scopes.

A unit is documented iff at least one signal fires — the disjunction, not the conjunction [verified: test_doc_coverage::test_docunit_documented_is_disjunction]. A docstring on the module alone keeps the module green [verified: test_doc_coverage::test_module_docstring_alone_keeps_module_green], a sources: glob match documents it via the drift signal [verified: test_doc_coverage::test_drift_map_glob_documents_module], and a whole-word symbol mention in a doc scope documents it via the mention signal [verified: test_doc_coverage::test_symbol_mention_in_doc_scope_documents_module]. The mention is a whole-word match, not a substring — a symbol embedded in a longer token does not count [verified: test_doc_coverage::test_mention_is_whole_word_not_substring].

The gate is deliberately lenient: it fails at module granularity. A module is flagged only when EVERY one of its public units is undocumented by all three signals — a genuinely bare file that nothing, anywhere, describes [verified: test_doc_coverage::test_fully_bare_module_fails]. One ::error file=<module>,line=1:: is emitted per fully-undocumented module so it surfaces inline on PRs [verified: test_doc_coverage::test_bare_module_emits_error_with_path_and_line], and the summary line reports the unit count, module count, undocumented count, and live-waiver count [verified: test_doc_coverage::test_clean_run_summary_reports_unit_and_module_counts].

Baseline ratchet. Beyond the binary bare-module gate, the check enforces a per-module floor: it recomputes each module’s live documented-unit ratio and compares it against the committed floor recorded at baselines/doc-coverage.json, hard-failing any module that slipped below its recorded ratio and naming the module with its before/after ratio [verified: test_doc_coverage::test_ratchet_regression_below_floor_fails_naming_module_and_ratio]. The ratchet composes with — it does not replace — the binary gate [verified: test_doc_coverage::test_ratchet_composes_with_binary_gate]: holding at the floor passes [verified: test_doc_coverage::test_ratchet_holding_at_floor_passes], improving above it passes [verified: test_doc_coverage::test_ratchet_improvement_above_floor_passes], and a module not yet in the baseline is first-seen-passes [verified: test_doc_coverage::test_ratchet_new_module_not_in_baseline_passes]. With no baseline committed yet the ratchet is inert and the summary line says so [verified: test_doc_coverage::test_ratchet_no_baseline_passes_and_reports_first_run]. The floor map is recorded — and ratcheted up — only through the explicit doc-coverage --update-baseline CLI flag (never emit all, so a floor can never silently self-heal); a write that would lower an existing floor is refused unless --allow-regression is also passed [verified: test_doc_coverage::test_update_baseline_lowering_refused_without_allow_regression], and re-recording an unchanged tree reproduces the file byte-for-byte [verified: test_doc_coverage::test_update_baseline_is_byte_idempotent].

Advisory floor. An optional doc_coverage.module_min_percent sets a soft per-module target: a module whose live documented-unit ratio is below that percentage emits a ::warning (surfaced inline on the PR) and is tallied in the summary line, but the advisory never fails the build — it is strictly additive to the binary gate and the ratchet [verified: test_doc_coverage::test_advisory_below_floor_warns_but_exit_stays_zero]. A module exactly at the floor is not flagged [verified: test_doc_coverage::test_advisory_at_floor_not_flagged], and when the field is unset the summary line is byte-identical to before, with no advisory clause and no warnings [verified: test_doc_coverage::test_advisory_unset_adds_no_clause_and_no_warning].

Escape hatch: a # doc-coverage-ok: <reason> marker in the file’s leading comment block (before the first code) waives the whole module in place [verified: test_doc_coverage::test_escape_hatch_waives_module]; a shebang above the marker is fine [verified: test_doc_coverage::test_escape_hatch_after_shebang_still_waives], but a marker buried in code or a docstring does not waive [verified: test_doc_coverage::test_marker_buried_in_code_does_not_waive]. Live waivers are counted in the summary line so rising escape-hatch usage stays visible in CI logs.

Scope: Python and C++. The default enumerator is an ast walk over the configured source roots; sources with a C++ suffix (.cpp, .cc, .cxx, .c++, .hpp, .hh, .hxx, .h++, .h, .c) are instead parsed with tree-sitter-cpp and enumerated by a public-declaration heuristic:

  • function — a named function declaration or definition at namespace scope (recursing into namespace/extern "C" bodies so the true name is used).
  • class / struct — a named class or struct specifier; anonymous ones are skipped since no doc could name them.
  • method — a public member function, resolved with C++ default-access rules: a class starts private and a struct starts public, and each public:/private:/protected: label flips visibility for the members that follow, so only currently-public methods are enumerated.

The has_docstring signal fires when a Doxygen doc comment — one opening with ///, //!, /**, or /*! — sits on the line immediately above the declaration; a plain // or /* */ comment does not count, mirroring how a Python docstring (not any comment) is the signal. The drift and mention OR-signals, the disjunction rule, and the module-granular gate all carry over unchanged — a C++ unit is documented iff it has a doc comment, its file matches a drift-map sources: glob, or its bare name is mentioned in a doc scope.

Known limits: the native grammar ships only in the optional jk-standards[cpp] extra. When a C++ source root is configured but tree-sitter-cpp is not installed, the check degrades gracefully rather than failing — the C++ files contribute zero units and a single summary line reports how many were skipped and points at jk-standards[cpp], so a grammar-less repo keeps working on the PyYAML-only zero-dependency default. A C++ file that fails to parse into a translation unit likewise contributes zero units instead of raising. With no source roots configured the check skips cleanly.

Every GitHub Actions uses: reference under .github/workflows/**/*.yml (and *.yaml) must be pinned to a full 40-char commit SHA. A floating ref (actions/checkout@v6, @main, a bare tag) lets the upstream owner — or anyone who compromises their account — change what runs in your CI without a diff on your side, so it is flagged with file:line [verified: test_checks::test_action_pinning_floating_ref_flagged]; a 40-hex-SHA pin passes [verified: test_checks::test_action_pinning_sha_pinned_passes]. A docker:// image with no digest is unpinned and flagged too [verified: test_checks::test_action_pinning_docker_image_flagged], and every unpinned ref adds to the returned error count [verified: test_checks::test_action_pinning_multiple_unpinned_counted].

Local uses: ./… (or ../…) action and reusable-workflow refs are accepted — they live in your own tree and move with it [verified: test_checks::test_action_pinning_local_ref_accepted].

Escape hatch: a # action-pin-ok: <reason> marker on the offending line [verified: test_checks::test_action_pinning_marker_same_line_exempts] or the line immediately above it [verified: test_checks::test_action_pinning_marker_line_above_exempts] suppresses the finding, for the rare ref that genuinely cannot be SHA-pinned. With no .github/workflows directory the check skips cleanly [verified: test_checks::test_action_pinning_missing_workflows_dir_skipped].

The workflow directory and scanned extensions are configurable via the action_pinning config section. The shipped templates/dependabot.yml is the companion that keeps pinned SHAs current by surfacing each upstream update as a reviewable Dependabot PR — pinning without update automation rots.

Docs point readers at slices of source two ways: an MDX <CodeSnippet file=… region=… /> component that renders the named region, and prose region:<name> mentions. Each reference must resolve to a region:<name> marker that actually exists in the declared source tree — otherwise the snippet silently rots when the region is renamed or deleted, with no diff on the doc side to warn anyone.

A <CodeSnippet> names its own file=; that file must exist [verified: test_checks::test_snippet_regions_codesnippet_missing_file_flagged] and must define the region [verified: test_checks::test_snippet_regions_codesnippet_mdx_resolves]. A region with no matching marker is flagged with path:line [verified: test_checks::test_snippet_regions_dangling_codesnippet_flagged_with_path_line].

Prose mentions carry no file, so they resolve against the union of markers across the configured snippet_regions.source_roots — poly-style # region: in shell and // region: in C++ both feed that union [verified: test_checks::test_snippet_regions_prose_resolves_shell_and_cpp], and a mention matching no marker is flagged with path:line [verified: test_checks::test_snippet_regions_dangling_prose_flagged_with_path_line]. With no source roots configured there is nothing to resolve against, so prose scanning is skipped [verified: test_checks::test_snippet_regions_no_source_roots_skips_prose]; CodeSnippet references, which name their own file, are still validated.

Marker syntax mirrors site/src/components/CodeSnippet.astro, the three forms the repo already renders — // region:, # region:, and <!-- region: --> — and is per-file-type overridable via snippet_regions.markers, so a repo can map its own comment style (a SQL -- region:, say) onto a file extension [verified: test_checks::test_snippet_regions_per_file_type_marker_syntax].

Escape hatch: a # snippet-region-ok: <reason> marker on the reference line [verified: test_checks::test_snippet_regions_escape_hatch_same_line] or the line immediately above it [verified: test_checks::test_snippet_regions_escape_hatch_line_above] suppresses the finding — the same two-line window used by action-pinning, count-drift, and file-line-refs.

A boundary is a directed constraint between components — one directory MUST NOT reference another (the CLI may call the check registry, but a check must not reach back into the CLI). It is the most common architectural invariant and the easiest to break by accident, so this check turns a stated boundary into a grep-level gate: each configured rule names a from directory, an optional file-extensions filter, and a forbid regex, and any line under that directory matching the regex is flagged with file:line [verified: test_checks::test_boundaries_forbidden_reference_flagged_with_file_line]. A clean tree passes [verified: test_checks::test_boundaries_clean_tree_passes], and every matching line adds to the returned violation count [verified: test_checks::test_boundaries_multiple_violations_counted]. This is a line-level textual gate, not an import graph — it catches the reference forms you name and nothing subtler.

The extensions filter narrows the scan to the relevant source files, so a prose mention of the forbidden path in a neighbouring .md note is not a violation [verified: test_checks::test_boundaries_extensions_filter_ignores_other_files]. A rule whose from directory is absent skips cleanly rather than crashing [verified: test_checks::test_boundaries_missing_from_dir_skips_rule], and a malformed forbid regex is surfaced as a violation so a broken rule fails loudly instead of silently passing [verified: test_checks::test_boundaries_invalid_forbid_regex_is_a_violation]. With no boundaries rules configured the check is a clean no-op [verified: test_checks::test_boundaries_no_rules_configured_skips].

Escape hatch: a # boundary-ok: <reason> marker on the offending line [verified: test_checks::test_boundaries_ok_marker_same_line_suppresses] or the line immediately above it [verified: test_checks::test_boundaries_ok_marker_line_above_suppresses] suppresses the finding, honoring any language-appropriate comment opener (#, //, /*, <!--, --, ;). Unlike the other escape hatches, live suppressions are counted and reported in the check’s summary line [verified: test_checks::test_boundaries_suppression_count_reported_in_summary], so rising escape-hatch usage is visible in CI logs rather than silent. Rules are declared in the boundaries config section.

Documentation that summarises published scholarship must make its provenance mechanically visible, so summarised prior work can never be mistaken for original research. The check is opted in by configuring research_provenance.bib_file; with no bibliography configured it is skipped [verified: test_checks::test_provenance_unconfigured_skips], and a configured path that doesn’t exist is flagged rather than silently passing [verified: test_checks::test_provenance_missing_bib_file_flagged].

Citation resolution applies to every non-archived doc: a citation link (#ref-* / #fr-*, or whatever anchor_pattern matches) must resolve to an id="..." defined in the bibliography file — a dangling anchor is flagged with path:line [verified: test_checks::test_provenance_dangling_citation_flagged_with_path_line], a resolving one passes [verified: test_checks::test_provenance_resolved_citation_passes], and a bibliography id defined twice is flagged at its redefinition [verified: test_checks::test_provenance_duplicate_bib_ids_flagged_with_file_line] — entries are stable anchors, never renumbered. The anchor pattern is configurable [verified: test_checks::test_provenance_custom_anchor_pattern], and a malformed pattern is surfaced as a violation so a broken config fails loudly [verified: test_checks::test_provenance_invalid_anchor_pattern_is_a_violation].

Pages opted in via provenance: research front-matter must additionally carry a provenance sentence matching the configured phrase regex (default not original (research|theory)) [verified: test_checks::test_provenance_research_page_missing_sentence_flagged] and an **Attribution:** note assigning claims to the three provenance classes — sourced claim, practical distillation, project-specific value [verified: test_checks::test_provenance_research_page_missing_attribution_flagged]. A page carrying both passes [verified: test_checks::test_provenance_conformant_research_page_passes]; pages without the front-matter marker get citation checking only [verified: test_checks::test_provenance_unmarked_page_needs_no_markers], and archived docs are exempt throughout [verified: test_checks::test_provenance_archived_docs_exempt]. The prose discipline behind the markers is the research-provenance skill.

Escape hatch: a # provenance-ok: <reason> marker on the citing line [verified: test_checks::test_provenance_ok_marker_same_line_exempts] or the line immediately above it [verified: test_checks::test_provenance_ok_marker_line_above_exempts] suppresses citation-resolution findings, for links that legitimately point outside the project’s bibliography — the same two-line window used by action-pinning, count-drift, and snippet-regions. The page-level requirements have no marker hatch: a page that shouldn’t carry them shouldn’t declare provenance: research.

A circular module-level import between two files of the same package is a latent crash — one import-order change away from an ImportError — and it couples the files so tightly that neither can be read, or moved, alone. This check builds the runtime import graph from each package’s own module-level imports, finds every strongly-connected component of more than one module (a cycle), and reports it. It is opted in by listing the packages to scan under import_cycle.packages; with none configured it emits a skip summary and passes [verified: test_import_cycle::test_run_skips_and_returns_zero_when_no_packages_configured].

A detected cycle is emitted as one ::error at the file:line of a real in-cycle import, naming the full member chain, and every unsuppressed cycle adds to the returned count [verified: test_import_cycle::test_run_emits_error_at_file_line_and_returns_cycle_count] — a transitive A -> B -> C -> A loop is caught as one cycle spanning all three modules [verified: test_import_cycle::test_analyze_detects_transitive_three_module_chain]. An acyclic graph passes [verified: test_import_cycle::test_analyze_acyclic_graph_returns_no_cycle], and a module that imports itself is not a cycle between distinct modules [verified: test_import_cycle::test_analyze_self_importing_module_is_not_a_cycle].

Only imports that actually run at import time form an edge. An import nested in a function or method body is deferred until the unit is called, so it can never close an import-time loop [verified: test_import_cycle::test_analyze_zero_when_the_cycle_import_is_function_local]; an import guarded by if TYPE_CHECKING: never executes at runtime at all [verified: test_import_cycle::test_analyze_type_checking_guarded_import_forms_no_cycle]; but a try: import x except ImportError: fallback is a real runtime edge and is counted [verified: test_import_cycle::test_try_except_import_is_a_real_runtime_edge]. Breaking a cycle is exactly that move: push one of its imports inside the function that uses it. Output is deterministic across repeated runs [verified: test_import_cycle::test_run_output_is_deterministic_across_repeated_runs], and a file that does not parse is surfaced in a summary line rather than dropped silently [verified: test_import_cycle::test_run_surfaces_parse_failures_in_a_summary_line].

Every run ends with a summary line — import-cycle: N package(s), N cycle(s), N suppression(s) via import-cycle-ok, N unparseable file(s) — so cycle, waiver, and parse-failure counts stay visible in CI logs even on an otherwise-green run.

Escape hatch: an # import-cycle-ok: <reason> marker on an in-cycle import line [verified: test_import_cycle::test_run_waives_cycle_via_inline_import_cycle_ok_marker] or the line immediately above it [verified: test_import_cycle::test_run_honors_import_cycle_ok_marker_on_line_above] waives the whole cycle, honoring any language-appropriate comment opener (#, //, /*, <!--, --, ;). Like boundaries, live suppressions are counted and reported in the summary line, so rising waiver usage stays visible rather than silent [verified: test_cli::test_import_cycle_escape_hatch_suppresses_and_counts]. An out-of-shape import_cycle config value fails loudly as a config error (exit 2) rather than a traceback [verified: test_cli::test_import_cycle_out_of_shape_config_exits_2], and a non-string entry in packages is rejected the same way [verified: test_cli::test_import_cycle_non_string_package_entry_exits_2]. Packages to scan are declared in the import_cycle config section.

A workflow_call producer can never exceed the token scope its caller was granted. When a caller grants less than a callee’s permissions: block asks for, the run does not fail inside a job — it fails to compose, before any job starts, as a startup_failure carrying no annotation. Nothing points at the cause.

This check reads that relationship statically. Every job calling a local reusable workflow (uses: ./…) has its effective grant compared against the union of the scopes the callee declares; a scope the callee asks for and the caller does not confer is flagged at the uses: line [verified: test_workflow_composition::test_permissions_caller_missing_scope_flagged], while a caller granting a superset passes [verified: test_workflow_composition::test_permissions_caller_superset_passes]. The union spans the callee’s job-level blocks as well as its top-level one, since the ceiling applies to the whole called workflow [verified: test_workflow_composition::test_permissions_callee_job_level_scope_counted]. A permissions: block on the calling job takes precedence over the caller file’s own [verified: test_workflow_composition::test_permissions_calling_job_block_overrides_workflow_level]. The write-all shorthand satisfies every request [verified: test_workflow_composition::test_permissions_write_all_caller_satisfies_everything]; read-all does not satisfy one for write [verified: test_workflow_composition::test_permissions_read_all_caller_fails_a_write_requirement]; and an explicit permissions: {} grants nothing, which is a denial rather than an absent declaration [verified: test_workflow_composition::test_permissions_empty_caller_block_grants_nothing]. Several missing scopes on one call are reported as a single reviewable finding [verified: test_workflow_composition::test_permissions_one_finding_per_edge_not_per_scope].

Two silences keep the check honest rather than noisy. A caller declaring no permissions: block at all is skipped, because its effective grant comes from a repository-level default this check cannot see and any finding would be a guess [verified: test_workflow_composition::test_permissions_undeclared_caller_grant_skipped]; a callee declaring none anywhere requests nothing to satisfy [verified: test_workflow_composition::test_permissions_callee_declaring_nothing_passes]. Only local ./… callees are resolved — a pinned owner/repo@sha reference lives outside the tree and cannot be read from disk [verified: test_workflow_composition::test_permissions_remote_callee_ignored] — and a ./… ref to a file that is not there is left for GitHub to report [verified: test_workflow_composition::test_permissions_missing_callee_file_not_flagged]. A workflow that does not parse is skipped rather than crashing the check [verified: test_workflow_composition::test_permissions_unparseable_workflow_skipped], and with no workflows directory the check skips cleanly [verified: test_workflow_composition::test_permissions_missing_workflows_dir_skipped].

Escape hatch: a # workflow-permissions-ok: <reason> marker on the uses: line [verified: test_workflow_composition::test_permissions_marker_same_line_exempts] or the line immediately above it [verified: test_workflow_composition::test_permissions_marker_line_above_exempts] suppresses the finding. The scanned directory and extensions are configurable via the workflow_permissions config section [verified: test_workflow_composition::test_workflow_dirs_configurable].

A concurrency: group is a mutex whose name is a string. Two runs sharing a name queue, and with cancel-in-progress: false GitHub cancels the older pending entry once a third contender arrives. That is correct when the name stands for something genuinely shared — a Pages deployment, a staging environment — and a silent repository-wide serialiser when it does not.

The failure mode does not look like a config bug. A group omitting github.ref funnels every branch and pull request into one lock, so unrelated pull requests cancel each other’s jobs; the symptom is a cancelled job and a failing aggregate gate on a pull request containing no defect, which reads as CI flake. It also appears only under concurrent load, so a quiet repository looks fine until it is not.

This check makes the distinction explicit: a group must either carry a ref-scoping expression [verified: test_workflow_composition::test_concurrency_ref_scoped_group_passes] or name a lock declared under workflow_concurrency.global_locks [verified: test_workflow_composition::test_concurrency_declared_global_lock_passes]. Anything else is flagged at the group: line [verified: test_workflow_composition::test_concurrency_unscoped_group_flagged], and declaring one lock does not bless every other unscoped group [verified: test_workflow_composition::test_concurrency_undeclared_lock_still_flagged]. The group that serialised this repository’s own pull requests — pages-deploy-${{ inputs.deploy }}, carrying no ref token — is exactly what the check rejects [verified: test_workflow_composition::test_concurrency_catches_the_deploy_site_regression]. Job-level concurrency: blocks are covered alongside workflow-level ones [verified: test_workflow_composition::test_concurrency_job_level_group_flagged], as is the concurrency: <name> string shorthand [verified: test_workflow_composition::test_concurrency_shorthand_string_form_flagged]. A block with no group: key has nothing to scope and is left alone [verified: test_workflow_composition::test_concurrency_block_without_group_ignored], and an unparseable workflow is skipped rather than crashing the check [verified: test_workflow_composition::test_concurrency_unparseable_workflow_skipped]. With no workflows directory the check skips cleanly [verified: test_workflow_composition::test_concurrency_missing_workflows_dir_skipped].

A group built from an expression that contains a ref token passes even when one branch of that expression is a literal — a ternary yielding a global lock for a real deploy and a ref-scoped name for a build-only smoke is the intended shape [verified: test_workflow_composition::test_concurrency_expression_containing_ref_passes]. This is the check’s known limit: it reads the group as text and cannot tell which branch of an expression applies, so a genuinely global branch hidden inside an otherwise ref-scoped expression is accepted. What counts as ref-scoping is configurable via workflow_concurrency.ref_tokens [verified: test_workflow_composition::test_concurrency_custom_ref_tokens_respected], and the scanned directory via workflow_concurrency.workflow_dir [verified: test_workflow_composition::test_workflow_dirs_configurable].

Escape hatch: a # concurrency-scope-ok: <reason> marker on the group: line [verified: test_workflow_composition::test_concurrency_marker_same_line_exempts] or the line immediately above it [verified: test_workflow_composition::test_concurrency_marker_line_above_exempts] suppresses the finding.

Adoption instructions pin by tag, which makes the tag the load-bearing artefact: rev: v1.2.0 in a consumer’s .pre-commit-config.yaml, or uses: OWNER/REPO/.github/workflows/x.yml@v1.2.0 in their CI, works only if that tag exists. When a release ships a changelog entry but the final git tag && git push is skipped, nothing notices — the tree is green, the changelog reads correctly, and the documented instructions become dangling refs that fail in the consumer’s CI rather than this repo’s.

This check closes that loop with two rules. First, every ## [X.Y.Z] heading in the changelog must have a matching vX.Y.Z tag [verified: test_release_pins::test_released_version_without_tag_flagged], satisfied when the tag is present [verified: test_release_pins::test_released_version_with_tag_passes]. An [Unreleased] heading never requires one — holding unshipped work is that section’s purpose [verified: test_release_pins::test_unreleased_heading_never_requires_a_tag]. The newest release section is also exempt: it is the release in flight [verified: test_release_pins::test_newest_release_section_may_await_its_tag]. A release commit dates its changelog section before the tag is pushed — the tag is cut from the merged result — so requiring one there would fail the release pull request on a required check, leaving it unmergeable and the tag uncuttable. The check would block the process it exists to protect. That costs one release of detection latency and no more: skip the tag and the next release pushes the section down, where it is judged like any other [verified: test_release_pins::test_skipped_tag_is_caught_once_the_next_release_lands]. An [Unreleased] heading is not a release section and shields nothing beneath it [verified: test_release_pins::test_unreleased_heading_does_not_consume_the_in_flight_exemption]. Versions released before this check existed are recorded under untagged_versions, so it ratchets on future releases instead of relitigating history [verified: test_release_pins::test_declared_untagged_version_exempted]; declaring one version exempts only that one [verified: test_release_pins::test_declaring_one_version_does_not_exempt_another], and both the declared-exemption and awaiting-tag counts are reported in the summary line so neither state fades into silence.

Second, every pin naming this repository must resolve to a real tag. A uses: reference to a missing tag is flagged [verified: test_release_pins::test_uses_pin_to_missing_tag_flagged] and one to an existing tag passes [verified: test_release_pins::test_uses_pin_to_existing_tag_passes]. The pip install "git+…@ref" form is checked too [verified: test_release_pins::test_pip_git_install_form_checked], as are the commented consume-from-a-pinned-tag examples in a reusable workflow’s own header — a comment is still guidance [verified: test_release_pins::test_commented_pin_in_a_workflow_header_checked].

A rev: names no repository itself, so it counts only when the nearest preceding repo: line names this one [verified: test_release_pins::test_rev_under_this_repo_checked]. A third party’s rev: in the same file is left alone [verified: test_release_pins::test_rev_under_a_third_party_repo_ignored], and scanning resumes correctly at the next block naming this repository [verified: test_release_pins::test_rev_switches_back_to_this_repo_after_a_third_party_block]. A uses: naming another owner is likewise ignored [verified: test_release_pins::test_uses_naming_another_owner_ignored]. Only release-shaped refs are judged: a commit SHA [verified: test_release_pins::test_sha_pin_is_not_a_release_pin] or a branch name [verified: test_release_pins::test_branch_pin_is_not_a_release_pin] is somebody else’s rule to enforce. Vendored trees such as node_modules are never scanned [verified: test_release_pins::test_node_modules_not_scanned].

Historical records belong in exclude [verified: test_release_pins::test_excluded_path_not_scanned] — a migration note describing what a project actually adopted at the time must keep its original pin, and without the exclusion it would be flagged like any other [verified: test_release_pins::test_excluded_path_still_flagged_when_not_excluded].

Three skip contracts keep the check from inventing findings. With no release_pins.repo configured there is nothing to recognise a pin to this project by [verified: test_release_pins::test_unconfigured_repo_skips]. Outside a git checkout the tag list is unreadable [verified: test_release_pins::test_non_git_directory_skips]. And a repository reporting no tags at all is skipped rather than treated as one where every pin dangles [verified: test_release_pins::test_repo_without_tags_skips]: a shallow CI checkout — actions/checkout fetches no tags unless fetch-depth: 0 — and a project before its first release are indistinguishable here, and reporting every pin as broken on the strength of an incomplete checkout would be worse than staying quiet. The dogfood CI job already sets fetch-depth: 0.

Escape hatch: a # release-pin-ok: <reason> marker on the offending line [verified: test_release_pins::test_pin_marker_same_line_exempts] or the line immediately above it [verified: test_release_pins::test_pin_marker_line_above_exempts] suppresses the finding, honouring any language-appropriate comment opener (#, //, /*, <!--, --, ;) — the HTML form matters most, since pins live in markdown [verified: test_release_pins::test_changelog_marker_above_heading_exempts]. Repository identity and scanning scope are configured in the release_pins section; an out-of-shape untagged_versions fails as a config error (exit 2) [verified: test_release_pins::test_untagged_versions_non_list_raises], as does a non-string entry [verified: test_release_pins::test_untagged_versions_non_string_entry_raises].

A delivery ledger is the whole state of a programme in one Markdown file: milestones, the slices they decompose into, the rows each slice closes, each slice’s definition of done, and the validation tokens that must pass before it may claim to be done. The ledger standard defines the format; this check is what makes it a format rather than a convention.

The trade the ledger makes is that a file cannot enforce its own invariants — so every guarantee a schema would have given is recovered here as a gate.

Structure. A milestone heading declares an M-prefixed three-digit ID, and a slice heading declares that milestone’s ID plus a two-digit slice number. A ledger with no milestones is flagged [verified: test_ledger::test_ledger_with_no_milestones_flagged], as is a slice written before any milestone [verified: test_ledger::test_slice_before_any_milestone_flagged] or one naming a milestone other than the section it sits in [verified: test_ledger::test_slice_naming_a_different_milestone_flagged] — a slice belongs to the section it is written in, so the two can never disagree. Duplicate slice IDs are flagged [verified: test_ledger::test_duplicate_slice_id_flagged], and a milestone missing one of its required keys is named [verified: test_ledger::test_milestone_missing_required_key_flagged].

Slice extent. A slice runs to the next slice heading or the next milestone-level (##) heading, whichever comes first. Sections below the last slice — ## Sequencing, ## Related issues — are siblings of the milestones, so their tables are not read as anybody’s rows [verified: test_ledger::test_milestone_level_section_after_the_last_slice_is_not_absorbed]. Headings deeper than ## leave the slice open, so a slice may sub-divide its own body without orphaning the rows below [verified: test_ledger::test_sub_heading_inside_a_slice_keeps_the_slice_open].

Evidence commit SHAs. A SHA an evidence file names must resolve to a real commit [verified: test_ledger::test_evidence_naming_an_unresolvable_commit_flagged]; one that does passes [verified: test_ledger::test_evidence_naming_a_real_commit_passes]. Evidence is what separates an asserted completion from a demonstrated one, so an unresolvable SHA is worse than none — unlike a TBD it is indistinguishable from a real one. The failure is structural rather than careless: evidence written as work lands ships inside the commit it would name, so the value cannot be known, and a field that cannot be filled truthfully gets filled falsely. Omitting it is the correct answer there, and passes [verified: test_ledger::test_evidence_without_a_commit_line_passes]; the Slice: trailer is the join. Backfilled evidence, whose commit already exists, still names it. Where git cannot answer — outside a repository, or a shallow clone that does not reach the commit — the SHA is skipped rather than reported [verified: test_ledger::test_evidence_shas_unchecked_outside_a_git_repo], the same posture release-pins takes toward a checkout with no tags fetched.

Vocabulary. Slice and row statuses come from the declared set; anything else is flagged [verified: test_ledger::test_invalid_slice_status_flagged] [verified: test_ledger::test_invalid_row_status_flagged]. accepted is a first-class status, not an error — a deliberate no-change recorded so a later pass does not rediscover it as an omission [verified: test_ledger::test_accepted_is_a_valid_slice_status].

Dependencies. A Depends entry must name a slice the same ledger declares [verified: test_ledger::test_depends_on_unknown_slice_flagged]; a declared ID resolves rather than being reported as unknown [verified: test_ledger::test_depends_on_declared_slice_passes].

Definition of done. Every slice carries a non-empty checklist [verified: test_ledger::test_slice_without_definition_of_done_flagged] — a slice without one cannot be finished, only abandoned. A done slice must have every box checked [verified: test_ledger::test_done_slice_with_unchecked_item_flagged] and every row closed or accepted; one whose boxes and rows agree passes [verified: test_ledger::test_done_slice_fully_closed_passes].

Plan and evidence. A slice past open implements a plan, so it must name one [verified: test_ledger::test_slice_past_open_without_plan_flagged], and a named plan must exist [verified: test_ledger::test_named_plan_that_does_not_exist_flagged]. Evidence is owed later than a plan: a done slice without its evidence file is flagged [verified: test_ledger::test_done_slice_without_evidence_file_flagged] while an open one is not [verified: test_ledger::test_open_slice_without_evidence_file_passes], because evidence records what was run and nothing has been. Both paths must resolve inside the ledger’s own directory [verified: test_ledger::test_path_outside_the_ledger_directory_flagged], so a programme stays one movable tree.

Validation tokens. A slice names tokens, never commands; the consuming repo maps them to commands in its validations file. A token that file does not declare is a failure rather than a silently skipped gate [verified: test_ledger::test_undeclared_validation_token_flagged], as is an empty set [verified: test_ledger::test_empty_validation_set_flagged]. When the repo has no validations file the arm skips and says so [verified: test_ledger::test_token_arm_skips_without_a_validations_file] — it cannot tell a typo from an unconfigured project, so it declines to judge rather than reporting every token as wrong.

Rows. A slice’s table must carry the ID, Item, Verification and Status columns [verified: test_ledger::test_row_table_missing_required_column_flagged]; extra columns (severity, disposition, source section) are carried through untouched. A row with no verification is flagged [verified: test_ledger::test_row_without_verification_flagged] — the cell names the test, check, or artifact a reviewer audits. Rows are optional: an infrastructure slice with no table passes [verified: test_ledger::test_slice_without_a_row_table_passes].

Placeholders. TBD, TODO, FIXME, XXX and friends never survive into a committed ledger [verified: test_ledger::test_placeholder_flagged]. State the real value, or record the row as open with what is still unknown.

Skip contracts. A repo with no ledger under the configured roots reports itself as having nothing to check [verified: test_ledger::test_no_ledger_is_not_a_violation], and a ledger outside those roots is not governed [verified: test_ledger::test_ledger_outside_configured_roots_is_ignored].

Escape hatch: a <!-- ledger-ok: <reason> --> comment suppresses the finding on the line it sits on [verified: test_ledger::test_escape_hatch_suppresses_the_line_it_sits_on]. The reason is load-bearing — an empty hatch suppresses nothing [verified: test_ledger::test_escape_hatch_without_a_reason_does_not_suppress], because a suppression without a written reason is the silent exemption this discipline exists to prevent. Roots and the validations file are configured in the ledger section.

Source resolution. A ledger-level Source: whose value is a single whitespace-free token is a repo-relative path and must resolve to a file or directory [verified: test_ledger::test_source_path_dangling_flagged] [verified: test_ledger::test_source_path_directory_passes]; a prose value — a description, an external archive, an input document deleted after assessment — is not a path and is never checked [verified: test_ledger::test_source_prose_skipped], and the standard hatch suppresses a flagged line [verified: test_ledger::test_source_dangling_hatch_suppresses].

A skill is a skills/<name>/SKILL.md instruction file an agent runtime loads, optionally with bundled assets beside it. The pieces a runtime actually depends on — the front-matter that names and triggers the skill, and the scripts its body tells the reader to run — can each rot with no test failing: the generated inventory and the installer key on the front-matter name, selection depends on the description’s trigger phrasing, and a renamed or unshipped asset leaves the skill instructing readers to run a file that is not there.

Four rules close those gaps. Front-matter must parse and its name must match the directory name [verified: test_checks::test_skill_lint_name_dir_mismatch_flagged] [verified: test_checks::test_skill_lint_missing_frontmatter_flagged], satisfied by a conforming skill [verified: test_checks::test_skill_lint_clean_skill_passes]. The description must carry the “Use when” trigger phrasing every skill in this repo uses to make an agent select it [verified: test_checks::test_skill_lint_missing_trigger_flagged]. A backtick-referenced same-directory script (foo.sh / foo.py, no path separator) must exist beside the skill [verified: test_checks::test_skill_lint_dangling_asset_flagged] [verified: test_checks::test_skill_lint_existing_asset_passes]; a reference carrying a path separator is out of scope [verified: test_checks::test_skill_lint_pathed_reference_ignored]. And a sibling *.sh must carry the owner-executable bit [verified: test_checks::test_skill_lint_nonexecutable_sh_flagged], while *.py assets are exempt because skill bodies invoke them through the interpreter [verified: test_checks::test_skill_lint_py_asset_needs_no_exec_bit].

A repository without a skills/ directory — every downstream consumer — is skipped, not failed [verified: test_checks::test_skill_lint_no_skills_dir_skips].

Escape hatch: a skill-lint-ok: <reason> marker on the offending line or the line immediately above suppresses exactly that finding [verified: test_checks::test_skill_lint_marker_exempts].