Adding support for a metadata format#
An adapter is one supported metadata file or ecosystem: the logic needed to find it, read it, check it against its own specification, and express what it says in CodeMeta concepts.
Adapters know nothing about each other and nothing about comparison. Adding one costs an adapter and a mapping file; nothing in the comparison engine changes, and no existing adapter is touched.
The contract#
detect(root) -> Path | None # is this format present?
parse(path, relative) -> ParsedSource # read it; raise SourceError if malformed
validate_source(parsed) -> list[Diagnostic] # check it against its own spec
map_to_codemeta(parsed) -> ConceptMap # express it as CodeMeta concepts
detect and validate_source have working defaults. parse and
map_to_codemeta are the two you must write.
Four steps#
1. Write the mapping#
src/rs_metadata/mappings/<id>.yaml. This is data, not code: which concepts
correspond, how strictly they must agree, and where the correspondence came
from.
id: julia-project
format: Julia Project.toml
formatVersion: "1.0"
provenance:
origin: codemeta-upstream
source: https://github.com/codemeta/codemeta/blob/master/crosswalks/Julia%20Project.csv
retrieved: "2026-08-14"
notes: >
Field correspondences follow the CodeMeta project's Julia crosswalk.
fields:
- property: name
source: name
strategy: name
rule: equal
severity: warning
- property: version
source: version
strategy: version
rule: equal
severity: error
- property: author
source: authors
strategy: people
rule: subset
severity: warning
anchorProperties: [author, maintainer]
note: >
Subset because packaging metadata commonly names only the current
maintainer.
- property: description
source: description
comparable: false
reason: >
Julia's description is a one-line summary, not a CodeMeta abstract.
Per-field keys:
Key |
Meaning |
|---|---|
|
The CodeMeta concept |
|
Where it lives in the source format (a label, shown in reports) |
|
Comparison strategy — see consistency.md |
|
|
|
|
|
Anchor concepts to compare against, if not just |
|
|
|
Required when |
|
Why the rule and severity are what they are |
Reuse an upstream CodeMeta crosswalk wherever one exists. Define your own
only where none is adequate, and set origin honestly: codemeta-upstream,
mixed, or rs-metadata. Provenance travels with every finding the mapping
produces.
Choose severities conservatively. An error should be something that is
genuinely wrong, not merely untidy. See the “what is deliberately not
reported” list in consistency.md — that list is the
difference between a validator people keep and one they delete.
2. Write the adapter#
src/rs_metadata/adapters/<name>.py.
class JuliaProjectAdapter(Adapter):
id = "julia-project"
format = "Julia project metadata"
role = "optional" # anchor | required | optional
filenames = ("Project.toml",)
mapping_name = "julia-project"
def parse(self, path: Path, relative: str) -> ParsedSource:
try:
text = path.read_text(encoding="utf-8")
document = tomllib.loads(text)
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
raise SourceError(f"{relative} could not be read: {error}") from error
return ParsedSource(
adapter_id=self.id,
file=relative,
format=self.format,
document=document,
source_map=TomlSourceMap(text),
)
def map_to_codemeta(self, parsed: ParsedSource) -> ConceptMap:
document = parsed.document
concepts: ConceptMap = {}
add(concepts, "name", document.get("name"), ("name",))
add(concepts, "version", document.get("version"), ("version",))
add_each(concepts, "author", document.get("authors"), ("authors",))
return concepts
Then register it in src/rs_metadata/adapters/__init__.py. Registration order
is reporting order.
Points that matter:
Always pass a path. The third argument to
addis where the value came from. It becomes the line number in a CI annotation, which is the difference between a finding a maintainer can act on and one they have to hunt for.Build a source map.
json_source_map,yaml_source_map,TomlSourceMapandDcfSourceMapcover the common cases. Positions are best-effort and must never raise.Raise
SourceError, never let a parser exception escape. Include a line number when the parser gives you one.Never read the same fact twice. Two code paths reaching the same conclusion — a Python classifier and the mere existence of a
pyproject.tomlboth meaning “Python” — record it twice. Comparison deduplicates, so the only symptom is a finding pointing at whichever path ran first. A test enforces this.Tolerate wrongly typed values. A field holding a number where a list belongs must not raise; use
as_list, which never does. A file being wrong is what the tool is for, so it may not be a reason to crash. A fuzz-style test covers every mapped field.Extract fields you have declared
comparable: false. A mapping that claims to have considered a field the code never reads is dishonest, and the report cannot say it was skipped. A test enforces this, as does its converse: every rule must be reachable from the adapter.role = "required"means the file’s absence is an error. Reserve it for a file something downstream actually depends on; anything else should beoptionaland skipped silently when missing.Do not reach the network.
3. Handle the format’s real-world spellings#
This is where most of the work is, and where most false alarms come from. Existing adapters deal with:
npm’s
user/repo,github:user/repoandgit+https://….gitrepository forms, and itsName <email> (url)author strings;PEP 621’s
dynamicfields, the PEP 639 license expression, the older{text = …}and{file = …}tables, and the free-formproject.urlslabels;Poetry’s pre-PEP-621
[tool.poetry]layout;R’s
Authors@R, which holds R code rather than data, and its own license spellings;YAML resolving
version: 1.20to the float1.2anddate-released: 2026-06-01to adatetime.date— the CFF adapter recovers the text as written.
If a value cannot be interpreted, map nothing. A missing value is skipped silently; a wrong one produces a false alarm.
4. Test it#
Add cases to tests/adapters/test_adapters.py, and a fixture to
RICH_FIXTURES in tests/unit/test_contracts.py — a file exercising every
field your mapping declares. Two contract tests then run automatically against
it:
every concept your adapter emits is declared in your mapping;
every field you declared
comparable: falseis actually extracted.
Cover both directions for each rule: equivalent representations that must not be reported, and genuine disagreements that must be.
Then regenerate the crosswalk documentation:
poetry run python scripts/generate_docs.py
Adding a comparison strategy#
Only if no existing strategy fits. Subclass Strategy in
normalize/strategies.py:
class ChecksumStrategy(Strategy):
name = "checksum"
def key(self, value):
# None means "carries nothing comparable"
return normalize_text(value)
Register it in STRATEGIES and re-export it from normalize/__init__.py.
Override compare only when the concept has real
structure that key equality cannot express — people and dependency-set do;
most do not.
Adding a diagnostic code#
Register it in diagnostics.py. A test asserts that every code emitted
anywhere in the source is in the catalog, so an unregistered one fails CI
rather than shipping without documentation or an explain entry.
Codes are a public interface. Within a major version, a code never changes meaning: adding one is a minor release, retiring one is a major release.