Skip to content

Output formats & exit codes

manni meta validate renders results in one of five formats, chosen with -f/--format. Whatever the format, the process exit code follows a fixed contract so CI can branch on it.

The default. Human-readable, one line per file, with per-error detail and a summary. A passing file is marked ; a failing file is marked .

✓ docs/intro.md
✗ docs/api.md
type must have required property 'type' (line 2) [google:okf:0.1]
2 files checked, 1 passed, 1 failed, 1 error

Each error line has the form:

<field> <message> (line N) [schema-id]
  • <field> is the failing field’s path, or (root) for a whole-document error such as a missing required field.
  • (line N) is omitted when no source line is known.
  • [schema-id] is the schema that produced the error.

When the value at fault came from an external-metadata manifest rather than the document, the location names the manifest instead, as (docs-meta.yaml:6). The file line above it is still the document, because that is the finding’s identity; the parenthesis is where the value was written.

✗ docs/billing.md
/jira must match pattern "^PLAT-[0-9]+$" (docs-meta.yaml:6) [./private.schema.json]

The summary line is N files checked, M passed, K failed, E errors, where E is the total number of error lines across all files. Pass -q/--quiet to hide passing files and show only failures and the summary.

When .gitignore removed candidate documents from the walk, the line says so too. A gate that got quieter is visible rather than inferred from a count that shrank:

1 file checked, 1 passed, 0 failed, 0 errors, 1 skipped by .gitignore

The clause is omitted when nothing was skipped, which is every run in a repo that ignores no documents.

summary.baseline appears only when a baseline governed the run.

Field Type Description
path string The baseline file, spelled as the run referred to it.
written boolean true on --write-baseline, false when the baseline was only read.
recorded number Fingerprints the baseline holds. On a read this counts only the files the run checked, so validating one file does not report the rest of the repo as prunable.
suppressed number Findings the baseline forgave this run.
stale number Recorded fingerprints for checked files that no longer occur. Never fatal.
added number Fingerprints this write added. Write only.
removed number Fingerprints this write dropped. Write only.

Every manni tool speaks one severity scale, notice | warning | error, least severe first. It is defined once for the whole family rather than per tool. A warning therefore means the same thing in manni cite check output as it does here. The scale was chosen to match the sinks these tools write to. GitHub annotations, ESLint, Vale and pa11y all use exactly these three words.

Tool Levels it emits How a level is decided
manni meta validate error and warning Every finding is an error except location:external and location:page, the warnings for a value stored on the side its schema’s x-manni-location does not prefer. The reporters carry notice for sibling tools.
manni cite check all three Per rule. Each rule has a default, and cite.severity.<rule> moves any of them, or sets it to off.
manni a11y check all three Mapped from axe’s impact when a page is read. Its --severity flag then drops everything below a floor.
manni key rotate none It has no findings model. A value either re-encrypts or is skipped, and a skip exits 1.

So a repository that runs only manni meta validate sees errors, and warnings once its schemas carry x-manni-location marks. Adding a second tool is what puts notices on screen.

Format notice warning error
pretty, this tool the word notice, dim, before the message the word warning, yellow; a file with warnings and no errors is marked no word; the file line is
pretty, cite , dim , yellow , red
pretty, a11y the word notice, dim the word warning, yellow the word error, red
json "severity": "notice" "severity": "warning" the field is absent in meta, and "error" in cite and a11y
github ::notice ::warning ::error
sarif "level": "note" "level": "warning" "level": "error"
junit not a <failure>; the testcase passes not a <failure>; the testcase passes one <failure> element

sarif has no level named notice, so note is the nearest thing it defines. junit has no level below failure, and a <failure> that did not fail the run would make the tab disagree with the exit code. sarif and junit are emitted by this tool and by manni cite; manni a11y check reports in pretty, json and github.

A pretty summary line counts the lower levels only when there are some. This tool prints 2 files checked, 2 passed, 0 failed, 0 errors, 1 warning, 2 notices, and manni cite check prints the counts in parentheses, as 2 files checked, 2 passed, 0 failed, 2 findings (1 warning) (1 notice).

Tool What makes it exit 1
manni meta validate A file with at least one error-severity finding.
manni cite check A file with at least one unbaselined error-severity finding. Warnings and notices never move it.
manni a11y check A page that failed to load, or one with any remaining violation. Severity is not consulted, so raising --severity is what stops a lower level failing the run.

There is no --fail-on and no --min-severity anywhere in the family. A tool either fixes its levels, as this one does, or exposes the one lever its shape allows.

