Check content like tests
Goal: verify content before it ships (over-long strings, forbidden phrases, voice drift, structural faults) with a repeatable gate: structured findings, a pass/fail, and an exit code. kapi check reads content and changes nothing; kapi fix applies the fixes it computed, and kapi apply lands the other edits. For the Report model and every check family, see Checks.
- Desktop
- Agent (Claude)
- CLI
Open your project. It opens on its Map, whose findings count reports what the checks found.
- Run the checkset. Run on everything on the Map runs the project's bound rules (hygiene, length, patterns, the word rules held as terms, and the voice's patterns when a profile is bound) over the tracked content.
- Read the findings. Click the findings count. Each finding names its rule, whether it fails or only reports, and the exact block; click through to see the flagged text in context.
- Fix and re-run. Edit the source and re-run; the fixed findings clear. The panel shows the same gate result as the CLI.


The findings the Map's count opens: a list grouped by rule, failing findings first, with a block-level detail pane and the pass/fail summary.
With kapi connected, ask for the outcome:
Go through
docs/and fix anything that fails our content rules.
The assistant runs the loop itself: check_file returns the Report (stable rule ids, block locations), it rewrites the flagged blocks, lands them with apply_edits (guarded by the revision it read, structure preserved), and re-checks until the gate is green. Because rule ids are stable across runs, it can tell a fixed finding from a new one, and stops when the Report is clean.
Point kapi check at files; add constraints inline or from a profile:
kapi check src/locales/en.json # default checkset
kapi check release-notes.md --max-chars 280 --forbid "click here"
kapi check content/*.md --pack marketing-blog # built-in starter pack
kapi check blog-post.md --profile-file brand/voice.yaml
--json emits the structured kapi.check/v2 Report: pass, a 0–100 score, one finding per stable rule id, each anchored to a block and marked as failing or reporting, and the state the run was evaluated against:
{
"schema": "kapi.check/v2",
"pass": false,
"verdict": "failed",
"summary": { "findings": 5, "failing": 2, "reporting": 3, "score": 47 },
"findings": [
{
"rule": "length.max-chars-exceeded",
"fails": true,
"message": "Block exceeds the 120-character limit (148 chars).",
"location": { "file": "src/locales/en.json", "block": "onboarding.welcome" }
}
],
"evaluation": {
"at": "2026-03-17T09:45:00Z",
"context": { "project": "prj_aaaabbbbccccddddeeee", "name": "Acme Documentation", "revision": 412, "stale": false },
"tool": { "name": "kapi", "version": "1.3.0", "commit": "b1a3033dc" },
"analyzers": [{ "id": "hygiene", "ran": 2 }]
}
}
Evaluation metadata identifies the context revision, checker and plugin
versions, and analyzer coverage. evaluation.context.stale indicates that the
stored content differs from the files on disk. The metadata is for information
only: scores, verdicts and exit codes ignore it. Checks outside
a project omit evaluation.context. kapi check --ship --json carries the same
object at the top level of its gate output, once for every gate it ran.
Field-by-field, the Report is documented in Checks; the flags in the check reference.
Exit codes are the gate: 0 pass, 3 at least one finding fails, 4 did not run, 1 operational error, so kapi check works as a CI step as it is. A check did not run when it examined no content, or when one of its checks reported nothing on the known-bad sample it is given beside your content. It never exits 0. Whether a finding fails is set by the rule that raised it: an established term fails unless it is marked advisory: true, a voice pattern or a style measure fails only when it is marked fails: true, and a suggested rule reports (see What fails). The score is reported and never decides the exit code. --no-fail exits 0 when a finding fails, for report-only sweeps, and never turns a check that did not run into a pass.
Check only what changed: kapi check --diff-against HEAD checks the content blocks your edits touched, each block whole, --staged checks what the next commit records, --diff-range checks the change between two commits, and --diff-file takes a diff you already have. See Check changes in git. --validate report|strict folds reader structure/encoding findings into the same Report.
Bilingual checks (placeholder integrity, do-not-translate, terminology) are opt-in: add --target <file> --target-lang <lang> to check a translation against its source.
Fix what kapi can
A finding of a word rule that names a replacement carries its fix: the
replace_text operation that puts the replacement in place of the words. kapi fix runs the same check and applies those fixes, one change set per file,
through the change service kapi apply uses, so each file keeps its formatting.
kapi fix # the project's declared content
kapi fix docs/guide.md # one file
kapi fix 'docs/**/*.md' --dry-run
4 fixed in 2 files
docs/guide.md 3 fixed: utilize → use, utilize → use, e-mail → email
strings/en.json 1 fixed: utilize → use
1 finding needs a rewrite, with no automatic fix (voice.style). Edit by hand.
1 finding not fixed. Edit by hand:
docs/guide.md guide/p#2 "mail address": another fix changes the same words
kapi check: 1 failing finding remains (voice)
kapi fix changes failing findings with a replacement and nothing else. A
finding about tone or style, a rule with no replacement, words inside a code
span, and words that hold a link, a formatting span or a placeholder have no
automatic fix, and kapi lists them for you to edit. A finding that only reports
stays as it is. A fix the change service refuses is listed with the reason, and
the file's other fixes land. After writing, kapi fix checks the files it
wrote again and prints how many failing findings remain.
kapi fix previews each file's change set before it writes. When the preview
shows a change outside the replaced words, such as an escape the format's
writer spells another way, kapi leaves the file as it is and lists its fixes as
not fixed.
--dry-run prints the diff each file would get and writes nothing. --json
prints the kapi.fix-result/v1 result, with each file's change set result. The
exit code is 0 when every fix landed, 1 when a fix did not, and 4 when
the check did not run.
Check changes in git
kapi check can take a change from git and check only the content blocks the
change touched, each block whole. See
Checking a change for how a changed
line becomes a block and what the report's scope lists.
| Flag | What kapi checks |
|---|---|
--diff-against <rev> | The working tree against a revision, with untracked files as new |
--staged | What the next commit records, each file read from the index |
--diff-range A..B | The change between two commits, each file read from B |
--diff-range A...B | The change B made since it left A |
The exit code carries the outcome: 0 the check passed, 3 a finding failed,
4 the check did not run, and 1 kapi could not run it. A check that did not
run is never a pass. The recipes below map each code to what the tool around
them expects.
Before each commit
A pre-commit hook checks what the commit records:
#!/bin/sh
# Stops a commit whose staged content fails the check. A commit that touches
# no content kapi reads goes through.
cause=$(kapi check --staged --jq '.did_not_run_cause' 2> /dev/null)
status=$?
if [ "$status" -eq 0 ] || { [ "$status" -eq 4 ] && [ "$cause" = '"nothing_to_check"' ]; }; then
exit 0
fi
kapi check --staged
exit "$status"
Make it executable with chmod +x .git/hooks/pre-commit. Inside a project, the
hook applies the rules the recipe binds. An unstaged edit and an untracked file
leave the result unchanged. A commit that touches only files kapi does not
check, such as a binary or content the recipe does not declare, exits 4 with
nothing_to_check, and the hook lets that commit through. Any other outcome
stops the commit and prints the findings. A finding that only reports lets the
commit through.
On every pull request
In GitHub Actions, check the change a pull request makes with kapi-action:
name: Content checks
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: neokapi/setup-kapi@v1
with:
version: "1.2.0"
plugins: ""
- uses: neokapi/kapi-action@v1
with:
command: check
args: --diff-range origin/${{ github.base_ref }}...HEAD
fetch-depth: 0 fetches the base branch and the history kapi needs to find the
merge base. With A...B, the job checks only what the pull request changed,
however far the base branch has moved since. A failing finding exits 3, which the
action reports as an unmet gate. A check that did not run fails the job too.
Which commits added findings
A range check reads each file from the commit it names, so it can walk history without checking anything out. This script lists the commits since a base whose change adds a finding in the Go files it touches:
#!/bin/bash
# kapi-sweep.sh <base> <voice-profile>
# Lists the commits since base whose change adds a finding in the Go files it
# touches, held to the rules in the voice profile. Run it from the top of the
# repository. A commit that kapi could not check is named on standard error.
set -u
base="$1"
profile="$2"
for commit in $(git rev-list --reverse "$base..HEAD"); do
files=()
while IFS= read -r -d '' f; do files+=("$f"); done < <(git diff -z --name-only "$commit^" "$commit" -- '*.go')
[ ${#files[@]} -eq 0 ] && continue
count=$(KAPI_NO_PROJECT=1 kapi check --diff-range "$commit^..$commit" --profile-file "$profile" \
--jq '.summary.findings' "${files[@]}" 2> /dev/null)
case $? in
0 | 3) [ "${count:-0}" -gt 0 ] && git log -1 --format='%h %s' "$commit" ;;
4) echo "$(git log -1 --format=%h "$commit"): not checked" >&2 ;;
*) echo "$(git log -1 --format=%h "$commit"): kapi check failed" >&2 ;;
esac
done
./kapi-sweep.sh origin/main comments.yaml
KAPI_NO_PROJECT=1 and --profile-file hold every commit to the rules in the
profile you name, whatever the project's recipe said when the commit was made.
.summary.findings counts every finding, failing or reported. The script
names the changed Go files, so a commit that changes only other files is left
out, and a change to Markdown with no comment to check cannot stop the check
from running.
When a file started breaking a rule
git bisect run finds the first commit where a file breaks one rule. It
checks out each commit it tests, so keep kapi and the profile outside the
repository:
#!/bin/bash
# kapi-bisect.sh <file> <rule>, for git bisect run.
# Exits 1 when the commit checked out breaks rule in file, 0 when it does not,
# and 125, which skips the commit, when kapi could not check it. KAPI names a
# copy of kapi and PROFILE the voice profile, both kept outside the repository
# so that every commit is checked by the same binary against today's rules.
set -u
file="$1"
rule="$2"
[ -e "$file" ] || exit 0
count=$(KAPI_NO_PROJECT=1 "$KAPI" check --profile-file "$PROFILE" "$file" \
--jq "[.findings[] | select(.rule == \"$rule\")] | length" 2> /dev/null)
case $? in
0 | 3) ;;
*) exit 125 ;;
esac
case "$count" in
0) exit 0 ;;
'' | *[!0-9]*) exit 125 ;;
*) exit 1 ;;
esac
cp "$(command -v kapi)" /tmp/kapi
cp comments.yaml /tmp/comments.yaml
git bisect start HEAD v1.0
KAPI=/tmp/kapi PROFILE=/tmp/comments.yaml git bisect run ./kapi-bisect.sh internal/parse/parse.go comment.length
git bisect reset
Exit 0 and 3 both mean the check ran, so the wrapper reads the findings: a
finding for the rule marks the commit bad, and none marks it good. A check that
did not run, exit 4, and a kapi error, exit 1, skip the commit with 125,
because that commit can say nothing about the rule. A commit where the file
does not exist yet counts as good.
In a project: the ship gate
Inside a kapi project, kapi check with no file arguments checks the project's declared content with the recipe's bound rules. kapi check --ship is the project gate mode: it runs the bound quality gates (voice, terminology, rule-based checks, staleness) plus the ship/source coverage gates over the project's content, and exits non-zero when any gate is unmet. Run it before a release. Ordinary builds never fail on target drift; --ship is where you opt in to enforcement. See Ship gates & CI.
A target identical to its source fails the gate unless the project records that
the unchanged wording is intentional. This evidence can be an approval bound to
that exact source–target pair or a terms entry with identical source and target
text. kapi check --ship and kapi up apply the same rule. Other checks still
apply; an approved block with a missing placeholder fails.
The staleness gate reads provenance: the record of the context each target was written under. Every producer stamps the governing context it was given onto what it writes, so kapi can compare a target with the context in force at the point that governs its file. Change the voice profile or the terminology, and the targets written under the old one fail the gate, naming what changed; kapi up reproduces them under the current context. Content that carries no stamp, such as a target written by hand, is reported and never failed.
The two are different bars: a bare kapi check <files> gates the files you name (a checkset over content: the test runner), while --ship gates the project against its ship_gates: coverage thresholds across every target language (the release bar).
Check-only content in other assets
Product copy is not confined to the documentation. A package manager prints a description before anything installs. Windows shows a summary in file properties. Homebrew prints cask notes after an install finishes. Readers often meet these lines before they open the docs.
kapi can read this copy and check it. It cannot write these files back, because the formats that parse them supply a reader and no writer.
Declare such a collection with source_only: true:
collections:
- name: desktop-cask
channel: acme/desktop
base: deploy/homebrew
source_only: true
content:
- path: "*.rb"
format:
name: sourcecode
config:
language: ruby
# The calls whose arguments hold sentences. Everything else in a
# cask is a path, a version or an identifier.
nodePathPatterns: [desc, caveats]
source_only: true says the collection has no target language. kapi up leaves
it alone, and the ship gates that measure
target coverage exclude it. The rules that only read still apply: the voice
profile bound to this point, the project's terms, and the hygiene rules.
Check it the way you check anything else:
kapi check 'deploy/homebrew/*.rb'
kapi rejects a recipe that sets source_only: true on a collection that also
carries a target. Without that check, a deliberate omission and a forgotten
target look the same in the file.
Comments in source code
The comments in a source file are prose, and an assistant that writes code writes them too. kapi checks the comments in Go source files with the same rules as any other content.
Name a Go file and kapi checks its comments:
kapi check internal/parse/parse.go
Each comment is one block, named for what it documents, such as func/Parse or
type/Block/ID. Directives such as //go:embed and //nolint, the comments in
generated files, and the code blocks and references inside a doc comment stay
out of the prose, so a rule reads sentences and nothing else.
kapi also compares each comment with what gofmt writes. A comment gofmt would
rewrite is a formatter.gofmt finding, and it fails, so a formatting problem
surfaces before the commit.
Beside your file, each run gives the comment reader and gofmt a small Go file
with a known fault. If either reports nothing on it, the check did not run and
exits 4, whatever it found in your file. A check over a Go file that holds no
comment prose did not run either.
To check the comments as project content, declare the files with
comments: true in a source-only collection:
collections:
- name: engine-comments
channel: acme/engineering
source_only: true
content:
- path: "internal/**/*.go"
comments: true
A bare kapi check and kapi check --ship then check those comments under the
voice profile and terms bound to acme/engineering. kapi up, flow runs and
source coverage leave the files alone.
Some comment lines are written for your own tools, such as the marker a test audit collects. Declare those markers as directives and kapi sets their lines aside, so a rule reads them as neither prose nor content:
defaults:
comments:
directives: ["okapi-skip:", "okapi-unmapped:"]
collections:
- name: engine-comments
channel: acme/engineering
source_only: true
content:
- path: "internal/**/*.go"
comments:
directives: ["audit-note:"]
A comment line is set aside when its text, after the comment marker and any
leading spaces, starts with a declared marker. The match is exact and
case-sensitive, so a sentence that mentions okapi-skip: later in the line
stays prose. A marker in the middle of a comment splits it into two comments,
each checked on its own. The markers under defaults.comments apply wherever
kapi reads comments in the project, and an item's own directives add to them
for its files. A marker that is empty, starts with whitespace, or is declared
twice makes the recipe fail to load, with an error naming the key.
Comments can also sit at a governance point of their own, so a style written for code comments applies to the comments in a file and the values a YAML reader extracts keep the voice they ship under:
defaults:
comments:
channel: acme/comments
collections:
- name: deploy-config
channel: acme/engineering
content:
- path: "deploy/*.yaml"
comments: true
Each comment is then checked under the voice profile and terms bound to
acme/comments, and each value under those bound to acme/engineering. An
item's own comments: {channel: ...} places its comments at another point. A
bare kapi check, a check of named files, kapi check --ship, a check scoped
to a diff and the MCP check_file tool all hold each block to its point, and
each finding reports the point it was checked at.
The values in some files belong to another tool, such as the steps of a CI workflow or the settings of a build. Declare such files for their comments alone:
collections:
- name: workflow-comments
channel: acme/engineering
source_only: true
content:
- path: ".github/workflows/*.yaml"
comments:
only: true
kapi then checks each comment in those files under the voice and terms of its
point, as it checks Go comments, and a check of the project reads none of the
values. The item governs only the comments. When another item also matches
those files, that item claims their values wherever either item is listed, so
kapi up converges the values and the comments stay at the comments-only
item's point. A check that names a file, such as
kapi check .github/workflows/release.yaml, checks its values too, at the next
item that claims the file or at the project's default point, and
kapi context <file> answers for the same item. kapi up,
kapi merge, kapi extract, flow runs, kapi stats, kapi status coverage
and the --ship gates leave the values alone. only works for every format
that supplies comments: YAML, the XML-based formats, HTML, Markdown, MDX, PO and
properties. An item declared this way has no values to deliver or shape, so a
target, target_languages, redaction, format.config or format.preset
beside it makes the recipe fail to load, with an error naming the item. A
format: that names the format alone stays allowed, and says which format's
comments the files hold.
The voice profile at the comments' point can hold them to word limits of their
own under style.comments:
name: Acme comments
style:
sentence_length: short
comments:
sentence_words: 50
comment_words: 100
doc_words: 150
package_doc_words: 300
density: { ratio: 1, min_comment_lines: 8 }
A sentence over 50 words is then a comment.sentence-length finding, and a
comment over the limit for what it documents is a comment.length finding. A change that adds 8 or more comment lines to a file
and more comment lines than code lines is a comment.density finding. These
limits are style measures, so their findings report. Add fails: true under
style.comments to make a sentence over the limit, a comment over its
length limit and a too-dense change fail. The package doc comment does not count toward it, and only a check
scoped to a diff measures it. comments: {} applies these numbers, which
are the defaults. A profile that holds only these limits can be named on its
own, as in kapi check --profile-file comments.yaml parse.go, and the comment
checks then decide the verdict. In a check scoped to a diff, the limits apply to the comments
the change touched. See Comment limits
for how sentences and words are counted.
Prohibited patterns in the same profile hold comments to a writing style. These report phrasings that read as machine-written or that narrate a change instead of describing the code:
style:
prohibited_patterns:
- regex: "\u2014"
description: An em dash; use a comma, a full stop or two sentences.
scope: prose
- regex: '(?i)\bused to\b'
not_after: '(?i)(?:(?:\b(?:is|are|was|were|be|been|being|get|gets|got|isn''t|aren''t|wasn''t|weren''t)|[''’]s)\s+(?:\w+\s+)?|[^\w\s)\]"''\x60’”]\s*|(?:^|\s)["“‘''\x60]|^\s*)$'
description: The past-habitual "used to"; describe what the code does.
scope: prose
- regex: "(?i)\\b(?:crucially|importantly|load[- ]bearing|worth noting)\\b"
description: A significance label; state the claim.
scope: prose
Each pattern reports where it matches. Add fails: true to a pattern to make a
match fail the check.
not_after keeps "is used to" and "an id, used to flag" out of the rule, which
a regular expression alone cannot say.
kapi context <file> --comments prints the voice and the limits that apply to
the comments of a file, such as a Go file, so an assistant about to write a
comment there reads them first. Without --comments, the answer is for the
file's own content.
Repair a comment finding
A comment finding names the file, the comment's id and its lines, and the JSON
report gives it a location.comment_sha256: the SHA-256 of the comment's bytes.
Rewrite the comment with kapi apply instead of editing the file around it, so
every other byte of the file stays as it is.
Check the change first:
kapi check --diff-against HEAD
outcome rule location message
FAILS voice.style internal/parse/parse.go:func/Parse L5-7 Prohibited pattern: Say use rather than utilize.
A comment is a block of its source file. kapi inspect lists each comment of
the file with its prose and the revision an edit sends as if_match, which is
r: and the first sixteen hex digits of the finding's comment_sha256:
kapi inspect internal/parse/parse.go --jsonl
{"ref":{"doc":"internal/parse/parse.go","block":"func/Parse"},"rev":"r:8d65ce744d4fe688","text":"Parse reads the input from an [io.Reader] and helps callers utilize the result.\n\nIt stops at the end.","ops":["set_content"],"role":"comment"}
Write the new prose as a set_content operation on that block:
{"ops": [{"op": "set_content", "at": {"doc": "internal/parse/parse.go", "block": "func/Parse"}, "if_match": "r:8d65ce744d4fe688",
"text": "Parse reads the input from an [io.Reader] and helps callers use the result.\n\nIt stops at the end."}]}
kapi apply edits.json
text is the comment's prose without the // markers. Keep the comment's code
blocks, references such as [io.Reader] and list items, and keep a
Deprecated: paragraph if the comment has one. kapi writes the comment at its
own indentation, wraps a paragraph that is too wide, and writes a doc comment
as gofmt does.
kapi writes the edit only when the file still parses and gofmt agrees with it.
It refuses the edit with a reason when the comment's revision differs from the
one you read, because someone changed it since, when the text drops or
adds a code block, reference or list item, or when the id names a directive, a
generated file's comment or a /* */ comment. A refused edit leaves the file
untouched, and kapi apply exits 3. A comment that only moved, because code
above it changed, keeps its revision, and kapi writes the edit at its new
lines. A change set edits code comments or documents, never both.
After writing, kapi apply checks what changed and reports the check's
findings beside the edit, in its JSON result under the document:
wrote internal/parse/parse.go
change set applied: 1 applied
A finding in that check is one the new prose still holds. Send another edit for
it, then run kapi check --diff-against HEAD again before you finish.
The same key checks the comments in YAML files, in XML-based files, such as Android string resources, .NET RESX and XLIFF, and in HTML, Markdown, MDX, PO and Java properties files, beside the values their readers extract:
collections:
- name: deploy-config
channel: acme/engineering
content:
- path: "deploy/*.yaml"
comments: true
A bare kapi check, kapi check --ship and a check scoped to a diff read those
comments as well. If the item also names a target:, kapi up converges the
YAML file as it would without the key, each comment stays where it is in every
file it writes, and the ship gate checks the comments once, in the source
language. Set on a format whose comments kapi cannot read, the key makes the
check report that it did not run.
In an XML-based or HTML file each <!-- --> comment is one block, named for the
element it sits on, such as comment/resources/string[greeting]. A
commented-out element and a tool's suppression, such as
<!--suppress UnusedResources -->, stay out of the prose. In HTML, so do
conditional comments, server-side includes and the markers a framework such as
React writes for its own use. In Markdown, a comment is an HTML comment outside
code, named for the section it sits in, such as
comment/install/from-homebrew, and the <!-- truncate --> marker a blog uses
stays out of the prose. In MDX, a comment is a {/* */} expression on lines of
its own, named the same way, and the comments of a page marked DO NOT EDIT stay
out of the prose. In a PO file, translator comments are checked, and the lines
gettext writes and reads, such as #: references and #. extracted comments,
stay out of the prose. In a properties file, a # or ! line is a comment
unless it continues a value. When kapi cannot place a file's comments exactly,
as in an XML file with -- inside a comment, which XML does not allow, the check
reports that it did not run. kapi has no comment formatter for any of these
formats, so a report lists the formatter check for those files as unsupported.
The key also checks the comments in TypeScript, TSX, JavaScript, Python, Bash, CSS, Rust, Java, C#, C, C++ and Ruby files. The sourcecode plugin reads them, so install it first:
kapi plugins install sourcecode
collections:
- name: app-comments
channel: acme/engineering
source_only: true
content:
- path: "src/**/*.ts"
comments: true
- path: "src/**/*.tsx"
comments: true
- path: "scripts/**/*.py"
comments: true
Each comment is one block named for what it documents, such as func/parse,
interface/Options/keep or rule/.header. Directives a tool reads, such as
eslint-disable-next-line, @ts-expect-error, # noqa, # shellcheck,
stylelint-disable and an SPDX licence tag, the shebang, the comments in generated files, and the tags,
links and code spans inside a TypeScript, JavaScript or Java /** */ doc comment,
and the XML tags of a C# /// comment, stay out of the prose. These languages have no formatter comparison. A file whose
comments kapi cannot place exactly is reported as not checked, never read in part. Without the
plugin, a bare kapi check and kapi check --ship warn that no reader for those
comments is installed, name the plugin, and report the files as not checked.
Next
- Ship gates & CI:
kapi check --shipon every PR. - Edit content: the inspect → edit →
applyloop that fixes what a check flags. - Content governance for AI: the rules that make agent output pass.
- Checks: the Report schema and every check family.