Skip to main content

extant

Your docs cite commits. Some of those commits no longer exist.

tests license: MIT python: 3.9+ dependencies: none on 3.11+

extant (adj.) - still in existence; surviving.


The problem

A plan document says the work landed in 8f2a91c. A spec says its predecessor merged to develop at abc1234. A status file says a branch is not merged yet.

Then someone squash-merges, or rebases, or force-pushes a cleanup, and those sentences quietly stop being true. Nothing complains, because documentation is just writing. Tests check code. Type checkers check types. Nothing checks whether a commit you wrote down still exists.

This got much worse recently, and not for a subtle reason. Coding agents write plan documents, spec documents and status files at a volume humans never did, and those documents are dense with commit SHAs, branch names and file paths. Then the next agent session reads them back as fact. It cannot tell that a line expired, so it plans around something untrue and hands back confidently wrong work.

Here is one real project, swept with a single command and no configuration. 49 tracked markdown files, 43 findings, counted by rule:

Count Rule
37 dead-sha a commit that no longer resolves
5 bare-dead-sha the same, written without backticks
1 dead-path-pointer a file that moved

42 of 43 were dead commit references, and every located finding sat in the project's plan and spec directories - the documents written during agent sessions. Zero in the README. Zero in CONTRIBUTING. The human-facing docs were fine; the machine-facing ones had rotted where nobody looks.

That is the shape of the problem, and a link checker will not find any of it, because none of these are links.

What it looks like

A plan document written during an earlier session. It says the work merged to main. The commit is real, so nothing looks wrong, but it sits on a branch that was never merged:

$ extant --repo . --sweep

UNREVIEWED - surveyed only, not gated
docs/plans/phase-3.md: line 4: [dead-sha] `8f2a91c` does not resolve in this repo
docs/plans/phase-3.md: line 3: [false-merge-claim] claims work merged to main at `1828109`, but that commit is not an ancestor of main
docs/plans/phase-3.md: line 6: [dead-path-pointer] points at `docs/plans/phase-2.md`, which does not exist

swept 2 markdown file(s): 0 configured (0 finding(s)), 2 unreviewed (3 finding(s))
  2 repository-wide rule(s) ran once (0 finding(s))
  examined: dead-sha 1, stale-live-claim 0, unknown-branch 0, false-merge-claim 1, dead-release-tag 0, dead-path-pointer 1, dead-md-link 0, dead-md-anchor 0, inconsistent-artifact 0, dead-pinned-ref 0, raw-lfs-blob 0, manifest-floor-mismatch 0, dead-line-pointer 0
  NOTE: these rules examined nothing anywhere here - either no document makes such claims, or the pattern does not match how this project writes them: stale-live-claim, unknown-branch, dead-release-tag, dead-md-link, dead-md-anchor, inconsistent-artifact, dead-pinned-ref, raw-lfs-blob, manifest-floor-mismatch, dead-line-pointer
  nothing is configured, so nothing here can fail. Set primary_doc or extra_docs in .extant.toml to gate on a file.

That is real output, not an illustration. The middle line is the one nothing else will give you: the commit exists, and the sentence about it is still false. Answering that means asking git for ancestry, which a text linter has no way to do.

The last three lines count what it looked at, not what it found, and on a repository this small most rules have nothing to bite on. Saying so is the point: a rule reporting 0 examined either has no such claims to read here or does not recognise how this project writes them, and neither is the same as a pass. It matters as much as the findings, and there is a section about why.

Try it in one line

Nothing installed, nothing written into your project, no configuration, no file to name:

$ uvx extant --repo . --sweep

That reads every .md, .markdown, .mdx and .rst file git tracks and tells you what has rotted. On a real project it took one command to surface 42 dead commit references across 49 files.

A sweep cannot fail your build. Findings in files you have not configured are surveyed and reported, never gated on, because some of them will be examples rather than claims. It always prints how many files it looked at, so "nothing found" is distinguishable from "nothing checked".

The full set of modes:

Command For
--sweep the survey. No config, exits 0, shows everything
--deleted-since <ref> claims removed while still false. Always exits 0; pass the merge base in CI
--validate <file> one document, exits 1 on findings
--verify every document .extant.toml names. What the git hooks run
--check-text one document on stdin, for text not on disk yet. Pair with --as-path

Four ways to install properly are below.


What it covers

Thirteen rules. Every one answers a question git or the filesystem can settle.

Rule Catches
dead-sha "released in commit abc1234" when that commit does not exist
false-merge-claim "merged into develop at abc1234" when that commit is not on develop
stale-live-claim "not merged yet" about something merged last week
unknown-branch "work is on branch X" when git has never seen that name
dead-release-tag "released in v2.1" when that tag is in your repo but reached no integration branch. Also when no such tag exists, if you set release_claims_name_our_tags - the installer does
dead-path-pointer "see the file at this path" when the file moved
dead-md-link [a link](to/a/file.md) whose target is gone
dead-md-anchor a #jump-to-section link with no such heading, in this file or a linked one
inconsistent-artifact two files in your project stating different values for the same thing
dead-pinned-ref an install snippet pinning a version of your project that does not exist
raw-lfs-blob an asset your .gitattributes says is in Git LFS, committed into git as a real binary instead
manifest-floor-mismatch your README says "requires Python 3.8+" while pyproject.toml says >=3.10. Only in a README or install guide, never in a changelog
dead-line-pointer "see core/engine.py:123" when that file has 40 lines. Only for files git tracks, and it never judges what line 123 now contains

