Quickstart
Amiss checks the links and file references in your documentation against the git tree and fails the run when one stops resolving. This page goes from install to a green run. The Introduction says what the tool is and is not, and Invocation has the whole grammar.
Install from crates.io:
cargo install --locked amiss
Or download a binary from the release page.
Each release ships amiss-linux-x86_64, amiss-linux-aarch64, amiss-macos-x86_64,
amiss-macos-aarch64 and amiss-windows-x86_64.exe, plus THIRD_PARTY_LICENSES.txt, a
SHA256SUMS file and its sigstore bundle; the amiss-probe-* files beside them are the external
prober, not the CLI. The download arrives without the executable bit, and SHA256SUMS lists every
asset, so the check skips the ones you did not fetch:
curl -sSLO https://github.com/HardMax71/amiss/releases/latest/download/amiss-linux-x86_64
curl -sSLO https://github.com/HardMax71/amiss/releases/latest/download/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
chmod +x amiss-linux-x86_64 && mv amiss-linux-x86_64 amiss
Put amiss on your PATH. On macOS, shasum -a 256 -c --ignore-missing SHA256SUMS is the
same check. cargo binstall amiss does the download and rename for you, and
gh attestation verify amiss --repo HardMax71/amiss (gh 2.49 or later) proves the file came
from this repository’s release workflow; Security model
has the CI form of that.
Now run it from the repository root. The command checks the staged index against HEAD, so
it works on a depth-1 clone and on any repository with a commit. Before the first commit there
is nothing to compare against, and the published hook passes with a note until there is one.
amiss check --repo . --object-format sha1 \
--base "$(git rev-parse HEAD)" --index --profile observe
--object-format is sha1 for nearly every repository; git rev-parse --show-object-format
prints yours. --base takes a full commit id, never a branch name, and HEAD~1 has no
answer on a one-commit repository or a shallow clone, so the parent form is refused before
the scan starts.
The first line is the verdict, and the rest is detail:
amiss: pass (fix 0, check 0, pre-existing 0, errors 0, exit 0)
A Fix is a reference this change broke, or one whose history the comparison could not
decide, which blocks the same way; observe reports it without blocking, enforce fails
the run on it. A Check is a file that changed under a paragraph that did not, listed
for a person to read and never a verdict. Pre-existing is the backlog, the problems that were
already there before this change. The same Fix counts under both profiles; the exit code
carries the verdict. 0 means the run completed and nothing blocks, 1 means a finding blocks,
and 2 means the run itself could not be trusted, so there is no verdict to act on.
Profiles and findings lists every finding kind with its disposition, and
Analysis errors every code behind that 2.
There is no ignore file, no exclude list, and no way to silence one finding. The nine skipped
directory names are fixed (node_modules, vendor, third_party, dist, build, .next,
target, test, tests), and a run always reads the whole repository, so a monorepo cannot
scope it to one package. The ramp for a repository with a backlog is
--profile enforce-introduced: what a change introduces blocks, the pre-existing rows stay
warnings, and you work them off on your own clock. External http links are never fetched;
the report lists them, and Amiss and link checkers shows the one pipe that
hands them to lychee.
A first run can still report hundreds of missing targets against a tree nobody has broken. That happens when the site is built somewhere else. A generator’s configuration file in the tree is what selects its rule, so a repository that keeps only its documentation gets none, and every destination is read against the source files rather than the URLs the site publishes.
A repository can name its own router in one line. .amiss/router.yml says which router
publishes the directory it sits in, and that directory is where the rule anchors:
router: directory-pages
That name is a shape rather than a generator: a site publishing every page at a directory of
its own name and rewriting no destination, which Hugo, Jekyll, Eleventy and Astro all build by
default. docusaurus, mkdocs, sphinx and zola are the other four names that turn a
spelling on, and every router name is read for the second key below, whether it turns one on
or not.
A second line says where that directory is published, and it is what answers the destinations opening with a slash:
router: astro
base: /
With that file under src/content/docs, /en/guides/astro-components/ is the .mdx of that
name under it. A Starlight site needs no file, since its Astro configuration already says where
its pages are and what they are served under. A route the tree cannot answer stays undecided,
so the key adds no missing target.
The promise above holds. A declaration can move a destination onto a file the tree already
holds, and it can never clear one the tree lacks, so nothing that is really missing goes quiet.
Grafana keeps its Hugo configuration in a Docker image and a sibling repository. With that file
under docs/sources its run reports 153 missing targets instead of 546, and every one of the
393 that go reaches a file the tree already holds. Adding the file is safe under every profile:
both sides of a comparison are read under the routers the candidate declares, so the commit that
writes one introduces nothing. Deleting one is reported at the file that held it.
What a documentation router serves lists every name you can write and
what each one turns on. A file that says anything else, such as a misspelled name or a base
without its leading slash, stops the run with CONFIGURATION_INVALID at that file rather than
declaring nothing in silence.
In CI the same engine ships as a GitHub Action that derives both commits from the event:
name: docs
on:
pull_request:
merge_group:
push:
branches: [main]
permissions:
contents: read
jobs:
amiss:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
- id: amiss
uses: HardMax71/amiss@v0
with:
profile: observe
- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: amiss-report
path: ${{ steps.amiss.outputs.report }}
if-no-files-found: ignore
The profile input defaults to enforce-introduced, which blocks what a change introduces
and leaves the backlog as warnings; the snippet starts at observe so the first report can be
triaged without blocking anyone. fetch-depth gives the checkout both commits
the action compares: a pull request compares the merge commit with its first parent, a push
compares the event’s before and after. The upload keeps the JSON report where a failed run
can be read. Running it in CI has the direct form, GitLab, and the pre-commit hook.