Skip to content

JSON schemas

Validate lockrot's machine-readable documents against these schemas and build on the fields they type. Under one schema number a document only gains fields, the sets in Open sets only gain values and a field's type only widens.

Document Schema Written by / read from
The report https://lockrot.dev/schema/report-1.json composer lockrot --format=json
The explanation https://lockrot.dev/schema/explain-1.json composer lockrot --explain=vendor/package --format=json
The baseline file https://lockrot.dev/schema/baseline-1.json --generate-baseline writes lockrot-baseline.json; every later run reads it
The configuration https://lockrot.dev/schema/config-1.json extra.lockrot in composer.json
  • Dialect. JSON Schema draft-04.

  • What lockrot validates. composer lockrot exits 2 on a baseline file or an extra.lockrot that fails its schema (exit codes). At install time lockrot skips its install-time check and prints one lockrot: install-time check skipped: … line (install-time.md).

  • Copies. The PHAR and each release tag ship the same files as resources/lockrot-<name>.schema.json. The copy in your PHAR, or at your release's tag, describes exactly that release; the URL describes the newest release under the same number.

The documents say which schema they follow

The report, the explanation and the baseline file open with a $schema key naming their URL:

{
    "$schema": "https://lockrot.dev/schema/report-1.json",
    "lockrot": {
        "version": "0.13.0",
        "schema": 1
    },
    …
}

An editor that reads $schema completes and checks these documents as you edit them.

extra.lockrot sits inside composer.json, which has a schema of its own, so it carries no $schema: map config-1.json to the extra.lockrot path in your editor's JSON schema settings.

The number, and what may change under it

The 1 in report-1.json is the lockrot.schema value the document carries.

  • Fields are only added. No object sets additionalProperties: false, so a field your code does not know is not an error.

  • Types may widen. A field may accept a further type (for example null) under the same number. A document that uses the wider type fails an older copy, so refresh a pinned copy when you upgrade.

  • The number moves only when a field is removed, renamed, made required or narrowed, or a closed set changes. The old file stays published at its old URL.

  • The file at a URL widens in place: every document a lockrot release of the same number wrote validates against it.

compatibility.md lists what else 1.0 freezes: the closed sets and their order, the identity fields of the other formats and the command line.

Open sets

Objects are open, and so are these sets of values, which grow in minor releases:

  • signal ids: a signal's id and S10's blocks;
  • S10's check and reason;
  • S6's reason, and S8's floor_source;
  • a finding's libyears_unmeasured, and the keys of the report's libyears.unmeasured;
  • a priority_basis step's reason, and a no_fix_expected item's reason;
  • run.mode, run.fail_on_kind and gate.tripped_by, and a finding's gate.exempt_by;
  • a finding's origin.kind and origin.registry;
  • a run note's code, and the forge_id and reason in its data;
  • the explanation's php_blocked_by, misses_target_php and misses_project_php;
  • the configuration's format, and format names wherever lockrot writes one.

Repository hosts are an open set only in a run note's forge_id, and Composer registries only in a finding's origin.registry; S3 and S4's host and the explanation's activity.forge are plain strings.

How the schemas write an open set:

  • It is a string with a pattern, plus x-known-values: the values this release writes. Every known value matches the pattern, and the list only grows under one number.

  • A draft-04 validator ignores x-known-values, so your copy accepts a value a later release adds.

  • The pattern for signal ids, note codes, origin kinds and formats also admits <vendor>:<name>, the form reserved for names that do not come from lockrot. The vendor and the name are each lower-case letters, digits, _, . and -, starting with a letter or a digit: acme:licence validates, Acme:Licence does not.

  • A signal whose id the schema does not list validates with any object as its data. A listed id keeps its data typed: S2 with S4's data fails, and so does S2 with no data. A run note's data is typed per code the same way.

  • The keys of the report's libyears.unmeasured grow with libyears_unmeasured. A key a later release adds is typed as a count and never joins required. A later release may also split a reason into narrower keys, which moves findings out of the old one.

How to read one:

  • Read a value you do not know as "other": show it as written, and do not fail on it.

  • To hold a document to the values you know, read x-known-values as an enum. lockrot does this when it validates extra.lockrot, so a mistyped format is a configuration error.

The closed sets stay closed: verdicts, priorities, signal levels, a finding's standing against the baseline, a baseline entry's verdict and the schema number. A new value there needs a new number.

Older releases

A copy of a schema taken before 0.13.0 spells these sets as enums, and rejects a value a later release adds until you refresh it.

What the report schema types

The report's fields and what each holds. Since is the release that added a field: the schema marks it optional so earlier reports validate, and lockrot writes it in every report from that release on. A field without one is in every report that has its parent. report-1.json itself holds every nested key.