A tool whose source speaks another scale folds onto these three and keeps the source’s own value in a field of its own. Nothing is lost for lookup, and one word still means one thing across the family.

manni a11y check is the worked example. axe reports four impacts, and each violation carries both:

axe impact Family severity
critical error
serious error
moderate warning
minor, or none notice

A violation keeps impact as its own JSON field, and the pretty line prints both, as error image-alt (axe: critical). The severity is what the floor and the exit code use. The impact is what you look up in axe’s own documentation.

manni cite has no foreign scale to fold. It assigns a severity per rule instead, and adds one value the family scale does not have. A rule set to off produces no finding in any format and never enters a baseline. The cite configuration reference has all fourteen rules and their defaults.

Code Meaning
0 All files passed (or nothing failed).
1 One or more files failed validation.
2 Operational or usage error. That covers an unknown flag, an unknown subcommand, or a missing required argument. It also covers no inputs, no files matched, an unknown --format, a missing config file, a missing --baseline file, and an unresolvable schema.

The first three are the parser’s own rejections; the rest are a DocmetaError. Both are exit 2, because both mean the invocation was wrong rather than the documents. manni meta validate --nope docs/ is a mistyped command, not a non-conformant file, and a pipeline that branches on 1 must not see it there.

Exit code 1 reflects validation failures in the documents. Exit code 2 reflects a problem running manni meta itself, and means no validation verdict was produced.

A baseline changes which findings count toward exit 1; it does not relax any schema.

  • A finding the baseline already records does not fail the run. It is subtracted from summary.errors and counted in results[].baselined, so exit 0 here means “nothing new”, not “nothing wrong”. The N baselined findings line reports the outstanding debt on every run.
  • A finding the baseline does not record fails the run as usual (exit 1). That includes every finding in a file that has no entry at all, such as a renamed document.
  • A recorded fingerprint that no longer occurs is reported and never fails.
  • --write-baseline records what it saw and exits 0, so it always succeeds regardless of how many violations there were.
  • A --baseline file that does not exist is exit 2, not a silent pass.

The rename invalidates two baselined findings

Section titled “The rename invalidates two baselined findings”

A fingerprint is built from the schema ref, the instance path, the keyword and the subject. The schema ref of these two findings is the identity that was renamed. sidecar:owned became external:owned, and sidecar:duplicate became external:duplicate, so every recorded fingerprint for them changes. Every other finding’s fingerprint is untouched, so the --write-baseline that fixes this reports a small, explicable diff. The same count comes back, moved from the old fingerprints to the new ones.

Keeping sidecar: in the SARIF ruleId would have avoided the regeneration. The price would have been making the rule id the only place the old word survived. That is exactly the string a reader of a SARIF alert is least equipped to decode.

An input set that resolves to zero files is exit 2, not exit 0. Two shapes, both covered by --allow-empty (or config allowEmpty: true):

Situation Message
A named path that does not exist, even if other inputs matched. File not found: "docs/typo.md".
Nothing matched, whether a glob with no hits, a missing directory, or an --ext/--exclude filter that removed everything. No files matched. Patterns tried: …

Exit 2 rather than 1 is deliberate, because nothing was validated, so no verdict exists. 1 sends you looking for a bad document; 2 sends you to look at the invocation, which is where the problem is. Reading stdin with - is one input, so a piped document still produces a verdict, even an empty one.

Exit 2 when a fetched schema is not a schema

Section titled “Exit 2 when a fetched schema is not a schema”

A schema reference that is a URL is checked before it is compiled, because a 200 OK response is not proof that the body is a schema. These are exit 2. manni meta could not establish the contract, so it produces no verdict rather than a green one:

Situation Message
The body parsed, but constrains nothing. Most often a JSON error envelope served with 200 OK. Schema "<url>" does not look like a JSON Schema: it carries no JSON Schema keyword, so it constrains nothing and every document would pass it. The server returned: {"error":"not found","requestId":"abc123"}
The body exceeds the 5 MB response limit. Schema "<url>" is too large: the response exceeds the 5242880-byte limit.
The request, or the response body, did not finish inside the timeout. Failed to fetch schema "<url>": timed out after 10000ms.
A non-2xx status. Failed to fetch schema "<url>": HTTP 404.
The body is not JSON at all. Schema "<url>" did not return valid JSON: …

The first row is the one worth internalizing: without that check the run exits 0, because a schema with no constraints passes every document. Exiting 2 means a broken gate announces itself instead of reporting success. The error quotes what the server actually returned. It is immediately clear whether you are looking at your schema host or at a proxy in front of it. See what manni meta requires of a fetched schema.

