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 never modifies content; fixing flagged blocks is kapi apply's job. For the Report model and every check family, see Checks.
- Desktop
- Agent (Claude)
- CLI
Open your project and switch to the Checks view.
- Run the checkset. Kapi runs the project's bound rules (hygiene, length, patterns, and voice vocabulary when a profile is bound) over the tracked content.
- Read the findings. Each finding names its rule, severity, and the exact block; click through to see the flagged text in context.
- Fix and re-run. Edit the source, re-run, and watch the findings clear. The same gate result the CLI reports is what the panel shows.
The Checks view: a findings list grouped by rule and severity, with a block-level detail pane and the pass/fail gate 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 (content-hash guarded, 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/v1 Report: pass, a 0–100 score, a gate, one finding per stable rule id, each anchored to a block:
{
"schema": "kapi.check/v1",
"pass": false,
"verdict": "failed",
"summary": { "score": 74, "critical": 0, "major": 2, "minor": 3 },
"findings": [
{
"rule": "length.max-chars-exceeded",
"severity": "major",
"message": "Block exceeds the 120-character limit (148 chars).",
"location": { "file": "src/locales/en.json", "block": "onboarding.welcome" }
}
]
}
Field-by-field, the Report is documented in Checks; the flags in the check reference.
Exit codes are the gate: 0 pass, 3 gate failed, 4 did not run, 1 operational error, so kapi check drops straight into CI. 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. --strict fails on any critical or major finding. --lenient and --no-fail loosen the gate for report-only sweeps, and neither 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.
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 the gate 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 --strict --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 --strict
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. --strict stops it on a critical or
major finding.
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 --strict
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 failed gate 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
KAPI_NO_PROJECT=1 kapi check --diff-range "$commit^..$commit" --profile-file "$profile" \
--max-major 0 --max-minor 0 "${files[@]}" > /dev/null 2>&1
case $? in
0) ;;
3) 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.
--max-major 0 --max-minor 0 turns any finding into a failed gate. 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. It is the pre-release bar. Ordinary builds never fail on target drift; --ship is the explicit enforcement point. See Ship gates & CI.
The gate reads the project's own record where a rule can only guess. A target
identical to its source is normally a unit nobody translated, so it fails the
gate, unless the project has settled it: an approval bound to that exact pairing
(a person read it and said the wording is right), or a terms entry whose target
is its source. Both are the same rule wherever the gate is evaluated, so kapi check --ship and the checks kapi up runs inside the loop cannot reach opposite
verdicts on one unit. Nothing else is settled by it: a dropped placeholder on an
approved unit still fails.
The staleness gate is the one that reads provenance rather than content. Every producer stamps the governing context it was given onto what it writes, so a target can be compared against the context in force at the point that governs its file. Move the voice profile or the terminology and the targets written under the old one fail the gate, naming what moved; kapi up reproduces them under the context now in force. Content that carries no stamp (written before the stamp existed, or 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 the gate, so formatting
work is caught before the commit rather than after it. --lenient reports it
without failing.
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 voice guide and kapi context answer for that point. 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: { minor: 50, major: 70 }
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 minor comment.sentence-length finding and
one over 70 a major one, and a comment over the limit for what it documents is
a major comment.length finding. A change that adds 8 or more comment lines to
a file and more comment lines than code lines is a major comment.density
finding. 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.
severity: major
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.
severity: major
scope: prose
- regex: "(?i)\\b(?:crucially|importantly|load[- ]bearing|worth noting)\\b"
description: A significance label; state the claim.
severity: major
scope: prose
not_after keeps "is used to" and "an id, used to flag" out of the rule, which
a regular expression alone cannot say.
kapi voice guide --comments <file> prints the voice and the limits in force
for 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 guide answers 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
severity rule location message
CRITICAL voice.style internal/parse/parse.go:func/Parse L5-7 Prohibited pattern: Say use rather than utilize.
Write the new prose for that comment as a comment entry, with the id, lines
and comment_sha256 from the finding in kapi check --diff-against HEAD --json:
{"kind":"comment","file":"internal/parse/parse.go","id":"func/Parse","lines":{"first":5,"last":7},"comment_sha256":"8d65ce744d4fe688bd8c64ca408fa32ccde8d0ae979b33ce9936f61993b8f26b","text":"Parse reads the input from an [io.Reader] and helps callers use the result.\n\nIt stops at the end."}
kapi apply edits.jsonl
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 bytes no longer match
comment_sha256, because someone changed it after the check, 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 fingerprint, and kapi writes the edit
at its new lines. Without a fingerprint, pass the prose you read in
current_text. An entry with neither is rejected before anything is written.
After writing, kapi apply checks what changed and reports it beside the edit:
comment internal/parse/parse.go func/Parse: written (lines 5-7)
check internal/parse/parse.go: passed, 0 finding(s)
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 MCP
apply_edits tool takes the same entries and returns the same check.
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.