Architecture#
The shape#
flowchart LR
CM[codemeta.json] --> P[profile validation]
OF[other files] --> A[adapter] --> M[mapping] --> N[normalization] --> C[comparison]
P --> R([one Report])
C --> R
R --> T[CLI text]
R --> G[GitHub annotations]
R --> J[JSON artifact]
Two ideas carry most of the design.
One report model. Everything the validator learns becomes a Report. The
CLI and the GitHub Action are renderers over it and contain no validation logic
of their own. Someone debugging locally therefore sees exactly what CI
saw.
One anchor, no pairwise comparators. codemeta.json is compared against
every other metadata file; no two companions are ever compared with each other.
Supporting an N+1th format costs one adapter, not N new comparators.
The pipeline#
core.validate() runs a fixed sequence:
flowchart TD
F[find codemeta.json] -->|absent| S1([source.missing, stop])
F --> P[parse it]
P -->|malformed| S2([source.unreadable, stop])
P --> V["validate against the profile"]
V --> MC["map to concepts"]
MC --> L{{for each companion adapter}}
L --> D[detect]
D -->|absent| S3([recorded in sources, no finding])
D --> PA[parse]
PA -->|broken| S4([source.unreadable, skip file])
PA --> VS[validate against its own spec]
VS -->|invalid| S5([source.invalid, skip comparison])
VS --> CP["compare against the anchor"]
A file that fails its own specification is not compared against the anchor.
Layers#
Each has exactly one job, and keeping them apart is what lets one engine serve every format.
Layer |
Answers |
Lives in |
|---|---|---|
Mapping |
Which concepts correspond, and how strictly must they agree? |
|
Adapter |
How do I find, read and understand this file format? |
|
Normalization |
When do two representations mean the same thing? |
|
Comparison |
What diagnostic does this outcome deserve? |
|
A crosswalk is not a validator. It states that CFF’s title corresponds to
CodeMeta’s name; it says nothing about whether MyTool and my-tool are the
same, and nothing about whether a difference should fail a build. Those are the
other two layers.
Module map#
Module |
Holds |
|---|---|
|
One module per supported metadata format, plus the registry that lists them. |
|
The central comparison engine. |
|
Resolve a path inside a parsed document back to a line in the file. |
|
Normalization and comparison strategies. |
|
Validation of |
|
Renderers turn a report into an output format. |
|
The vocabularies the profile is built on, and everything derived from them. |
|
The |
|
The common representation every metadata source is mapped into. |
|
The validation pipeline. |
|
The catalog of stable diagnostic codes. |
|
Loading of the declarative crosswalk mappings. |
|
The validation report: rs-metadata’s single output contract. |
|
Creating the metadata files a repository is missing. |
Key types#
ConceptValue(raw, path) one value plus where it came from
ConceptMap {codemeta property: [ConceptValue, ...]}
ParsedSource one file: document, source map, concepts
Diagnostic one finding: code, severity, locations, fix
Report sources + diagnostics + summary → the output contract
ConceptValue.path is the reason findings carry line numbers. It is threaded
from extraction through comparison so that a mismatch discovered late can still
point at the exact place in the original file.
Invariants#
Validation semantics live in the core. If the CLI and the Action can disagree, the logic is in the wrong place.
No network access in the validator. Not for DOI resolution, ORCID lookup or
EDAM. The scripts/vendoring package is the only code that touches the
network, and the validator never calls it. CI must be deterministic, work
offline, and give the same answer in five years.
Every finding names the file, the property, the value and a fix. A diagnostic a maintainer cannot act on teaches them to ignore the tool.
One mistake, one diagnostic. A misspelled featureList is simultaneously
an unknown property, a missing mandatory field, and a set of unchecked EDAM
terms. Report the one that explains the actual consequence; suppress the rest.
The profile/ modules do this in several places, always with a comment
saying which check owns the message.
Severities are conservative. A validator that fires on correct metadata gets deleted from CI, and then it catches nothing at all. Before adding an error, read the “what is deliberately not reported” list in consistency.md.
Source positions are best-effort. Nothing in locate/ may raise. A
diagnostic without a line number is still a correct diagnostic.
Diagnostic codes are a public interface. Within a major version a code never changes meaning. A test asserts that every code emitted anywhere in the source is registered in the catalog.
Notable implementation details#
A hand-written JSON position scanner (locate/json_map.py). json.loads gives
correct values but no positions. The scanner runs separately and only
collects offsets; values always come from json.loads, so a disagreement
between the two can corrupt line numbers but never data.
The CFF loader drops PyYAML’s timestamp resolver. PyYAML resolves
date-released: 2026-06-01 to a datetime.date, but the Citation File Format
specifies that field as a string. Resolving it would make a valid file fail
its own schema.
The CFF adapter recovers raw scalars. An unquoted version: 1.20 is the
YAML float 1.2. The adapter re-reads the scalar text from the compose tree so
the version the author actually wrote is what gets compared.
Fields declared comparable: false are still extracted. Otherwise the
mapping would claim to have considered a field the code never reads, and the
report could not honestly say it was skipped.
Name splitting knows about particles. Jan van der Berg is Jan + van
der Berg. Splitting on whitespace gets many names wrong.
Missing recommendations are aggregated into one diagnostic.
Where to add what#
Recipes walks through the changes you can make, in order.
Tests#
tests/ mirrors the concerns:
Directory |
Covers |
|---|---|
|
Every profile rule, positive and negative |
|
What must agree, and what is allowed to differ |
|
Per-format parsing and mapping |
|
Normalization, CLI, renderers, and structural contracts |
Most tests build a throwaway repository and run the real pipeline over it rather than calling internals, so a refactor that breaks the wiring fails the suite.
Docstring examples are tests. pytest runs with --doctest-modules over
src/, so every >>> in the source is executed on every run. This is the
main reason the examples are worth reading: an example that stops being true
fails the build. Prefer an example over a paragraph when documenting what a
validation rule accepts and rejects — and give both directions, the value that
passes and the one that does not.
tests/unit/test_contracts.py is worth knowing about: it guards the seams
between the declarative parts — that the schema’s field policy matches its
required keyword, that every mapping references a real strategy, that every
adapter emits only concepts its mapping declares, that every emitted code is
documented, and that the generated docs are current.
See also#
The comparison rules in depth |
|
Adding a metadata format |
|
Generated per-format mapping tables |
|
The JSON output interface |
|
Version streams and release process |
|
Setup and conventions |