Changelog¶
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
0.13.0 - 2026-10-01¶
Act if you publish reports, set COMPOSER or an invalid LOCKROT_FAIL_ON or LOCKROT_TARGET_PHP,
use lockrot's PHP classes, validate with Ajv strict or a vendored schema, run self-update in CI or
set an egress allowlist from 0.12.0's SECURITY.md. A bad command line now exits 2, not 1. No
verdict or priority rule changed, but COMPOSER can change verdicts; old documents still validate.
Verdict changes¶
- Breaking: Config: with
COMPOSER=alt.json, lockrot readsextra.lockrotfromalt.json, keeps the default baseline beside it and analysesalt.lock, so its thresholds,ignoreandfail-onapply and verdicts can differ.github,gitlabandsarifnamealt.lock, with unchanged fingerprints. Action: keepextra.lockrotand the baseline with that manifest. (Environment overrides)
Security¶
-
Breaking: json, html: reports gain
run.root_package, thenameof the manifest Composer reads, to match a report to its repository.extra.lockrot.projectrenames the project and hides nothing, and no option hidesroot_package. Action: if you setprojectto keep a client's name out of published reports, remove the name from each report before publishing it. (What the run was told) -
All formats: no report and no
--explainoutput quotes a credential or a machine path (SARIF's%SRCROOT%aside): a URL loses userinfo, query and fragment, a path keeps its last segment,baseline.pathkeeps no absolute path. Earlier releases printed logins and URL tokens whole. Action: check older published reports; rotate any token. (What a report says about your repositories) -
CLI: text from outside lockrot (a package name, version or constraint, a note, an error message, an
--explainargument, aself-updatetag or URL) prints as written, never as console markup: it can no longer restyle a line, open a terminal link or end the run with exit2. The install-time block and the-vBitbucket token warning show a control character as an escape (\x1B,\n). -
Baseline, CLI: the baseline and each
--outputfile are written through a temporary file created exclusively beside the target: a file or symlink already at that temporary name fails the write instead of being followed, and a replaced file keeps its permission bits. Action: give--outputa directory the job can write to; an interrupted run can leave a*.tmpthere. (What lockrot does and does not do) -
PHAR: each release publishes
lockrot.phar.meta.json(its PHP floor and signing key), whichself-updateand--checkread fromgithub.com; checksum and signature still decide what installs. A release ships only if it verifies against the previous release's key, so a rotation ships a release signed with the old key that carries the new one. (Which release it installs, Key custody and rotation) -
Docs:
SECURITY.mdlists every host lockrot contacts, every credential it reads and every file it writes. Earlier releases also contacted GitLab, Bitbucket and GitHub's release downloads and read the GitLab token variables and Composer's credentials. Action: recheck a review or an egress allowlist based on the 0.12.0 page against this list. (What lockrot does and does not do)
Added¶
-
CLI:
--output=<format>:<path>, repeatable, writes several reports from one run, each as its--formatprints it (atablefile has no colours and is 120 columns wide). A relative path is relative to the directory lockrot runs in (-dchanges it); the directory must exist. A path tocomposer.json, the lock or a baseline, and--outputwith--explain, are refused (exit2). (Writing reports to files) -
Config: an
extra.lockrotkey lockrot does not read gets a line on stderr, above the install-time block too, naming the nearest known key when one is close (did you mean install-time?). When the schema rejectsextra.lockrot, the error lists the unknown keys too.extensionsand keys starting withx-are never warned about. (Unknown keys) -
json:
gatesays whether the run fails and why:fails,tripped_by(strict_network,fail_on) andfail_on_applied. Each finding'sgategivesreaches_fail_on,exempt_by(baseline) andfails, andrungains the inputs:strict_network,modeandfail_on_kind. (The gate) -
json:
note_detailstypes each entry ofnotes, at the same index:code,text,docs_url(the code's section of Run notes),sets_network_failuresanddata, typed per code. The--explaindocument carries it too. (note_details) -
json: each finding says where its lock entry came from:
origin(kind,registry,package_url,local),from_composer_repository(whether lockrot asks a repository about it) andreplacement_url(the replacement's packagist.org page, where packagist.org named it).origincopies nothing from the lock's or the manifest's URLs. (Where a package came from) -
json: each finding shows how its priority was reached.
priority_basisgives thebaselevel and every step that applied as{reason, from, to};no_fix_expectedlists the advisories no fix is expected for, as{id, reason}; S9'sdatagainsreleases_read. (Priority) -
json:
libyears_unmeasuredsays why a finding'slibyearsis null, as one of the keys oflibyears.unmeasured.libyears.unmeasuredaccepts keys it does not list, each a count (Open sets). (Libyears) -
json:
exposure_rule.max_fan_in: a flagged transitive package reached from more direct requirements than this is attributed to none of them;unattributedlists those packages (package,verdict,fan_in). (Shared packages) -
json:
run.project_phpis the project's ownrequire.php. In--explain --format=jsoneach release branch says whether the project can move onto it:admits_target_php,admits_project_php,php_blocked_by, andmisses_target_phpandmisses_project_php(which side of each floor it misses); the document gainsproject_php. (Within reach) -
json: S6's
datasays whether a pinned package ever released:reason,has_stable_release,last_stable_release,last_stable_version,last_stable_dated_byandsnapshot_time. The--explaintext prints those that have a value. (Pinned: what S6 carries) -
Docs: Compatibility drafts what 1.0 freezes, the verdict-change policy and deprecation.
Changed¶
-
Breaking: json, Config: the value sets that grow in minor releases (signal ids and others, see Open sets) are open strings in the published schemas: a
patternplusx-known-values. The schema URLs serve the newest release's files, so a validator that fetches them is affected even if you stay on 0.12. Action: with Ajv strict, declarex-known-valuesor setstrict: false; refresh an earlier vendored copy. -
Breaking: Config: an invalid
LOCKROT_FAIL_ONorLOCKROT_TARGET_PHPis a configuration error (exit2) naming the variable, even when--fail-onor--target-phpoverrides it. Action: fix or unset the variable. (Environment overrides) -
Breaking: self-update:
--checkexits1only when an update would install. With or without--check, it exits2when it cannot read the chosen release'slockrot.phar.meta.json, or when a newer release it would take is signed with a key this archive lacks and none installs. Action: where onlyapi.github.comis reachable, allowgithub.comand the host it redirects to. (self-updateexit codes) -
Breaking: self-update: from a 0.13 archive on, installs only within the running major (all of 0.x is one;
--allow-majormoves one) and skips, saying why, a release needing a newer PHP or a key it lacks. With nothing newer to install,--forcereinstalls the newest release at or below the running one in its major, else exits2. Action: a job running--forceshould allow exit2. (Which release it installs) -
Breaking: PHP API: every class, interface and trait under
src/, the plugin class included, is marked@internal, so PHPStan and IDEs can flag its use from code outsideLockrot\; the namespaceLockrot\Extension\is reserved. Action: depend on the CLI and thejsonreport, not on lockrot's classes. (Names reserved for extensions) -
html: the page is lockrot-report 0.13.0 (changelog). It reads the fields this release adds, among them
gate,priority_basisandrun.root_package. -
Docs: the semver promise, drafted for 1.0, covers the command line,
extra.lockrotkeys and environment variables (not the testing hooks),json,sarif,gitlab,github, the reporthtmlembeds, the baseline andlockrot-action@v1's inputs; nottable,markdown, the look ofhtml, the--explaintext, the install-time summary orsrc/classes: parsejson. (What 1.0 freezes) -
Docs: a change that can move a verdict, a priority or what
--fail-on=uncheckedmatches ships only in a minor release, under Verdict changes; the one patch exception is a curated-data fix that moves a package tofinishedorok. (Verdict changes) -
Config:
LOCKROT_RELEASE_URL, the testing hook that pointsself-updateaway from GitHub, names a release list in the shape of GitHub'sreleasesAPI instead of one release document. (Testing hooks)
Fixed¶
-
Breaking: CLI: a command line lockrot cannot read (an unknown option, a missing value, a value given to a flag, an extra argument) exits
2with onelockrot:line, fromcomposer lockrotandself-updatealike, instead of Composer's error box and exit1. An unknown command name is still exit1. Action: a job that allows exit1now fails on a bad option; fix the command line. (Exit codes) -
Config:
LOCKROT_DISABLE=1skipscomposer lockrotbefore anything is read, so a broken configuration or a bad option no longer exits2under it. (Environment overrides) -
Config, Baseline: a key starting with a NUL byte is a configuration error (exit
2) instead of switchingextra.lockrotvalidation off, and a number too large for a float (1e400) under a key that wants an integer is exit2instead of a crash with exit1. In the baseline file both give abaseline file is invalid:message naming the key or field. (extra.lockrotkeys) -
Docs: a GitLab merge request shows only the
--format=gitlabfindings that are new or fixed compared with the target branch, not every finding in thecomposer.lockdiff. (--format=gitlab) -
Docs:
--fail-on=uncheckedreads S10, not the verdict, so anokpackage carrying S10 fails the run.finishedranks equal took, lowest in the severity order. (What was not checked) -
Docs: an advisory with no fix expected raises the priority reached after the transitive and dev steps, so a transitive production
left-behindpackage with one ishigh, notcritical. (Priority) -
Docs:
not_from_composer_repositorycounts every lock entry without a Composernotification-url, which includes a Satis build withoutnotify-batch. (Not from a Composer repository) -
Docs: the
pulled in by:line appears only whenexposureis not empty, andexposurelists a direct requirement only when it pulls in an attributable flagged package. (Transitive exposure) -
Docs: to keep the JSON report beside an
htmlpage, write both from one run with--output=json:lockrot.json. The earlier recipe that cut the report out of the page wrote an empty file when the page did not match. Action: if you copied it, replace it with--output. (The JSON beside the page)
0.12.0 - 2026-09-24¶
Changed¶
- The
--format=htmlpage is built in its own repository now, somework/lockrot-report (0.12.0), in TypeScript with tests in real browsers, and vendored here as one file with the manifest of its release. The report and its address format are the same; the page carries its own Content-Security-Policy, which pins its one script and one stylesheet by hash and allows no connection, so a host serving it no longer needs'unsafe-inline'for styles. On a phone the findings come first. The fixes that came with the rewrite — a bad escape in the address no longer blanks the page, the theme button is right the first time under a dark OS setting,j/kwalk the rows on screen, a release-branch label in the timeline wraps instead of being cut short, and a dozen more — are in the renderer's changelog.tools/report/update-renderermoves the pin after checking the release's build provenance.
Added¶
tools/corpus/, the corpus checks, which read lockrot's output against the data lockrot read rather than against lockrot. Two of them had been ad-hoc scripts living in a scratchpad: one audits every finding's libyears and signal claims against the raw Packagist documents in the run's own cache, re-deriving Composer's version semantics and lockrot's date-trust rules independently; the other reads every--explainpage against the JSON it was rendered from. On the 0.11.0 branch they found 31 and 6 real problems respectively, over 4,124 findings and 283 pages, and nothing after the fixes. Nothing in the tool may import or shell out to lockrot's PHP: a checker that asks the subject what it is about agrees with it by construction.- The corpus is now pinned.
tools/corpus/corpus.lock.jsonnames 39 projects — 21 already pinned by git as recorded fixtures, 18 by commit with a digest per file — so a run is reproducible instead of being whatever those projects' default branches held that day, and--todaypins the clock so the calendar cannot be filed as a code change. - An offline
corpus-selftestjob on every pull request and every push to main, which is the part that proves the checks have not quietly stopped checking: every rendered sentence they read is registered with a declared minimum or a dated note that it is unexercised, every problem key a check can emit has a recorded document mutated to make exactly that key come out, and a sentence that no longer parses is reported rather than skipped. It does not watch lockrot's wording — a frozen page cannot — which a re-record or a corpus run does. Recording the Composer version oracle found three faults in the checker itself, affecting 3,346 of 96,599 recorded versions.
0.11.0 - 2026-09-22¶
Added¶
-
S10: what the run could not check.
okmeant two things — every check ran and found nothing, or a check never ran — and the difference was invisible on the finding. Anonymously the activity round asks only about packages already stale on release age, so a repository archived a week after its last release cannot be seen, and the only trace was a note counting how many packages were skipped without naming one. A package whose newest releases are dated by a commit their tags share has the same shape: S2 has nothing to measure, and the finding read asok. S10 carries the missing check on the finding, with the reason and the signals it blocked (repository_activity→ S3, S4;release_dates→ S2, S8). It is informational, like S7 and S9: it never decides a verdict, and it is raised only where the missing check could have changed one — never on a package the repository already marks abandoned, nor on an allowlisted one. Credentials for every host take away therepository_activityreasons and only those —release_datesasks no forge — and a branch snapshot, which is on no release branch, is told about S2 alone.--fail-on=uncheckedfails a run that could not check everything, which is how a pipeline catches the workflow that never passedGITHUB_TOKENthrough. See What was not checked. -
One number for how far behind the lock is: libyears. For each package, the years between the release installed and the package's newest stable release, summed over the analysed lock — 171.3 on the wallabag fixture. Every finding carries its
libyearsin--format=json(nullwhen not measured: a branch snapshot, no dated stable release, not from a Composer repository, metadata unavailable), and the document alibyearsblock that is the arithmetic over them —total,direct_requirements(the same sum over the direct requirements, the nearest number to php-libyear's, which readscomposer.json),measured,unmeasuredby reason andfurthest_behind.totalanddirect_requirementsare null where nothing could be measured, so that a reader adding the field up over several projects never counts an unmeasurable lock as a lock with nothing to fix; a measured lock with nothing behind reports0. The table and markdown footers print one line (libyears: 171.3 behind across 195 of 200 packages · 111.7 from direct requirements · furthest behind smalot/pdfparser v1.1.0 at 4.7); the HTML page shows the total in its ledger, a sortable column, and the counts by reason on the Run tab;--explainprints the package's own value under its verdict, or the reason it was not measured. It is laid over the verdicts, not one of them: it counts every drift, healthy patches included, and enters no priority,--fail-onor baseline. The schemas gain the fields under the same number, optional so that older documents still validate. No new request is made: both dates were already in the data. A split package's installed version is dated by its monorepo parent's tag of the same version, not by the lock: the lock copies the date Packagist gave the split's tag, the commit its tags share, and illuminate/contracts v8.83.27 would read as 4.65 libyears behind for 3.75.--explainprints that date asinstalled releaseand names the monorepo it came from. That holds for an installed tag on a shared commit under a branch whose newest tag has a commit of its own, as after a change to the split's directory: the parent is asked for the installed version, not only for the branch.--explainsays whose date it is underinstalled_releaseandinstalled_release_dated_by, and so does the package card. Where no parent dates it, such a tag is not measured at all rather than measured from the commit's date: the installed tag's own entry in the repository decides that, not what dated the package's newest release. -
left-behindsuggests a branch the project can actually move to. S8 named the newest releasing branch and wrote the constraint that follows it, whatever PHP that branch requires: Matomo supportsphp >=7.2.5, locks monolog 1.27.1, and was toldrequire ^3.12— monolog 3.x needs PHP 8.1. On the weekly watch, 99 of the 138 constraints suggested to projects that declare arequire.phpcontradicted it. Every release branch now carries the php requirement of the release it names (read from the same repository data, no new request), and S8 holds the higher branches to two floors — the project's ownrequire.phpand the target PHP, the version Composer resolves against. The newest branch still proves the upstream moved on; the branch the evidence tells the project to follow is the newest releasing one within both floors, and when that is not the newest the line says what holds the newest back:3.x released 3.12.0 (2026-09-09), needs php >=8.1 above the project's php >=7.2.5; 2.x released 2.11.1 (2026-09-02); require ^2.11 to follow. With no releasing branch within reach it says so and suggests nothing: the way forward is a PHP upgrade, not acomposer.jsonline.--format=jsongainsnewest_php,newest_within_reach,floor_php,floor_sourceandreachable_branch/_version/_releaseon the signal;--explainshows each branch's php requirement in the branch table and in the JSONbranchesrows. See Within reach. -
abandonedsays whether there is somewhere to go. Packagist's marker comes with a free-textreplacement, and on the weekly watch 19 of 72 abandoned packages carried one — 17 naming a package, two namingSymfonyand a sentence. The verdict stays one (it is what--fail-on, the baseline, the SARIF rule and every count key on); what changes is the reading. Each finding carriesreplacementin--format=json— the named package when it is a Composer package name, null otherwise, the free text staying in the evidence as text — the document carries"abandoned": {"total", "with_replacement"}next tocounts, the summary line readsabandoned 7 (6 with a replacement)where that is not zero, and the HTML page tags the row with the replacement and links it on the card. Both fields are optional in the schemas. See Abandoned, and where to.
Changed¶
-
One reading of the installed version's date. The lock's
timeis a release date only sometimes — a branch snapshot carries its commit's, a subtree split's tag the date of a commit its tags share, a monorepo parent can date the version instead — and each surface re-derived that for itself, which is how one explanation came to call a date a release four lines above saying it was not one.InstalledReleaseanswers it once. No output changes. -
old-promisereads the date against the target's major, not its minor. S5 held a release against the GA of the target PHP minor: anything older than 2024-11-21 with a>=7.xconstraint was an old promise about PHP 8.4. That caught the wrong thing. A>=7.2cut in 2022 was written with PHP 8.1 on every CI matrix and differs from a^7.2 || ^8.0of the same day in spelling alone, yet only the first was flagged — and the evidence blamed the style (has no upper bound), which is Symfony's own convention. The line is now the GA of the target's major (8.0, 2020-11-26, for any 8.x target): the release predates the major it admits, and its constraint was written for an older one. On the weekly watch that is 22 of the 178old-promiseverdicts the minor line produced; the other 156 were releases of the PHP 8 era. The evidence readsreleased 2020-01-11 for PHP 5 (php ">=5.3.2"), before PHP 8 existed (8.0 GA 2020-11-26); admits 8.4 untested. In--format=jsonthe signal'sga_dateis now the major's, next to a newtarget_major, andwritten_for_phpnames the constraint's lower major. The verdict, its priority and--fail-onare unchanged; a project's count ofold-promisewill drop.
Fixed¶
--explainsays what the lock's date is a date of. Thecomposer.lockblock called that date a release whenever the entry carried one, so a branch snapshot readreleased 2026-09-21 · branch snapshotand a subtree split readreleased 2023-06-05four lines above the block saying that same date is a commit its tags share, not a release. It now readsdated … by its commitfor a snapshot anddated … by a commit its tags sharefor a split, andreleased …only where the repository dated the version by a release. The JSON keeps itslock.releasedfield, whose schema description now says which of the three it is carrying.- The report schema accepts the empty chain a run can really produce.
chainwas declared withminItems: 1, and two ordinary runs break that: acomposer.lockwith nocomposer.jsonbeside it has no direct requirements at all, so nothing reaches any package, and a lock can hold a package only the skippedrequire-devasks for. Every finding of such a run was valid JSON that its own published schema rejected. Documents themselves are unchanged. migrate tonames a package, or says nothing. The clause read the abandoned marker as the repository wrote it, so free text became an instruction —no fix expected; migrate to Symfony,migrate to EnglishInflector from the String component— and a repository that names the package itself sent the reader back to what they were leaving, while the newabandoned.with_replacementcounted it. The clause, the JSONreplacementand that count now all read the same validated successor: a Composer package name that is not this package's own. The repository's text is still shown in the evidence, as all free text is.
0.10.0 - 2026-09-22¶
Security¶
- A repository URL no longer carries its credentials into a report. A private Composer source is
routinely configured with a token in the URL —
https://gitlab-ci-token:$CI_JOB_TOKEN@…is how GitLab CI hands a job access to one, and Bitbucket app passwords take the same shape — and Composer keeps it in the lock because it has to fetch with it.--explainprinted that value verbatim, in the text output as thesourceline and in--format=jsonaslock.repository, so a pasted terminal buffer or an uploaded artifact carried a working token. Every repository URL lockrot prints now has its userinfo removed, the host kept (Lockrot\Data\Repository\RepositoryUrl). Affects 0.8.0 and 0.9.0, where--explainwas the only path to it.
Added¶
- The report says what it was decided against. Every verdict depends on settings the document
did not record: the thresholds separate
stalefromsilent, the target PHP decides whether a release predates it. Until now--format=jsonnamed the target in exactly one place — inside the data of an S5 signal — so a run where S5 never fired left no trace of what it aimed at, and the thresholds left none at all; two people comparing two reports could not tell whether they differ because the locks do or because the settings do. The report now carries arunblock with the project's own name from composer.json — until now nothing in a report said which project it was about, every lock being called composer.lock, andextra.lockrot.projectoverrides it where the manifest has no name or where its name is not the one to publish — the target PHP, the thresholds, thefail-on, the name of the lock (never its path) andflagged_verdicts, the verdicts the run counted as findings. Each finding also carriesbaseline, where it stands against the baseline file —known,neworworsened, with the verdict the baseline accepted — beside the totals thebaselineblock already gave. Both are optional in the published schema, so documents written by 0.9.0 still validate. --format=html: the whole run as one self-contained page. It carries the report, the release branches behind every finding and the baseline comparison inside a single file, so it opens fromfile://, uploads as one CI artifact and attaches to a ticket — no server, no network, no fonts or scripts fetched from anywhere. What a stream cannot show: one line per signal instead of one sentence with four semicolons in it, every release branch on a time axis with the installed one marked, advisories grouped by whether the fix is a patch on your own branch or a move to another, and what is new or worsened since the baseline. Filters and the open package live in the URL hash, the query understandsverdict:,priority:,signal:,severity:,cve:,direct:anddev:, and?opens a glossary of every verdict and signal. A report is usually read by someone who did not run it, so the page closes with the two commands that produce the same page for their own lock. The payload'sreportkey is byte for byte what--format=jsonwrites, envelope included, so it validates against the published report schema.--allputs every package in the page, at roughly 4 KB each; without it a 100-package lock lands around 250 KB. The page carries a description and an Open Graph card so a shared link says what was found, and norobotsdirective: whether a published report may be indexed is the publisher's call, made in their robots.txt, not this file's. See ci.md.
0.9.0 - 2026-09-20¶
Added¶
-
Published JSON schemas for every document lockrot writes for a machine, and the one it reads: the
--format=jsonreport (https://lockrot.dev/schema/report-1.json), the--explaindocument (explain-1.json), the baseline file (baseline-1.json) andextra.lockrot(config-1.json). The report, the explanation and the baseline file now open with a$schemakey naming theirs, so an editor completes a baseline file and a CI step can validate a report with any draft-04 validator. The files ship underresources/in the repository and the PHAR. Objects are open: under one number a document only gains fields, and the number moves only when a field is removed or renamed —lockrot.schemastays1. The test suite validates what the formatters write, and the JSON samples in the docs, against the files, with a strict copy that rejects undeclared fields. See schema.md. -
A split package's release branches are dated by the monorepo they are cut from. Since 0.8.0 a tag sharing its commit with two others is read as undated, which stopped the false
left-behindonilluminate/macroablebut also stopped measuring a branch that really did end. The monorepo's own tag for the same version carries the release date, andreplace: {illuminate/contracts: self.version}says the two are one release, so where a branch of the split package has no date the branch of the same name in its parent supplies one. A lock onilluminate/contracts v5.8.36reported nothing in 0.8.0 and now readsbranch 5.x last released 2020-08-18 (6.1 years ago, dated by laravel/framework); 12.x released v12.69.2, and every other Laravel component on a branch of its own is measured again rather than skipped. Laravel's late security tags on 6.x, 7.x and 8.x are recent enough that those branches read as current under the default thresholds; being measured is the difference, not the verdict. The parent is taken from the lock when it is there; otherwise it is loaded from the configured repositories, one request, and only whenresources/monorepo-parents.jsonlists that monorepo as carrying a package this lock needs dates for —laravel/framework,symfony/symfonyandcakephp/cakephpwith the components each replaces, refreshed bybin/refresh-monorepo-parents. A lock without such a package fetches nothing, and neither does one whose undated packages belong to no listed monorepo, which is the common case:symfony/polyfill-*is cut by a repository no Packagist package replaces. An install-time run that has used up its budget skips the request and the branch stays as it was. What a parent dates is its livereplacelist, never the snapshot.--format=jsoncarries the parent asdated_byon S8 and S2, and--explainmarks the branch rows it supplied.
0.8.0 - 2026-09-20¶
Added¶
--explain=vendor/package: one package, everything it was decided on, one call. The verdict and priority with how the package is reached; every signal with its summary and raw data (the dates the years were computed from, one line per advisory with what fixes it and where); thecomposer.lockentry; the repository metadata with the table S8 reads — every release branch, its highest tag and that tag's date, the installed branch marked, and a line saying so when that branch's highest tag is undated and S8 therefore does not measure it; the repository activity; the thresholds and target PHP; the run's notes. Text, or the same as JSON with--format=json. Exit 0 — it answers a question, it does not gate; a package not in the lock, or inpackages-devwithout--dev, is a configuration error (exit 2). See configuration.md.left-behindsays what to require. S8 carriessuggested_constraint— the constraint that follows the upstream onto the branch fixes land on, written ascomposer requirewrites it (^8.2from 8.2.0,^0.4.3below 1.0) — and for a package the project requires itself the evidence ends…; 8.x released 8.2.0 (2026-09-06); require ^8.2 to follow. A transitive package's parent owns that line, so there the clause stays off the row and the constraint stays on the signal's data.no fix expectedsays where to go when there is somewhere: on anabandonedpackage whose repository names a replacement, an advisory nothing fixes readsno fix expected; migrate to symfony/mailer— the fix is not coming here, and the package that took over is where it lands.
Fixed¶
- A false
left-behindon packages split out of a monorepo. A subtree split (illuminate/*,symfony/*) cuts a tag on every release whether or not the directory changed, so tags pile up on one commit and Packagist dates each of them by that commit:illuminate/macroablehas 83 stable tags on the commit behindv10.49.0, all dated 2023-06-05, and a lock on 10.x read asbranch 10.x last released 2023-06-05 (3.3 years ago)while Laravel 10 kept releasing. A tag that shares its commit with two or more other stable tags is now read as undated — the date is the directory's, not the release's — so S8 does not measure the branch and S2 does not measure the package. Two tags on one commit keep their date: a re-tag, or a branch's last two releases cut with nothing changed between them (symfony/*3.4.46 and 3.4.47), where the date is one release interval off at most — so a Symfony 3.4 lock still readsleft-behindon every component. The cost is the other way: a split branch that really did stop and piled up more tags (illuminate/contracts8.x, 31 on one commit) is no longer reportedleft-behind, since its last tag is dated the same way. Dev branches and pre-releases on a tag's commit do not count.
Changed¶
- The footer's advisory line says why
composer auditcounts more. Without--devit now reads… the report does not flag; see composer audit (it counts packages-dev too, which this run skipped; pass --dev to include them): plaincomposer audittotalspackages-devand a plain lockrot run does not, and the difference should read as the scope it is.--format=jsonrecords the scope asinclude_dev.
0.7.0 - 2026-09-19¶
Added¶
left-behind: a verdict for the branch you are on, not the package. Signal S8 takes the newest stable release on the installed version's release branch (1.x;0.3.xbelow 1.0; the patch alone below 0.1 — what a caret constraint stays inside; pre-releases do not count), measures its age againstrelease-warn-years/release-high-years, and fires only when a higher branch has released since and withinrelease-warn-yearsof today — a package dead on every branch stays S2's.composer outdated --major-onlysays a newer major exists; S2 sees the package's newest release and stays quiet; this says the branch installed here gets no fixes. A branch whose highest tag the repository leaves undated is not measured: Packagist dates a tag by its commit, and a subtree split (illuminate/*,symfony/*) has undated tags and tags dated by the last change to the directory, years before the release. The verdict isleft-behindat either threshold — betweenpinnedandold-promisein severity, base priorityhigh; the signal's level records whether the branch has also passedrelease-high-years. The evidence readsbranch 1.x last released 2021-08-03 (5.1 years ago); 2.x released v2.12.5 (2026-04-17)— the higher branch whose release is newest, named as the branch fixes land on.- Security advisories on the finding. Signal S9 carries the advisories that affect the installed
version — the same ones
composer auditreports, fetched through Composer's own advisory API from the configured repositories — withid,cve,title,link,severityandreported_atunderdata.advisoriesin--format=json. Advisories the project ignores in Composer's own configuration (config.policy.advisorieson 2.10+,config.audit.ignorebefore) are dropped ascomposer auditdrops them. S9 never decides a verdict. Each advisory is held against the highest stable tag on the installed version's branch and the package's highest stable tag, each only when above the installed version; one out of both ranges is already fixed, and the line says by what (fixed by 6.3.0;1 fixed by v3.4.47, 3 fixed by v8.1.7when they differ), withaffected_versions,fixed_byandfixed_on_branchon each advisory in the JSON. On anabandoned,silentorleft-behindpackage the advisories nothing listed fixes — on a left-behind branch, nothing listed on the branch — earnno fix expected(no fix expected on 3.xnext to a fix in a higher branch) and the priority goes up one step,criticalat most; a package whose every advisory is fixed by a listed release is not raised. The three advisories named on the line are the worst by severity. Advisories on packages the report does not flag stay off the rows and are totalled in the footer:53 security advisories on 17 packages the report does not flag; see composer audit. On Composer 2.2, under--offlineand once the install-time budget is spent the report carries one note instead — each says that a priority the advisories would raise stays one step lower; a repository that could not be reached for advisories is a note and, under--strict-network, exit1.
Changed¶
- A package on a quiet branch of a living upstream is now
left-behindwhere it wasok,staleorold-promise;--fail-on=left-behindsees it. A baseline holding such a package atstaleorold-promise— both belowleft-behindin severity — reports itworsened. - A priority threshold can trip on a verdict that did not move: an advisory on an
abandoned,silentorleft-behindpackage liftshightocritical, so--fail-on=criticalnow fails on it.left-behinditself starts athigh, so a run that passed--fail-on=highcan fail on a package that wasokbefore. The baseline, keyed on the verdict, still calls the findingknown. - S2 (no stable release for years) stays quiet when the package's highest non-dev tag carries no
release date: "last release" would otherwise date the newest tag the repository dated, and say
nothing about the undated ones above it. Before, such a package could read
staleorsilentfrom a date that was not its last release's. --strict-networknow covers the advisory request too: every repository that publishes advisories is asked, ascomposer auditasks them, including one the metadata pass never reached because Packagist had already answered for every name. A private repository that is down fails a strict run where it used to pass unnoticed.- The counts, priority and
pulled in by:lines fold between their·-separated items, never between a label and its number. The install-time block shows up to three notes, one per source that could not answer. - The evidence line opens with the signal that decided the verdict —
pinned to branch snapshot dev-masterbefore the php-constraint clause on apinnedrow,repository archived on GitHubright aftermarked abandoned— then the rest in signal order, then what the package pulls in.--format=jsonkeeps the signals in signal order; GitLab fingerprints read package and verdict only and do not change. - The counts line gained
left-behind; theverdictenums in the config and baseline schemas, the SARIF rule list and--fail-onaccept it. The JSONschemanumber stays1: an added enum value and two new signal ids, nothing removed or renamed.
0.6.1 - 2026-09-18¶
Fixed¶
phive install somework/lockrotworks again. PHIVE takes any release asset ending in.ascor.sigfor the GPG signature of the PHAR, last one wins, and 0.6.0's self-update signature was published aslockrot.phar.sig— so PHIVE tried to verify the archive with a JSON document and failed. The asset islockrot.phar.sig.jsonfrom this release on, and was renamed on the 0.6.0 release as well. The 0.6.0 archive looks for the old name, so it cannotself-updateto this or any later release: it reportsrelease v0.6.1 has no lockrot.phar.sig assetand leaves itself in place — download 0.6.1 by hand orphive update. Every other build, 0.5.0 included, updates as before.
0.6.0 - 2026-09-18¶
Added¶
self-updateverifies the release signature. Every release from 0.6.0 on publisheslockrot.phar.signext to the archive — an RSA signature (PKCS#1 v1.5 over SHA-384) by the new lockrot self-update key, in the{"sha384": "<base64>"}file format Composer uses for its own self-update — and the archive checks it withopenssl_verify()against the public key built into itself before anything is written, after the sha256 check it already made. A release signed with a key the archive does not know, a signature over other bytes, or a signature file that is not one is reported and not installed. The key is RSA 4096, separate from the GPG release key (which still signslockrot.phar.ascfor people and PHIVE), published aslockrot-selfupdate-key.pubin the repository root; SECURITY.md says how it is rotated. The archive running 0.5.0 still checks the checksum only when it updates to 0.6.0; releases before 0.6.0 carry no.sig, so a 0.6.0 archive cannot--forceits way back to one.- The PHAR is built reproducibly.
build/build-phar.shon the tagged commit — with Box 4.7.0, which the script downloads and checks, and the Composer version the release workflow pins at that tag (tools: composer:…in.github/workflows/phar.yml) — produces the archive byte for byte, whatever the PHP version, so a release can be verified against its own source without trusting the builder: dependencies come from the committedbuild/phar/composer.lock, the autoloader suffix, the PHAR alias and the recorded package versions are fixed, every file inside the archive carries the commit date of the build (orSOURCE_DATE_EPOCH), only an allowlist of lockrot's own files goes in, and Box compiles the files in sorted order. CI rebuilds every commit on a second machine and compares the bytes.
Changed¶
- The mutation-testing gate now covers the whole source tree — self-update, the Composer integration, configuration, the baseline, the allowlist, the graph and the data layer — instead of the verdict engine, the signals, the analyzer and the output formats alone. No behaviour changes: the escapes it surfaced were closed with sharper tests, and a handful of statements that no test could ever observe were removed as redundant — in the allowlist merge, the baseline comparison, the dependency graph, the Composer cache adapter, the recorded HTTP client and the activity client.
- The PHAR's alias is
lockrot.phar; Box used to generate a randombox-auto-generated-alias-<hex>.pharfor every build, which alone made no two builds compare equal. Itsinstalled.phpnames lockrot asdev-mainwith no commit reference rather than the branch and commit of the build checkout. lockrot reads neither — its version comes fromsrc/Version.php— and the stub'sphar://lockrot.phar/…paths are the only use of the alias.
0.5.0 - 2026-09-17¶
Added¶
- Releases are signed. From this release on,
lockrot.phar.ascships next to the PHAR, a detached OpenPGP signature by the lockrot release key (39EC C3F6 4AE8 D06A 9A63 FD99 AB6F 7F52 AE51 3141, public half inlockrot-release-key.ascand onkeys.openpgp.org), together with a GitHub build-provenance attestation (gh attestation verify lockrot.phar --repo somework/lockrot). The release workflow verifies its own signature against the committed public key before it publishes anything.phive install somework/lockrotnow works. The sha256 checksum andself-updateare unchanged:self-updatestill verifies the checksum only.
Fixed¶
- A package is no longer
abandonedbecause an older release points at an archived repository. The repository asked about activity is the one its highest stable release names — itssource, else itssupport.source— then the lock entry's, and when none of those names one the package is judged without a repository-activity check (Packagist's ownabandonedflag still counts). phpstan/phpstan was reportedabandonedon every project that runs lockrot with a GitHub token: its recent releases carry nosource,support.sourcenames the live phpstan/phpstan-src, and three old releases point at a one-off build repository that has since been archived. Asupport.sourcein the shape Packagist fills in by default,<repository>/tree/<version>(or/src/<ref>on Bitbucket), is reduced to the repository first.
0.4.0 - 2026-09-16¶
Added¶
- Repository activity from GitLab and Bitbucket, next to GitHub. A package whose source lives on
gitlab.com, on any instance in Composer's
gitlab-domains, or on bitbucket.org now gets the S4 signal — the newest commit on any branch,last commit …in the evidence — and, on GitLab when the run has credentials there, the archived flag behind S3 (repository archived on GitLab). GitLab is never capped anonymously; Bitbucket is capped like GitHub until Composer has credentials for it. Credentials: Composer's owngitlab-token,gitlab-oauth,http-basicandbitbucket-oauthentries are used as they are, andGITLAB_TOKEN/LOCKROT_GITLAB_TOKENserve gitlab.com the wayGITHUB_TOKENserves github.com. S3 and S4 carry the host in their JSONdata; every existing key is unchanged and the JSONschemastays1. --fail-on(andextra.lockrot.fail-on,LOCKROT_FAIL_ON) accepts a priority —critical,high,mediumorlow— next to the verdicts, so a build can fail on an abandoned direct production requirement and pass on the same verdict in a transitive development package. The error/warning line of the github, sarif and gitlab formats follows the same threshold; the baseline stays keyed by verdict.- The report says how old its repository-activity data is. When any activity answer came from
lockrot's cache, the footer's source clause reads
(package repositories; repository activity from lockrot's cache, up to 23 h old)— under a day for a fresh hit, more after a failed refetch fell back to a stale entry or under--offline— instead of(package repositories, repository hosts). The table footer had that plain clause already; the markdown footer gains it.--format=jsoncarries the oldest cached answer's fetch time asactivity_cache_oldest_at(null when everything was fetched in the run). Schema stays1. A cache envelope whose fetch time cannot be read now counts as a miss instead of an answer of unknowable age.
Changed¶
--strict-networknow also trips when GitLab or Bitbucket is unreachable or rate-limits the run, the way it always has for GitHub. A repository a host does not answer for (private, renamed or removed) is now counted in a note —GitHub did not answer for 2 repositories …— instead of passing for a healthy one; the rate-limit note counts repositories, as it always did, and now says so.- An empty
--fail-on=is a configuration error (exit 2), like an empty--baseline=, instead of silently falling back to the next source. - The table footer reads
Data as of … (package repositories, repository hosts)instead of naming GitHub alone. - The mutation-testing gate now covers the analyzer and every output format as well as the verdict engine and the signals (MSI threshold 97, from 94 over the narrower scope). No behaviour changes; the code the mutants proved unreachable or redundant is gone, and the behaviour they proved untested is now tested.
0.3.0 - 2026-09-16¶
Added¶
- Transitive exposure (signal S7). A transitive finding now names every direct requirement it is
reachable from, not only the one its shortest chain starts from:
via a › b, also via c, din the table, markdown, GitHub, GitLab and install-time formats, anddirect_dependentson every finding in--format=jsonand in SARIFproperties. Each direct requirement that pulls in flagged transitive packages gets an informational signalS7listing them, with the shortest chain from that requirement to each, so its evidence reads…; pulls in 15 flagged packages: hoa/compiler (abandoned), …. Apulled in by:line in the summary block (table, GitHub, markdown) and anexposurelist in the JSON document sum that up per direct requirement. A flagged transitive package reached from more than eight direct requirements is shared infrastructure and is left out of both. S7 never decides a verdict, a priority, the exit code or the baseline comparison; the JSONschemanumber stays1(a consumer that validatessignals[].idagainstS1–S6will meet the new valueS7).
Changed¶
--format=githubprints thepulled in by:line as a plain log line before the summary line;--format=markdownprints it as a paragraph between the table and the notes. The install-time block is unchanged: it shows neither the other parents nor S7.
0.2.2 - 2026-09-16¶
Added¶
- Documentation for the GitHub Action, somework/lockrot-action,
and the Docker image it publishes,
ghcr.io/somework/lockrot.
Fixed¶
--format=markdownrenders everything that comes from the project or from package metadata as plain text: names, versions, evidence (including an abandoned package's replacement string), notes and the baseline file name are backslash-escaped, so a craftedcomposer.lockcan no longer put an image, an HTML tag or Markdown link markup into the pull-request comment or job summary the report is posted to (a bare URL still autolinks, as plain text does on GitHub). A package name containing a backtick gets a code-span fence longer than the name can close.- Repository-activity checks no longer fail with
HTTP/2 401("Bad credentials") when Composer has agithub-oauthtoken for github.com — inauth.json, inCOMPOSER_AUTH, or written by setup-php on a CI runner. Composer adds its ownAuthorizationheader to every api.github.com request in that case, lockrot added a second one, and GitHub refuses a request that carries two. lockrot now sends none of its own when Composer does; the token resolved fromGITHUB_TOKEN/LOCKROT_GITHUB_TOKENstill lifts the 50-package cap. Before this, every run on a machine withgithub-oauthconfigured reportedGitHub unreachablefor every repository.
0.2.1 - 2026-09-16¶
Fixed¶
self-updateno longer ends withinclude(): zlib: data error(orinternal corruption of phar) and exit 255 after successfully replacing the archive: nothing that runs after the swap needs code from the old PHAR any more. The file was replaced correctly before; only the process's exit was wrong.
0.2.0 - 2026-09-16¶
Added¶
- Every finding now carries a priority next to its verdict:
critical,high,medium,low, ornonefor a package the report does not flag. The verdict sets a base level —abandonedandsilentstart atcritical,pinnedandold-promiseathigh,staleatmedium— which drops one step for a transitive package and one more for a development-only one, never belowlow. A package nothing in the project reaches counts as transitive. --format=json: each finding gainspriority,directanddev, and the document gains aprioritiesobject with all five counts next tocounts. These are additions, so theschemanumber stays1.- The exit code,
--fail-onand the baseline are unchanged and still read the verdict alone. - Every remaining format now names the priority next to the verdict, always as
<verdict> (<priority>).--format=githubputs it in each annotation's title (lockrot: abandoned (critical));--format=gitlabopens each issue's description with it;--format=markdowngains aPriorityfirst column; and--format=sarifcarries each result's priority asrank— the 0.0–100.0 number SARIF 2.1.0 defines for it,criticalbeing100.0— alongsideproperties.priority,properties.directandproperties.dev. - Nothing that decides an outcome reads the priority: the annotation level, the GitLab severity and
fingerprint, and the SARIF
ruleIdandlevelall still follow the verdict alone, and the SARIF document still validates against the official 2.1.0 schema.
Changed¶
- The report is ordered by priority first, then verdict severity, then direct dependencies ahead of transitive ones, then package name. The install-time summary shows the same order, so its 10-line budget now goes to the packages that apply most directly to the project.
- The default
tableformat is now a width-aware list grouped by priority instead of a five-column box table that only read on a very wide terminal. Groups come highest priority first, headed by their level and count (not flagged (N)for the rows--alladds); each finding takes one line for its verdict, package, version and requirement chain and as many as it needs for its evidence, wrapped to the terminal with continuation lines indented past the verdict column. Critical and high rows are marked in red, medium in yellow. The summary block keeps its wording and order and gains apriority: …line right after the counts, printed whenever the run flagged anything; its lines wrap to the terminal too, at the full width and with no indent. The format id and the machine formats are unchanged. - The terminal width is taken from
COLUMNSwhen it is set, otherwise from the console itself, falling back to 120 columns and never going below 40. - The evidence for the
abandonedverdict now readsmarked abandoned by its repositoryormarked abandoned in composer.lock, and the SARIF rule description says the same, so flagged carries one meaning across lockrot's output: a finding lockrot itself raised. - Package metadata and community files for the first Packagist release: a
descriptionandkeywordswritten for search, ahomepage,support.docsandsupport.security, plusSECURITY.md,CONTRIBUTING.md, issue and pull-request templates, and a Dependabot configuration.
Fixed¶
--format=json,sarif,gitlab,githubandmarkdownare now written raw, so a<in a constraint or a package name reaches the parser on the other end exactly as lockrot produced it.
0.1.0 - 2026-09-15¶
composer lockrot(aliascomposer rot) command reporting dependency rot incomposer.lock.- Signals S1–S6: abandoned flag, no stable release, repository archived, no repository push, old release with an open-ended PHP constraint, and pinned branch/hash snapshots.
- Built-in finished-package allowlist (
resources/finished-packages.json) plus a project-levelextra.lockrot.ignorelist with mandatory reason and optional expiry. - Install-time summary on Composer's
PRE_OPERATIONS_EXEC:composer require/update/installprint a block of at most 10 lines for the packages the transaction is about to install or update, above Composer's own operations list. Bounded by a 5-second budget, and it never fails an install — an error lockrot cannot interpret becomes onelockrot: install-time check skipped: …line. The block is silent only when the transaction was both checked and clean: if nothing is flagged but a lookup failed (exhausted budget, unreachable repository), a shorterlockrot: N of M changed packages could not be checkedblock carries the reason instead. - Two new
extra.lockrotkeys:install-time(on/off, defaulton) andinstall-time-strict(defaultfalse), which appliesfail-onat install time and stops the transaction before any operation runs.LOCKROT_DISABLE=1still silences everything. - The install-time pass's time budget is configurable via
extra.lockrot.install-time-budget(integer seconds, 1–120, default 5); like the other install-time keys it has no CLI option or environment override. - The install-time summary's
viachain now resolves even when the lock on disk predates the transaction's own packages — a--dry-runupdate, or a project's very first lock — instead of showing no chain for a package new to the dependency graph. - The "Repository metadata unavailable" note now breaks out each distinct reason with its own
count when a run hits more than one, e.g.
Repository metadata unavailable for 4 packages: not checked: install-time budget exhausted (3); connection refused (1), instead of only naming the first reason it reached. table(default) and--format=jsonoutput.--format=githubprints GitHub Actions workflow commands, one per finding, so flagged packages appear as annotations on their own line ofcomposer.lockin a pull request; findings at or abovefail-onare annotated as errors, the rest of the flagged ones as warnings, and the rows only--allshows as notices.--format=sarifprints a SARIF 2.1.0 document forgithub/codeql-action/upload-sarif, with one rule per verdict,composer.lockline numbers, and stable per-package fingerprints so code scanning can track a finding across runs. Exit codes are unchanged by the chosen format.- Baseline file:
composer lockrot --generate-baselinewriteslockrot-baseline.jsonnext tocomposer.json(path configurable via--baselineorextra.lockrot.baseline), and every later run compares against it, so--fail-ononly trips on findings that are new or worse than the ones the project accepted. Matching is by package name, so a version bump that keeps the same verdict stays accepted;first_seensurvives regeneration; baseline entries whose package has left the lock are reported as stale, never failed on. The table gains abaseline: N known · M new · K worsened · S staleline and marks rows(baseline)/(was stale), the JSON report abaselineobject, and SARIF aproperties.baselineper result — with known findings reported at notice/note level so annotations keep matching the exit code.install-time-stricthonours the same comparison. The baseline is the only file lockrot writes, only on that explicit flag, and it is written atomically; an unreadable, schema-invalid or unwritable baseline is exit 2, never a silently ungated run. --format=gitlabprints a GitLab Code Quality JSON report (one issue per flagged finding, every finding with--all), soartifacts.reports.codequalityputs findings inline in a merge request's diff. Severity mirrors the GitHub/SARIF level (major/minor/info); each issue's fingerprint is a stable hash of the package name and verdict so it survives a version bump or a reformatted lock. Report notes are not representable in this format and are dropped; use--format=jsonfor them.--format=markdownprints a PR-comment-shaped report — heading, a findings table, notes as bullets, a<sub>summary footer — forcomposer lockrot --format=markdown > comment.mdpiped intogh pr comment --body-file. A clean run prints a one-line heading with no table; with a baseline, a second line carries the same known/new/worsened/stale counts as the table format, and a verdict is bold only when it is not already accepted by the baseline.- Exit codes 0/1/2 driven by
--fail-on, with network failures defaulting to exit 0 unless--strict-networkis set. - Package metadata is loaded through the repositories configured for the project
(
ComposerRepository::loadPackages()), in their configured order — Packagist by default, but Private Packagist, Satis instances and mirrors are honoured the same way, with Composer's own authentication and proxy settings. The first repository to answer for a package name wins, and a repository that could not answer hands the name on to the next one. Composer's own metadata cache is reused and revalidated on every run; GitHub repository-activity responses keep a fixed 24-hour cache in Composer's cache directory. - Repository metadata is loaded in two passes: tagged releases first, the
~devbranch file only for packages with no tagged release — roughly half the requests on a cold cache (wallabag: 403 → 206). --offlineserves both from cache and never opens a connection, including when lockrot runs as a Composer plugin. A package with no cached metadata is reported as unavailable rather than as absent from the repository, so an empty cache cannot read as a clean result.- Optional GitHub token (
GITHUB_TOKEN/LOCKROT_GITHUB_TOKEN/Composergithub-oauth) for repository activity signals; runs without one at a reduced candidate budget. - Standalone PHAR build (
build/lockrot.phar) for use without adding a Composer dependency. lockrot.phar self-updatereplaces the running PHAR with the latest GitHub release: it readsreleases/latest, compares the tag against the running version with Composer's own semver, then downloads the archive and thelockrot.phar.sha256published beside it, refuses any download whose hash does not match, checks that the PHP runtime can open it, and swaps it in with the old file's permissions.--checkreports without downloading and exits 1 when an update is available, so CI can notice a release;--forcereinstalls the current version. Every failure is exit 2 with the running PHAR untouched and the temporary file removed; there is no rollback in 0.1, since earlier releases stay downloadable from GitHub. The command exists only in the PHAR — a plugin install is updated withcomposer update somework/lockrot— and it needs write access to the PHAR's own directory and nothing else.- The PHAR now answers to command names (
lockrot, still the default, andself-update) instead of being a single-command application. Composer's own commands and the inspected project'scomposer.jsonscripts are deliberately not registered, solockrot.phar listshows lockrot's two commands and nothing in that project can be installed, updated or executed through the PHAR. - Configuration in
extra.lockrotis validated againstresources/lockrot-config.schema.json; threshold values must be integers. - Releases publish
lockrot.pharwith alockrot.phar.sha256checksum; GPG signing and a Docker image are deferred to a later release. - The standalone PHAR (and any lock-only run without an existing Composer instance) sets
COMPOSER_ROOT_VERSION=1.0.0when the variable is unset: lockrot never reads the root package's version, so Composer's VCS probing (git/hg/fossil/svn, about 5 of the 6 seconds a PHAR run took on a lock-only directory) and its "could not detect the root package version" warning on stderr are skipped. - The "GitHub token not set" note counts packages whose repository activity was checked, not distinct repositories, so packages sharing one repository are no longer under-reported.