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]
google:okf:0.1 type: A short string identifying the kind of concept.
Guide: https://hawkeyexl.github.io/manni/meta/reference/schemas/google-okf/
2 files checked, 1 passed, 1 failed, 1 error

Each error line has the form:

<field> <message> (line N) [schema-id]
<schema-id> <property>: <description>
Guide: <url>
  • <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.
  • The two dim lines under a schema finding are its guidance. The first names the schema and the property, and says what the property is for. The second links a built-in schema’s reference page, and a house schema has none. manni meta’s own findings, such as a parse error, carry no guidance lines.

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.

Each finding a schema produced carries guidance: the schema, the property it judged, what the property is for, and where to read more. The property is found by walking the field path through the schema’s properties, local $ref, allOf, anyOf, oneOf, then, else and items. The description is the first sentence of the property’s own description. Run manni meta schemas show to read the whole text.

A finding is reported once, at the error that caused it. Ajv also reports a summary error for a failed if branch, and for an anyOf or oneOf no branch passed. Those are left out, because each only ever sits beside that error. A oneOf that more than one branch passed has no other error, so it is reported. A baseline recorded with those summary errors lists them as entries that no longer occur, and the exit code does not change.

Messages name what to write where Ajv’s do not:

KeywordMessage
enummust be one of: 1, 2, "deep", false, every value that passes.
constmust be "command", the one value that passes.
minLength or minItems of 1must not be empty
type with several typesmust be string or array
A key the schema forbids outrightmust not be present

On a value the schema marks x-manni-encrypt, an enum or const finding lists no values, because they are the set the plaintext comes from.

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

FieldTypeDescription
pathstringThe baseline file, spelled as the run referred to it.
writtenbooleantrue on --write-baseline, false when the baseline was only read.
recordednumberFingerprints 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.
suppressednumberFindings the baseline forgave this run.
stalenumberRecorded fingerprints for checked files that no longer occur. Never fatal.
addednumberFingerprints this write added. Write only.
removednumberFingerprints 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.

ToolLevels it emitsHow a level is decided
manni meta validateerror and warningEvery 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 checkall threePer rule. Each rule has a default, and cite.severity.<rule> moves any of them, or sets it to off.
manni a11y checkall threeMapped from axe’s impact when a page is read. Its --severity flag then drops everything below a floor.
manni lint checkall threeEvery finding from manni’s own structure check is an error except unsupported-content-kind, the warning for a rule the file’s format cannot answer. Under DITA-OT, a message keeps its log level: ERROR and FATAL are errors, WARN is a warning and INFO is a notice. There is no severity map. Exit 1 is defined on severity rather than on “any finding”, so a warning or a notice never fails a run.
manni key rotatenoneIt 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.

Formatnoticewarningerror
pretty, this toolthe word notice, dim, before the messagethe word warning, yellow; a file with warnings and no errors is marked ⚠no word; the file line is ✗
pretty, citeℹ, dim↕, yellow✗, red
pretty, a11ythe word notice, dimthe word warning, yellowthe word error, red
pretty, lintthe word notice, dim, before the messagethe word warning, yellow; a file with warnings and no errors is marked ⚠no word; the file line is ✗
json"severity": "notice""severity": "warning"the field is absent in meta, and "error" in cite, a11y and lint
github::notice::warning::error
sarif"level": "note""level": "warning""level": "error"
junitnot a <failure>; the testcase passesnot a <failure>; the testcase passesone <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, by manni cite and by manni lint; 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).

ToolWhat makes it exit 1
manni meta validateA file with at least one error-severity finding.
manni cite checkA file with at least one unbaselined error-severity finding. Warnings and notices never move it.
manni a11y checkA page that failed to load, or one with any remaining violation. The floor is the gate, so --severity is what stops a lower level failing the run.
manni lint checkA file with at least one error-severity finding.

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.

manni a11y check is the one tool whose floor is also its gate, and that is deliberate. Here and in manni cite check you can move a finding onto the level you want. A rule you care about becomes an error, through cite.severity.<rule> or by writing it as a requirement in the schema. The exit code can sit at error and stay useful. axe assigns a11y’s severities from its own impact, and no config moves one. Pinning that exit code to error would leave the domain with no way to gate on an axe moderate finding. So --severity does both jobs there, and the floor a run reports at is the floor it fails at. pa11y and Vale, the two linters this scale was borrowed from, read their own level the same way.

The practical shape is a ratchet. Set severity: error to gate on axe’s critical and serious alone, fix what it finds, then lower the floor to warning and to notice as each level reaches zero. Lower it as findings are fixed; never raise it to get a build green. Proposal 0064 is the record.

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 impactFamily severity
criticalerror
seriouserror
moderatewarning
minor, or nonenotice

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.

CodeMeaning
0All files passed (or nothing failed).
1One or more files failed validation.
2Operational 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):

SituationMessage
A named path that does not exist, even if other inputs matched.File not found: "docs/typo.md". Check the path. Named paths resolve from the working directory.
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:

SituationMessage
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:

SituationMessage
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.
A query _path move would leave a page’s values in a manifest per page, which is named after the old path.docs/a.mdx cannot move to docs/b.mdx, because its "citations" lives in docs/a.citations.yaml, a {page} manifest named after the page's path. Move the page and its manifest together, then rename the entry's key.
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:

SituationMessage
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.

SituationMessage
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, and no page’s schemas claim a merge-safe field.nothing to derive: no page's schemas claim a merge-safe field (owner, created, last-updated, provenance); set derive.fields or pass --fields
The same, but some page’s schemas could not be read, so what they claim is unknown.nothing to derive: the schemas of <page> could not be read, so what they claim is unknown; fix them or pass --fields (with and <n> more after the page when there are several)
derive has 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.
CommandExits 1 when
validateOne or more files failed validation.
getOne 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.
fillA 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.
deriveA 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.
relocateAt 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.
schemasNever.

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:

ConditionColor
--no-color passedoff
NO_COLOR environment variable set to a non-empty valueoff
stdout is not a TTY (e.g. piped or redirected)off
stdout is a TTY and none of the aboveon

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.