Five details that are easy to miss:

More than one integration branch works. A merge claim names the branch it means, so "merged to develop at abc1234" is checked against develop and "merged to main at ..." against main. Nothing to configure. Gitflow teams write both kinds, and against a single configured trunk half of those claims were never examined at all - not judged and found true, simply not read.

Case matters, and only on some machines. Windows and macOS open docs/PLAN.md happily when the file is docs/plan.md. Linux does not. Without the check a document passes on your laptop and fails on the server, or worse, passes everywhere while misleading every Linux reader. Wrong-case paths are reported on every platform.

Examples are left alone. Claims inside fenced code blocks are not read as promises, so a README showing what a claim looks like is not accused of making one.

Install snippets are the exception. dead-pinned-ref is the one rule that reads inside code blocks, because an install snippet is the opposite of an example: it is the block a reader copies verbatim, and a version that does not exist fails for them on first use. It only checks pins whose repo: names your repository, so documenting somebody else's hook is never flagged.

Renames are followed. When a file has moved, you get told where:

line 5: [dead-md-link] links to `docs/old-name.md`, which does not exist;
        git shows it renamed to `docs/new-name.md`

What it deliberately ignores

It never judges whether a number or a date is right. "We had 2238 tests in March" was true in March. It is not wrong now, only old, and there is nothing to check it against.

That restraint is the whole design. A tool that cries wolf gets ignored, and an ignored tool is worse than none. This one reports only what it can prove.

The single exception proves the rule: it will compare two files against each other, because that has a definite answer. See files that contradict each other.


Is this for you?

Strongest fit You run coding agents, and sessions leave behind plan, spec, design or status documents. Those cite commits and branches, rot within days, and are read back as fact by the next session. This is where 42 of the 43 findings above came from.
Good fit You write ADRs, RFCs, postmortems, migration notes or release notes that reference commits, branches or tags. Same shape, written by hand.
Probably yes Your project uses git and has documentation that names files and paths. The link and path rules work on any markdown.
Probably not Your documentation is one paragraph that never mentions a file, a commit, or a version. There is nothing here for it to check, and it will honestly tell you so.

Note what is not required: a status file, a changelog, a particular workflow, or any change to how you work.

Be clear about the trade. This checks claims git can settle. It does not check external URLs, whether a code sample still compiles, or whether a documented flag still exists. If dead external links are your problem, use lychee; if committed credentials are, use gitleaks. They are better at those than this will be, and they cannot answer whether abc1234 is on main.


Requirements

Git, and Python 3.9 or newer. Check both:

$ git --version
$ python --version
If missing Get it
Git https://git-scm.com
Python 3.9+ https://www.python.org

On macOS and Linux you may need python3 rather than python.

The tool itself has no third-party dependencies. Standard library and git, nothing else.

3.9 and 3.10 are supported for the enterprise distributions that ship them: RHEL 9 and Debian 11 are on 3.9, Ubuntu 22.04 LTS on 3.10. The one thing newer than 3.9 anywhere in the tool is tomllib, which joined the standard library in 3.11. Below that the tool runs on its defaults with no parser at all, and reading a .extant.toml wants pip install tomli - the same parser under its original name, so a config file is read identically on every version.


Install

Four ways in. The table picks one for you.

If you Use You get
already use pre-commit A validation on every commit, one config block
want to look before committing to anything B the validator as a command, nothing written into your project
use Claude Code and want the slash command C the plugin, plus everything in D
want the hooks and agent instructions in-repo D the full install, no plugin manager

Options A and B give you the validator only. The git hooks and the /extant command have to live inside the repository they check, so C and D are the routes that install those. A route quietly delivering half the tool would be worse than no route.

Option A: pre-commit

If you already use pre-commit, this is the whole setup. Add to .pre-commit-config.yaml:

repos:
  - repo: https://github.com/scooter-sensei/extant
    rev: v0.26.1
    hooks:
      - id: extant

Then pre-commit install, and create a .extant.toml naming the document to check:

primary_doc = "README.md"
extra_docs = ["CONTRIBUTING.md"]

It runs on every commit whether or not you touched documentation. That is deliberate: merging a branch can make a sentence false without editing a line of prose, and that is the case this exists for.

A second hook id, extant-annotate, emits GitHub Actions annotations instead of plain text.

Option B: pip, pipx or uv

Installs the validator as a command. Nothing is written into your project, so this is the quickest way to see what the tool says before deciding anything:

$ uvx extant --repo . --validate README.md

uvx downloads and runs it without installing. To keep it around:

$ pipx install extant
$ extant --repo . --validate README.md

Once a .extant.toml names your documents, --verify checks all of them at once and is what you want thereafter. Before that file exists it will report no such document: NEXT_SESSION.md, because that is the default primary_doc and your project has no reason to have one.

pip install extant works too, though a tool you run against many projects is usually happier in its own environment.

This is the VALIDATOR only. The git hooks and the /extant command have to live inside the repository they check, so use Option C or D to install those. Point it at a document with --validate, or create a .extant.toml naming one, exactly as in Option A.

