Skip to content

Run it in CI

A pull request that breaks the docset’s structure should be red before review. Two commands do it, over the same collection the metadata gate already uses. There is no second tool to install and no second config to keep.

This is Devin’s page.

name: Docs graph
on:
pull_request:
paths:
- "docs/**"
- "manni.config.yaml"
jobs:
graph:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Git history is detected, not declared: creator, created and
# modified come from commits. A shallow clone degrades those
# silently, so fetch the lot.
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx @hawkeyexl/manni graph build
- run: npx @hawkeyexl/manni graph check -f github
- run: npx @hawkeyexl/manni graph stats --check

Nothing in that job reaches a model or the network. build, check and stats are deterministic and local, which is why the graph gate is cheap enough to run on every push. fill is the one verb that reaches a provider, and it is not a CI step.

check validates the built graph against SHACL shapes. This is where the emergent failures live. A concept split across two spellings. A broader cycle closed by three pages. A variant a page is both about and excluded from. They exist only after the corpus merges into shared nodes, so no per-file check can see them. See Govern the graph.

stats --check gates two different things: a broken internal link, and vocabulary coverage below its floor. See Coverage.

Run both. Neither subsumes the other.

-f github writes one workflow annotation per finding:

Terminal window
$ manni graph check -c test/graph/fixtures/dd/violations.config.yaml -f github
::error file=violations/alpha.md::"Alpha" and "Beta" form a broader cycle, so each is its own ancestor. Remove one `broader` or `narrower` value on the cycle. (https://example.com/dd/concept/alpha)%0Amanni:graph-shapes:1.3.0 Concept-broader: A more general concept. A concept is never its own ancestor.%0AGuide: https://hawkeyexl.github.io/manni/graph/fix/

The message ends with the focus node in parentheses. After each %0A, a line names the shape behind the finding and says what it is for. The last line links the fix page.

A graph finding has a focus node rather than a line, so the annotation lands on the file rather than inside it. A finding can come from several pages, and the annotation lands on the first page it names. The pretty report and the JSON docs list name every one, because the fix may be in any of them.

check also accepts -f json, for a dashboard. Every other verb offers pretty and json. github and sarif are check’s alone, because check is the one that annotates a diff.

-f sarif writes one SARIF 2.1.0 log for GitHub code scanning. A run with findings is the one worth uploading, so the upload step runs if: always():

- run: npx @hawkeyexl/manni graph build
- name: Graph check
run: npx @hawkeyexl/manni graph check -f sarif > graph.sarif
- uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: graph.sarif
category: manni-graph

Each result lists every page the finding names, at line 1. Its rule is the shape’s, such as manni:graph/Concept-broader, with the shape’s description and the fix page as its help. A finding that names no page has nowhere to land, so it is left out of the results. The run’s notes count them instead: 2 findings name no page. Run manni graph check to see them. The exit code is the same as in any other format, so an error still exits 1.

The family’s contract, with nothing added:

CodeMeaningWhose problem
0Clean. A warning or notice finding is still 0.Nobody’s.
1Findings. A SHACL error, a broken link or coverage below floor under stats --check, or a validate failure.The author’s.
2Operational. No input files matched, a bad config, or an unknown flag value.Yours.

A recipe that treats any non-zero exit as “the docs are bad” is wrong, and it routes your own failures to contributors. Split them:

- name: Graph check
run: |
npx @hawkeyexl/manni graph check -f github
code=$?
if [ $code -eq 2 ]; then
echo "::error::manni graph could not run — this is a pipeline problem"
fi
exit $code

Ramping in on a corpus that is not clean yet

Section titled “Ramping in on a corpus that is not clean yet”

A first honest run on an unmeasured corpus reports a great deal. The instinct that follows is to weaken the rules until the build is green, and that destroys the standard for good. Two better moves:

Enter new shapes at sh:Warning. A finding at warning reports and exits 0. Promote the rule to sh:Violation when the corpus is clean. See Severity.

Set coverage thresholds just below today’s numbers, and ratchet. Run graph stats --check --coverage-threshold <n> to find the honest line, write the per-field map, and raise it as the gaps close.

There is no findings baseline file for graph check, deliberately. A graph finding’s identity is a focus node, and a focus node moves when a concept is renamed. A recorded baseline would then forgive the rename as though it were the same finding. Severity is the ratchet here instead.

build reads every document and shells out to git. Two things keep it quick:

  • Narrow the collection. A graph gate over a docset rarely wants the changelog or the generated API dump in it. exclude: on the collection decides membership for every tool at once.
  • Trim build.derive. A corpus with no images and no code blocks to model gains nothing from deriving them. Narrowing the list makes a smaller graph, not a wrong one.

The artifact a retrieval system consumes should be the one that passed. Add the export to the job that already gated it:

- run: npx @hawkeyexl/manni graph export jsonld
- run: npx @hawkeyexl/manni graph export search
- uses: actions/upload-artifact@v7
with:
name: graph
path: graph/

See Publish the graph.