An external-metadata manifest is a config-supplied input, so one that is wrong is the run’s problem rather than a document’s. These are exit 2, each naming the manifest and, where there is one, the entry’s line:

Situation Message
The manifest is missing, or not valid YAML. Manifest nope.yaml could not be read: ENOENT: …
The top level, or an entry, is not a mapping. Manifest bad-entry-scalar.yaml:1: "docs/auth.md" must be a mapping of owned keys to values.
An entry sets a key the manifest does not own, or $schema. Manifest bad-unowned-key.yaml:1: "docs/auth.md" sets "team", which this manifest does not own. Add it to the manifest's "keys", or remove it from the entry.
An entry names a document the run did not load, on a config-corpus run. Manifest docs-meta.orphan.yaml:3 names "docs/gone.md", which this run did not load. Fix the entry, or remove it.
A field-joined entry names a value no loaded document carries, on a config-corpus run. Manifest docs-meta.orphan.yaml:3 names id "gone-guide", which no loaded document carries. Fix the entry, or remove it.
A remote manifest could not be fetched. The status is named, the token never. Manifest https://…/docs-meta.yaml could not be fetched: HTTP 404 (a private file answers 404 without a token; set "tokenEnv").
The variable tokenEnv names is not set. Manifest https://…/docs-meta.yaml: the environment variable PRIVATE_DOCS_TOKEN named by "tokenEnv" is not set.
The manifest is remote and the run is --offline. Manifest https://…/docs-meta.yaml is remote and the run is offline. Vendor it to a path, or drop --offline.
A query ALTER TABLE … RENAME COLUMN would rename an owned key. "docs/auth.md": "jira" is owned by manifest docs-meta.yaml; edit the manifest instead.
A query write would touch a key a URL manifest owns. "owner" is owned by manifest https://…/owners.yaml, which is fetched and cannot be written; set it in that repository.
A query write would set an owned key on a page with no value for the field its manifest joins on. "docs/new.md": "jira" is owned by manifest docs-meta.yaml, which joins on "id", and this document has no id; set id first.
A query write would change the join field of a document that has an entry. "docs/auth.md": "id" is the field manifest docs-meta.yaml joins on, and this document has an entry; change the manifest first.
A query UPDATE or INSERT would give a document a join value that names an entry it did not already match. "docs/noid.md": "id" "auth-guide" names the entry of another document in manifest docs-meta.yaml; choose another value.
A query write would touch a key owned by a manifest of a collection --collection leaves out. "docs/auth.md": "owner" is owned by manifest docs-meta.yaml of collection b, which --collection leaves out; include it or edit the manifest.
A query _path move would leave an entry behind in a manifest of a collection --collection leaves out. "docs/x.md": manifest b-meta.yaml of collection b names it, which --collection leaves out; include it or rename the entry first.
A query DELETE would keep, or orphan, an entry in a manifest of a collection --collection leaves out. "docs/x.md": manifest b-meta.yaml of collection b names it, which --collection leaves out; include it or remove the entry first.
One document is a member of two collections whose manifests both own one key. docs/api/auth.md: "owner" is owned by manifests in two of its collections, guides (guides-meta.yaml) and api (api-meta.yaml); a key has one manifest per file. Narrow one collection's paths or exclude.

A schema violation on a value the manifest supplied, and a document carrying a key the manifest owns, are exit 1. So are two documents sharing one value of a join field. All three are verdicts about the documents. The last row above is not. Two collections owning one key for one file is a config overlap, and there is nothing about the document to fix.

--collection selects a configured collection, so all three of its refusals are usage errors:

Situation Message
No collection has that name. A comma-separated value lands here, because the flag repeats instead. no collection named "gides" in manni.config.yaml. Configured: guides, blog.
Positional paths were given beside it. --collection selects a configured collection; it cannot be combined with paths.
No config governs the run, whether none was found or --no-config was passed. --collection needs a config file to select from.

Narrowing a run also skips the corpus checks rather than running them over a subset. That is a notice on stderr, not an error, and the exit code still comes from the findings:

manni: corpus checks skipped: run is scoped to collections guides, blog

With derive: in config, validate, derive, get, and a query naming the derived or resolved table read evidence from git, a CODEOWNERS file, the gh or glab CLI, and any command the config names. A source that cannot answer is exit 2 naming the fix, never a skipped field. A field that was quietly not compared would make a stale stamp read as current. A green gate over nothing is the failure this whole contract exists to end. The deliberate opt-outs are --no-derive on validate, --no-derived on get, and --sources narrowing on derive.

