CLI Reference
For command-line flag documentation directly in your terminal, run blockwatch --help.
Quick Options Reference
Section titled “Quick Options Reference”- Select Files:
blockwatch src/main.rs docs "**/*.md"checks only those files, directories and globs. An argument that selects no file to check fails the run. - Read a Diff:
git diff --patch | blockwatch --diffmarks which blocks the diff changed. - Only Changed Blocks:
git diff --patch | blockwatch --diff --only-changednarrows the run to them, instead of every block in the repository. - List Blocks:
blockwatch listoutputs a JSON report of all discovered blocks. It takes--diff,--only-changed,--config,-Eand--ignore. - Explain a Block:
blockwatch explain FILE:BLOCK_NAMEchecks one named block and prints what each of its validators read, as JSON. See TheexplainCommand. - Print the Agent Skill:
blockwatch skillprints the skill for AI agents, as aSKILL.md - Config File:
blockwatch --config FILEreads the project’s settings from FILE instead ofblockwatch.toml - Custom Extensions: Map custom file extensions:
blockwatch -E ino=cpp(config key:extensions) - Disable Validators:
blockwatch -d check-ai(config key:disable) - Run Only Some Validators:
blockwatch -e keep-sortedruns onlykeep-sorted(config key:enable) - Check Only Some Blocks:
blockwatch --only-block FILE:BLOCK_NAMEchecks only that block (config key:only-blocks) - Skip Blocks:
blockwatch --skip-block FILE:BLOCK_NAMEchecks every block but that one (config key:skip-blocks) - Ignore Files:
blockwatch --ignore "**/generated/**"(config key:ignore) - Report What Ran:
blockwatch --verbosity summary(orfullfor JSON on stdout) - Suppress Violations:
blockwatch --suppress FILE[:BLOCK[:VALIDATOR[:HASH]]]reports them but stops them failing the run - Suppress From File:
blockwatch --suppress-from FILEreads suppressions from a text file (e.g., commit message) - Violation Format:
blockwatch --format sarifwrites a SARIF log instead of the JSON diagnostics
Selecting Files
Section titled “Selecting Files”By default, blockwatch scans every file in the repository, respecting .gitignore. Paths are reported relative to the
repository root, whichever directory you run from. Linked worktrees and submodules are supported too: a run inside one
checks that repository only.
A symlink is skipped. The file it points at is checked under its own path, if the run selects that file. So when
CLAUDE.md is a symlink to AGENTS.md, the blocks are reported once, under AGENTS.md. A diff’s change to a symlink
is skipped too. It changes the path the link points at, not the lines of that file. An affects reference through a
symlink, such as affects="CLAUDE.md:r", counts as changed when the diff changes AGENTS.md.
VCS directories like .git, .hg, .jj and .svn are skipped.
# Check everything in the repositoryblockwatch
# Check one file and one directoryblockwatch src/main.rs docs
# Restrict checks to specific glob patternsblockwatch "src/**/*.rs" "**/*.md"
# Exclude specific pathsblockwatch "**/*.rs" --ignore "**/generated/**"Note: Quote glob patterns to prevent shell expansion before passing arguments to blockwatch.
Each argument is a path or a glob:
- A path to a file checks that file, even when its name is also a glob, such as
app/[id].tsx. - A path to a directory checks every file under it.
.checks the whole repository. - Anything else is a glob.
Paths and globs start from the repository root, whichever directory you run from. So from src/, write src/main.rs,
not main.rs. An absolute path works too.
An argument that selects no file to check fails the run. A file is not checked if .gitignore, --ignore or the
ignore key in the config file leaves it out, or if its extension is not supported and the config file has no
[[block]] entry for it. With --only-changed, an argument only has to select a file in
the repository, not a changed one.
Exclusions that hold for the whole project belong in the ignore key of the config file.
Paths and globs intersect with whatever the run mode selected, in every mode. They only ever narrow a run:
passing "src/**/*.rs" alongside a diff checks the changed blocks under src/, and never adds an unchanged file back.
Paths and globs choose which blocks are validated, not which files a rule may resolve a reference against. A
rule such as affects still finds its target in a file they leave out, so narrowing a run
to one language does not turn every cross-language rule into a failure. An excluded file is read to answer the
reference and for nothing else: it is never validated, and never appears in a run report.
Run Modes
Section titled “Run Modes”Which files are parsed and which blocks are validated are two separate decisions, and each has its own flag.
| Invocation | Files parsed | Blocks validated |
|---|---|---|
blockwatch |
Every file in scope | Every block found; none counts as changed |
blockwatch --diff |
Every file in scope | Every block found; the diff marks which ones changed |
blockwatch --diff --only-changed |
Only the files in the diff | Only the blocks the diff touched |
# Check every block in the repositoryblockwatch
# Check every block, and enforce the rules that need a diffgit diff --patch | blockwatch --diff
# Check only the blocks the diff changedgit diff --patch --unified=0 | blockwatch --diff --only-changed
# The same, for staged and unstaged changesgit diff --patch --unified=0 HEAD | blockwatch --diff --only-changed
# Changed blocks under specific paths or globs onlygit diff --patch | blockwatch --diff --only-changed src "**/*.md"A block counts as changed when the diff overlaps its line range or its start tag. To inspect which blocks a diff
touches, use blockwatch list --diff.
The hooks and the GitHub Action run --diff on its own. It checks the whole repository and still enforces the rules that
fire only on changed content. --only-changed cuts a run down to the changed blocks, for when a full scan costs too
much — see Checking Only Changed Blocks.
Rules of the Modes
Section titled “Rules of the Modes”- stdin is never read without
--diff. A diff piped to a bareblockwatchis ignored and the whole tree is scanned. --only-changedrequires--diff. Without a diff there is nothing to narrow the run down to.--diffwith a terminal on stdin is an error. No diff is coming, and quietly scanning the tree instead would hide that.- Under
--diff, an empty diff means nothing changed. It is whatgit diffprints on a clean tree.--diffthen checks every block, and--diff --only-changedchecks none. - Under
--diff, stdin that cannot be a diff is an error. Input carrying ANSI color escapes (produce the diff with--color=never) and input with no unified-diff header are each reported by name rather than treated as “nothing changed”. - A diff that resolves to nothing is an error. Under
--only-changedthe diff is the scope, so any path in it that blockwatch would parse but cannot find fails the run. Under--diffalone such a path is passed over — unless none resolves, which means the diff was taken against a different root (diff.relative=true, a wrong-plevel) and no rule that needs a diff could fire. affectsneeds--diff. It compares blocks that a diff has touched, so it reports nothing at all without one.--verbosity summarysays how many blocks carry a rule that needs a diff — see Run Reports.
Supported Diff Input
Section titled “Supported Diff Input”Each diff header points at a file, and blockwatch resolves that path against the repository:
- Path prefixes are required. Git writes each diff path behind a one-component prefix —
a/andb/by default, ori/,w/,c/ando/underdiff.mnemonicPrefix. Exactly one is removed, so a repository directory that happens to share a prefix’s name (a top-levelb/, for example) is preserved. - Quoted paths are decoded. Git’s default
core.quotePathwrites a name containing non-ASCII characters, tabs, quotes or backslashes in an escaped form such as"b/caf\303\251.py"; the real name is recovered from it.
Diffs written without prefixes — git diff --no-prefix, diff.noprefix=true — or with a custom
diff.srcPrefix / diff.dstPrefix are rejected. They cannot be distinguished from prefixed paths whose repository
directory shares the prefix’s name, and guessing would risk validating a file the diff never mentioned while the changed
one went unchecked. diff.relative=true is likewise unsupported: it writes paths relative to the current directory, and
nothing in the diff records that it did.
Two details make the detection reliable. Git draws the two prefixes from opposite sides of the comparison — a/ against
b/, or i/, c/ and o/ against w/ — so a header repeating one prefix on both sides is recognised as unprefixed
rather than stripped. An added file is the exception: its source is /dev/null, which says nothing either way, so both
readings are checked against the working tree and a target where both name a real file is reported as ambiguous.
A diff with a file that does not exist in the repository is an error too — but only for files blockwatch would parse. Entries whose extension maps to no language, such as binary assets or lockfiles, contribute no blocks and are passed over, so a diff carrying them alongside source changes still validates normally.
In each case blockwatch stops rather than check the wrong file, reporting the diff target at fault. The messages
describe the input rather than the command that produced it, since a diff need not come from Git:
$ git diff --no-prefix | blockwatch --diffError: diff target "rules.py" has no recognized path prefix (a/, b/, i/, w/, c/, o/)With Git, that means --no-prefix, diff.noprefix, or a custom diff.srcPrefix / diff.dstPrefix. To produce
configuration-independent output in a repository that sets any of these globally:
git diff --patch --default-prefix --no-relative | blockwatch --diff--default-prefix requires Git 2.41 or newer. On older versions, use
git -c diff.mnemonicPrefix=false -c diff.noprefix=false diff --patch.
Custom File Extension Mappings
Section titled “Custom File Extension Mappings”Language detection relies on file extensions. Use -E to map unrecognized or custom extensions to a supported grammar:
blockwatch -E ino=cpp -E cu=cppFiles with extensions that do not map to any supported grammar are ignored.
The extensions table of the config file holds the mappings a project always needs.
Enabling and Disabling Validators
Section titled “Enabling and Disabling Validators”Control which validators run using -e (enable only) or -d (disable):
# Run all validators except check-aiblockwatch -d check-ai
# Run only keep-sorted and keep-uniqueblockwatch -e keep-sorted -e keep-uniqueNote: -e and -d cannot be combined in a single invocation.
The enable and disable keys of the config file make a selection the default for a project.
Selecting Blocks
Section titled “Selecting Blocks”--only-block checks only the blocks you list, and --skip-block checks every block but those. Each takes the address
of one named block: FILE:BLOCK_NAME. A violation of a named block prints an address that starts with it, so you can
copy it from there.
# Check only the cli-docs blockblockwatch --only-block docs/cli.md:cli-docs
# Check every block but these twoblockwatch --skip-block docs/cli.md:cli-docs --skip-block src/flags.rs:cli-flags- Repeat a flag to list more blocks.
--only-blockand--skip-blockcan’t be combined. - An unnamed block has no address. Give it a
nameto select it. To leave out a whole file, use a path argument or--ignore. - An address that matches no block fails the run: a missing file, a file without a parser, or no block with that name in the file. So a renamed block can’t quietly turn its checks on or off.
- The block doesn’t have to be in this run. An address of a block that a path argument,
--ignoreor--only-changedleaves out is not an error. - A block is checked only if every filter lets it through: paths and globs,
--ignore,-eand-d,--only-changed, and these flags. - A skipped block still exists.
affectsandsame-ascan still refer to it, andblockwatch liststill shows it. No validator runs on it, and--verbositydoesn’t count it.
The only-blocks and skip-blocks keys of the config file make a selection the default for a project.
A flag replaces the config’s selection. So a block that needs the network can be skipped in blockwatch.toml, and a
scheduled job can run just that block:
skip-blocks = ['src/models.rs:latest-model']blockwatch --only-block src/models.rs:latest-modelConfig File
Section titled “Config File”Settings that stay the same for a project can live in blockwatch.toml at the repository root. Then the pre-commit
hook, the CI job and local scripts no longer each repeat them:
ignore = ['**/generated/**', 'tests/testdata/**']disable = ['check-ai']
[extensions]ino = 'cpp'avsc = 'json'- The file is read from the repository root, whichever directory you run from.
- Without the file, a run uses the flags alone.
--config FILEreads another file instead. The path is relative to the working directory, not to the repository root, and the file must exist.- The settings apply to
listtoo, exceptonly-blocksandskip-blocks.listshows every block.
Write globs in single quotes. TOML then takes them as written, backslashes included.
Each key matches a flag:
| Key | Flag | When the flag is given too |
|---|---|---|
ignore |
--ignore |
Both lists apply. |
extensions |
-E |
Both apply. The flag wins for the same extension. |
enable |
--enable |
The flags’ selection replaces the config’s. |
disable |
--disable |
The flags’ selection replaces the config’s. |
only-blocks |
--only-block |
The flags’ selection replaces the config’s. |
skip-blocks |
--skip-block |
The flags’ selection replaces the config’s. |
So blockwatch -e keep-sorted runs only keep-sorted, whatever the config enables or disables. An extra --ignore
still skips the files the config skips.
A value is checked as the flag’s would be. An unknown validator, a glob that does not compile, an extension mapped to an
unsupported language, a block address that is not FILE:BLOCK_NAME, or both enable and disable (or both
only-blocks and skip-blocks) in one file all fail the run. So does an unknown key, so a typo
cannot turn a setting off without anyone noticing. The error quotes the bad key or value. For an unknown key or a value
of the wrong type, it also shows the line and column:
Error: invalid config file "blockwatch.toml"
Caused by: TOML parse error at line 1, column 1 | 1 | ignor = ['x'] | ^^^^^ unknown field `ignor`, expected one of `ignore`, `extensions`, `enable`, `disable`, `only-blocks`, `skip-blocks`, `block`Everything else stays out of the file:
- The paths and globs,
--diff,--only-changed,--suppressand--suppress-fromdescribe one run, not the project. --formatand--verbositydepend on who reads the output.- The environment variables of
check-aiandcheck-luastay in the environment. Anyone who can open a pull request can edit the config file. From there, unsafe Lua could run any code in CI, and an API URL could send the code and the API key to any server.
Blocks in the Config File
Section titled “Blocks in the Config File”A [[block]] entry in the config file declares a block in a file, instead of tags in a comment. Such a block is a
virtual block. Use one when:
- The file can’t hold a comment, such as
package.json. - The file must not change, so it can’t take a tag. For example, a module that a build copies byte for byte, an applied database migration, or a prompt sent to a language model.
- The rule is about one value, such as a version. The block then sees just the value, without the key and the quotes.
- The file should stay free of tags, or the project keeps its rules in one place.
An entry wraps one of three things:
target |
target-pattern |
The block wraps |
|---|---|---|
package.json#/version |
not allowed | the symbol |
settings.py |
'^MODEL = "(?P<value>[^"]+)"' |
the text that the pattern matches |
settings.py |
none | the whole file |
For example:
# When the React version changes, the install guide must change too.[[block]]target = 'package.json#/dependencies/react'name = 'react-version'affects = 'docs/install.md:react-version'
# A scalar is checked as its value, without the key and the quotes.[[block]]target = 'package.json#/version'line-pattern = '^\d+\.\d+\.\d+$'
[[block]]target = 'package.json#/keywords'keep-sorted = truekeep-sorted-pattern = '^"(?P<value>[^"]+)",?$'
# A build copies settings.py as it is, so it can't hold a tag. The block is just `model-b` in `MODEL = "model-b"`.[[block]]target = 'settings.py'target-pattern = '^MODEL = "(?P<value>[^"]+)"'name = 'model'same-as = 'config/models.json#/default'
# Every environment variable the worker reads must be one that src/config.rs lists.[[block]]target = 'worker/env.py'same-as = 'src/config.rs:env-vars'same-as-mode = 'subset'same-as-pattern = 'os\.environ\["(?P<value>[A-Z_]+)"\]'targetis required. It is a symbol in a file, such aspackage.json#/version, or a file, such assettings.py. Any other target, such as a named block, is an error. The file can be a glob, such asplugins/*/package.json. See Glob targets.target-patternis a regex that finds the block in a file. It needs a file astarget. See Target patterns.- Every other key is an attribute, with the same name and meaning as in a tag. An unknown attribute is an error.
- A value is a string.
truestands for an attribute without a value, sokeep-unique = trueiskeep-unique. An integer counts as its digits, socheck-lua-timeout = 30works. Any other value is an error. - A target has at most one block. Put all its attributes in one entry. A second entry with the same
targetand the sametarget-patternis an error. So is a glob that matches a file another entry already wraps the same way.
Target patterns
Section titled “Target patterns”- It must match exactly once. The pattern runs on the whole file. No match, or more than one, stops the run. The error shows the lines where the matches start.
^and$match at the start and the end of each line, as in the other patterns..does not match a line break, so a match stays on one line.- A match can span lines with
(?s).(?s)^CREATE TABLE users \(.*?^\);matches the whole statement. Write.*?, not.*. A.*runs to the last);in the file, and that still counts as one match. - A
valuegroup picks the content, as in the*-patternattributes. Without one, the content is the whole match. A match of a branch without the group stops the run, such asMODEL = nonefor^MODEL = (?:none|"(?P<value>.+)").
A virtual block is a block of the file it wraps:
- Any text file can hold a pattern or a whole-file block, even one that blockwatch has no grammar for, such as
.txtor.env. Such a file has no tags, only the blocks that the config file declares. A symbol needs a file with symbols. - A symlink stands for the file it points at. When
package.jsonis a symlink tobase.json, the targetpackage.json#/versionwrapsbase.json#/version, and its violations are reported inbase.json. - Its content depends on what it wraps:
- A symbol gives what a reference to it reads. A scalar gives its value:
1.2.3, not"version": "1.2.3". An object, a list or a table gives its text, key included. - A pattern gives its
valuegroup, or else the whole match. - A whole file gives the file’s text.
- A symbol gives what a reference to it reads. A scalar gives its value:
- Its violations are reported in that file. A violation of the whole block points at the first line of the symbol, of the match or of the file. A violation inside a scalar points at the start of the value.
- Its
nameis a block name in that file. A name that another block in the file already has is an error. A reference such asaffects="package.json:react-version"finds the block, as it finds a tag. - The run’s filters apply to that file. When the paths and globs,
--ignoreor.gitignoreleave the file out, its virtual blocks are not checked. A reference to one of them still finds it. - It counts as changed when the diff touches what it wraps, or the entry. That is the symbol with its key, the
whole match, or any line of a whole file. A change elsewhere in either file does not count. The entry plays the part
of the start tag. So when you edit an entry,
--diff --only-changedchecks its block, even if the diff does not touch the file the block wraps.
A target that does not resolve stops the run when its file is checked or a reference reads that file. That is a missing file, a file without symbols or with a syntax error, a missing or ambiguous symbol, a TOML table written in several places, or a pattern that matches no text or matches more than once. The error shows the entry’s line:
Error: invalid block at line 7 of "blockwatch.toml"
Caused by: 0: target package.json#/versoin does not resolve 1: symbol not found; did you mean: /versionblockwatch list shows a virtual block under the file it wraps.
Glob targets
Section titled “Glob targets”A glob in target declares a block in each file that matches. Use it when many files of the same shape hold one value.
A file added later gets the rule too.
# Every plugin has the version of the root package.[[block]]target = 'plugins/*/package.json#/version'same-as = 'package.json#/version'
# Every module uses the Go version of the root module.[[block]]target = 'go.mod'target-pattern = '^go (?P<value>\S+)$'name = 'go-version'
[[block]]target = 'modules/*/go.mod'target-pattern = '^go (?P<value>\S+)$'same-as = 'go.mod:go-version'- The glob works like the one in
same-as-paths.*stops at/.**matches any number of directories. Braces list options:{plugins,themes}/*/package.json. - Files that
.gitignoreleaves out, and symlinks, don’t match. - Each file gets its own virtual block, with every attribute of the entry. Everything above applies to each one on
its own: its violations are reported in its file, its
nameworks in its file (plugins/red/package.json:version), and it counts as changed when the diff touches what it wraps. So--diff --only-changedchecks a plugin that the diff adds. A diff that edits the entry changes every block it declares. - A path that exists is never a glob.
app/[id]/meta.jsonis that one file. A path that doesn’t exist is a glob only when it has*,?,[,{or\. - A glob that matches no file stops every run, so a typo can’t turn the rule off.
Suppressing a Violation
Section titled “Suppressing a Violation”--suppress takes the address of a violation and stops it failing the run. The violation is still reported, at its
declared severity, marked "suppressed": true; only the exit code changes.
blockwatch --suppress docs/cli.md:cli-docs:keep-sortedUse it when a rule is wrong at one particular site and you do not want to edit the source or turn the validator off
everywhere with -d. Repeat the flag to suppress several violations.
The Address of a Violation
Section titled “The Address of a Violation”FILE[:BLOCK_NAME[:VALIDATOR[:HASH]]]Every violation of a named block carries its full address in the diagnostics, so the usual way to write a --suppress
flag is to copy one from the output.
FILE:BLOCK_NAMEis the samefile:namegrammaraffectsandsame-asuse, and identifies exactly one block. As there, a file path containing a:cannot be addressed.VALIDATORis the rule that reported the violation, spelled as in-dand-e.HASHis an opaque hex string telling one violation of a block from its siblings, emitted by the five validators that can report several:keep-sorted,keep-unique,line-pattern,affectsandsame-as.
Every length is valid, and the shorter it is, the more it covers. Only FILE is required:
| Address | Suppresses |
|---|---|
FILE |
every violation in that file, named blocks or not |
FILE:BLOCK_NAME |
every violation of that block |
FILE:BLOCK_NAME:VALIDATOR |
every violation that validator reports on the block |
FILE:BLOCK_NAME:VALIDATOR:HASH |
exactly one violation |
A block with no name can only be suppressed file-wide. Its violations carry no address in the diagnostics,
because there is nothing narrower to point at, but a FILE address covers the whole file and so covers them too.
An invalid address that covers nothing is ignored.
Suppressing from a File
Section titled “Suppressing from a File”--suppress-from <FILE> reads a text file and applies every matching line it finds as a --suppress:
blockwatch-suppress: api.md:handler:affectsMatching is case-insensitive (e.g. BLOCKWATCH-SUPPRESS: is accepted). Any other line is ignored, so an ordinary commit
message is valid input. A commit-msg hook can pass the message file that Git gives it, as the
plain commit-msg hook does. In CI, collect the messages of a pull request:
git log --format=%B "$BASE..$HEAD" > msgsgit diff --patch "$BASE...$HEAD" | blockwatch --diff --suppress-from msgsRepeat the flag to read from multiple files.
The file may sit anywhere the run can read, inside the repository or not, because the path comes from the command line rather than from any scanned file. Over a pull request range the trailers are written by whoever opened it — see Suppressing Violations From a Job for when to rely on that.
SARIF Output
Section titled “SARIF Output”--format sarif writes the violations as a SARIF 2.1.0
log instead of the JSON diagnostics. It goes to the same place they do, stderr, so the run report keeps stdout to
itself:
blockwatch --format sarif 2> blockwatch.sarifNothing else about the run changes: the same violations are found, and the exit code is decided the same way.
{ "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", "runs": [ { "tool": { "driver": { "name": "blockwatch", "version": "0.18.0", "semanticVersion": "0.18.0", "informationUri": "https://github.com/mennanov/blockwatch", "rules": [ { "id": "keep-sorted", "name": "keep-sorted", "shortDescription": { "text": "Requires the lines of a block to stay in order." }, "helpUri": "https://github.com/mennanov/blockwatch/blob/v0.18.0/docs/validators/keep-sorted.md" } ] } }, "columnKind": "unicodeCodePoints", "results": [ { "ruleId": "keep-sorted", "ruleIndex": 0, "level": "error", "message": { "text": "Block fruits.py:fruits at line 2 has an out-of-order line 4 (asc)" }, "locations": [ { "physicalLocation": { "artifactLocation": { "uri": "fruits.py" }, "region": { "startLine": 4, "startColumn": 5, "endLine": 4, "endColumn": 13 } } } ], "partialFingerprints": { "blockwatchAddress/v1": "fruits.py:fruits:keep-sorted:bbd61689" }, "properties": { "address": "fruits.py:fruits:keep-sorted:bbd61689", "data": { "order_by": "asc" } } } ] } ]}What to expect from the log:
- A clean run still writes one, with an empty
resultsarray. A service that reads SARIF treats a missing log as a run that never happened, rather than as a run that found nothing. - Only the rules that fired are described. Each carries a
helpUrito its documentation, pinned to the version that produced the log. levelfollows severity:errorandwarningkeep their names, and bothinfoandhintbecomenote, the weakest level SARIF consumers display.- A suppressed violation is reported like any other, with
"suppressions": [{"kind": "external"}]alongside it — SARIF’s own way of saying a reviewer accepted it. The justification is left out: it lives wherever the suppression itself is recorded. partialFingerprintscarries the violation’s address, so a service can match a violation against the same one in an earlier run. It is absent for a violation on an unnamed block, which has no address. The address is repeated inproperties, where it is easier for a person to find and copy into a--suppressflag.- Paths are repository-relative, the same paths the JSON diagnostics are keyed by, percent-encoded where a character
would otherwise change how the path reads as a URI (a
#or a space, say). - Lines and columns are 1-based, with an exclusive end column. A column counts characters, which the run declares as
"columnKind": "unicodeCodePoints"so that a consumer does not count UTF-16 code units instead.
--format cannot be combined with the list and explain subcommands, which write JSON of their own. To upload a log
to GitHub code scanning, see CI Integration.
The list Command
Section titled “The list Command”The list command outputs details on all discovered blocks in JSON format without running validation. It mirrors the
run modes exactly, so list and the default command always agree on which blocks exist.
# List all blocks in the repositoryblockwatch list
# Restrict block listing to specific paths or globsblockwatch list src "**/*.md"
# List all blocks, marking the ones the diff changedgit diff --patch | blockwatch list --diff
# List only the blocks the diff changedgit diff --patch | blockwatch list --diff --only-changedLike the default command, blockwatch list reads stdin only when --diff is explicitly provided, preventing blocking
during non-interactive scripts or pipeline commands (e.g. blockwatch list "src/**/*.ts" | jq).
Each block entry carries an is_content_modified boolean field. Without a diff nothing marks a block as changed, so it
is false throughout; under --diff it identifies the blocks the diff touched.
A virtual block also has config_line: the line of the config file that declares it.
Its line and column are where the symbol, the match or the file starts.
Lines and columns are 1-based, with an exclusive end column. A column counts characters rather than bytes, so a
multi-byte character such as é or an emoji advances it by one. The range of a violation follows the same
convention: the start is inclusive and the end is exclusive ([start, end)).
Output Example
Section titled “Output Example”{ "src/lib.rs": [ { "attributes": { "affects": "README.md:supported-langs", "name": "languages-code" }, "column": 4, "is_content_modified": false, "line": 12, "name": "languages-code" }, { "attributes": { "keep-sorted": "asc" }, "column": 8, "is_content_modified": false, "line": 40, "name": "(unnamed)" } ], "package.json": [ { "attributes": { "line-pattern": "^\\d+\\.\\d+\\.\\d+$" }, "column": 3, "config_line": 7, "is_content_modified": false, "line": 3, "name": "(unnamed)" } ]}A block with no name attribute is reported as (unnamed). Within a file, blocks appear in source order and each
block’s attributes are sorted by name; the files themselves are not ordered, and two runs over an unchanged tree may
emit them differently. Sort by key downstream if you need to diff one run against another — or use
--verbosity full, which does order its files.
The explain Command
Section titled “The explain Command”blockwatch explain FILE:BLOCK_NAME checks one named block and prints what each of its validators read, as JSON. Run it
after you write a rule, to see that the rule matches what you meant. A rule can be wrong and still pass. For example, a
same-as-pattern can take the same wrong part of each line on both sides.
blockwatch explain src/colors.py:colorsblockwatch explain package.json:version # a block from blockwatch.tomlgit diff --patch | blockwatch explain --diff src/colors.py:colors # affects needs the diff- It takes one address, as
--only-blockdoes. A block without anamecan’t be explained, so give it one first. - It takes
--diff,--config,-Eand--ignore, and rejects the other flags. only-blocksandskip-blocksinblockwatch.tomlplay no part, because the address already picks the block.enableanddisablestill apply.- The explanation goes to stdout. Violations go to stderr, and the exit code is the one a normal run
gives.
check-aicalls the API, as in a normal run.
Explain Output Example
Section titled “Explain Output Example”For this block:
COLORS = [ # <block name="colors" keep-sorted="asc" keep-sorted-pattern='"(?P<value>\w+)"'> "blue", "red", # </block>]blockwatch explain src/colors.py:colors prints:
{ "block": { "attributes": { "keep-sorted": "asc", "keep-sorted-pattern": "\"(?P<value>\\w+)\"", "name": "colors" }, "column": 7, "is_content_modified": false, "line": 2, "name": "colors" }, "checks": { "keep-sorted": { "format": "lexicographic", "lines": [ { "line": 3, "text": "\"blue\",", "value": "blue" }, { "line": 4, "text": "\"red\",", "value": "red" } ], "order": "asc" } }, "content": { "start": { "line": 2, "character": 85 }, "end": { "line": 5, "character": 5 }, "text": "\n \"blue\",\n \"red\",\n " }, "disabled": [], "file": "src/colors.py", "with_diff": false}| Key | What it holds |
|---|---|
block |
The block, as list writes it. |
checks |
What each validator read, by validator. See What Each Validator Shows. |
content |
The text the validators read. start is its first character, and end is the first character after it. The column is character, as in the range of a violation. For a virtual block around a quoted or escaped value, text is the decoded value. |
disabled |
The validators that the block asks for, but the settings turn off. They have no entry in checks. |
file |
The block’s file. |
with_diff |
Whether --diff was given. Without it, no block counts as changed, so affects can’t fire. |
What Each Validator Shows
Section titled “What Each Validator Shows”A trace stops where its validator stops, such as at the first line out of order. A line is a line of the block’s
file, and text is that line trimmed, as the validator compares it. A key that holds what a violation reports has the
name the violation’s data gives it, such as reason.
| Validator | What it shows |
|---|---|
same-as |
records: each match of same-as-pattern in the block, or each line without a pattern, with its line, the text the regex matched (match), and each named group after same-as-format (groups). mode: the same-as-mode it compared with. targets: the records of each target, and the reason of its violation, or null when the two agree. paths: with same-as-paths, the glob, the paths it matched, their records and the reason. |
keep-sorted |
lines: each line read, with the value it compares, or null when keep-sorted-pattern skips the line. format and order. |
keep-unique |
lines, as for keep-sorted. A repeated value has duplicate_of: the line where it first appeared. |
line-pattern |
lines: each line read, with the text line-pattern matched (match). A line that fails has fails: the check it fails. forbidden_match is the text line-pattern-forbidden found. |
line-count |
lines, as for keep-sorted. A line counts when its value isn’t null. The number of lines counted (actual), and the rule (op and expected). |
affects |
Whether the diff changed the block (block_changed). For each target: whether the diff changed it, or the reason it doesn’t resolve. |
check-lua |
The content and the affects the script got, and the message it returned (lua_error), or null when it passed. affects is ctx.affects without the content of each target. |
check-ai |
The model, the prompt sent and the response. Both are null when check-ai-pattern matched nothing, so no request was sent. |
Run Reports
Section titled “Run Reports”By default blockwatch prints nothing when a run succeeds. That makes a check that passed look exactly like a check
that never ran. Use --verbosity to see what was actually checked.
| Level | Output |
|---|---|
none (default) |
Nothing. |
summary |
One line of counts. |
full |
A JSON report of every block and the validators that checked it. |
The report goes to stdout. Violations go to stderr. A run can print both, and each one can be piped and parsed on its own.
blockwatch --verbosity summaryblockwatch: mode=all, 34/240 files, 61 blocks (3 unchecked, 2 need --diff), 73 checks, 0 violationsReading that line:
mode=all— which run mode this was:all,all+diff, oronly-changed. Each name is a single token, so the line stays tokenizable on whitespace.34/240 files— 240 files were read, and 34 of them contain blocks.61 blocks (3 unchecked, 2 need --diff)— 61 blocks were in scope, no validator checked 3 of them, and 2 carry a rule that cannot fire at all without a diff. Fixing that means supplying one, so the figure is a prompt to change how you invokedblockwatch.73 checks— validators ran 73 times in total, once per block they applied to.0 violations— nothing failed.
need --diff appears only under mode=all. Every other field is present in every mode. Once a diff is supplied
those rules can fire, so the question the figure answers no longer arises — and the obvious substitute, counting the
blocks the diff did not happen to reach, would just measure the size of your change. On a repository with fifty
affects blocks, a one-line commit would report forty-nine, every time, with nothing wrong. So the clause is left out
rather than reported as zero or as noise.
A parser should therefore read mode= first and expect the clause only for all; the remaining fields keep a fixed
shape in every mode.
A block goes unchecked for one of three reasons:
- It is only a reference target. A block that carries nothing but a
nameexists so that other blocks can point at it withaffectsorsame-as. It declares no rule of its own, so nothing checks it. This is normal and needs no fixing. - The validator does not apply to this run.
affectsonly compares blocks that a diff has touched, so it checks nothing without--diff. These are the blocks theneed --difffigure counts. Under a diff the same block goes unchecked whenever the diff did not reach it, which is normal for an incremental run and is not counted. - The attributes do not add up to a rule. A modifier such as
keep-sorted-patternonly refines the validator it belongs to; on a block with nokeep-sorted, it has nothing to modify and no validator claims the block. Afullreport lists every attribute as it was written, which is usually enough to see what is missing.
Reports Under a Diff
Section titled “Reports Under a Diff”Under --diff --only-changed the diff scopes the report exactly as it scopes the run: only the blocks the diff touched
are described. A block the diff never reached is absent from the report rather than listed with an empty checks
array, so blocks_unchecked counts only blocks that were in scope and that nothing checked. This is the same rule
blockwatch list --diff --only-changed follows, so the two commands always agree on which blocks exist. Under --diff
alone the report covers the whole tree, exactly as a run without a diff does.
Reference targets are reported by the run’s scope, even though they are not resolved by it. When a block declares
affects or same-as, its target is read from disk and compared wherever it lives — but under --only-changed the
target appears in the report only if the diff touched it as well. A diff that changes the source alone therefore reports
a single file, even though two were involved:
git diff --patch | blockwatch --diff --only-changed --verbosity summaryblockwatch: mode=only-changed, 1/1 files, 1 block (0 unchecked), 1 check, 1 violationNothing about the failure is hidden by this. Violations are printed to stderr as JSON, keyed by the file the violating block lives in, and the message names both sides:
{ "fileA.py": [ { "address": "fileA.py:a:affects:1d7a4c02", "code": "affects", "data": { "affected_block_file_path": "fileB.py", "affected_block_name": "b" }, "message": "Block fileA.py:a at line 1 is modified, but fileB.py:b is not", "range": { "end": { "character": 40, "line": 1 }, "start": { "character": 3, "line": 1 } }, "severity": 1 } ]}severity follows the LSP numbering: 1 error, 2 warning, 3
info, 4 hint. For a code-scanning service, ask for the same violations as SARIF instead — see
SARIF Output.
address is what a --suppress flag points at — see Suppressing a Violation. It is absent
when the block has no name, which leaves a file-wide address as the only way to suppress the violation. A suppressed
violation carries "suppressed": true alongside it; the key is absent otherwise. A consumer that decides on
severity alone has to skip the suppressed violations, which keep the severity their author declared.
The division of labour is deliberate — the report describes what the run examined, and the violation explains what went
wrong. Once the diff touches the target as well, it appears like any other block, with an empty checks array because a
block that carries nothing but a name declares no rule of its own:
git diff --patch | blockwatch --diff --only-changed --verbosity summaryblockwatch: mode=only-changed, 2/2 files, 2 blocks (1 unchecked), 1 check, 0 violationsFull Reports
Section titled “Full Reports”--verbosity full describes every block the same way blockwatch list does, and adds a checks array naming the
validators that ran on it. A block checked by several validators lists all of them. The summary object carries the
same counts as the one-line report and follows the same rule: blocks_needing_diff is present only under mode=all,
and is absent — not zero — in the other two modes.
{ "summary": { "mode": "all", "files_scanned": 240, "files_with_blocks": 34, "files_skipped": 179, "blocks": 61, "blocks_unchecked": 3, "blocks_needing_diff": 2, "checks": 73, "violations": 0, "validators": { "affects": 41, "check-lua": 12, "keep-sorted": 20 } }, "files": { "src/validators/check_ai.rs": [ { "attributes": { "affects": "docs/validators/check-ai.md:check-ai-env-vars", "name": "check-ai-env-vars", "same-as": "docs/validators/check-ai.md:check-ai-env-vars", "same-as-pattern": "BLOCKWATCH_AI_[A-Z_]+" }, "checks": [ "affects", "same-as" ], "column": 4, "is_content_modified": true, "line": 31, "name": "check-ai-env-vars" } ] }}Files are sorted by path, and each block’s checks by validator name, so two runs over an unchanged tree print the same bytes.
The report says which validators looked at a block, not what each one concluded. Violations are not repeated here; they stay on stderr, under the same file paths and line numbers.
--verbosity cannot be combined with the list subcommand, because list already prints its own JSON to stdout.
The skill Command
Section titled “The skill Command”blockwatch skill prints the skill for AI coding agents, as a complete SKILL.md. The skill tells an agent where blocks
add value, how to write them, and how to check its work. The text is built into the binary, so it always matches the
installed version. The command works in any directory, even outside a repository.
Don’t save the output itself, because a saved copy goes stale when blockwatch is upgraded. Save the stub from the
Installing this skill section instead. The stub runs blockwatch skill each
time an agent loads it. See Annotating Codebases with AI Agents.
Exit Codes
Section titled “Exit Codes”| Code | Description |
|---|---|
0 |
Success. No violations found, or every reported violation is either non-error severity or suppressed. |
1 |
Failure. At least one unsuppressed error-severity violation was detected. |
2 |
Error. The run could not finish, so the code was not checked. For example: a bad flag, config file or tag, an unreadable file, or check-ai without an API key. |
A script can tell 1 from 2: the code breaks a rule, or blockwatch could not check it. The error for 2 is written
to stderr.