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.
Output formats
Section titled “Output formats”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 errorEach 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 .gitignoreThe clause is omitted when nothing was skipped, which is every run in a repo that ignores no documents.
Machine-readable. Prints a single JSON object with a run summary and a
results array.
{ "summary": { "files": 2, "passed": 1, "failed": 1, "errors": 1 }, "results": [ { "file": "docs/intro.md", "format": "markdown", "ok": true, "schemas": ["google:okf:0.1"], "errors": [] }, { "file": "docs/api.md", "format": "markdown", "ok": false, "schemas": ["google:okf:0.1"], "errors": [ { "schema": "google:okf:0.1", "instancePath": "", "message": "must have required property 'type'", "keyword": "required", "subject": "type", "line": 2 } ] } ]}| Field | Type | Description |
|---|---|---|
summary.files |
number | Files checked. |
summary.passed |
number | Files that passed. |
summary.failed |
number | Files that failed. |
summary.errors |
number | Error-severity findings reported across all files. Findings a baseline forgave are not counted here, and neither are warnings or notices. |
summary.warnings |
number | Warning-severity findings reported across all files. A warning never fails a file or the run. Omitted when there are none, which is every run whose schemas carry no x-manni-location marks. |
summary.notices |
number | Notice-severity findings reported across all files. A notice never fails a file or the run. Omitted when there are none, which is every run of manni meta validate today. |
summary.gitignoreSkipped |
number | Candidate documents .gitignore removed from the walk. Counted after the extension filter, and excluding --exclude removals. Omitted when none were skipped. |
summary.baseline |
object | Present only when a baseline governed the run. See baseline fields. |
results[].file |
string | File path. |
results[].format |
string | Extractor that read the file. |
results[].ok |
boolean | Whether the file passed every schema in its set. |
results[].schemas |
string[] | The schema set the file was validated against. |
results[].baselined |
number | Findings a baseline forgave for this file. Omitted when none were. |
results[].errors[].schema |
string | Schema that produced the error. |
results[].errors[].instancePath |
string | JSON Pointer to the field ("" for the root). |
results[].errors[].message |
string | Human-readable message. This is generated prose, and an Ajv upgrade may reword it, so match on keyword instead. |
results[].errors[].keyword |
string | The JSON Schema keyword that failed (required, format, pattern, type, …), or parse / schema for manni meta’s own errors. |
results[].errors[].subject |
string | The identifier within that keyword, which is the missing property, the additional property, the format name, or the expected type. Omitted for keywords whose parameter is a schema-authored value, such as pattern’s regex or minLength’s number. |
results[].errors[].line |
number | 1-based source line. Omitted when unknown. |
results[].errors[].severity |
string | error, warning or notice. Omitted when error, which is every finding manni meta validate produces except the location:external and location:page warnings; sibling tools in the family report advisory findings as warning or notice through the same shape. A file is ok iff none of its errors has severity error, and a warning or a notice never moves the exit code. |
results[].errors[].col |
number | 1-based column. Supplied by the html and xml extractors, from the content= attribute of a <meta> tag or the value of an XML attribute. Omitted for every other format, and for a required violation in any format. That error reports the parent object, and a missing property has no column. |
results[].errors[].file |
string | The file line and col refer to, when it is not results[].file. Set only for a value an external-metadata manifest supplied, spelled the way the run spells file labels (docs-meta.yaml). Omitted for every violation whose value is in the document, which is every violation a run with no externalMetadata: on any collection produces. The finding’s subject file stays results[].file; this names where the value was written. |
keyword and subject are the machine-stable half of a violation’s identity.
They are what a script should key on when it needs to recognize “the same
violation” across runs or across manni meta versions. message is for humans.
GitHub Actions workflow-command annotations. Prints one ::error:: line per
finding, which GitHub renders inline on the pull request. A finding whose
severity is warning prints as ::warning:: instead, and one whose
severity is notice as ::notice::. Both render the same way and fail
nothing. Files with no findings produce no output.
::error file=docs/api.md,line=2::[google:okf:0.1] (root) must have required property 'type'Each line has the form:
::error file=<file>,line=<N>,col=<N>::[<schema-id>] <field> <message>line and col are included only when known. col today means an html or
xml document and a violation other than required. <field> is the field
path, or (root) for a whole-document error. For a value an external-metadata
manifest supplied, file= and
line= name the manifest and its line, because that is the line the annotation
should land on:
::error file=docs-meta.yaml,line=6::[./private.schema.json] /jira must match pattern "^PLAT-[0-9]+$"The message is escaped the way GitHub’s workflow-command protocol requires: %
becomes %25, a carriage return becomes %0D, and a newline becomes %0A.
GitHub decodes them again when it renders the annotation, so you see the
original characters on the pull request. The escaping matters because the
protocol is line-oriented. An unescaped newline would end the command early and
spill the rest of the message into the log as plain text. It also matters
because a schema pattern is quoted into the message verbatim. A regex
containing % then reaches the annotation. Only those three are encoded; ,
and : are special in the file= / line= properties, not in the message.
SARIF 2.1.0,
the interchange format GitHub code scanning, GitLab, and Azure DevOps ingest
directly. Where github annotations vanish with the job log, SARIF findings
become tracked alerts with state across commits. A clean run still prints a
complete envelope with an empty results array. It is never an empty file,
which upload-sarif rejects.
{ "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", "runs": [ { "tool": { "driver": { "name": "manni", "version": "3.5.0", "informationUri": "https://hawkeyexl.github.io/manni/meta/", "rules": [ { "id": "google:okf:0.1/format", "shortDescription": { "text": "Schema \"google:okf:0.1\" rejected the document on its \"format\" keyword." }, "helpUri": "https://hawkeyexl.github.io/manni/meta/fix/" } ] } }, "results": [ { "ruleId": "google:okf:0.1/format", "level": "error", "message": { "text": "/timestamp must match format \"date-time\"" }, "locations": [ { "physicalLocation": { "artifactLocation": { "uri": "docs/api.md" }, "region": { "startLine": 4 } } } ], "partialFingerprints": { "docmetaViolation/v1": "a0e67da36246d5d9" } } ] } ]}| Field | Type | Description |
|---|---|---|
runs[].tool.driver.name |
string | Always manni. |
runs[].tool.driver.version |
string | The manni meta version that produced the log. |
runs[].tool.driver.rules |
object[] | Only the rules this run actually hit, deduplicated. manni meta does not enumerate every keyword of every schema. |
results[].ruleId |
string | <schema-ref>/<keyword>, for example google:okf:0.1/required. Built from keyword, never from the message, so an Ajv rewording cannot close and reopen every historical alert. |
results[].level |
string | error; warning for a finding whose severity is warning; note for one whose severity is notice. See severity below. |
results[].message.text |
string | The field path (or (root)) followed by the violation message. |
results[].locations[].physicalLocation.artifactLocation.uri |
string | The file, relative to the repository root. See paths. For a value an external-metadata manifest supplied, this is the manifest. Each finding resolves its own URI, so a manifest inside the repository is kept even when the document is not, and the reverse. |
results[].locations[].physicalLocation.region.startLine |
number | 1-based source line. The whole region is omitted when no line is known. SARIF requires startLine >= 1. A 0 would be invalid, and a 1 would silently mislocate the finding at the top of the file. A location with only an artifactLocation renders as a file-level alert, which is true. No startColumn is ever emitted, even for a finding whose JSON col is set. The SARIF region stays line-only for now. |
results[].partialFingerprints |
object | {"docmetaViolation/v1": "<fingerprint>"}, the same fingerprint the baseline records. |
Reserved rule ids
Section titled “Reserved rule ids”manni meta’s own failures do not get a rule id built from (parse). A document
whose metadata block could not be read reports manni/parse-error, and one
whose schema set could not be resolved reports manni/schema-error. Each is a
real entry in rules[]. More ids are built by the ordinary join from a
reserved first segment rather than a schema. A corpus
check named n reports
check:n/check. A document carrying a key an external-metadata
manifest owns reports
external:owned/external. Its rules[] entry reads “The document carries a key
a manifest owns; the manifest is the only place that key may be set.” Two
documents sharing one value of a manifest’s join field report
external:duplicate/external, with the shared value as the finding’s subject.
Its rules[] entry reads “Two documents carry the same value of a manifest’s
join field, so one manifest entry matched both.”
A property its schema marks
x-manni-encrypt
reports one of two more, both with the reserved encrypted first segment. A
plain value reports encrypted:plain/encrypted. Its rules[] entry reads “A
property the schema marks x-manni-encrypt holds a plain value; the page must
hold it encrypted.” A value that does not decrypt under the current key reports
encrypted:unreadable/encrypted. Its rules[] entry reads “A property the
schema marks x-manni-encrypt holds a value that does not decrypt under the
current key.” Neither carries a subject, so neither fingerprint depends on a
value. An encrypted value a run has no key to check is not a finding. The run
warns once on stderr, and the exit code is unaffected.
Those last two ids were sidecar:owned/sidecar and sidecar:duplicate/sidecar
before external metadata was renamed, and a baseline recorded under the old ones
no longer matches.
A managed field whose stamp
disagrees with the evidence reports derived:stale/derived, with the field
as its instance path. Its rules[] entry reads “The document’s stamp for a
managed field disagrees with the evidence; run manni meta derive.” derived
is the third reserved first segment, beside check and external. A
provenance finding keeps the same id and instance path, /provenance. It
adds the first file line of the range, and the subject provenance <integrity>, so each range has its own fingerprint.
The - (stdin) input produces no SARIF result. <stdin> is not a path any
consumer can resolve, and a location pointing at one would be dropped anyway.
SARIF paths are repository-root-relative
Section titled “SARIF paths are repository-root-relative”GitHub resolves artifactLocation.uri against the repository root, and
silently drops results that do not resolve. A wrong path means the upload
succeeds with zero alerts, which reads as a clean scan. So manni meta rebases
every path onto the nearest git repository root.
manni meta validate api.md -f sarif run from inside docs/ reports
docs/api.md, exactly as a run from the repository root does.
When there is no git repository above the working directory, paths stay relative
to that directory and manni meta says so on stderr. A file outside the
repository, validated through a ../ path, has no repository-relative name at
all. Its findings are omitted, and the count is reported on stderr rather than
quietly missing from the log.
The fingerprint does not include the path
Section titled “The fingerprint does not include the path”partialFingerprints carries the baseline
fingerprint verbatim, which deliberately excludes the file path. The path sits
beside it in artifactLocation.uri, exactly as the baseline keeps it as the
entry key. So two files with the same violation share a fingerprint. They
still track as separate alerts. GitHub derives alert identity from fingerprint
plus rule plus location. Do not use the fingerprint alone as an alert key.
Severity
Section titled “Severity”SARIF’s level is its main triage axis, and a finding’s severity maps onto
it directly. A finding is error by default, warning for a warning, and
note for a notice, since SARIF has no level named notice. Every finding manni meta’s own
validation produces is an error, except the location:external and
location:page warnings. The other levels exist for sibling tools in
the family whose advisory findings ride this reporter, and it is what closes
issue #78.
JUnit XML, which Jenkins, GitLab, CircleCI, and Azure render natively in their “Tests” tab. A clean run still prints a complete envelope.
<?xml version="1.0" encoding="UTF-8"?><testsuites name="manni" tests="2" failures="1" errors="0"> <testsuite name="manni" tests="2" failures="1" errors="0"> <testcase name="docs/api.md" classname="manni.validate"> <failure type="google:okf:0.1/format" message="/timestamp must match format "date-time" (line 4)"/> </testcase> <testcase name="docs/intro.md" classname="manni.validate"/> </testsuite></testsuites>| Attribute | On | Description |
|---|---|---|
name |
testsuites, testsuite |
Always manni. |
tests |
testsuites, testsuite |
Files checked, as one test per file rather than per violation. |
failures |
testsuites, testsuite |
Files with at least one error-severity violation. A warning or a notice is not a <failure>, because JUnit has no level below failure. A file holding only warnings or notices is a passing testcase, matching the exit code. |
errors |
testsuites, testsuite |
Always 0. Every violation is a <failure>; errors is reserved for a test that could not run, which has no analogue here. |
name |
testcase |
The file path, as manni meta reported it. Not rebased onto the repository root. Unlike SARIF, nothing resolves it. |
classname |
testcase |
Always manni.validate. |
type |
failure |
The same rule id SARIF uses: <schema-ref>/<keyword>. |
message |
failure |
The field path (or (root)), the violation message, and (line N) when the line is known. For a value an external-metadata manifest supplied, the location is (docs-meta.yaml:6), as in the pretty format. |
One test per file is deliberate. The tab then reads “2 tests, 1 failed” and
matches 2 files checked, 1 failed. One test per violation would make the
test count rise and fall with document quality, which reads as a suite someone
broke.
Baseline fields
Section titled “Baseline fields”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. |
Severity across the family
Section titled “Severity across the family”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.
Which tool emits which
Section titled “Which tool emits which”| 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.
How each level renders
Section titled “How each level renders”| 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).
What each level does to the exit code
Section titled “What each level does to the exit code”| 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.
How a domain maps its own scale on
Section titled “How a domain maps its own scale on”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.
Exit codes
Section titled “Exit codes”| 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.
Exit codes with a baseline
Section titled “Exit codes with a baseline”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.errorsand counted inresults[].baselined, so exit0here means “nothing new”, not “nothing wrong”. TheN baselined findingsline 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-baselinerecords what it saw and exits0, so it always succeeds regardless of how many violations there were.- A
--baselinefile that does not exist is exit2, 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.
Exit 2 when nothing was checked
Section titled “Exit 2 when nothing was checked”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.
Exit 2 from an external-metadata manifest
Section titled “Exit 2 from an external-metadata manifest”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.
Exit 2 from collection selection
Section titled “Exit 2 from collection selection”--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, blogExit 2 from a derive source
Section titled “Exit 2 from a derive source”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.Per-command meaning of exit 1
Section titled “Per-command meaning of exit 1”| 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.
stdout vs stderr
Section titled “stdout vs stderr”- 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 witherror: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
.gitignorethat could not be consulted, are others. They go to stderr too, with the samemanni: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:
manni meta validate docs/ -f json > results.jsonColor behavior
Section titled “Color behavior”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.