Option C: Claude Code

Two lines:

/plugin marketplace add scooter-sensei/extant
/plugin install extant@extant

Then open the project you want to protect and ask:

Set up extant in this project.

It inspects your repository, works out the settings, and reports what it found and how confident it is about each value.

Option D: by hand

Works with or without Claude Code.

1. Get the files. Download the ZIP from the repository (green Code button, then Download ZIP), or clone it:

$ git clone https://github.com/scooter-sensei/extant

2. Preview. This changes nothing:

$ python extant/plugin/skills/extant/install.py --repo /path/to/your/project --dry-run

Read what it prints. If it looks wrong, stop, and nothing has happened.

3. Do it for real. The same command without --dry-run, plus a preset:

$ python extant/plugin/skills/extant/install.py --repo /path/to/your/project --preset readme

4. Turn on the automatic checks.

$ cd /path/to/your/project
$ sh tools/hooks/install

They run after a commit is already recorded, print what they found, and never stop you doing anything.

Checking more than one document

The installer configures the one or two documents it can name with confidence. Measured across 50 public repositories that is a narrow gate: it covers 12 findings of the 4,429 the whole-repository survey sees in ordinary documents, and 40 of the 45 repositories it agreed to configure would exit 0 forever.

--wide-docs widens it to every tracked document at the repository root, plus up to three levels under docs/, doc/, documentation/, website/ or site/:

$ python extant/plugin/skills/extant/install.py --repo /path/to/your/project --wide-docs

On the same 50 repositories that gates 1,126 findings across 3,305 pinned paths, and 25 of the 50 report something rather than 5 of 45. Precision of exactly those findings, measured against every reading a citation can legitimately have, is 98.7 to 98.8 per cent.

Four things worth knowing before using it:

  • Changelogs, vendored trees, generated API references and per-release documentation snapshots are left out, by the same classification the sweep uses, and the installer prints how many it excluded and why. That restriction is the feature rather than a refinement of it: enumerating the root without it measured worse than configuring nothing, because most of what it collects is CHANGELOG.md.
  • Every enumerated path is pinned. extra_docs is a literal list, so a document that later moves or is deleted becomes a missing-document finding until somebody edits the config. The count is printed before anything is written.
  • Three levels is deliberate. A fourth was measured and is a cliff: 16 more findings for 2,220 more pinned paths. The depth is settable anyway, because a project that keeps its documentation deeper should be able to say so.
  • Translated documentation is grouped rather than repeated. Depth 3 is what reaches a translated page at docs/<lang>/<section>/, so a defect on a page that exists in four languages is still FOUND four times - and it is now DESCRIBED once, on a single entry naming all four documents and the lines in each. Measured on a niche corpus of 38 repositories, PX4-Autopilot's 4,490 findings report as 1,393 entries and 54 per cent less output. Nothing is suppressed and no path is hidden - the sweep prints the finding count and the entry count side by side - so this shortens the reading rather than the answer. Two documents are only grouped when their paths differ in exactly one DIRECTORY segment and they say the same thing once that segment is masked out; sibling files in one directory that happen to share a dead link stay separate. Grouping is --sweep text output only. SARIF and workflow annotations still carry one result per location, because an annotation's whole value is the file and line it lands on.

It also works on a project with no status document at all, which is what the installer refuses on its own. When nothing else has named a document, --wide-docs nominates the root README - the first of README.md, README.markdown, README.mdx or README.rst that git TRACKS - says which one it chose on the console, and records that provenance in the generated config. The suffixes are the set the validator already sweeps, so a project whose README is reStructuredText is not refused over the spelling: five of the fifty benchmark repositories are exactly that case.

The name comes from git rather than from the filesystem, and it is the name git reports. That matters on Windows, where a filesystem check is case-insensitive: a repository tracking readme.md would otherwise be configured as README.md, which checks the same file twice locally and names a file that does not exist on a case-sensitive filesystem. Six of the corpus repositories track a lowercase root README and no other.

What actually lands in your repo

Worth being precise about, because "installs a Claude thing" is the usual assumption and it is wrong:

