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 lockrotexits2on a baseline file or anextra.lockrotthat fails its schema (exit codes). At install time lockrot skips its install-time check and prints onelockrot: 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
idand S10'sblocks; - S10's
checkandreason; - S6's
reason, and S8'sfloor_source; - a finding's
libyears_unmeasured, and the keys of the report'slibyears.unmeasured; - a
priority_basisstep'sreason, and ano_fix_expecteditem'sreason; run.mode,run.fail_on_kindandgate.tripped_by, and a finding'sgate.exempt_by;- a finding's
origin.kindandorigin.registry; - a run note's
code, and theforge_idandreasonin itsdata; - the explanation's
php_blocked_by,misses_target_phpandmisses_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, plusx-known-values: the values this release writes. Every known value matches thepattern, 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:licencevalidates,Acme:Licencedoes not. -
A signal whose id the schema does not list validates with any object as its
data. A listed id keeps itsdatatyped:S2with S4's data fails, and so doesS2with nodata. A run note'sdatais typed percodethe same way. -
The keys of the report's
libyears.unmeasuredgrow withlibyears_unmeasured. A key a later release adds is typed as a count and never joinsrequired. 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-valuesas anenum. lockrot does this when it validatesextra.lockrot, so a mistypedformatis 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:
-
transitive, orunreachedwhenchainis empty: exactly whendirectis false. -
dev: the finding'sdevis true. -
no_fix_expected: the finding'sno_fix_expectednames 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:
fromthen equalsto. -
The first step starts at
base, each later one where the last ended, and the last ends atpriority. With no steps,priorityisbase. -
An unflagged verdict (
unknown,finished,ok) gives{"base": "none", "steps": []}. -
fromandtotake the priority order withoutnone. A step whosereasonyou 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,artifactorpackagerepository accounts for, such as one from atype: composerrepository that advertises no notify URL (asset-packagist.org, a Satis build withoutnotify-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
repositorieslist 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_repositoryis true when the lock entry carries anotification-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.--explainwrites it aslock.from_composer_repository, and the rootnot_from_composer_repositorycounts the false ones. It stays a boolean in 1.x;originis the finer account. -
registrytakes its values from the schema'sx-known-values, the hosts this release knows. Ofpackagist.org,repo.packagist.com,wp-packages.organdpackages.drupal.org, only packagist.org and wp-packages.org get apackage_url: Private Packagist keeps no public pages, and a drupal.org project page does not follow from the package name. -
package_urlis the page the registry keeps for that name. Whether this run's repositories still list the package is the finding'snote. -
replacement_urlis the page packagist.org keeps for an abandoned finding'sreplacement. 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 fromreplacement. -
localsays nothing about where. An explanation'slock.repositoryshows 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
artifactrepository 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): itstext, itsnotesentry, and amessageorcomposer_repositoryin itsdata; -
a finding's
noteandevidencewhen 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. Whenlock.repositoryis one, the finding'sorigin.localis true. -
A message lockrot cannot read whole is replaced by
(withheld: lockrot could not redact this message). -
baseline.pathis relative to the project directory, or the file name alone;run.lock_fileis 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, fordate-time; -
x-known-valuesdeclared, since strict mode refuses a keyword it does not know:ajv.addKeyword({keyword: "x-known-values", schemaType: "array"}), orstrict: false.
Related¶
- 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
dataand what to do about it - baseline.md — writing and updating the file
baseline-1.jsondescribes - configuration.md — the
extra.lockrotkeysconfig-1.jsontypes, and--explain - compatibility.md — what 1.0 freezes beyond the schemas
- changelog — the release each schema change shipped in