Situation Message
The checkout is shallow, as actions/checkout leaves it by default. git source unavailable: this checkout is shallow; use actions/checkout with fetch-depth: 0, or --sources codeowners,github,gitlab
There is no git on PATH, or the path is outside any repository, and git is a requested source. git source unavailable: …, naming which, and --sources as the way out.
gh or glab is not on PATH, and the github or gitlab source the origin remote matches is requested. github source unavailable: gh is not on PATH (origin is github.com); install gh and run gh auth login, or drop reviewed-by from --fields
gh or glab is present and not logged in. github source unavailable: … or gitlab source unavailable: …, naming gh auth login or glab auth login.
The sources name only the one of github and gitlab the origin remote does not match, and reviewed-by or last-reviewed is managed. the origin remote is github.com, which is GitHub; add github to sources, or drop reviewed-by and last-reviewed from the managed fields
There is no origin remote, and reviewed-by or last-reviewed is managed. no origin remote to tell GitHub from GitLab
The origin host names neither platform, and sources does not list exactly one of github, gitlab. the origin remote is git.example.com, which names neither GitHub nor GitLab; list exactly one of github, gitlab in derive.sources to say which it is
Config derive.codeowners names a file that is not there, and codeowners is a requested source. codeowners source unavailable: …, naming the path. A repository with no CODEOWNERS file at all is not this case: owner derives null and stderr says codeowners: no CODEOWNERS file found (…) once.
A configured command exited non-zero, and command is a requested source. command source unavailable: `jq -r .version package.json` failed (exit 2): jq: error: Could not open package.json; narrow --sources or --fields, with the last stderr line after the exit code.
A configured command’s program is not on PATH. command source unavailable: `jq` is not on PATH (derive.commands.verified-against); narrow --sources or --fields
A configured command ran past its timeout. command source unavailable: `node scripts/source-for.mjs docs/install.md` timed out after 60s; narrow --sources or --fields
A configured command wrote more than 8 MiB on stdout or stderr. command source unavailable: `cat big.log` wrote more than 8 MiB; a command reports one field's value (derive.commands.notes). The command is killed rather than read to the end.
Config derive.commands names a field a built-in source already derives. derive.commands.last-updated targets a field git already derives; a command may only derive a field no built-in source claims. $schema is refused the same way, and a key a URL manifest owns when the config loads.
Config derive.commands names _path or _sources. derive.commands._path collides with a column of the derived table a query builds (_path, _sources). Pick another field name.
derive was given -. cannot derive <stdin>: no history behind it
derive was given a field that is not derivable. "stakeholders" is not derivable; derivable fields are created, last-updated, authors, owner, reviewed-by, last-reviewed, provenance, or any key with an entry in derive.commands
Config derive.fields names a key a collection’s URL external-metadata manifest owns. The config is refused when it loads. A local manifest is where derive writes the field. manni.config.yaml: meta.derive.fields[3] "owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository.
Config derive.commands names a key a collection’s URL external-metadata manifest owns. The config is refused when it loads. manni.config.yaml: meta.derive.commands.verified-against is owned by manifest https://example.com/versions.yaml, which is fetched and cannot be written; set it in that repository.
derive --fields named a key a collection’s URL external-metadata manifest owns. "owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository.
derive has no derive.fields in config and no --fields. nothing to derive: set derive.fields in manni.config.yaml or pass --fields
Config derive.machines is empty or not a list of strings. manni.config.yaml: meta.derive.machines must be a non-empty list of globs, matched against a trailer's name and its email. Default: ["*[bot]"].
Config derive.machines holds a blank entry. manni.config.yaml: meta.derive.machines[0] is blank; a glob must name something.
Config derive.machines repeats a glob. manni.config.yaml: meta.derive.machines lists "*[bot]" twice.
derive was given a <path>:L1-L2 range without --generated-by or MANNI_GENERATED_BY. docs/limits.md:12-31 names lines, which only --generated-by uses. Pass --generated-by, or drop the range.
derive was given --generated-by, or a range, on a run whose fields leave out provenance. MANNI_GENERATED_BY alone is never refused. --generated-by attributes provenance, which is not in --fields. Add provenance, or drop --generated-by.
A range ends before it starts. docs/limits.md:31-12 ends before it starts.
A range names a directory or a glob, or no file the run reads. docs:12 does not name one file that derive reads; a range names lines of one file.
A range runs past the end of the file. docs/limits.md has no lines 12-99: the file ends at line 40.
A range reaches into the frontmatter. docs/limits.md:2-5 reaches into the frontmatter; provenance pins body lines, which start at line 8.
Git evidence names a different machine for a line in the range. docs/limits.md:12-31: blame attributes these lines to claude-sonnet-5 (9b0e2c1); --generated-by cannot overrule a recorded machine.
The manifest that owns provenance is a URL, which derive cannot write. collection site: provenance cannot come from a URL manifest, because manni meta derive writes it.
A page belongs to two collections whose manifests both own provenance. docs/limits.md is in collections site and limits, and both keep provenance in a manifest.
The manifest that owns provenance joins on a field the page does not carry. docs/limits.md carries no id, which private/by-id.yaml joins on, so its provenance has no entry there.
derive was given a findings format without --check. sarif is a findings format, which only --check produces
A query write would touch a managed key. "last-updated" is managed by derive; run manni meta derive instead.
A query write names the resolved table. cannot modify resolved because it is a view; the resolved table is read-only — it is `docs` and the evidence joined, so write to docs, or stamp the evidence with manni meta derive.