File Needs
tools/extant_collect.py, tools/extant/ Python 3.9+ and git
tools/hooks/* a POSIX sh
.extant.toml nothing
.agents/skills/extant/SKILL.md nothing. Agent Skills is an open standard, and this is its cross-tool path: read by Codex, Gemini CLI, Copilot, Cursor and Kimi as well as Claude
.claude/commands/extant.md Claude Code

Only the last line is tool-specific, and it is the only one written conditionally. It appears if your repo already has a .claude/ directory or a CLAUDE.md; otherwise the installer says it skipped it and names the flag. --claude-command and --no-claude-command decide it outright.

The validator is standard-library Python and git subprocesses. There is no agent framework in it, nothing to adapt for one, and no runtime dependency on any assistant: options A and B do not write an agent file at all.

Presets

A preset picks the documents and the shape, so you are not deriving configuration before you have seen the tool work once.

Preset For
readme any project. Your README and CONTRIBUTING. Nothing else needed.
node the same, plus package.json and CHANGELOG.md version agreement
python the same, with pyproject.toml
rust the same, with Cargo.toml
enterprise long-lived projects. Also SECURITY.md, SUPPORT.md, UPGRADING.md, MIGRATION.md
ml data and model projects. Also MODEL_CARD.md and DATA_CARD.md, and that pyproject.toml and environment.yml pin the same Python
legacy-web older web apps. Also INSTALL.md, DEPLOY.md, UPGRADING.md, and that .nvmrc and package.json agree on Node
go a Go module. Also SECURITY.md, and that go.mod and your Dockerfile build with the same Go
jvm Gradle or Maven. Also UPGRADING.md, MIGRATION.md, and gradle.properties against the changelog
k8s Helm charts. Also RUNBOOK.md, and Chart.yaml's chart version against the changelog
terraform Terraform modules. Also UPGRADING.md and MIGRATION.md, beside the terraform-docs README
docker images and compose. Also DEPLOY.md, RUNBOOK.md, OPERATIONS.md
monorepo a workspace root. Also ARCHITECTURE.md, docs/README.md, and the root version
mobile iOS and Android. Also RELEASE_NOTES.md, PRIVACY.md, and one marketing version across both stores
unity a Unity project. Checks the editor-version badge against ProjectSettings/ProjectVersion.txt
godot a Godot project. Checks the version in doc/setup_instructions.md against project.godot
agent files AI agents read as fact: AGENTS.md, CLAUDE.md, GEMINI.md, copilot-instructions.md
status a running status file with dated entries

agent is worth a note. AGENTS.md is the cross-tool standard for instructing AI coding agents, and CLAUDE.md and GEMINI.md are the vendor-native equivalents. Those files are mostly paths and commands, and an agent cannot tell an expired line from a current one. A dead path in a README wastes somebody's afternoon; a dead path in an AGENTS.md becomes confidently wrong work.

Where two files look like they should agree and do not, no check is written. Helm documents appVersion as unrelated to the chart version. Terraform's required_version is a constraint (">= 1.5.0") while .terraform-version is an exact pin. Go's go directive is a minimum and toolchain is exact. A mobile versionCode is a build counter, not the version anyone sees. Every one of those pairs would report a correct repository, so docker and terraform carry no cross-check at all, and the rest compare only what genuinely must match.

The last three exist because long-lived documentation rots in a particular place. An enterprise project rarely has a stale README. It has a MIGRATION.md last edited in 2021 pointing at three files that have since moved.

Two rules govern every preset:

A preset never overrides something measured. Detection reads your repository for the trunk name, branch naming and commit conventions. A template would be guessing at those, and a preset that quietly replaced your real branch name would be the copied-configuration problem this tool was built around. Choosing the document is your call, which is why passing a preset settles that one.

Checks whose files are absent are skipped and reported. A preset never opens by complaining about a file you do not have. A tool whose first act is a false positive has taught a lesson that is very hard to unteach.

Works with any coding agent

The checker is Python, git hooks and a pre-commit entry, so it never depended on a particular assistant. What did was one line deciding where the agent-facing instructions went.

Setup now writes them to .agents/skills/extant/SKILL.md, the location the Agent Skills open standard defines. One file, read by OpenAI Codex, Gemini CLI, GitHub Copilot, Cursor, Kimi Code, Claude Code and the twenty-odd other tools that adopted it.

It is rendered for your repository rather than copied, so it names your document and your paths, and it carries the discipline that matters: read the denominator, never make a document pass by deleting the claim.

And it says when to run it, which is the part the commit hook cannot cover. An agent crosses two boundaries a commit does not: it reads a document at the start of a session and takes what it says as fact, and it writes one at the end. The skill instructs it to check at both - before trusting a document, and after editing one - because the commit hook fires later than the first and can only report what a session has already planned around.

This is where the findings actually are. In the project measured above, every located finding sat in the plan and spec directories that agent sessions read back; the README and CONTRIBUTING were clean. A human notices a broken link. An agent cannot tell an expired line from a current one, and there is nothing in the prose to tell it.

Claude Code additionally gets /extant, a slash command for the end-to-end workflow. Both are rendered from the same observations, so they cannot end up describing different documents.

Checking a document that is not on disk yet

The commit hook checks what was written. --check-text checks what is about to be, from stdin:

$ cat draft.md | python tools/extant_collect.py --check-text --as-path docs/plan.md
line 12: [dead-sha] `77afb4e` does not resolve in this repo
checked docs/plan.md: dead-sha 3, false-merge-claim 1, dead-path-pointer 4, ...

Same rules, same denominators, same formats as --verify. It is the primitive under anything that wants an answer before a file exists - a post-write hook handing findings back in the same turn, an editor, a harness.

It reads a baseline (--baseline) so a draft is judged by the same amnesties as the rest of the project, and refuses the two flags that write or judge one. --write-baseline would record a single document's findings over a baseline recorded from all of them, and --baseline-check would call every entry this document happens not to mention "stale" and tell you to delete it. Both are --verify's job. --format=sarif needs --as-path, because SARIF locates every result by a URI and a document with no path has none.

--as-path is worth passing. A document with no path is a narrower question: rules that key on the filename cannot answer, relative links resolve against the repository root rather than the document's own directory, and the markup language falls back to markdown - which would run the markdown-only rules over reStructuredText. --as-path says where the text would live and all three follow from it. Leave it off and the run says so on its own line, rather than letting a thinner denominator read as a clean pass.

Two things it deliberately does not do. It reads no archive and no extra_docs, because one document arrived and inventing two more to read beside it would answer a question nobody asked. And it writes nothing at all: --sha-map still applies its translation to the text in memory, so the findings match the repaired document, but there is no file to write back to and none is created.

What lands in your project

tools/ the checker
.extant.toml settings, written by reading your project
git hooks the automatic checks. They report, they never block
.agents/skills/extant/SKILL.md instructions any agent reads, rendered for your project
/extant command Claude Code only, for the full workflow

Features

Every check reports its denominator

The summary line counts what was examined, not what was found:

checked STATUS.md: dead-sha 36, stale-live-claim 1, false-merge-claim 2,
  dead-path-pointer 5

If one of those reads 0, that check found nothing to look at, which usually means a setting is wrong rather than that your file is spotless. Any rule that examined nothing is named on a NOTE: line.

--sweep reports the same counts summed over every file it read, so a rule that is inert across a whole repository says so on the first run rather than after you have started trusting it.

This is the single most important line in the output. "Found no problems" and "did not look" print identically otherwise, and only one of them is good news.

Anything that narrows what a count MEANS prints beside it. In a shallow clone, dead-sha describes the slice that was cloned rather than the repository, so a live SHA can read as dead; the run says so rather than leaving the number to speak for itself. A sweep also names any document it dispatched that came back with no result, and fails the run: a file the survey lost is not a file with no findings, and exiting 0 there would report a clean run for work that never happened. That is the one thing a sweep gates on besides a configured finding.

A finding says what kind of document it came from

A first sweep of a large repository can print an alarming number, and most of it is usually not about your documentation. Measured over 50 pinned public repositories: 54,790 findings, of which 4,431 are in ordinary documents. The rest come from trees nobody would call documentation-with-claims - and bazelbuild/bazel keeps twelve per-release copies of one documentation tree, so a single dead link written once is reported twelve times.

So every finding carries a stratum, and the summary leads with the number you would act on:

swept 3 markdown file(s): 0 configured (0 finding(s)), 3 unreviewed (3 finding(s))
  1 finding(s) in ordinary documents; 2 elsewhere:
    vendored  1 finding(s) in 1 of 1 document(s)
    historical-record  1 finding(s) in 1 of 1 document(s)
stratum what it is
vendored somebody else's code, checked in - node_modules, vendor, third_party
version-snapshot documentation kept as one directory per release
generated machine-written trees - api, reference, _build, dist
historical-record changelogs, release notes, and machine-maintained allowlists
ordinary everything else. This is the number that matters

A changelog entry naming a file that has since moved is not a claim that rotted. It is an accurate record of what was true at that release. The same goes for an allowlist of test-case paths in someone else's suite.

Three things this deliberately is not:

  • It is not an exclusion. Excluding hides; a stratum labels. Every finding is still reported and still counted, because a rule that goes quiet after a tree was excluded is indistinguishable from a rule that broke. exclude_paths still exists and answers a different question.
  • It does not change your exit code. A sweep already reported without gating, and a project that deliberately configured a CHANGELOG as an extra_doc keeps gating on it.
  • It does not invalidate a baseline. The label lives beside the finding rather than inside its identity, so every fingerprint you have recorded still matches.

Each row carries its own denominator for the reason the section above exists: "2 historical-record findings" and "2 findings from the only changelog in this repository" are different facts and would otherwise print identically. The table appears only when there is something to separate.

It can prove its own checks fire

The worry above deserves more than a warning, so there is a command:

$ python tools/extant_collect.py --selftest

It takes a real claim from your document, breaks it on purpose, confirms the matching rule notices, and puts everything back. Nothing is written.

  dead-sha             FIRED
  stale-live-claim     FIRED
  dead-path-pointer    FIRED
  dead-release-tag     NO PROBE       nothing to corrupt

  3 fired, 1 had nothing to corrupt, 0 stayed silent

A check that stays silent after you break something it should catch is broken. This runs in CI here on every change, so the tool is not merely tested, it is watched failing.

Findings inside pull requests

If you have no step to add a flag to yet, there is an action:

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: scooter-sensei/extant@v0.26.1

fetch-depth: 0 is not optional. actions/checkout defaults to a depth-limited checkout, and this tool asks whether commits still exist - in a depth-limited repository nearly none of them do. Measured on a held-out corpus, one project produced 2,094 dead-SHA findings checked out that way and 3 with its history. The run says when it is looking at a depth-limited repository, but the fix belongs in the workflow.

Depth is not the only axis: a trunk ref has to resolve. Two different failures, depending on what is missing, and they look nothing alike:

  • No main at all - a single-branch checkout, however deep. The rules that ask about trunk cannot ask, so they go INERT. Measured on this repository, cloning the working branch alone took false-merge-claim from 5 examined claims to 0 and dead-release-tag from 19 to 0, and the run still exited 0: two rules silent, 24 claims unchecked, nothing failing. The denominator line names them, which is what it is for, but the exit code cannot.
  • main only as a remote-tracking branch - what a pull_request checkout gives you, because it is a detached merge ref. Ancestry checks then fail to resolve and report commits that are perfectly fine, which is the opposite symptom: noise rather than silence.

fetch-depth: 0 fixes the objects, not the ref. The workflow in this repository does both, and the second half is one line:

      - name: Make the trunk ref resolvable
        run: |
          git show-ref --verify --quiet refs/heads/main || git branch main origin/main
          git rev-parse --verify main

It takes mode (verify, the default, or sweep), repo, format and args for anything else. Under it is one command:

$ python tools/extant_collect.py --verify --format=github
$ python tools/extant_collect.py --verify --format=sarif

github emits GitHub Actions annotations, so each problem is highlighted on its own line in the pull request diff rather than buried in a log nobody opens. Add the flag to your existing step. Nothing else is needed, and it requires no extra permissions.

sarif emits the standard format code-scanning tools exchange, as pure JSON on stdout, so it pipes straight to a file. To get results into GitHub's Security tab:

      - name: Check the documentation
        run: python tools/extant_collect.py --verify --format=sarif > extant.sarif
        continue-on-error: true

      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: extant.sarif

That upload needs permissions: security-events: write. This repository uses the annotation route instead, so treat the SARIF upload as a starting point rather than something proven here.

Severity says whether the finding can fail your build. A finding in a document you configured arrives as error, because it decides the exit code. A finding from --sweep in a file you have not reviewed arrives as note, because a sweep exits 0 and cannot gate. Each result also carries properties.gates, so a policy can filter on it without parsing severities. Before 0.20.0 everything was published as error, which contradicted the exit code the same command returned.

And what kind of document it came from. properties.stratum is one of vendored, version-snapshot, generated, historical-record or ordinary, beside gates and for the same reason: a consumer should be able to filter on it without the tool having decided by hiding the result. See the strata section.

The alert shows the claim, not a line number. Each result carries the cited line as a snippet, and the column range points at the token itself - the SHA, the tag, the path - so the alert underlines abc1234 rather than pointing vaguely at line 12.

The run states what it examined. properties.examined carries the per-rule count, and the invocation's notifications repeat it in prose along with a warning naming any rule that examined nothing. Zero results with a full denominator means the documentation is clean; zero results with zeros everywhere means nothing was checked, and those two must never look alike.

A sweep upload and a verify upload use different automationDetails.id values (extant/sweep, extant/verify), so uploading both does not silently replace one with the other.

Files that contradict each other

The tool never asks whether a number is correct. It will ask whether two files disagree, which has a definite answer needing only the filesystem.

This project shipped the bug that motivated it: three manifests said the version was 0.1.0 while the changelog said 0.3.0, and everyone installing was told they were getting the first release.

[extant.consistency.version]
"package.json" = '"version":\s*"([^"]+)"'
"CHANGELOG.md" = '^## (\d+\.\d+\.\d+)'

Each line names a file and how to find the value inside it. Disagree, and you are told which file says what.

Four shapes are refused when the settings load, because each would produce a check that can never fail: a check listing one file, a pattern with no capture group, a pattern with more than one, and the same file named twice under different spellings. A pattern that matches nothing is reported rather than passing quietly.

Corrections as a patch, never an edit

When a file has been renamed, the tool already tells you where it went. It can also write the correction:

$ python tools/extant_collect.py --verify --suggest-fixes

This prints a patch and changes nothing. Read it, and apply it if you agree:

$ python tools/extant_collect.py --verify --suggest-fixes | git apply -

It only offers corrections for files git actually recorded as renamed. If a file is simply gone it says nothing, because guessing where it went means writing something that might not be true, and that is the one thing this refuses to do. Its authority rests on checking claims and never authoring them.

After a history rewrite

git filter-repo gives every commit a new id. Any document naming an old one is now wrong - all at once, in every file, without anybody editing anything. This is the single largest cause of dead commit references in agent-written documents: measured on one real project, 12 of its 12 dead SHAs came from one rewrite, and none of them could be found by any amount of searching the object store, the reflogs or the unreachable objects.

They can all be found in one place. filter-repo writes what each commit became to .git/filter-repo/commit-map, and extant reads it, so a finding says what to put back:

$ python tools/extant_collect.py --verify
line 14: [dead-sha] `77afb4e` does not resolve in this repo; the rewrite map records it as `d60aac9`

To apply that across a document, pass the map:

$ python tools/extant_collect.py --verify --sha-map .git/filter-repo/commit-map
translated 12 stale SHA reference(s) in NEXT_SESSION.md

--sha-map is the one place this tool rewrites your prose, and it is opt-in for that reason: the substitution comes from a file git wrote, not from anything extant inferred. Reading the map to explain a finding happens on every run; rewriting a document never happens unless you ask. A prefix that two old commits share is left alone rather than resolved by guess - a wrong SHA reads as correct, where a dead one is visibly broken.

That path does not exist until somebody has actually run git filter-repo, so naming a map that is not there is the ordinary way to get this flag wrong. It says so and stops, rather than rewriting anything:

$ python tools/extant_collect.py --verify --sha-map .git/filter-repo/commit-map
cannot read the rewrite map at .git/filter-repo/commit-map (FileNotFoundError).

Search across the archive

If you keep a status document, old entries get archived so the live file stays short. That helps until you need to remember why a decision was made.

$ python tools/extant_collect.py --search "checkout"

It searches the live document and the archive together and returns whole entries rather than matching lines. That is the only reason it beats grep: a decision lives in a dated entry with its reasoning, and a line from the middle tells you a phrase exists rather than what was decided.

Every other document you keep

Your primary document is not the only one that rots. A CLAUDE.md, an AGENTS.md, a CONTRIBUTING.md all make the same kinds of claim:

extra_docs = ["CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md"]

They get every rule that does not depend on dated entries, which is most of them.

Documents that are input to a test

Some markdown is not a promise to a reader. A renderer's test fixtures link to files that are missing on purpose, to exercise the error path. Nothing git or the filesystem can answer separates that from a real broken link, so it is not a rule and never will be:

exclude_paths = ["testdata", "**/test/fixtures/**"]

Empty by default, because a skip-list that ships with entries is one nobody audits. Patterns are gitignore-shaped: * stops at a separator, ** spans them, and a bare name matches a segment at any depth, so testdata finds it wherever it lives.

The sweep prints what it removed, per pattern, and names any pattern that matched nothing. That second half is not decoration. A skip-list fails silently in both directions, and the same two patterns are right for one project and dead in another:

  excluded 3 of 1016 tracked file(s) via 3 exclude_paths pattern(s)
        0 **/e2e/fixtures/**
        0 **/test/fixtures/**
        3 testdata
  matched nothing, so they exclude nothing and may be stale: **/e2e/fixtures/**, **/test/fixtures/**

Excluding a document that primary_doc or extra_docs also names is refused rather than resolved. One setting says gate on this file and the other says never read it, and quietly dropping a document you asked to gate on is the direction that costs you something.

Adopting on a project that already has years of prose

Point this at a ten-year-old repository and the first run reports everything at once. That is accurate and useless: CI goes red, nobody has a week for decade-old documentation, and the tool comes back out.

Record what is already there, once:

$ python tools/extant_collect.py --verify --write-baseline
recorded 47 finding(s) in .extant-baseline.json

Then every run checks new claims and ignores the recorded ones:

$ python tools/extant_collect.py --verify --baseline
1 new finding(s), 47 suppressed by .extant-baseline.json

New documentation is held to the standard from day one; the backlog waits.

It always says how much it is hiding. "No findings" and "no new findings, 47 suppressed" are different facts, and a baseline that concealed its own size would be exactly the failure this tool exists to surface, reintroduced by one of its own features.

Nothing is ever recorded implicitly. --write-baseline is a separate, deliberate command. A baseline that rewrote itself on every run would forgive whatever it had just found, and the check would decay to nothing while still reporting success.

An amnesty must not outlive its finding:

$ python tools/extant_collect.py --verify --baseline-check
baseline: 47 entr(y/ies), 45 still occur, 2 do not
  STALE  docs/setup.md: [dead-md-link] links to `scripts/old.sh`, ...

Those two were fixed. Their entries now forgive something that is not there, so they are reported and should be deleted. A baseline nobody prunes becomes a permanent exemption, and it is itself a stale claim.

The file is JSON, and deliberately readable: each entry carries the path, the rule and the message alongside its fingerprint. It is a list of things your project has agreed to leave broken for now, so it belongs in review like any other change.

The optional wrong-branch guard

One check is off unless you ask for it, because unlike everything else here it can refuse to save your work:

$ sh tools/hooks/install --with-trunk-guard

What it solves. Git lets you keep several versions of a project going at once. If you lose track of which one you are on, you can commit to the wrong branch. The work is not lost, but it is filed in the wrong place, and finding out later is unpleasant.

Why it is off by default. It has nothing to do with your documentation. You came for a tool that checks whether your writing is still true, and a tool that suddenly refuses to save your work for an unrelated reason is a tool people uninstall.

Should you? If you work on one branch, no. If you juggle several, or have ever pushed work and found it on the wrong branch, yes. It is also worth it if AI assistants commit in your project, because they are particularly good at losing track of which branch they are on.

Remove it by deleting pre-commit from your project's .git/hooks, or bypass it once with git commit --no-verify.

Any language

The checking does not care what your project is written in. Only the optional "run the tests" step does, and you name the command:

suite_command = ["npm", "test"]
suite_command = ["cargo", "test"]

Fast enough to leave on

A 16,000-line document validates in under a second. A 100,000-line document in about four. The rules that query git batch their questions, so a document naming two thousand distinct commits asks git once per commit rather than twice per claim.

A large --sweep spreads across processes. Above 100 documents the survey splits the work across up to eight workers; below that it stays in one process, because a pool costs more than it saves on a small repository. Measured on a 200-document corpus, twelve cores, best of three:

one process     7327 ms
eight workers   2919 ms   2.51x

The floor is 100 rather than the point where the curve first leaves zero: at 40 documents parallelism is worth about 4%, which does not pay for a process pool's failure modes. The two paths are required to produce identical output, and the sweep prints which one ran:

swept 200 markdown file(s): 0 configured (0 finding(s)), 200 unreviewed (820 finding(s))
  surveyed across 8 worker process(es)

If the pool cannot start - a sandbox that forbids spawning, a worker the OS killed - the survey finishes in one process and says so on a NOTE: line. It never degrades quietly, because a run that silently stopped using the machinery it reports using would keep printing the summary of a healthy one.


Read the settings it writes

Setup works out your project's habits by reading it, and prints each value with how confident it is:

  trunk         [derived ] origin/HEAD -> main
  branch_token  [derived ] 128 branches sampled
  entry_prefix  [guessed ] highest-scoring header '## Release'
  merge_claim   [unknown ] no matching phrasing found

Anything it could not work out is left switched off rather than guessed.

This is the part that matters. If a setting is wrong, that rule quietly does nothing and you get a tool reporting "all clear" forever without looking at anything. It is the one way this fails badly. Read what setup prints, and see porting.md to fill gaps.


What it cannot do

  • Only catches sentences that can be proven wrong. "Nearly finished" is beyond it, on purpose.
  • Cannot judge whether a summary is a good summary.
  • Expects consistent headings for dated entries. A heading that does not match is skipped.
  • Reads the branch a merge claim NAMES, plus the conventional integration branches that exist. A branch with an unconventional name that no claim names is not treated as an integration branch.
  • Checks links to your own files, not to the web. Nothing here touches the network. Checking external links would make a passing run depend on someone else's uptime and rate limits, turning a definite answer into a coin flip. Issue and pull request links go unchecked for the same reason.
  • Does not complain about a branch merged and then deleted. That is normal tidying, and the branch is still named in the merge commit. Only a name git has never seen is reported.
  • Settings load next to the tool, not next to the folder you point at. Correct once installed in your project, wrong if you run it from elsewhere. It says so on stderr rather than quietly using the wrong ones.
  • It cannot tell a corrected claim from a deleted one. Removing the sentence it complained about makes a document pass. The /extant workflow works around this by reporting first-run findings even after fixing them, so a deletion is visible in both the report and the diff. The checker alone cannot know.
  • A pattern you write yourself can hang it. Settings take regular expressions, and a badly shaped one can take effectively forever on some text. The fix is to simplify the pattern, but the failure looks like a freeze rather than a complaint.

If something goes wrong

"python is not recognised" on Windows, or "command not found" on macOS and Linux: try python3, or reinstall Python with the "add to PATH" box ticked.

The checks never run. Confirm you did step 4, then run --verify by hand.

It flags something you think is fine. Do not silence it by deleting the sentence. Either that sentence really is out of date, or a setting needs adjusting. Both are worth knowing.

Everything reports 0. A setting is almost certainly wrong. See Read the settings it writes.

Deeper documentation

All under plugin/skills/extant/:

File What is in it
references/porting.md Getting the settings right for your project. Read first.
references/config.md Every setting explained.
references/design.md Why each rule works as it does, and the real mistake behind each decision.
SKILL.md What Claude reads.
What is in this repository
.claude-plugin/marketplace.json   lets Claude Code install this
.pre-commit-hooks.yaml            lets pre-commit install this
plugin/
  .claude-plugin/plugin.json      plugin details
  skills/extant/
    SKILL.md                      what Claude reads
    install.py, detect.py         the setup program
    payload/                      what gets copied into your project
    references/                   the deeper documentation
tests/                            the unit suite
tests/harnesses/                  slow audits, run by hand
NEXT_SESSION.md                   this project's own status document

That last one is not decoration. This project runs its own tool on its own document, on every change, in CI. If it stopped working, this repository would be the first to find out.

tests/harnesses/ holds the audits pytest cannot perform: one that breaks the code on purpose to see whether any test notices, one that installs into unlike real projects, one that tries to abuse the tool, one that sweeps a corpus of outside repositories and compares the result against a recorded baseline, and ones that measure speed and load. Each prints its own counts when it runs; none are repeated here, because a number in prose is not checked by anything. Between them they found every defect fixed in 0.3.0, and the stale assertion caught in the audit before 0.6.0. The unit suite found none of them, because the unit suite was the thing being audited.

The bug fixed in 0.6.1 was found by none of them. It took installing the published release and using it as a stranger would, which is its own lesson.

The one idea worth taking elsewhere

When you write a rule that checks something, look at your real data first and shape the rule to fit it, rather than writing what the rule ought to look like and hoping. Done the second way, the file-path rule produced 23 false alarms on the first project it ran against. Done the first way it produced none, and still caught the real problem.

And always report how many things you looked at. "Found no problems" and "did not look" print exactly the same otherwise.


MIT licensed. Use it freely, including commercially. See LICENSE.

Metadata

Release files for extant 0.26.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for extant 0.26.1
File Size Uploaded
extant-0.26.1.tar.gz 564.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for extant 0.26.1
File Interpreter ABI Platform
extant-0.26.1-py3-none-any.whl Python 3 none any Details

Total release size: 815.5 kB

Release files / extant-0.26.1.tar.gz

Download URL extant-0.26.1.tar.gz
Size 564.8 kB
Tags Source
SHA-256 checksum
How to use checksums
725356caba83352e14d7a25ef75139078d07403e11046d6254a7dc639aa4da81
BLAKE2b-256 checksum
How to use checksums
b5482e4b1e940cd736908a3655a4db46b52c9599a6f25007a324565887be3a4f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / extant-0.26.1-py3-none-any.whl

Download URL extant-0.26.1-py3-none-any.whl
Size 250.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
eed778fc75e910f3debd743396a555dc740812d987d43f6fe0be200c0bccb907
BLAKE2b-256 checksum
How to use checksums
92a413acd31e02f2d6373a019db9802a9608e30754e7dffc84909fce17248b7d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page