Measures how the cyclomatic complexity of PHP open source software evolved over time.
christopher-hertel.de/oss-complexity-report
Every repository on github.com that is mostly written in PHP can be submitted - a composer.json is not
needed, so wordpress/wordpress works just as well as symfony/console. Submitted repositories are
grouped by the GitHub account that owns them, and the start page and the overview chart focus on the most
starred ones.
- PHP 8.4 or newer
- Node 26 (see .nvmrc) & Yarn
- PostgreSQL - the migrations are written for it,
docker-compose.ymlstarts a 15 on port 8432, which is whatDATABASE_URLin.envpoints at - git, since analysing a repository shells out to it
- The Symfony CLI - every command below is written as
symfony console, which runsbin/consolewith the environment of the local web server
git clone git@github.com:chr-hertel/oss-complexity-report
cd oss-complexity-report
composer install
yarn install
yarn build
docker-compose up -d
symfony console doctrine:migrations:migrate
symfony serve -dThat is an empty report - it fills up by submitting repositories, either with the form on the start page or on the command line (see below). The fixtures submit a handful to start with.
Assets are bundled by Vite and wired into Twig by symfony/reprise.
Run yarn build for a one-off build, or yarn dev to start the Vite dev
server with hot module replacement - reprise picks it up automatically.
bin/check runs everything a pull request runs, plus prettier and yarn audit:
bin/checkIndividually, if only one of them is interesting:
symfony php vendor/bin/phpunit # add --filter testName tests/Some/FileTest.php for one
symfony php vendor/bin/php-cs-fixer fix # --dry-run to only report
symfony php vendor/bin/phpstan analyse # level 8
symfony console lint:yaml config --parse-tags
symfony console lint:twig templates
symfony console lint:containerThe tests cover the domain logic that is pure - parsing what people paste, mapping the GitHub API, deciding which releases count, rolling the report up into the trend. Everything that needs a booted kernel is not covered yet.
Analysing a repository clones it and runs phploc over every release, which is far too slow to happen while someone waits for a HTTP response. It is therefore handled by symfony/messenger with the Doctrine transport, and kept up to date by symfony/scheduler:
| Message | Does |
|---|---|
ScanForNewReleases |
fans out into one ScanRepository per submitted repository |
ScanRepository |
asks github.com for tags and queues an analysis if a release is missing |
AnalyseRepository |
clones, checks out every new release and measures it |
RefreshRepositories |
re-reads stars and metadata, which decide the order of the whole report |
Only AnalyseRepository is expensive. ScanRepository reads refs with git ls-remote, so the nightly
check neither clones anything nor touches a working copy that is being analysed, and the queue only ever
fills up with repositories that really did release something.
Two workers are needed - one for the queue, one for the schedule:
symfony console messenger:consume async -vv
symfony console messenger:consume scheduler_default -vvThe async transport may be consumed by several workers, scheduler_default must stay at exactly one -
otherwise the nightly run happens more than once. Checking out a tag rewrites a working copy, so an
analysis holds a lock per repository and a second worker on the same one waits instead of measuring
whatever the first one just checked out. The lock is a flock by default (see LOCK_DSN), which only
works if all workers run on the same machine - switch it to postgresql+advisory:// if they ever do not.
Failed messages are kept: messenger:failed:show lists them, messenger:failed:retry puts them back.
Clones in repositories/ are scratch space, not a cache. A working copy is only needed while releases are
measured - looking for new ones reads refs from github.com - so an analysis removes it when it is done, and
a repository that never releases again never occupies disk. That bounds the disk by what is being analysed
instead of by everything ever submitted, at the price of cloning again when a repository does release.
app:repositories:clean removes what predates that: working copies from before this behaviour, from
repositories that were renamed, and from workers that were killed mid-analysis. It skips whatever is being
analysed right now, so it is safe to run while workers are busy.
# resetting database and caches
symfony console doctrine:database:drop --force
symfony console doctrine:database:create
symfony console doctrine:migrations:migrate
symfony console cache:pool:clear cache.app
# submits a couple of well known repositories to start with - submitting queues them right away
symfony console doctrine:fixtures:load -n
# this clones and analyses them - the long one, run it until the queue is empty
symfony console messenger:consume async -vv
# fix the datasets where git history lies
symfony console app:data:fix -vvmessenger:stats shows what is left to do. Nothing queues the analysis by hand: a submission dispatches
it, and app:releases:scan picks up whatever a run left unfinished, since it asks github.com for the
releases a repository is still missing.
One repository that will not get through the queue is app:repository:analyse, which measures it right
here instead of dispatching it:
symfony console app:repository:analyse moodle/moodle -vvThat is what a worker would have done, only where it can be watched, given a memory limit and stopped -
an analysis a worker loses to a memory limit is never acked, so it comes back on the next delivery and
fails the same way, while the report keeps showing the repository as queued. --queue dispatches it for a
worker instead.
The schema is managed by doctrine/migrations, never by doctrine:schema:update - run
doctrine:migrations:diff after changing an entity and doctrine:migrations:migrate to apply what came
out of it. A deploy runs the pending migrations before it switches to the new release.
Errors are reported to Sentry - uncaught exceptions of the web app, of the console commands and
of everything the workers run. It is configured by SENTRY_DSN, which is empty everywhere but production:
without a DSN the SDK collects nothing and sends nothing, so nothing has to be switched off for local
development. Set it in .env.local to try it out, and in the environment of the deployment to turn it on
in production.
Two things are deliberately not reported: 404 and 405, which on a public site are what bots produce rather than what is broken, and messages that are going to be retried. A repository another worker is holding throws by design, and github.com failing once is what the retry strategy is for - only what runs out of retries and lands in the failure transport is an incident.
Every deploy writes the revision it puts live into SENTRY_RELEASE, so an error says which release it
happened on. Tracing is off: this is error reporting, not performance monitoring.
Repositories are submitted with the form on the start page, or on the command line:
symfony console app:repository:submit wordpress/wordpress https://github.com/symfony/consoleSubmitting queues the repository for analysis right away, so it shows up as soon as a worker gets to it.
Every night the schedule looks for releases that are missing and refreshes the stars, which means there
is nothing left to run by hand - app:releases:scan and app:repositories:refresh only trigger the same
work earlier.
The form is protected by a stateless CSRF token - a double submit cookie written by the csrf-protection
Stimulus controller, so readers of the report never get a session - and submissions are limited to five per
quarter of an hour and IP, since each one spends github.com API quota and ends in a clone.
Set GITHUB_TOKEN in .env.local to raise the github.com API rate limit from 60 to 5.000 requests per
hour. The token only reads public data, so it does not need any scope.
MIT, see LICENSE.