Protect your Markdown numbers with CI
From a document nobody checks to a build that refuses a wrong number.
This guide is about one job: making a machine read your documents on every commit, and stopping a merge when the arithmetic in them stops adding up.
It is written for people who know Git and have used a CI system once or twice.
You do not need to know VisiMark well. Chapter 4 gets you a working document in
a few minutes, and tutorial.md is there when you want the
language itself.
The language is kept simple on purpose, so that it reads the same way for everyone.
Every visimark output here is a real transcript, captured with
visimark 0.1.5, except in chapter 21: simulate is newer, and its output
there was captured from the build that introduced it. Nothing is invented.
How to read this
There are 30 short chapters in seven parts. Parts 1 and 2 are the whole job: read those two and you have a working check. The rest answers the questions that turn up afterwards.
| Part | Chapters | What you get |
|---|---|---|
| 1. Why a build should read your documents | 1–3 | The case, and the contract |
| 2. The five-minute setup | 4–8 | A check that blocks a bad merge |
| 3. Choosing what to check | 9–12 | Globs, coverage, pinning |
| 4. Making a failure useful | 13–16 | Logs, annotations, summaries |
| 5. Patterns that come up | 17–21 | fmt, artifacts, reading values, simulations |
| 6. Other runners | 22–27 | Plain npx, GitLab, Git hooks, linters, the MCP server |
| 7. Rolling it out | 28–30 | Adoption, troubleshooting, a checklist |
The Action is published on the GitHub Marketplace as VisiMark check. If all you want is the copy-paste block, it is in chapter 5.
Part 1 — Why a build should read your documents
1. The edit that nobody catches
Here is a pull request. One line changed.
-| Team | 45 | 79.00 | 948.00 |
+| Team | 45 | 89.00 | 948.00 |
Somebody raised a price by ten. The diff is two characters. It is obviously correct, so it gets approved in eleven seconds.
It is wrong. Annual should now be 1068.00, and the yearly total further down
the page should move with it. Neither did.
Look at what did not happen:
- The file still renders perfectly on GitHub.
- The Markdown preview shows a clean table.
- The test suite passed, because no test knows this file exists.
- The reviewer read a plausible number and approved it.
- The linter was happy. Nothing in a Markdown linter has an opinion about arithmetic.
Every guard rail your repository has was designed for code. A document full of numbers passes through all of them untouched. The only thing standing between a wrong price and a customer is whether a human happened to multiply in their head.
That is what this guide replaces.
Why now
Quotes, invoices, budgets, estimates and capacity plans are increasingly written
in Markdown and kept in Git, because text reviews well. At the same time, a
growing share of them is drafted by AI agents, which are reliable at writing
Annual = Monthly * 12 and unreliable at working out that 89 * 12 is 1068.
So the volume of arithmetic going through pull requests is rising, and the amount of it that anybody verifies is not.
2. What check proves, and what it does not
visimark check recomputes every value a document says is derived, and compares
the result against what the file actually says. It writes nothing. It reads the
document and exits.
It proves that the document agrees with itself. Every number that follows from another number was recomputed from the inputs, and matched.
It does not prove the inputs are right. If somebody types the wrong price,
check will happily verify every figure that follows from the wrong price. That
is a human question and it stays a human question — which is exactly why review
gets easier: reviewers can stop checking arithmetic and start checking inputs.
It does not prove your document is wired up at all. A document with no formulas has nothing to disagree with. Chapter 4 is entirely about this, and it is the one thing people get wrong when they adopt a checker.
Everything in the rest of this guide is plumbing around those three sentences.
3. The three exit codes
This is the whole contract with CI. Learn it now and most of the rest follows.
| Code | Meaning | What CI should do |
|---|---|---|
0 |
Nothing to fix. | Pass. |
1 |
The document has problems. | Fail the build. |
2 |
The command could not run — a missing file, no file given, a name that does not exist. | Fail the build, but read it as "the workflow is wrong", not "the document is wrong". |
With several files, the worst code wins.
The distinction between 1 and 2 matters more than it looks. A 1 means a
person should read a document. A 2 means a person should read the workflow
file — you pointed the tool at something that is not there. A CI setup that
treats them the same will one day report a green-turned-red build as a document
problem when the real cause is a renamed directory.
$ visimark check docs/pricing.md
docs/pricing.md
0 problems (0 stale, 0 errors)
$ echo $?
0
$ visimark check docs/nope.md
visimark: cannot read docs/nope.md
$ echo $?
2
Part 2 — The five-minute setup
4. Before CI: make one document honest
Do not start with the workflow file. Start with one document, on your own machine, and prove it is really checked. A check you have not tested is a green tick that means nothing, and that is worse than no check at all.
Here is a small document. Save it as docs/pricing.md.
# Hosting plan pricing
| Plan | Seats | Monthly | Annual |
|------------|------:|--------:|--------:|
| Starter | 10 | 29.00 | 348.00 |
| Team | 45 | 79.00 | 948.00 |
| Enterprise | 120 | 199.00 | 2388.00 |
```vmark #plans
Annual = Monthly * 12
seats_total = SUM(Seats)
annual_total = SUM(Annual)
```
Across **175**<!--vmark=plans.seats_total--> seats the list price comes to
**3684.00**<!--vmark=plans.annual_total--> per year.
Three things are going on, and chapters 2 and 9 of the tutorial explain them properly:
- The fenced
vmarkblock holds the formulas for the table just above it.#plansis the sheet name. Annual = Monthly * 12is one rule for every row, not three formulas.<!--vmark=plans.annual_total-->is an invisible HTML comment that binds the bold number in front of it to a computed value. It does not show up on GitHub, in a preview, or in a PDF.
It checks clean:
$ visimark check docs/pricing.md
docs/pricing.md
0 problems (0 stale, 0 errors)
$ echo $?
0
Now break it on purpose
This is the step people skip, and it is the only one that proves anything.
Change an input — the Team price, from 79.00 to 89.00 — and change nothing
else:
$ visimark check docs/pricing.md
docs/pricing.md
STALE plans.Annual · Team 948.00 ≠ 1068.00 Monthly * 12
STALE plans.annual_total 3684.00 ≠ 3804.00 SUM(Annual)
STALE 1 prose anchors bound to the values above
3 problems (3 stale, 0 errors)
$ echo $?
1
One input moved and three things objected: the cell, the total, and the sentence in the prose. That is a wired-up document.
If check had still said 0 problems after that edit, nothing in the file is
connected, and putting it into CI would buy you a green tick and no safety.
Put the input back, or run visimark fmt docs/pricing.md to accept the new
numbers, and move on.
Do this once for every document you add to the check. It takes ten seconds.
5. The workflow file
Create .github/workflows/visimark.yml:
name: visimark
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: michal-niedzwiedzki/visimark@v0.1.5
with:
files: "docs/**/*.md"
That is the whole setup. Commit it, push it, and every pull request from now on
recomputes the arithmetic in docs/.
Four things worth knowing about that file:
You do not need actions/setup-node. The Action is a composite action, and
installing Node is its first step. Adding your own is harmless but pointless.
permissions: contents: read is enough. check is read-only. It does not
push, comment, or create anything. Give it nothing else, and a compromised
dependency in this job can do nothing to your repository.
push is restricted to one branch on purpose. With a bare
on: [push, pull_request], every commit on a pull-request branch runs the
workflow twice — once for the push and once for the PR. Restricting push to
your default branch keeps the merge-commit run and drops the duplicate.
The version is pinned twice over. @v0.1.5 pins the Action, and the Action
pins the engine it installs. Chapter 12 goes into why that matters.
The inputs
| Input | Default | What it is |
|---|---|---|
files |
**/*.md |
Space-separated glob(s) of Markdown files to check |
command |
check |
check, fmt or simulate (chapter 21) |
args |
(empty) | Extra flags for the command; each must be valid for command, for example --fix-dates for fmt |
version |
the release this Action ref ships | npm version or dist-tag of the engine to install |
There is no strictness dial, no config file and no rule set to choose. Pointing it at a glob is the entire configuration surface, and that is deliberate: a document's numbers depend on its own text and the version of VisiMark reading it, and on nothing else.
6. Watch it fail on a real pull request
Now do the thing from chapter 4, but in a branch, and let CI find it.
$ git switch -c raise-team-price
$ sed -i 's/| 79.00 |/| 89.00 |/' docs/pricing.md
$ git commit -am "pricing: raise the Team plan to 89"
$ git push -u origin raise-team-price
Open the pull request. The check job goes red, and the log holds exactly what
you saw on your own machine:
Run michal-niedzwiedzki/visimark@v0.1.5
docs/pricing.md
STALE plans.Annual · Team 948.00 ≠ 1068.00 Monthly * 12
STALE plans.annual_total 3684.00 ≠ 3804.00 SUM(Annual)
STALE 1 prose anchors bound to the values above
3 problems (3 stale, 0 errors)
Error: Process completed with exit code 1.
Read one of those lines, because the shape is fixed:
STALE plans.Annual · Team 948.00 ≠ 1068.00 Monthly * 12
───── ──────────── ──── ─────────────── ────────────
code what it is which row stored ≠ correct the rule
- code — the kind of problem. Chapter 13 lists them all.
- what it is —
sheet.name. - which row — the first cell of that row, so you can find it.
- stored ≠ correct — what the file says, then what the formula says.
- the rule — the formula that owns the number.
A reviewer who opens this log does not have to work anything out. The file says
948.00, the formula says 1068.00, and the formula is Monthly * 12.
7. Fix the failure
There are only ever two answers, and the finding tells you which one you are in.
The input was right and the derived numbers are stale. Run the formatter and push:
$ visimark fmt docs/pricing.md
docs/pricing.md: updated 1 cell, 1 anchor
$ visimark check docs/pricing.md
docs/pricing.md
0 problems (0 stale, 0 errors)
fmt writes only the values it owns — computed cells and anchored numbers. Your
prose, your headings and every input column are untouched. It does not re-render
the Markdown, so the diff is small and reviewable:
-| Team | 45 | 89.00 | 948.00 |
+| Team | 45 | 89.00 | 1068.00 |
Across **175**<!--vmark=plans.seats_total--> seats the list price comes to
-**3684.00**<!--vmark=plans.annual_total--> per year.
+**3804.00**<!--vmark=plans.annual_total--> per year.
That diff is the propagation. One input moved, and you can see everything that depends on it move with it. A reviewer can now ask the only question worth asking: did the right things move?
One cosmetic note: 1068.00 is wider than 948.00, and fmt does not re-pad
the table, because the padding is yours. Re-align by hand when it bothers you,
or leave a little room in computed columns when you first write them.
The input was wrong. Then fix the input, run fmt, and the derived numbers
follow. Never hand-edit a computed number to make the build green. That is
precisely the failure this tool exists to catch, and fmt will overwrite you at
the next run anyway.
8. Make the check block the merge
A red tick that nobody is required to look at is decoration. On GitHub, go to Settings → Branches → Branch protection rules (or Rulesets) for your default branch, and add the job as a required status check.
The name to look for is the job name, not the workflow name. In the file
from chapter 5 the job is check, so the required check is check.
Two practical points:
A required check only appears in that list once it has run at least once. Merge the workflow first, let one pull request run it, then go and require it.
Do not also require the push run. Requiring a check that only runs on the
default branch means no pull request can ever satisfy it.
From here on, "the numbers in this document contradict each other" is a merge blocker, in the same way a failing test is. That is the whole point of the exercise, and everything after this chapter is refinement.
Part 3 — Choosing what to check
9. Globs and the files input
The files input takes one or more space-separated globs:
with:
files: "docs/**/*.md"
with:
files: "docs/**/*.md quotes/*.md README.md"
** matches across directories, because the Action turns on bash's globstar
before expanding it. A literal path works too, which is how you check an exact
list of files.
The quoting rule, and why it is backwards from the shell
This trips up everybody once, so here it is stated plainly.
In the Action's files: input, quote the glob. The value is a string in
YAML. The Action expands it itself, inside a bash step that has globstar on.
In a plain run: step, do not quote the glob. There, the shell expands it
before visimark ever sees it, and a quoted glob arrives as a literal filename
that does not exist:
$ visimark check "docs/**/*.md"
visimark: cannot read docs/**/*.md
$ echo $?
2
So: quoted in with: files:, unquoted in run:. Chapter 22 covers the run:
form properly.
When nothing matches
An empty glob is an error, not a pass:
visimark-action: no files matched "docs/**/*.md"
and the step exits 2. This is deliberate. A check that silently passes because
a directory was renamed is the most expensive kind of green tick there is — it
keeps working exactly as long as nobody notices.
Directories are not arguments
visimark takes files, not directories:
$ visimark check docs
visimark: cannot read docs
Give it a glob that ends in *.md.
A file name with a space in it
The Action splits files on whitespace, so a path containing a space cannot be
expressed. This is rare in a repository, but if you have one, check it from a
plain run: step (chapter 22) where you control the quoting.
10. Documents that have no arithmetic
Point a glob at docs/**/*.md and it will pick up your README, your changelog
and your architecture notes. Most of them have no arithmetic at all, and they
pass without you doing anything.
But some of them have a table. And a table with no rules gets this:
$ visimark check docs/roadmap.md
docs/roadmap.md
COVERAGE a table with no `vmark` rules — nothing in this document is checked
run `visimark infer` to derive them, or mark it `<!--vmark:no-formulas-->`
1 problem (0 stale, 1 error)
$ echo $?
1
This is not the tool being fussy. COVERAGE exists to stop one specific lie:
computing the totals in your head, typing them as plain text, running check,
seeing 0 problems, and reporting that the checker passes. That is a green
build on a document with no build in it.
Two things keep it from being annoying:
- It needs a table to fire. Prose is never asked for arithmetic it does not have.
- It is counted for the whole document. A reference table that really is all input passes, as long as some other table in the file carries a rule.
The two honest answers
The document does have arithmetic. Run visimark infer docs/roadmap.md. It
reads the numbers already in the file and tells you which rules reproduce them —
including any rule that nearly fits, which is how it finds a wrong number in a
document that has adopted nothing. Chapter 20 of the tutorial covers this in
full.
The document genuinely has no arithmetic. Say so, in the document:
<!--vmark:no-formulas-->
On its own line, not indented, not inside a fenced block.
The marker lives in the file rather than in a CI flag, and that is a deliberate
design decision worth understanding, because it shapes how you configure the
check. The claim "this document has nothing to derive" is about a document, not
about a build. Putting it in the file means it travels with the content, shows
up in review when somebody later adds a table full of numbers, and grep finds
every one of them. Putting it in a workflow file would mean an exclusion list
that nobody revisits.
It is checked like everything else: add rules to a marked document later, and
check reports the marker as wrong.
Never add this marker to silence a failure you have not read. That is the
one move COVERAGE exists to prevent.
11. Check everything, not just what changed
The obvious optimisation is to check only the Markdown files touched by the pull request. Resist it for as long as you can, for three reasons.
It is already fast. check parses text and does decimal arithmetic. On a
repository with a few hundred Markdown files it is a fraction of a second — far
less than the time spent installing Node to run it.
A document can go stale without being edited. If a sheet takes its rows from
a CSV file (see chapter 19), changing the CSV makes the document stale while the
.md itself is untouched. A changed-files filter would skip precisely the file
that just broke.
Coverage is a whole-repository property. Somebody adds a new quote in a directory your glob does not reach, and a changed-files run never tells you, because the file they added is not the file that is failing.
If your repository really is large enough that this matters, filter with
tj-actions/changed-files or git diff --name-only, and add a second job on a
schedule that checks everything. Do not let the fast path be the only path.
12. Pin the version
The Action's version input defaults to the engine release that Action ref
ships. So @v0.1.5 pins two things at once: the Action, and the verifier it
installs. A visimark already on the runner's PATH is used only when it is
that same version, and the log's first line names the engine that ran.
That matters in a way that is easy to miss. Re-run an unchanged commit next month and you get the same answer. A build that was green stays green, and a new release of VisiMark cannot turn yesterday's merged pull request red while somebody is bisecting something unrelated.
The cost of the guarantee is that you have to bump a version to get fixes. That is the right trade for a gate that blocks merges.
If you would rather track releases as they land:
- uses: michal-niedzwiedzki/visimark@v0.1.5
with:
files: "docs/**/*.md"
version: latest
Now the Action is pinned but the engine floats. This is reasonable while you are still adopting, when a new finding is information rather than an interruption. It is a poor fit for a required check on a busy default branch.
A middle path: pin both, and let Dependabot raise the bump as a pull request you can read.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
Part 4 — Making a failure useful
13. Reading the log
A contributor whose build just went red needs to know which of three situations
they are in. Findings come in two classes: problems are counted in the
N problems line and fail the run, advice is printed and costs nothing.
The problems
| Code | What it means |
|---|---|
STALE |
A stored number disagrees with its formula. Also a chart file that no longer matches its data. |
COVERAGE |
Nothing in this document is checked. See chapter 10. |
DATE |
A date is not ISO 8601 YYYY-MM-DD. |
UNIT |
One column mixes two currencies or units. |
UNDEF |
A formula names something that does not exist. The message suggests the nearest name. |
DUP |
The same name is bound twice in one scope. |
VECTOR |
A column was used where one value is required. Wrap it in SUM(…). |
CYCLE |
Values depend on each other in a circle. The whole path is printed. |
TYPE |
Something cannot go where it was asked to go — for example a boolean into a cell. |
SHEET |
A block declares column rules but no table sits immediately above it. |
PRECISION |
A binding writes numbers but has no derivable decimal width. |
ASSERT |
An assert statement in the document is false. |
ANCHOR |
An anchor has nothing it can rewrite, or does not parse. |
IMPORT |
A declared CSV import cannot be resolved, or is unstamped. |
ARTIFACT |
A declared chart cannot be built or written. |
The advice
WARN — something is defined and never read. Usually a typo on the left-hand
side of a rule, which is worth treating as a real signal even though it does not
fail the build.
NOTE — something could not be verified because something it depends on is
broken. It disappears when the real problem above it is fixed.
The three answers, for a contributor
STALE— runvisimark fmt FILE, then read the diff and decide whether those new numbers are what you meant.COVERAGE— runvisimark infer FILEand read the proposal.- Anything else — a person has to answer it. The finding says which question it is.
fmt repairs stale values and nothing else. Do not paper over a DATE,
UNIT, CYCLE, UNDEF or DUP finding by editing the number it points at.
Changing the number a finding complains about is the one move that turns a
caught error into a hidden one.
The full list, with an example of each, is chapter 21 of the tutorial and
cli-reference.md.
14. Annotations on the diff
The log is fine. An annotation on the pull request's Files changed tab is better, because the reader never leaves the review.
Every command takes --json:
{
"command": "check",
"visimark": "0.1.5",
"status": "problems",
"files": [
{
"path": "docs/pricing.md",
"findings": [
{
"code": "STALE",
"class": "problem",
"location": { "file": "docs/pricing.md", "sheet": "plans", "name": "Annual", "row": "Team" },
"details": { "stored": "948.00", "computed": "1068.00", "formula": "Monthly * 12" }
}
],
"summary": { "problems": 3, "stale": 3, "errors": 0 }
}
],
"summary": { "files": 1, "problems": 3, "stale": 3, "errors": 0 }
}
Turn that into GitHub workflow commands with jq. This replaces the Action step
from chapter 5, because the JSON has to land in a file the next step can read:
- name: check the documents
id: visimark
continue-on-error: true
run: |
shopt -s globstar nullglob
npx --yes visimark@0.1.5 check docs/**/*.md --json > findings.json
- name: annotate the findings
if: always()
run: |
jq -r '
.files[]
| .path as $path
| .findings[]
| select(.class == "problem")
| ([.location.sheet, .location.name] | map(select(.)) | join(".")) as $name
| (if $name == "" then "this document"
elif .location.row then "\($name) · \(.location.row)"
else $name end) as $where
| (.details
| if .computed then "\(.stored) should be \(.computed)"
elif .raw then "`\(.raw)` is not an ISO 8601 date"
elif .suggestion then "unknown name — did you mean `\(.suggestion)`?"
elif .cyclePath then (.cyclePath | join(" -> "))
elif .suppressedCount then "\(.suppressedCount) more findings, in the log"
else "see the visimark check output" end) as $what
| "::error file=\($path),title=VisiMark \(.code)::\($where): \($what)"
' findings.json
- name: fail if the check failed
if: steps.visimark.outcome == 'failure'
run: exit 1
It produces lines like these, which GitHub turns into annotations:
::error file=docs/pricing.md,title=VisiMark STALE::plans.Annual · Team: 948.00 should be 1068.00
::error file=docs/pricing.md,title=VisiMark STALE::plans.annual_total: 3684.00 should be 3804.00
Three honest notes about this.
There are no line numbers today. A finding names sheet.name and the row's
first cell, not a byte offset, so an annotation lands on the file rather than on
the line. It is still a link straight to the right document with the right
numbers in the title.
continue-on-error plus an explicit exit 1 is the pattern. The check must
keep the job alive long enough for the annotation step to run, and then the job
must still fail. Dropping the last step gives you a beautifully annotated green
build.
--json prints no human report. The annotations are the report. If you want
both, run check twice — once plain for the log, once with --json for the
file — or add the job summary from the next chapter.
Do not depend on the human-readable output. Its layout is for people. Parse
--json, do not grep the report. Also do not depend on key order, and do not
treat "no findings printed" as success — check the exit code.
About --jsonn
A misspelled or misplaced option is refused, not ignored: --jsonn exits 2 with
visimark: unknown option --jsonn — did you mean `--json`?, and --fix-dates
with check exits 2 with --fix-dates is only valid with fmt. Nothing is read
or written first. A step that fails this way is wrong about how it calls
visimark; read the message, which names the option. A workflow that passes a
flag only a newer release knows must pin that release, because an older engine
now refuses it.
15. A job summary
Annotations live on the diff. A job summary is a panel on the run page, which is the better home for a per-file count.
It reads the same findings.json the previous chapter produced, so this is a
step to add after the annotation step, not another run of the tool:
- name: summarise
if: always()
run: |
{
echo "## VisiMark"
echo
jq -r '
"| File | Problems |", "|---|---:|",
(.files[] | "| `\(.path)` | \(.summary.problems) |"),
"",
"**\(.summary.problems) problems.** Files checked: \(.summary.files)."
' findings.json
} >> "$GITHUB_STEP_SUMMARY"
which renders as:
| File | Problems |
|---|---|
docs/pricing.md |
3 |
This is worth adding once you check more than a handful of documents, because the log stops being scannable and the summary does not.
16. What to tell a contributor
Put this in CONTRIBUTING.md, or in the pull request template. It is short on
purpose.
## The documents in `docs/` are checked
Numbers that follow from other numbers are computed, not typed. If the
`visimark` check fails on your pull request:
1. Install it: `npm i -g visimark` (or use `npx visimark`).
2. Run `visimark check docs/your-file.md` to see the same report CI saw.
3. If the finding is `STALE`, run `visimark fmt docs/your-file.md`, then
**read the diff**. Those new numbers are the consequence of your edit —
make sure they are the consequence you intended.
4. Anything else is a question for a person. The finding says which.
Never edit a computed number by hand to make the build pass. Change the input
or change the rule.
The fourth line is the one that matters. A contributor's first instinct on a red
build is to make the red go away, and for exactly one finding class — STALE —
there is a command that does that correctly. For every other class, "make the
number match" converts a caught error into a hidden one.
Part 5 — Patterns that come up
17. check in CI, fmt on your machine
Run check in CI. It is read-only, so it is safe to point at anything, and
a failure is a real answer: the document contradicts itself, and a person should
look at it.
Do not run fmt in CI and commit the result. It is tempting — the fix is
mechanical, so why not apply it? Because the whole value of the tool is that a
human sees the drift and decides whether the input or the formula was wrong. A
bot that silently rewrites 948.00 to 1068.00 and pushes has removed the only
moment at which anybody would have noticed that the price change was never meant
to alter the annual total.
Worse, it is indistinguishable from the tool working. Every build is green, and the documents are being corrected by a process nobody is reading.
fmt belongs in two places:
On a developer's machine, run by hand before committing.
Behind format-on-save in an editor. There is a language server and a VS Code
client; fmt runs as an ordinary formatter, so it behaves like every other
formatter you have. See chapter 28 of the tutorial.
A local Git hook is the third option, and chapter 24 has one.
18. The "you forgot to run fmt" gate
There is one reasonable way to involve fmt in CI: run it, and fail if it
changed anything. That still puts the decision on a person — it just tells them
sooner and more precisely.
- name: the documents must already be formatted
run: |
shopt -s globstar nullglob
npx --yes visimark@0.1.5 fmt docs/**/*.md
git diff --exit-code -- docs/
Continuing the story: the Team price is fixed and merged, and somebody now
raises the Enterprise plan from 199.00 to 219.00 without running fmt. The
log holds the exact repair they should have committed:
$ visimark fmt pricing.md
pricing.md: updated 1 cell, 1 anchor
$ git diff --exit-code -- pricing.md
diff --git a/pricing.md b/pricing.md
index 8c5f525..c0db72d 100644
--- a/pricing.md
+++ b/pricing.md
@@ -4,7 +4,7 @@
|------------|------:|--------:|--------:|
| Starter | 10 | 29.00 | 348.00 |
| Team | 45 | 89.00 | 1068.00 |
-| Enterprise | 120 | 199.00 | 2388.00 |
+| Enterprise | 120 | 219.00 | 2628.00 |
Across **175**<!--vmark=plans.seats_total--> seats the list price comes to
-**3804.00**<!--vmark=plans.annual_total--> per year.
+**4044.00**<!--vmark=plans.annual_total--> per year.
$ echo $?
1
The contributor copies nothing and guesses nothing: they run the same command locally and commit what it produces.
This gate and the plain check gate overlap almost entirely — a stale number
fails both. Pick one. check is the better default because it is read-only and
its report explains itself; the fmt gate is worth it when your contributors are
mostly running an editor with format-on-save and you want the rare miss caught in
the same shape they are used to.
If you run both, put check first, so the failure a contributor sees is the one
that explains itself.
19. Imports and charts: files CI must not ignore
Two features of the format produce or depend on files next to the document, and both have a CI consequence worth knowing before it bites.
A sheet whose rows come from a CSV
A sheet can take its rows from a local CSV instead of a Markdown table, and pin the exact bytes of that file:
```vmark #order from rows.csv labelled Item, Qty, Price, Net at sha256:618cac75…
total = SUM(Net)
```
The stamp is what keeps the document honest, and it is the reason chapter 11
told you not to filter by changed files. Change rows.csv and the document goes
stale even though the .md did not move:
STALE order. `rows.csv` does not match its recorded stamp — expected sha256:618cac…, got sha256:ad5069…
When the change is intended, visimark fmt re-stamps it, and that re-stamp is a
line in the diff that a reviewer can see and question. VisiMark never writes to
the CSV itself.
Charts are committed artifacts, not build output
A chart statement declares an SVG drawn from columns of its own sheet.
fmt draws it; check verifies that the file on disk is exactly what the
current data renders to.
That makes the SVG part of the checked state of the repository, so it has to
be committed. A .gitignore rule that excludes generated images will make
every CI run fail:
$ visimark check example-charts.md
example-charts.md
STALE sales.trend artifact missing at `charts/example-charts-trend.svg`
1 problem (1 stale, 0 errors)
Do not "fix" this by running fmt in CI to regenerate the file — see chapter 17.
Commit the SVG. It is data, not output: check proves its provenance, which is
the only claim that can be verified about a picture.
fmt --no-artifacts declines the artifact write for a caller that must not
touch files it did not name — a read-only checkout, a sandboxed build, a job
that runs fmt to diff the result rather than keep it. It is not a way to
stop committing charts. It declines a write, never a verdict: check still
reports every missing or stale artifact as STALE, still counts it, and still
exits 1. A repository that gitignores its SVGs still fails this way, and
still should.
20. Reading values out of a document in CI
A checked document is not only a thing CI verifies. It is a thing CI can
read. visimark eval prints computed values, so a workflow can act on them.
$ visimark eval docs/pricing.md --get plans.annual_total
3804
Nothing else on stdout — note that eval prints the value, not the width the
document writes it at, so a figure the page shows as 3804.00 comes back as
3804. This is the form to pipe:
- name: read the budget out of the document
id: budget
run: |
value=$(npx --yes visimark@0.1.5 eval docs/pricing.md --get plans.annual_total)
echo "annual=$value" >> "$GITHUB_OUTPUT"
--get takes sheet.name, or a bare name when it is unambiguous. An unknown
name exits 2 — "your request did not make sense" — which a script should treat
differently from 1. eval exits 1 when an assertion in the document is
false, and still prints the values first.
Why this is worth doing
Normally a number that both a person and a machine need lives twice: once in a
document for the person, once in a config file for the machine. The two drift,
and nobody notices until something breaks. eval removes the second copy.
Three shapes of this, each with a worked example:
- A CI shard matrix. Test-suite timings live in a table someone can read,
with an assertion that no suite may cost more than double the average. A
packaging step calls
eval --getand builds the matrix. There is no YAML file to forget to update. Seeexample-ci-sharding.md. - An agent spending gate. A run ledger has a budget scalar and one row per
tool call, with
assert spent <= budget. The harness callseval --getbefore dispatching each call. Seeexample-agent-budget.md. - A capacity decision. The maximum affordable number of worker nodes is
derived from the cost of everything else, so it stops being an independently
maintained configuration value. See
example-executable-documentation.md.
Two rules for anything that parses eval --json: quantities are decimal
strings ("3804.00", not 3804), because JSON numbers are IEEE floats and
money is not; and a column comes back as an array while a scalar comes back as a
single string.
21. Running the simulations in CI
A document can declare a sweep: lattice clauses on its params say which
values to try, and report statements say which readings to print
(tutorial chapter 31). visimark simulate asks every question
on that grid and prints the readings. It writes nothing. How to design the
levers and the reports, and how to read every report, is its own guide:
simulate.md. This chapter is only about the build.
In CI, simulate is a published reading, not a gate. check is still the
gate. simulate runs after it and puts the sweep where a reviewer will see
it.
The examples below use models/conference.md, the example from the
simulate guide, copied into a repository's models/ directory.
Its exit codes are not check's
| Code | simulate means |
check means |
|---|---|---|
0 |
Every report sheet that could start printed its readings, even where assertions failed | Nothing to fix |
1 |
No file had a report statement, or a sheet could not start under --fail-on-fault |
The document has problems |
2 |
A file could not be read, or the command line is wrong | The same |
A false assertion is a reading, not a failure. A sweep exists to find the
infeasible corners, and a build that went red every time it found one would
be useless. If you need the job to stop when a report sheet cannot even start,
pass --fail-on-fault. A sheet cannot start when check finds a problem in a
report line or in a value the report reads.
With the Action
simulate first ships in the release after 0.1.11. Pin that release or a
later one. The ref below stands for it.
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: michal-niedzwiedzki/visimark@vX.Y.Z # a release that ships simulate
with:
files: "docs/**/*.md models/*.md"
sweep:
needs: check
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: michal-niedzwiedzki/visimark@vX.Y.Z
with:
command: simulate
files: "models/*.md"
args: --progress
The first job is the gate. The second prints every sweep in the job log.
Make it a separate job, with needs: check. A sweep of a document that
does not check is a sweep of numbers nobody trusts, so it should not run at
all when the gate is red. A separate job also keeps the gate's own log short,
and its required status check (chapter 8) is unaffected by how long the sweep
takes.
Point its glob at the documents that declare reports. A file with no
report is only noted on stderr, but a glob in which no file has one exits
1, which almost always means the glob is wrong.
Give it a timeout-minutes. simulate has no cap on the grid. A document
edit that halves a lattice step doubles that lever's points, and the run grows
with it. The count line in the log says how big the run is, before the first
question. The timeout is what stops a mistake from holding a runner for six
hours.
Make sure check covers the same files. Above, the gate's glob includes
models/*.md. The gate is what stands behind the sweep, and a file the gate
never reads gets no protection from it.
Into the job summary
The readings are plain text, written to be read. A fenced block in the job summary keeps their columns aligned. Capture the output first, then write the summary, then exit with the command's code:
- name: publish the sweeps
run: |
shopt -s nullglob
status=0
npx --yes visimark@X.Y.Z simulate --fail-on-fault models/*.md > sweeps.txt || status=$?
{
echo "## Simulations"
echo
echo '```text'
cat sweeps.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit "$status"
stdout carries only the readings, so it goes into the summary. stderr carries
the question counts, any cannot start lines and the per-file totals, so it
stays in the log.
Do not run the command inside the { … } block. It is tempting to put
npx … simulate between the two echo lines. GitHub runs a run: step under
bash -e, so when the command exits non-zero the step stops right there. The
closing fence is never written, and the summary shows a code block that runs to
the end of the page. Capturing first, with || status=$?, keeps both the
summary and the exit code.
A job summary is limited to 1 MiB per step. The ledger is one line per question, about a hundred bytes, so a grid of tens of thousands of questions will not fit. Use an artifact for those.
Keep the full readings as an artifact
simulate prints every report sheet of a file. It cannot print just one, so a
long ledger and the short reports always come out together. When the ledger is
long, upload the whole text, and keep the summary to what fits:
- name: keep the readings
if: always()
uses: actions/upload-artifact@v7
with:
name: sweeps
path: sweeps.txt
if: always() uploads the file even when the step before it failed under
--fail-on-fault, which is when you most want to read it.
In the document, put the ledger in its own sheet, at the end. Sheets print in
document order, so the short readings are at the top of the file, and a
head -n 60 sweeps.txt in the summary step shows them and leaves the ledger to
the artifact.
What did this pull request change in the sweep?
The most useful reading in a review is not the sweep itself. It is how the sweep moved. A one-line change to a cost can move the break-even edge of a plan, and nobody sees that in a diff of the document.
Run the sweep twice, once on the base branch and once on the pull request, and diff the two:
sweep-diff:
if: github.event_name == 'pull_request'
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: lts/*
- name: compare the sweeps with the base branch
env:
BASE_REF: ${{ github.base_ref }}
run: |
shopt -s nullglob
git worktree add ../base "origin/$BASE_REF"
base_status=0
head_status=0
(cd ../base && npx --yes visimark@X.Y.Z simulate models/*.md) > base.txt || base_status=$?
npx --yes visimark@X.Y.Z simulate models/*.md > head.txt || head_status=$?
{
echo "## What this pull request changes in the sweeps"
echo
if (( base_status != 0 || head_status != 0 )); then
echo "Sweep exit status: base=$base_status, head=$head_status."
fi
if diff -u --label base --label head base.txt head.txt > sweep.diff; then
if (( base_status == 0 && head_status == 0 )); then
echo "No reading changed."
fi
else
echo '```diff'
cat sweep.diff
echo '```'
fi
} >> "$GITHUB_STEP_SUMMARY"
Here is what that prints for a pull request that raises the catering price in
models/conference.md from 32.00 to 36.00 a head, and runs fmt:
--- base
+++ head
@@ -6,7 +6,7 @@
64 questions
assert holds fails faulted base first failure
attendees <= venue_capacity 48 16 0 holds attendees=400 ticket=60.00 sponsors=0
- profit >= 0 46 18 0 holds attendees=100 ticket=60.00 sponsors=0
+ profit >= 0 45 19 0 holds attendees=100 ticket=60.00 sponsors=0
sponsor_share <= 50% 57 7 0 holds attendees=100 ticket=60.00 sponsors=2
report forbidden
@@ -17,7 +17,7 @@
report best scalar event.profit direction max among feasible
attendees=300 ticket=120.00 sponsors=3
- event.profit 26050.00 (+19075.00 against base)
+ event.profit 24850.00 (+18875.00 against base)
chosen from 25 feasible of 64 questions
report best scalar event.ticket direction min among feasible
@@ -29,9 +29,9 @@
report deltas on event.profit, event.sponsor_share among all
- event.profit base 6975.00
- low -11250.00 (-18225.00) attendees=100 ticket=60.00 sponsors=0
- high 34200.00 (+27225.00) attendees=400 ticket=120.00 sponsors=3
+ event.profit base 5975.00
+ low -11650.00 (-17625.00) attendees=100 ticket=60.00 sponsors=0
+ high 32600.00 (+26625.00) attendees=400 ticket=120.00 sponsors=3
event.sponsor_share base 0.333
low 0.000 (-0.333) attendees=100 ticket=60.00 sponsors=0
high 0.714 (+0.381) attendees=100 ticket=60.00 sponsors=3
@@ -41,13 +41,13 @@
attendees <= venue_capacity
inside room 0 attendees=300 ticket=60.00 sponsors=2
- outside miss 100 attendees=400 ticket=60.00 sponsors=1
+ outside miss 100 attendees=400 ticket=60.00 sponsors=2
13 questions tie inside; the first in grid order is shown
- 15 questions tie outside; the first in grid order is shown
- from 25 feasible and 15 failing only this, of 64 questions
+ 14 questions tie outside; the first in grid order is shown
+ from 25 feasible and 14 failing only this, of 64 questions
profit >= 0
- inside room 900.00 attendees=200 ticket=60.00 sponsors=2
- outside miss 100.00 attendees=200 ticket=80.00 sponsors=1
+ inside room 100.00 attendees=200 ticket=60.00 sponsors=2
+ outside miss 650.00 attendees=100 ticket=120.00 sponsors=1
from 25 feasible and 16 failing only this, of 64 questions
sponsor_share <= 50%
inside room 0.000 attendees=100 ticket=100.00 sponsors=2
@@ -109,7 +109,7 @@
47 300 120.00 2 yes
48 300 120.00 3 yes
49 400 60.00 0 no attendees <= venue_capacity; profit >= 0
- 50 400 60.00 1 no attendees <= venue_capacity
+ 50 400 60.00 1 no attendees <= venue_capacity; profit >= 0
51 400 60.00 2 no attendees <= venue_capacity
52 400 60.00 3 no attendees <= venue_capacity
53 400 80.00 0 no attendees <= venue_capacity
The reviewer reads, without working anything out: one more question loses
money, the best plan earns 1200 less, and the cheapest working ticket did not
move — its report is not in the diff at all. slack says what that ticket now
costs in safety: the 60-euro plan's room on break-even falls from 900.00 to
100.00 euros.
Four notes on that job:
- It works because grid order is fixed. The same document gives the same questions in the same order every run, so a line that moved is a reading that changed. Pin the engine version, or a release that changes the layout will show up as a diff of every line.
- The worktree keeps the paths the same. Both runs see
models/…, so the==> FILE <==headers match.git show origin/main:models/conference.mdinto a temporary file would change the header, and would also lose any CSV file the document imports (chapter 19). - A failed sweep is recorded, not fatal. The job is a reading. A new
document has no base version, and a base branch whose sweep cannot start
should still let you see the head's. Each exit status is captured, printed
when it is not
0, and "No reading changed" appears only when both sweeps ran: a sweep that failed with no output looks the same as one that did not change. BASE_REFis passed throughenv, not written into the script with${{ }}. That is GitHub's own advice for any value that comes from the event.
Gate on one plan, not on the sweep
Sometimes one particular plan must keep working: the budget the board
approved, the configuration a customer signed. That is not a sweep, it is a
scenario. Commit it as a JSON file and gate on eval --scenario, which
exits 1 when an assertion is false (tutorial chapter 30):
- name: the committed plans must still hold
run: |
shopt -s nullglob
status=0
for s in models/scenarios/*.json; do
echo "::group::$s"
npx --yes visimark@X.Y.Z eval --scenario "$s" models/conference.md > /dev/null || status=1
echo "::endgroup::"
done
exit "$status"
With one plan that holds and one that does not, the log reads:
::group::models/scenarios/cheapest.json
::endgroup::
::group::models/scenarios/three.json
ASSERT #event sponsor_share <= 50%
0.556 <= 50% is false under scenario (holds on defaults)
::endgroup::
and the step exits 1. The values go to /dev/null; the false assertion is
written to stderr, so it stays in the log.
This step belongs in the check job, not the sweep job. It is a gate. Each
scenario names its plan by its lever values, and a reviewer can read the JSON
in the pull request that adds it.
When --fail-on-fault adds anything
A sheet cannot start only when check finds an error in it, or in a value it
reads. So when the check gate covers the same files and runs first, a sheet
that cannot start has already failed the build, and --fail-on-fault changes
nothing.
It matters when the sweep runs on its own: a scheduled job, a job without
needs: check, or a glob the gate does not reach. Then it is the only thing
that turns a broken report line into a red build.
When to run it
Every pull request is a good default while the grid is small. A sweep of a few hundred questions takes seconds.
When it grows, a paths: filter on the sweep job is reasonable, though
chapter 11 argued against one for check. The sweep is a reading, so a run
skipped by mistake costs a reading, not a broken document on the default
branch. Include every file the swept documents import:
on:
pull_request:
paths:
- "models/**"
A scheduled run, nightly or weekly, catches what a filter misses.
Progress in a log
A sweep is one evaluation per question, and the count is printed before the first one:
$ visimark simulate --progress runway-sweep.md 2>&1 >/dev/null
simulate: runway-sweep.md: 29 questions (2 lattice params)
simulate: runway-sweep.md: question 3 of 29
simulate: runway-sweep.md: question 6 of 29
simulate: runway-sweep.md: question 9 of 29
simulate: runway-sweep.md: question 12 of 29
simulate: runway-sweep.md: question 15 of 29
simulate: runway-sweep.md: question 18 of 29
simulate: runway-sweep.md: question 21 of 29
simulate: runway-sweep.md: question 24 of 29
simulate: runway-sweep.md: question 27 of 29
simulate: runway-sweep.md: question 29 of 29
simulate: runway-sweep.md: 1 of 1 sheets ran in 46 ms
On a terminal, --progress rewrites one line in place. In a CI log, where
stderr is not a terminal, it prints a line at every tenth of the run, so a
long job shows that it is still working without flooding the log. There is no
time estimate while it runs; the last line of each file says how long the file
took. There is no cap on the grid
either. The count line is the warning, and the size is the document author's
decision. example-battery-storage.md asks 577
questions.
No --json
simulate has no --json mode, and simulate --json is refused with exit
2. When a script needs one value from one scenario, eval --scenario
(chapter 20) is the machine interface. Do not parse the readings to make a
build decision; chapter 14's rule about the human report applies here too.
Diffing them, as above, is fine, because a person reads the diff.
Part 6 — Other runners
22. Without the Action: plain npx
Nothing here needs GitHub. The portable core is one command:
npx --yes visimark@0.1.5 check docs/**/*.md
Two things to get right, and then it runs anywhere.
The command does not expand globs — your shell does. So quote nothing, and
turn on globstar if you want ** to cross directories:
shopt -s globstar nullglob # bash; zsh has ** already
npx --yes visimark@0.1.5 check docs/**/*.md
nullglob matters as much as globstar: without it, a glob that matches
nothing is passed through literally, and you get exit 2 and a confusing
cannot read message instead of a clean one.
If you would rather not think about shell options at all:
find docs -name '*.md' -print0 | xargs -0 npx --yes visimark@0.1.5 check
Pin the version. visimark@0.1.5, not visimark. The reasoning in chapter
12 is the same here, and without the Action there is nothing else holding the
engine still.
The same thing in a GitHub workflow, if you prefer not to use the Action:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 20
- name: check the documents
run: |
shopt -s globstar nullglob
npx --yes visimark@0.1.5 check docs/**/*.md
VisiMark needs the current Node LTS or newer, or Bun. On Windows the launcher needs sh on
the PATH — Git Bash or WSL provide it, plain PowerShell does not.
23. GitLab CI
visimark:
image: node:20
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- shopt -s globstar nullglob
- npx --yes visimark@0.1.5 check docs/**/*.md
GitLab runs script lines through sh in some configurations, and shopt is a
bash builtin. If the job fails with shopt: not found, either use a bash -lc
wrapper or take the find route from chapter 22, which needs no shell options
at all:
script:
- find docs -name '*.md' -print0 | xargs -0 npx --yes visimark@0.1.5 check
The exit-code contract is identical, so GitLab fails the job on 1 and 2
without any extra configuration.
The same shape works on Jenkins, CircleCI, Buildkite, Azure Pipelines and anything else that can run a shell command and read an exit code. There is nothing GitHub-specific about the tool — only the annotation and job-summary steps in chapter 14 and 15 use GitHub's own protocol.
24. Git hooks and pre-commit
CI is the gate. A hook is the shorter feedback loop, and the two are not alternatives: the hook saves a round trip, the gate is what actually enforces.
A plain .git/hooks/pre-commit:
#!/usr/bin/env bash
set -euo pipefail
files=$(git diff --cached --name-only --diff-filter=ACM -- '*.md')
[ -z "$files" ] && exit 0
# shellcheck disable=SC2086
npx --yes visimark@0.1.5 check $files
--diff-filter=ACM skips deletions, which would otherwise be handed to the tool
as paths that no longer exist and come back as exit 2.
With the pre-commit framework, four lines in
.pre-commit-config.yaml:
repos:
- repo: https://github.com/michal-niedzwiedzki/visimark
rev: v0.1.7
hooks:
- id: visimark
pre-commit autoupdate moves rev: forward as releases ship. Under the hood
the hook uses an already-installed visimark only when it is the version the
rev: pins, and otherwise fetches that version with Bun's bunx or npm's
npx. It works whether the machine has Node, Bun, or both, and an old global
install never stands in for the pinned engine.
Without the framework, or to pin the fetch command yourself, the same recipe by hand:
repos:
- repo: local
hooks:
- id: visimark
name: visimark check
language: system
entry: npx --yes visimark@0.1.7 check
files: \.md$
Either way, the framework passes the staged file names as arguments, which is
exactly the shape visimark check wants.
A hook checks only staged files, so it cannot see the CSV-import case from chapter 19 or the coverage-of-the-whole-repository case from chapter 11. Keep the CI job.
25. The remark/unified plugin
A project already running remark/remark-lint —
Docusaurus, Astro, or any other unified-based Markdown toolchain — adds
VisiMark findings to that same pipeline with remark-lint-visimark:
{ "plugins": ["remark-preset-lint-recommended", "remark-lint-visimark"] }
$ npx remark docs/ --frail
docs/quote.md
12:1 error 0.4266 <= 2.00 is false visimark-assert visimark
1 error
$ echo $?
1
--frail is remark-cli's own flag, not this plugin's: a STALE cell or any
other check-failing finding is always fatal and fails a plain remark run;
advisory findings (WARN, NOTE) are reported but only fail the run under
--frail, the same severity split check's own exit code already uses.
The plugin takes no options, reports a line but no column, and does no
autofix — see the package's own
README
for the full contract. Like the other entries in this part, it only ever runs
check; fmt, infer, explain, eval and --json are unaffected and
unavailable through it.
26. The markdownlint custom rule
A project already running
markdownlint or
markdownlint-cli2 gets the same findings in that run with
markdownlint-rule-visimark — two entries in the config it already has:
{
"config": {
"extends": "markdownlint-rule-visimark/recommended",
"default": true
},
"customRules": ["markdownlint-rule-visimark"]
}
extends goes inside config. It is a markdownlint config property, not
a markdownlint-cli2 one, and at the top level it is quietly ignored — the run
then reports advisory findings you asked it not to.
$ npx markdownlint-cli2 "docs/**/*.md"
docs/quote.md:7 error visimark-assert An `assert` statement evaluated false [assert spent <= budget: 5 <= 1 is false]
Summary: 1 issue in 1 file
$ echo $?
1
There is one rule per finding kind, named after it — visimark-stale,
visimark-assert, visimark-coverage, and so on for all seventeen, plus an
eighteenth, visimark-engine-error, that reports once if visimark itself
fails to analyse the document rather than finding something wrong with it.
The seventeen finding-kind rules are the same identifiers the remark plugin
in chapter 25 reports as its ruleId. That gives three switches, all
markdownlint's own rather than anything this package invented:
config entry |
Effect |
|---|---|
"visimark-date": false |
one finding kind off |
"visimark-advisory": false |
the advisory kinds (WARN, NOTE) off |
"visimark": false |
every VisiMark rule off |
recommended is the middle one of those, and nothing else:
{ "visimark-advisory": false }. It exists because markdownlint has no
non-fatal tier — every violation fails the run — while check does not fail on
advisory findings. Without the fragment, a document visimark check passes
with exit 0 fails markdownlint-cli2 with exit 1. Extending it gives you a
run whose exit code agrees with check's; not extending it gives you every
finding, which is a reasonable choice too.
One number will not match, by design. A stale cell with prose anchors bound to
it produces one summary finding on top of the cell's own, and this package
reports the cell rather than the summary, so anchors are not counted twice.
visimark check docs/example-invoice-drift.md prints
27 problems (22 stale, 5 errors) where the same document reports 19 issues
here — the difference is exactly the eight prose anchors folded into that
summary. The exit codes agree in every case; the headline number is the only
thing that differs, and the exit code is what a CI gate reads.
Like the other entries in this part, it only ever runs check: it takes no
options, does no autofix (markdownlint-cli2 --fix leaves these rules alone),
reports a line but no column, and fmt, infer, explain, eval and
--json are not reachable through it. See the package's own
README
for the full contract.
27. The MCP server
The other entries in this part put VisiMark in a pipeline. This one puts it in front of an agent — one working in a repository that has never heard of the project, with no clone and no reason to look for a CLI.
npm i -g visimark-mcp # or: bun add -g visimark-mcp
claude mcp add visimark -- npx -y visimark-mcp
visimark-mcp serves every command as a tool over stdio, the authoring
discipline as resources, and two prompts. It imports the engine as a library,
so a document the agent is drafting in context needs no temp file: the read
tools take content as readily as path.
It does not replace the CI check, and it is not a gate. A gate is a thing
that fails a build; this is a thing an agent consults. Keep
visimark check **/*.md in the workflow whatever else you run, for the reason
chapter 3 gives — an agent that can read a verdict can also decide not to.
It is read-only by default, and deliberately hard to make otherwise. The
apply tools refuse unless the server was started with --allow-write and the
host declared at least one root; no roots means no writes, even with the flag.
Neither condition is reachable from a tool call, which is the point. For a CI
context the answer is almost always to leave the gate shut and let the agent
propose a plan a human applies.
The full surface — every tool, what it reads, what it writes, which annotation
it carries — is mcp.md. Setting one up, adding it to a host, and
working through every tool by hand is mcp-server.md.
Part 7 — Rolling it out
28. Turning it on in a repository that already has documents
Adding a blocking check to a repository full of unchecked documents will produce
a wall of COVERAGE findings on the first run, and a strong urge to delete the
workflow file. Do it in four stages instead.
Stage 1 — see the size of it, without blocking anything
- uses: michal-niedzwiedzki/visimark@v0.1.5
continue-on-error: true
with:
files: "docs/**/*.md"
continue-on-error: true makes the step report without failing the job. Leave it
like that for a few days and read the runs.
Stage 2 — wire up the documents that matter
For each document with real arithmetic, run visimark infer FILE. Do not type
the rules by hand: infer reads the numbers that are already there and tells you
which rules reproduce them, exactly, at that column's own precision. It proposes
nothing it cannot verify.
Read the near-misses first. A rule that fits three rows out of four is the tool telling you the document has a wrong number in it — before you have adopted anything.
Then visimark infer FILE --write, which only inserts. It never rewrites an
existing byte.
And then do chapter 4's test on each one: change an input, confirm the check starts failing, put it back.
Stage 3 — mark the documents that have no arithmetic
Add <!--vmark:no-formulas--> to the ones that genuinely have nothing to derive,
having actually read each one. Chapter 10 explains why this belongs in the file
rather than in an exclusion list in the workflow.
Stage 4 — remove continue-on-error, then require the check
Chapter 8. From here the gate is real.
If stage 2 is going to take a while, narrow the glob instead of weakening the
check: point it at the directory you have finished — files: "quotes/**/*.md" —
and widen it as you go. A narrow blocking check is worth more than a wide
advisory one.
29. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
cannot read docs/**/*.md, exit 2 |
The glob was quoted in a run: step, so the shell never expanded it |
Unquote it in run:; quote it in the Action's files: |
cannot read docs, exit 2 |
A directory was passed | Pass a glob ending in *.md |
visimark-action: no files matched, exit 2 |
The glob matches nothing — usually a renamed or moved directory | Fix the glob. Do not make it pass silently |
usage: visimark check FILE..., exit 2 |
No file arguments at all | Check that the glob or find produced something |
** matched only one directory level |
globstar is off |
shopt -s globstar in bash; the Action does this for you |
| A glob that matches nothing is passed through literally | nullglob is off |
shopt -s nullglob |
COVERAGE on a README or changelog |
The file has a table and no rules | Chapter 10 — infer, or the marker |
| The workflow runs twice on every PR commit | on: [push, pull_request] |
Restrict push to your default branch |
| The annotation step runs but the job is green | The check step has continue-on-error and nothing re-fails the job |
Add the explicit exit 1 step from chapter 14 |
A step fails with unknown option or is only valid with |
A misspelled option, an option on the wrong command, or one this engine version does not know | Read the message: it names the option and, if misplaced, the command that owns it |
shopt: not found |
The job's shell is sh, not bash |
Use bash -lc, or the find form from chapter 22 |
ARTIFACT or a missing-artifact STALE on every run |
Chart SVGs are in .gitignore |
Commit them — chapter 19 |
| A required check can never be satisfied | The required check only runs on push to the default branch |
Require the job that runs on pull_request |
| The engine version changed without you asking | version: latest, or an unpinned npx visimark |
Pin both — chapter 12 |
simulate exits 1 with no report statement |
No file the glob matched declares a report |
Point the glob at the documents that do — chapter 21 |
simulate is green although assertions fail |
It treats a false assertion as a reading | Keep check as the gate; add --fail-on-fault only to stop on sheets that cannot start — chapter 21 |
| The job summary shows a code block that never ends | The command ran inside the { … } block and bash -e stopped the step before the closing fence |
Capture to a file first, then write the summary — chapter 21 |
| A sweep-diff shows every line changed | The engine version moved between the two runs, or the headers differ because the base was not a worktree | Pin the version; use git worktree — chapter 21 |
If a failure is genuinely confusing, reproduce it locally with the same version the workflow used:
$ npx --yes visimark@0.1.5 check docs/pricing.md
The tool reads nothing but the files you name. There is no config file, no environment variable, no network call and no clock, so a local run and a CI run on the same bytes give the same answer. If they differ, the bytes differ — check what your workflow actually checked out.
30. The checklist
The complete workflow, with everything this guide recommends:
name: visimark
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
# A second push to the same branch makes the first run irrelevant.
concurrency:
group: visimark-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: michal-niedzwiedzki/visimark@v0.1.5
with:
files: "docs/**/*.md"
And the things to have done around it:
- Every checked document has been broken on purpose once, and the check caught it. (Chapter 4.)
- The glob covers everything you want checked, and fails loudly when it matches nothing. (Chapter 9.)
- Documents with no arithmetic carry
<!--vmark:no-formulas-->in the file, not an exclusion in the workflow. (Chapter 10.) - The Action ref and the engine version are pinned. (Chapter 12.)
- The job is a required status check on the default branch. (Chapter 8.)
-
checkruns in CI.fmtdoes not. (Chapter 17.) - Chart SVGs are committed, not ignored. (Chapter 19.)
-
CONTRIBUTING.mdsays what to do about a red build. (Chapter 16.)
The five things worth remembering
- A green check proves agreement, not derivation. Change an input and watch it break — once per document, before you trust it.
- Exit
1is a document problem. Exit2is a workflow problem. They need different people. checkin CI,fmton your machine. A bot that repairs drift has removed the only moment anybody would have noticed it.fmtfixesSTALEand nothing else. Every other finding is a question for a person.- Pin the version. A gate that blocks merges should give the same answer next month.
Where to go next
| Document | What it answers |
|---|---|
tutorial.md |
The language itself, from a plain table to a full quote |
cli-reference.md |
Every command, option, exit code and finding |
playground.html |
The real engine in your browser, nothing to install |
example-invoice-drift.md |
One input changed and nothing else — 26 findings, each walked through |
example-battery-storage.md |
A model with a declared sweep: 577 questions, four covenants, every simulate report |
simulate.md |
Designing a sweep, reading every report, and acting on what it finds |
mcp.md |
The MCP server: every tool, the write gate, the plan/apply split |
mcp-server.md |
Setting up and running the MCP server, tutorial-style |