The document

Field Type Since Holds
$schema string The URL of report-1.json
lockrot object version, the lockrot release that wrote the document, and schema, the schema number
generated_at date-time When the report was generated
run object or null 0.10.0 What the run was told to do: see What the run was told
activity_cache_oldest_at date-time or null Fetch time of the oldest repository-activity answer served from lockrot's cache; null when every answer was fetched in this run
packages_checked integer Packages analysed
include_dev boolean Whether packages-dev were analysed
not_from_composer_repository integer Findings whose from_composer_repository is false
network_failures boolean True exactly when a note_details entry has sets_network_failures: true; --strict-network fails on it (ci.md)
counts object Findings per verdict; every verdict is a key, zero included
abandoned object 0.11.0 total, equal to counts.abandoned, and with_replacement, the abandoned findings whose replacement names a package
priorities object Findings per priority; every priority is a key, zero included
exposure array Direct requirements that pull in flagged packages: see Transitive exposure
exposure_rule object 0.13.0 max_fan_in, the fan-in cap this run attributed exposure by: see Transitive exposure
unattributed array 0.13.0 Flagged transitive packages above the cap
libyears object 0.11.0 Totals over the findings' libyears: see Libyears
baseline object or null path, the counts known, new and worsened, and stale, the baselined names absent from the lock; null when the run read no baseline
gate object or null 0.13.0 Why the run exits as it does: see The gate
notes array of strings What the run could not see, as sentences: text, not contract
note_details array 0.13.0 The same notes, typed: see Run notes
findings array One per analysed package, by priority, then verdict severity, direct before transitive, then name

Each finding

Field Type Since Holds
package string The package name, vendor/name
version string The installed version, as the lock records it
verdict enum A verdict
priority enum A priority
priority_basis object 0.13.0 How priority was reached: see How a priority was reached
direct boolean Whether the project requires the package directly
dev boolean Whether the lock lists it under packages-dev
signals array {id, level, summary, data} per signal; data is typed per id, summary is text
chain array Package names from a direct requirement down to this one; one element when it is direct; empty when no direct requirement reaches it
direct_dependents array The run's direct requirements that reach the package, sorted by name; a direct package lists itself
evidence string The sentence behind the verdict: text, not contract
allowlist_reason string or null The reason the allowlist gives; null when the package is not allowlisted
note string or null Why lockrot has no repository metadata for the package, as a sentence; null when it has
data_date date-time or null When the repository data behind the verdict was read: the later of the metadata and the repository activity; null when lockrot had neither
from_composer_repository boolean 0.13.0 Whether the lock entry carries a Composer notification-url: see Where a package came from
origin object 0.13.0 Where the lock entry came from: see Where a package came from
replacement string or null 0.11.0 On an abandoned finding, the package its repository names as the replacement, when that is a package name other than its own; null otherwise (Abandoned)
replacement_url string or null 0.13.0 The replacement's page: see Where a package came from
libyears number or null 0.11.0 Years behind the newest stable release, at least 0, two decimals; a lower bound when the newest tag is undated (Libyears); null when not measured
libyears_unmeasured string or null 0.13.0 Null when libyears is a number, 0 included; otherwise the libyears.unmeasured key the finding counts under. An open set
no_fix_expected array or null 0.13.0 The advisories lockrot expects no fix for: see Advisories with no fix expected
baseline object or null 0.10.0 status (known, new or worsened) and previous_verdict, the verdict the baseline accepted or null; null when the run read no baseline or the finding is not flagged
gate object or null 0.13.0 Where the finding stands against run.fail_on: see The gate; null exactly when the root gate is

A finding without libyears_unmeasured comes from a report that predates the field; its absence never means "measured".

What the run was told

run holds what every verdict was decided against. The command writes it on every run.

Key Type Since Holds
project string or null What the report calls the project: composer.json's name, or extra.lockrot.project instead. A display name, not an identifier; null when the manifest has no name and extra.lockrot.project is unset
root_package string or null 0.13.0 The name of the manifest Composer reads, exactly as written, whatever extra.lockrot.project says: the key to match a report to its repository or to join several projects' reports on
target_php string or null The PHP version the verdicts target, as the run resolved it (target-php)
project_php string or null 0.13.0 The manifest's require.php exactly as written (>=8.2): a constraint, not a version. The second floor S8 holds a higher branch against (Within reach); --explain writes the same value
lock_file string or null The lock's file name, never its path
fail_on string or null The threshold the run used, from --fail-on or its key (choosing one)
fail_on_kind string or null 0.13.0 Which kind of threshold fail_on is: none, verdict, priority or unchecked. An open set; null only where fail_on is
strict_network boolean 0.13.0 Whether --strict-network was on
mode string 0.13.0 check, or generate_baseline for --generate-baseline, which records the findings and judges none. An open set
thresholds object or null release-warn-years, release-high-years, push-warn-years and push-high-years (keys)
flagged_verdicts array The verdicts this run counted as flagged, most severe first