A stamp that disagrees with the evidence is exit 1, from validate and from derive --check. That is a verdict about the document.

--generated-by that finds no uncommitted body lines in a file is not an error. It is a notice on stderr, and the exit code comes from the run:

manni: docs/limits.md: no uncommitted body lines; --generated-by attributes only what is not yet committed.
Command Exits 1 when
validate One or more files failed validation.
get One or more files had a metadata block that could not be read. That means frontmatter that is not valid YAML, or that parses to something other than a mapping. A requested field that is merely absent is reported as unset and is not a failure. An absent file is different again: that is an operational error and exits 2.
fill A property the schema lists as required could not be filled confidently, or a file errored. Skipped optional properties leave the exit code at 0. A file whose missing property a URL external-metadata manifest owns is refused as a per-file error, so it counts here. A property a local manifest owns is written there and does not.
derive A file the run could not read or write, such as an unparseable document or a read-only format it could not stamp. That fails the run with or without --check, as it does for fill. A stamp that was never applied must not read as done. provenance on a page whose metadata is not a fenced block, such as HTML, is one such file. Its message ends Keep provenance in an externalMetadata manifest. Under --check any finding fails it too, such as a stale or unset managed field. A provenance range that only moved files no finding and passes. A run that writes cleanly exits 0 whatever it changed, because the work is done rather than pending.
relocate At least one value stayed on the side its schema’s x-manni-location does not prefer, or on the page while a manifest owns its key. Each is named with its reason: no-home, read-only-format, no-join-value, values-differ, url-manifest or unreadable. A run that moved everything, or found nothing to move, exits 0. Its exit 2 causes, -, --no-config, an unmarked --fields name and an undeclared file at the manifest path among them, are listed under meta relocate.
schemas Never.

A location:external or location:page finding from validate is a warning, so on its own it exits 0. On a terminal, validate then asks whether to move the values. The question, and relocate’s report after a yes, go to stderr, and the exit code is still the report’s.

The rule behind fill’s version is the same as validate’s: exit 1 means the documents still need work, not that manni meta malfunctioned.

  • Primary output is the rendered report in any format, and goes to stdout.
  • Diagnostics are operational errors that cause exit 2, and go to stderr, prefixed with manni: . Usage errors the argument parser rejects also go to stderr, prefixed with error: and followed by (add --help for usage).
  • Notices are things a run wants you to know that are not failures. The corpus checks a narrowed run skipped are one. A config that turned out to govern the run, or a .gitignore that could not be consulted, are others. They go to stderr too, with the same manni: prefix, and they never change the exit code. Nothing on stdout depends on them, so redirecting stdout keeps the report clean.

This separation lets you capture machine-readable output cleanly:

Terminal window
manni meta validate docs/ -f json > results.json

Color applies only to pretty output, and only when it is appropriate for the destination:

Condition Color
--no-color passed off
NO_COLOR environment variable set to a non-empty value off
stdout is not a TTY (e.g. piped or redirected) off
stdout is a TTY and none of the above on

The json, github, sarif, and junit formats are never colored, whatever the conditions above say. A machine format that changed shape with the terminal would not be a machine format.

For the same reason, those four own stdout completely. The “Using manni.config.yaml” notice that pretty prints to stdout goes to stderr instead, so the report stays parseable.