root_package and project_php are null when the manifest has no such key, and in a run that read a lock without its composer.json.

The gate

The root gate is the decision behind the exit code, taken over the document's own findings by what run says. It is null only where run is null or has no fail_on; the command always writes both.

Key Type Holds
fails boolean True: the run exits 1; false: 0. A later write error overrides both with 2 (exit codes)
tripped_by array Each cause once: strict_network (it was on and network_failures is true) and fail_on (some finding's gate.fails is true). Empty exactly when fails is false. An open set; the order is not contract
fail_on_applied boolean True in a check run; false in a generate_baseline run, which only --strict-network can fail

Each finding's gate:

Key Type Holds
reaches_fail_on boolean Whether the finding is at or above run.fail_on, decided in every mode; always false under none
exempt_by string or null baseline when the finding reaches run.fail_on and the baseline accepted it (baseline.status is known; a worsened finding is not exempt); otherwise null. Non-null only where reaches_fail_on is true. An open set
fails boolean True exactly when reaches_fail_on is true, exempt_by is null and the root fail_on_applied is true

Run notes

note_details has one entry per notes string, at the same index and with the same text. A code can repeat, so identify an entry by its index. It is [] when notes is. The --explain document carries the same list beside its own notes.

Key Type Holds
code string What the run could not see; the id of its section in notes.md. An open set
text string The notes string: text, not contract
data object The facts behind the note, typed per code; {} for a code with none
docs_url string or null The code's section on lockrot.dev; null means no page. Read it, never build it
sets_network_failures boolean Whether this note makes the root network_failures true

For a code the schema does not list, show text, link docs_url when it is a string, ignore data, and still honour sets_network_failures. Key on note_details, never on the notes sentences.

How a priority was reached

priority_basis has base, the level the verdict starts at, and steps, each {reason, from, to} in the order applied:

  1. transitive, or unreached when chain is empty: exactly when direct is false.

  2. dev: the finding's dev is true.

  3. no_fix_expected: the finding's no_fix_expected names an advisory.

How the steps chain:

  • A step is recorded whenever its fact holds on a flagged finding, also when it cannot move the level: from then equals to.

  • The first step starts at base, each later one where the last ended, and the last ends at priority. With no steps, priority is base.

  • An unflagged verdict (unknown, finished, ok) gives {"base": "none", "steps": []}.

  • from and to take the priority order without none. A step whose reason you do not know still reads as a move from one level to another.

What each step does to the level is in Priority.

Advisories with no fix expected

no_fix_expected Meaning
absent A report written before 0.13.0
null The verdict makes no fix prediction: every verdict but abandoned, silent and left-behind
[] One of those three verdicts, and every advisory is fixed by a release it lets the project reach, or there is none
non-empty {id, reason} per advisory no fix is expected for, in S9's order. They raise the priority (Priority)

id is the id of one of the finding's S9 advisories. reason is the first that applies, from an open set:

reason When
not_on_installed_branch The finding is left-behind and the fix is only on a higher branch
releases_unknown S9's releases_read is false, so no fix was looked for
affected_range_unknown The advisory gives no affected range
no_release_fixes No listed release above the installed version fixes it

Libyears

The root libyears block is derived from the findings' libyears:

Key Type Holds
total number or null Sum over the measured findings, two decimals; null when measured is 0. It sums the unrounded values, so it can differ from the sum of the findings' printed libyears by up to 0.005 per measured finding
direct_requirements number or null The same sum over direct: true; null when measured is 0
measured integer Findings whose libyears is a number
unmeasured object Findings whose libyears is null, counted per reason; every key this release writes is present
furthest_behind object or null {package, version, libyears} of the finding furthest behind; null when nothing measured is behind

measured plus every unmeasured value is the number of findings, and counting findings by libyears_unmeasured gives unmeasured key for key. Libyears defines the number and says what it is not.

Transitive exposure

Key Type Holds
exposure array {package, flagged} per direct requirement that pulls in attributed flagged packages, most first, then by name. flagged is the count S7 carries on that requirement's finding
exposure_rule.max_fan_in integer The most direct requirements a flagged transitive package may be reached from and still count under each; the value this run used, so read it, do not hard-code it
unattributed array {package, verdict, fan_in} per flagged transitive package above that cap, in report order; fan_in is how many of the run's direct requirements reach it. Such a package counts in no exposure entry and no S7

See Transitive exposure.

Signal data

Every signal's data is typed in report-1.json (S9 in definitions.s9), and The signals says when each fires. This table lists only the fields added or changed after 0.9.0, the first published schema, with when each appeared; a field it leaves out is still typed and still written.

Signal Field Since Holds
S5 target_major 0.11.0 The first release of the target's major, 8.0 for a target of 8.4
S5 ga_date The GA date of target_major; in a report written before 0.11.0, the GA of the target minor
S5 written_for_php 0.11.0 The PHP major of the constraint's lower bound; null when it has none (*)
S6 reason, has_stable_release, last_stable_release, last_stable_version, last_stable_dated_by, snapshot_time 0.13.0 See What S6 carries
S8 newest_php 0.11.0 The php requirement of the newest branch's release; null when it requires no PHP
S8 newest_within_reach 0.11.0 Whether the newest branch's php requirement admits both the project's own require.php and the target PHP (Within reach)
S8 floor_php, floor_source 0.11.0 What holds the newest branch back, and which floor that is (project or target, an open set); both null when it is within reach
S8 reachable_branch, reachable_version, reachable_release 0.11.0 The newest releasing higher branch within reach, the one suggested_constraint follows; null when none is
S9 releases_read 0.13.0 True when the releases were read and compared with the installed version, so a null fixed_by means none fixes it; false with no metadata or an incomparable version (Security advisories)
S10 unchecked, blocks 0.11.0 See What was not checked

Dates

Dates are RFC 3339 strings (format: date-time). S5's ga_date and a baseline entry's first_seen are plain YYYY-MM-DD.

The explanation

The explanation's finding has a report finding's fields without baseline and gate. Its project_php and note_details are the report's. explain-1.json types its lock, metadata and activity; Explaining one package says what each shows.

explain-1.json does not type a signal's data: validate it against the matching definitions.s<N> of report-1.json (S9 → definitions.s9), or read it as the report types it.

Field Since
metadata.installed_release, metadata.installed_release_dated_by 0.11.0
metadata.branches[].php 0.11.0
metadata.branches[].admits_target_php, admits_project_php, php_blocked_by, misses_target_php, misses_project_php 0.13.0
project_php, note_details 0.13.0

Where a package came from

A finding's origin says where its lock entry came from. lockrot reads it from the entry and the repositories of the manifest Composer reads (COMPOSER=alt.json means alt.json), without asking any repository: the same lock and manifest give the same origin on every machine.

Key Type Holds
kind string How the entry was served; see the next table. An open set
registry string or null The known host the notification-url reports to, or null; the next table gives it per kind. A label: build no URL from it. An open set
package_url string or null The package's page on registry, for a registry that keeps a public page per package name, when the lock's name is a package name; null otherwise. Link it only when it is a string; never build one
local boolean Whether Composer installed the package from the machine it ran on: a path or artifact repository, or a dist or source that is a path or a file:// URL, such as a VCS checkout on the disk

An entry with a notification-url is packagist or composer. For any other entry, lockrot walks the manifest's repositories in the order Composer consults them (one named packagist last, only and exclude applied), and the first repository that could have served the entry decides its kind. An entry no repository accounts for is path when its dist is a local directory, and unknown otherwise.

kind The lock entry registry from_composer_repository
packagist Its notification-url reports to packagist.org: packagist.org itself, or a mirror that keeps packagist.org's notify URL packagist.org true
composer Its notification-url reports to a host other than packagist.org A known host, or null for a host lockrot does not know true
path Its dist is a local directory null false
vcs Its source is a VCS repository the manifest lists null false
artifact Its dist is an archive inside an artifact repository the manifest lists null false
package An inline package definition in the manifest gives exactly this entry null false
unknown None of these could be decided (see below) null false

An entry is unknown when lockrot cannot decide, for example:

  • an entry with no notification-url that no path, vcs, artifact or package repository accounts for, such as one from a type: composer repository that advertises no notify URL (asset-packagist.org, a Satis build without notify-batch; see Which Composer repository answers);

  • a name a repository listed earlier could also have served;

  • an entry from a VCS repository renamed or removed since the lock was written: lockrot reads the manifest as it is at the time of the run;

  • in a run without composer.json, or with a repositories list Composer would refuse, every entry that has no notification-url and no local dist: there is no repository to read.

Read a kind you do not know by from_composer_repository, show it as written and link nothing.

  • from_composer_repository is true when the lock entry carries a notification-url. True does not mean packagist.org. lockrot asks a repository for metadata, advisories and repository activity only about such an entry, so a false one has no metadata, advisories, activity or libyears. --explain writes it as lock.from_composer_repository, and the root not_from_composer_repository counts the false ones. It stays a boolean in 1.x; origin is the finer account.

  • registry takes its values from the schema's x-known-values, the hosts this release knows. Of packagist.org, repo.packagist.com, wp-packages.org and packages.drupal.org, only packagist.org and wp-packages.org get a package_url: Private Packagist keeps no public pages, and a drupal.org project page does not follow from the package name.

  • package_url is the page the registry keeps for that name. Whether this run's repositories still list the package is the finding's note.

  • replacement_url is the page packagist.org keeps for an abandoned finding's replacement. It is a string only when the registry that named the replacement is packagist.org. That registry is the one whose metadata marked the package abandoned or, with no metadata, the one the lock entry came from. Otherwise it is null. lockrot does not check that the page exists: link it only when it is a string, and never build one from replacement.

  • local says nothing about where. An explanation's lock.repository shows such a path by its last segment (.../lib). A path dist is local whichever notification-url the entry carries.

What a report says about your repositories

origin copies nothing from the entry's URLs or the manifest's. It writes a kind, a registry from lockrot's own list and a URL built from that list. It never writes:

  • the notification-url, which can carry a login and a password;

  • a dist, which for an artifact repository is a path on the machine that wrote the lock;

  • a mirror URL, which for Private Packagist names the organisation.

A private registry is kind: composer with registry: null: its host is not written. repo.packagist.com says that the project uses Private Packagist, never which organisation.

Other parts of a report can name a private registry or a repository host, and origin does not change them:

  • a run note about a Composer repository lockrot could not read (advisories_unavailable, metadata_unavailable, monorepo_parent_unavailable): its text, its notes entry, and a message or composer_repository in its data;

  • a finding's note and evidence when its metadata did not come;

  • the hosts and repositories in S3, S4 and the repository activity notes;

  • on the HTML page, each package's details (lock.repository, metadata.repository, repository_link, activity).

None of them carries a credential or a local path. Hosts and URL paths stay visible, and name a private repository as plainly as its host does.

lockrot redacts what it quotes:

  • A URL loses its userinfo, query and fragment, whatever Composer masked of it.

  • A repository without a scheme loses its user (git.example.com:lib.git).

  • A file:// URL and every other path of the machine keep their last segment alone (.../ca.pem), and a home directory is ... alone.

  • A value starting ... is a redacted local path. When lock.repository is one, the finding's origin.local is true.

  • A message lockrot cannot read whole is replaced by (withheld: lockrot could not redact this message).

  • baseline.path is relative to the project directory, or the file name alone; run.lock_file is the lock's name alone.

The one absolute path a report carries is SARIF's %SRCROOT% (ci.md).

To remove the registry label from origin, and only from origin, clear both keys. The document stays valid, since both may be null:

jq '.findings |= map(if .origin then .origin.registry = null | .origin.package_url = null else . end)' lockrot.json > lockrot.public.json

Validating in CI

Any draft-04 validator works. With check-jsonschema:

composer lockrot --fail-on=high --target-php=8.4 --format=json > lockrot.json || test $? -eq 1
check-jsonschema --schemafile https://lockrot.dev/schema/report-1.json lockrot.json
jq -e '.gate.fails | not' lockrot.json

|| test $? -eq 1 lets validation run after exit 1; exit 2 still fails the step (exit codes). The jq line keeps the gate: it fails the step when gate.fails is true.

To keep the check independent of lockrot.dev, pin the schema next to the workflow. Refresh the copy when you upgrade lockrot, so new fields are listed and a new signal's data is typed:

curl -fsSL -o ci/lockrot-report.schema.json https://lockrot.dev/schema/report-1.json
check-jsonschema --schemafile ci/lockrot-report.schema.json lockrot.json

Ajv needs three things for these files:

  • ajv-draft-04, for the dialect;

  • ajv-formats, for date-time;

  • x-known-values declared, since strict mode refuses a keyword it does not know: ajv.addKeyword({keyword: "x-known-values", schemaType: "array"}), or strict: false.

  • ci.md — every output format, and what each exit code means
  • verdicts.md — what the verdicts, signals and priorities in a report mean
  • notes.md — each run note code, its data and what to do about it
  • baseline.md — writing and updating the file baseline-1.json describes
  • configuration.md — the extra.lockrot keys config-1.json types, and --explain
  • compatibility.md — what 1.0 freezes beyond the schemas
  • changelog — the release each schema change shipped in