What in this app is old, borrowed or load-bearing in a way nobody would choose today, what is simply dead, and what it would cost to change. Written for whoever has to decide where refactoring effort goes.
Verified against: app version 0.17.0, 2026-09-12, after the wave described
in What has been done. Every number was measured on that
tree rather than carried over — except the migration, repair-step, table,
class, controller, import and src/ file counts, which were re-measured on
0.19.49 on 2026-09-14 — and
Reproducing the measurements gives the command
for each.
The support floors are whatever appinfo/info.xml declares. Nothing else in
the repository holds them: the PHPUnit, lint and appstore workflows read the PHP
and Nextcloud ranges out of that file, through
icewind1991/nextcloud-version-matrix and a direct grep php min-version, so
changing two attributes there changes what CI tests against. They now say PHP
8.3–8.5 and Nextcloud 35–36, and composer.json, psalm.xml and the server
API stubs agree with them rather than contradicting them.
Like Performance.md, this file is not enforced by
tests/DocumentationTest.php beyond its title: its claims are structural rather
than checkable against a route table, so nothing fails when it goes stale.
Re-measure before quoting it. Query and scalability behaviour lives in
Performance.md; the schema is described in Architecture.md.
The app is 76,325 lines of PHP across 415 files in lib/, and 17,779 lines of
JavaScript and Vue across 81 files in src/.
| Theme | Severity | Size |
|---|---|---|
| Translation catalogue covers under 40 % of source strings | Low | — |
The two largest items in every previous version of this document are both gone.
The vendored toolkit's query builder no longer extends a private core class, and
the 856-line request model in front of the OCP HTTP client has been deleted
rather than ported. lib/Tools/ is 25 files and 2,657 lines of helpers and
traits now — a third of what it was, and nothing in it reaches outside OCP\.
Nothing in lib/ names a class outside OCP\ any more, and a unit test
holds it there — not the server's private classes, not Doctrine, not Guzzle.
\OC::$server, OC\SystemConfig, OC\DB\Connection, OC\DB\SchemaWrapper,
OC\DB\QueryBuilder\QueryBuilder, OC\User\NoUserException,
OC\Core\Command\Base, every Doctrine\ class and
GuzzleHttp\Exception\ClientException are all gone; the matches that remain
for those names are sentences in docblocks explaining what used to be there.
Lines of lib/ by the year they were last touched, from git blame:
| Year | Lines | Share |
|---|---|---|
| 2018 | 7,751 | 10.2% |
| 2019 | 8,506 | 11.1% |
| 2020 | 3,944 | 5.2% |
| 2021 | 10 | 0.0% |
| 2022 | 5,394 | 7.1% |
| 2023 | 3,491 | 4.6% |
| 2024 | 1,018 | 1.3% |
| 2025 | 50 | 0.1% |
| 2026 | 46,161 | 60.5% |
Run it on a complete checkout: git blame can only attribute lines it has
history for, so a shallow or out-of-date clone silently understates the recent
share. Measured on a checkout hundreds of commits behind, 2026 came out at
29 % rather than 60 %.
The app serves two API designs, and they share the /api/v1/ prefix.
- Mastodon-compatible — the controllers built on
MastodonApiController,ListController,FilterControllerand others, throughStreamService::getTimeline(ProbeOptions). - Custom local —
LocalController(1,068 lines), through the five@deprecatedStreamService::getStream*()methods and the fiveStreamRequest::getTimeline*_dep()query methods behind them.
So /api/v1/stream/home (local: since/limit cursor, default page size 5, no
Link header, {status, result} envelope) sits directly beside
/api/v1/timelines/home (Mastodon: max_id/since_id, page size 20, Link
header, bare entity).
Nineteen of LocalController's 31 routes had no caller anywhere in src/,
including all eight /api/v1/stream/* and /account/{username}/stream
endpoints — the ones backed by the _dep query methods. They were marked
deprecated in API.md for a release and are removed in 0.19.39,
with the whole query layer behind them: the nine StreamService::getStream*()
methods and the seven StreamRequest::getTimeline*_dep() / getTimelineLiked()
/ getTimelineTag() queries. API.md keeps the table of what replaced them. The
twelve routes the frontend does call stay.
201 of the 202 routes moved onto the methods they belong to; one stays in
appinfo/routes.php, and the file explains why. /api/v1/accounts/{id} accepts
slashes, so it also matches two routes that live in other controllers and has to
be offered to the matcher after them — and attribute routes are contributed one
controller at a time in filesystem order, so no arrangement of attributes can
put it last. The array file is loaded after every attribute route, which is the
guarantee that one route needs.
tests/DocumentationTest.php reads the attributes by reflection the way the
server does, and treats an empty route table as a failure rather than a pass.
Performance.md covers query behaviour and the index and schema-shape hazards.
What follows is the debt in the migration set itself.
All 43 schema steps use the frozen prefix Version1000Date, so only the date
orders them:
| Era | Steps |
|---|---|
| 2022-11-18 | 1 (creates all 14 original tables) |
| 2026-06-11 | 1 (drops 14 legacy social_3_* tables) |
| 2026-09-07 onward | 40 |
There are numbering gaps at 20260911000003, 000012 and 000015–000019,
and most steps were authored in an eight-day window.
The 2023 block is gone: its three steps repaired instances the 2022 step had created, and the 2022 step had been edited over the years to produce the repaired shape directly, so what they still did for a new instance was one column. Running both paths through the test doubles and diffing the schemas is what established that.
One squash candidate is left and is deliberately not taken.
Version1000Date20260908000001 widens a column introduced one day earlier, and
both shipped in 0.16.0 — but an instance on 0.15 running Nextcloud 35 has run
neither, so folding the width into the creating step would leave it with a
column too narrow for its own client secrets. The argument that retired the 2023
steps does not transfer: the Nextcloud floor says which server an instance is
on, not which version of this app.
The drop of fourteen social_3_* tables that no other file in the tree ever
creates went with the squash: the creating migrations had been deleted long
before, only the drop survived, and it was dead weight on every fresh install.
The legacy naming split it addressed is finished — there is no social_a2_*
prefix anywhere, and all 61 current tables are both read and written.
tests/Migration/schema.json records what the history produced, and
SquashedSchemaTest holds the one step that replaces it to that. That is what
any further change to the schema has to go through.
social_stream_dest.id and social_stream_tag.id are autoincrement surrogate
keys. The new tests record why they exist: both tables shipped in 2022 with no
primary key at all, only a unique index over the columns that identify a row.
Neither id appears in the column lists in CoreRequestBuilder nor in the
select lists of their request builders. These are the two highest-insert-rate
side tables in the app, and every row pays for a sequence and an index write
nothing reads. The keys are still worth having — this is a note on the cost.
Still Options API. There are setup() blocks now, because the composables
need them, but no <script setup> and no wholesale move to composition. That is
a style question rather than debt: the components are consistent with each other.
The JSDoc typedefs are checked. jsconfig.json runs checkJs over the
types, services, stores and utilities, and npm run typecheck is a script.
The single-file components are checked by vue-tsc in the same script, and
both checks allow no errors. There was a baseline of 265 while the components
were brought up to it; it is gone, along with the tooling that compared against
it.
Five ESLint rules are switched off, and eslint.config.mjs says why next to
each. Sorting imports and named imports (247 reports) detaches
the comments that explain the side-effect imports; the two component-naming
rules (8 reports) would rename Search.vue, Poll.vue and friends, which
changes what templates say. Neither pair is formatting, which is why they were
not taken with the rest. vue/no-boolean-default (10 reports) would turn every
prop that defaults to true into a negation — link into noLink — at every
call site. And vue/no-multiple-template-root is off because this app mounts
several roots and the rule is a Vue 2 leftover. Everything else is on, and the
tree reports no warnings.
| Tool | Pinned | Status |
|---|---|---|
nextcloud/ocp |
dev-stable35 |
Matches the declared minimum, so analysis checks this app against the oldest server it claims to support. |
| PHPUnit | ^12.5 (12.5.36) |
Current. |
| Psalm | ^6.17 |
Current, running on supported PHP. Its baseline covers six files and seven issues, none of them in lib/Db. |
| ESLint | 10.10.0 with @nextcloud/eslint-config 9 |
Current, flat config, five rules deliberately off, no warnings. |
| Stylelint | ^17.15 |
Current. |
| Vitest / jsdom | 5.0 / 30.0 | Current. |
| webpack / vue-router / vite | 5.110 / 5.3 / 8.3 | Current. |
node-polyfill-webpack-plugin |
exactly 4.0.0 |
Blocked upstream. @nextcloud/webpack-vue-config 6.3.2 peer-pins it; 4.1.0 needs a release there first. |
PHPUnit 12, with the mock hygiene it asks for. A double nothing sets an
expectation on is a createStub() (about 1,400 of them were createMock()),
and the ActivityPub dispatcher the model tests install builds stubs unless a
test names the interfaces it expects calls on. What still warns is the shared
fixture — a mock built once in setUp() that some tests of the class expect on
and others only stub — and those classes carry
#[AllowMockObjectsWithoutExpectations], which is PHPUnit's own answer to that
shape. The suite runs in about 190 MB.
terser-webpack-plugin is a direct devDependency because webpack 5.110 stopped
hoisting it where @nextcloud/webpack-vue-config expects to find it. Worth
knowing that the build depends on the shared webpack config's internal layout in
more places than that one.
20 @deprecated markers in lib/, 12 of them on methods with live callers,
led by CoreRequestBuilder::leftJoinStreamAction (17) and
SocialLimitsQueryBuilder::limitPaginate (8). The worst of them went with the
request model — Request::getUrl had 55 callers and had been deprecated since
toolkit version 19 — and what is left is concentrated in the query layer.
ACore::verify still carries its // TODO - Compare this with checkOrigin() - and delete this method. and is down to three callers.
Two joins that look like one. CoreRequestBuilder::leftJoinCacheActors()
joins case-insensitively on the full ActivityPub URL with a hand-written column
list; SocialCrossQueryBuilder::leftJoinCacheActor() joins on the indexed
hashed id, generates its column list from the schema, and pulls the actor's icon
with it. The first is the older, slower path — LOWER() on unindexed text, as
Performance.md describes. Collapsing them changes which rows match, in
timeline queries, so it wants a database to check against.
Constructor promotion is at 151 of 206 (73 %). The remaining 56 are the ones an automated pass should not touch: constructors that do real work in the body, that forward a parameter to a parent as well as storing it, or whose property name differs from the parameter.
The frontend contains 295 distinct translatable strings. The best-covered
locales (de, de_DE, en_GB) carry 135. The median locale across 98
languages carries 21, and 47 carry fewer than 20. The strings Quote,
Bookmark, Scheduled, Announcement, Follow requests and Photos appear
in zero translation files.
The cause is diagnosed in the header of .github/workflows/l10n.yml: nothing in
the repository ever extracted the source strings, so nothing was ever pushed for
translation. .tx/config is correct as written — translationfiles/ is what
the extractor produces, not something the repository holds. The workflow now
exists; the catalogue has not caught up, and that is a Transifex round trip
rather than a code change.
The extraction itself has been run and checked: it reads all 1,625 source
strings, and tests/TranslatableStringsTest.php now guards the three ways a
string can be written so that it never reaches a translator — a message used
both as a singular and as the singular of a plural, which gettext folds into
one entry t() cannot look up; a message that is only a URL or an address,
which ninety-eight locales can only copy back; and a message that is not a
literal, which xgettext cannot see at all. Two of those were present and are
fixed.
What remains is the one thing outside the repository: TRANSIFEX_API_TOKEN is
not set, so l10n.yml extracts and uploads the template as a build artefact
but pushes nothing, and l10n-pull.yml does nothing at all. Setting that
secret — or registering this app with the organisation's own translation sync —
is the whole of the remaining work.
Each item here is a search that does not need repeating.
Classes from outside OCP\: none. No OC\DB\QueryBuilder\QueryBuilder, no
\OC::$server, no OC\SystemConfig, no Doctrine\DBAL\Query\QueryBuilder. The
only matches are docblock sentences explaining what was removed.
Untyped properties: none. All 35 were typed, including six mutable public
statics in TArrayTools that were constants in everything but name.
Deprecated server APIs: none. IConfig's app-value methods (deprecated in
29) and user-value methods (deprecated in 33) are gone in favour of IAppConfig
and IUserConfig; IConfig is used only for system values, which are not
deprecated.
Vue 2 leftovers: none. Vue.prototype, new Vue(, $listeners,
$set/$delete, .sync, filters:, beforeDestroy, functional: true and
slot-scope all return zero hits in src/. The only occurrences anywhere are
the regexes in tests/js/vue3.test.js, which holds each at zero. Vuex is gone
too.
PHPDoc route annotations: none. Access control is 100 % PHP 8 attributes, with a regression test asserting the old form is not reintroduced.
Legacy PHP constructs: none. Zero occurrences of array(), list(),
strftime, utf8_encode, each(), create_function, ereg* or
money_format. No dynamic property creation.
Raw SQL string concatenation: effectively none. Not one
SELECT/INSERT/UPDATE/DELETE string literal in lib/; all IDBConnection
uses go through the query builder. The single exception is the visibility filter
in SocialCrossQueryBuilder, which binds its parameters.
Legacy bootstrap: none. No appinfo/app.php, no appinfo/application.php.
lib/AppInfo/Application.php is 71 lines of correct IBootstrap registration.
@nextcloud/vue v9 usage: current. All 74 imports use the v9 subpath-export
style; zero deep dist/ imports, zero removed or renamed components.
Unlinted code: none. npm run lint covers src, tests/js, the end-to-end
tests and the root configuration; npm run stylelint covers src and css.
Nothing in js/ is hand-written any more: the administration page was the last
script there, and it is a webpack entry like every other now.
Dead frontend code: none. The import graph over all 100 files in src/
resolves, and there are no unused exports.
Dead model classes: none (77 checked). Dead exception classes: none (48
checked). Unused controllers: none (28 checked; ClientApiController is the
abstract base of sixteen of them and SocialPubController is called by
ActivityPubController rather than routed to).
Repair steps that re-scan on every upgrade: none. All five carry a version marker in app config and return early.
Nextcloud controller methods are admin-required by default —
SecurityMiddleware enforces it unless the method carries #[NoAdminRequired]
or #[PublicPage]. The absence of an attribute is the guard, not a missing
one. Reading the routes as if the attributes grant access rather than relax it
produces a long list of false "unauthenticated endpoint" findings.
Do not measure this app on a working copy without checking it against a fresh
clone first. An earlier pass of this report was taken on a checkout hundreds of
commits behind origin/master: it reported ten untested controllers that in
fact have tests, put 45 % of lib/ in 2023 or earlier when the real figure is
different, and recorded a failing test suite that is green on master. Counting
"dead" methods without treating tests as callers likewise put the figure near
sixty when twenty survive the check.
In #2127
| Item | Outcome |
|---|---|
| Source maps in the release tarball | Excluded. The tarball was ~22 MB of maps against 6.7 MB of bundles. |
| Three disagreeing packaging lists | One list. build-package.sh calls make appstore; .nextcloudignore deleted. |
The elliptic stub |
Replaced with an overrides pin on the real ^6.6.1. The stub claimed a version that has never existed on the registry. |
| Cypress husk | Deleted: 8 files, 1 config, 4 npm scripts, 6 packages, 0 tests. |
| Unused npm dependencies | ical.js, uuid, cypress-wait-until, @nextcloud/cypress dropped; buffer and webpack-dev-server declared. |
| Dead code | 3 files and 20 methods with no caller anywhere, 4 dead config constants, an 80-line commented-out block, 4 obsolete Psalm suppressions. |
| Repair steps re-scanning on every upgrade | Markers added; RenameDocumentLocalCopy unregistered and deleted. |
| Missing indexes | social_actor.user_id and the four unindexed trend_* windows. |
| Status-id collisions | Width 1e6 -> 1e9, rand() -> random_int(), and a collision is retried instead of silently dropping the post. |
IInitialStateService |
Migrated to IInitialState in four controllers. |
Cron\Queue |
Given the wall-clock budget QueueController already had. |
The AP static registry |
Resolved lazily instead of by an AP::init(); at file scope. |
| Unreachable controller methods | Six deleted from LocalController. |
| Two unbounded loops | NotificationService::clear() and MigrationService::refollowLocalFollowers() bounded. |
| The rest of the unbounded work | Closed in #2132, with the N+1 behind accounts/relationships and the missing transaction around StreamRequest::save(). See Performance.md, which tracks that category. |
tests/stub.phpstub |
QueryBuilder::SELECT was the string 'select'; Doctrine assigns the int 0, so 28 comparisons were verified against fiction. Fixed and pinned by a test. |
| Psalm 5 -> 6 | Now runs on supported PHP. Found five real type defects and five orphaned access-control attributes. |
| Stylelint 15 -> 17 | Seven deprecated CSS declarations removed. |
| Constructor promotion | 107 -> 148 of 204, and 264 redundant @param tags removed with it. |
HtmlSanitizer, RemoteAddress |
Moved from lib/Tools/ to lib/Security/. |
- Delivery sends the stored bytes (0.19.39).
ActivityService::bodyFromQueue()returnedjson_encode(json_decode(...)); it returns the queued string. What this instance writes is encoded once at queue time, so nothing it sends changes; whatForwardServicequeues verbatim now arrives verbatim, which is the only way a third party's Linked Data signature survives. - The superseded Custom Local API is gone (0.19.39): nineteen routes, the
nine
getStream*()methods and the seven_depqueries, and their tests. - A browser-test layer (0.19.58):
tests/e2e/and.github/workflows/e2e.yml— Playwright against a throwaway Nextcloud on every pull request. 1,500 unit tests and an integration suite could not see a page that stays empty because a bundle did not load; the smoke suite can.
| Item | Outcome |
|---|---|
| ESLint 8 (end of life) | #2129: ESLint 10, @nextcloud/eslint-config 9, flat config. |
| Nine conflicting dependabot bumps | #2130: vue 3.5.42, webpack 5.110, jsdom 30, vue-router 5 with vite 8 behind it, dialogs 7.5. |
| Item | Outcome |
|---|---|
| The five post fields with no column | tag, language, updated, quote and quoteAuthorization are columns on social_stream, written where a row is written and read with a per-field fallback to the stored JSON, with a repair step backfilling existing rows behind a version marker. language is indexed, which is what a language filter needs. |
| The private core class under the whole data layer | ExtendedQueryBuilder holds a builder from IDBConnection::getQueryBuilder() and delegates all 60 IQueryBuilder methods instead of extending OC\DB\QueryBuilder\QueryBuilder. The \OC::$server reach that fed its private parent is gone, and so are the four Doctrine\DBAL\Query\QueryBuilder imports that existed only to read four constants. Nextcloud 35 had already broken the old arrangement by adding forUpdate() to the interface. |
| The support floors | PHP 8.1 -> 8.3, Nextcloud 28 -> 35, with composer.json's platform pin and constraint following. |
nextcloud/ocp two years stale |
dev-master at a 2024-10-23 commit -> dev-stable35. |
#[\Override] unavailable |
343 methods carry it; the psalm suppression that named the 8.1 floor is gone. |
IConfig deprecated methods |
IAppConfig and IUserConfig in five files. |
| PHPUnit 9 (end of life) | 12.5, via 10. Every data provider static and named by attribute, withConsecutive() and onConsecutiveCalls() rewritten, isType() gone, config migrated. |
| Test doubles drifting from the interfaces they double | FakeConnection completed; ten hand-rolled anonymous table classes replaced by one FakeTable; the test container serves an anonymous session because Response resolves one on every render from 35 onward. |
| Vuex 4, no Pinia | Five Pinia stores, mapStores at the call sites, vuex removed. |
| Three mixins, four spellings of one import | Three composables in src/composables/. |
| Dead pre-Nextcloud-25 profile branch | Deleted. |
OC.Notification.showTemporary |
@nextcloud/dialogs, imported lazily so the Vue-free admin bundle stays 28 KiB rather than 761 KiB. |
| Nineteen deferred ESLint rules | Sixteen adopted, ~1,100 reports fixed. Three stay off with the reason next to each. |
| Unlinted code | The hand-written admin script and the root configuration are linted; css/ is stylelinted. |
| 35 untyped properties | Zero. Six mutable public statics became constants. |
| The 2022–2023 migrations, untested | CoreSchemaTest, which asks the installed schema rather than one step. |
| Private core classes elsewhere in lib/ | OC\DB\Connection (a dead import), OC\DB\SchemaWrapper (two occ social:reset paths, now IDBConnection::tableExists()/dropTable()), Doctrine\DBAL\Schema\SchemaException (the OCP one exists) and OC\User\NoUserException (thrown by this app at this app, now its own). Six analysis suppressions went with them. |
| Every occ command on a private core class | All twenty-one extend Symfony's Command through the app's own SocialCommand, whose output is byte-identical to the server's Base across 109 diffed cases and the rendered --help of every command. |
| Routes as an array | 201 of 202 moved onto the methods; DocumentationTest reads the attributes by reflection and fails on an empty table. |
| The 2023 migration repairs | Squashed into the step they repair, after measuring that the whole difference they still made was one column. |
| 73 migration files | Seven. The sixty-seven that only shape tables are one generated step, Version1000Date20221118000002; the six that move rows are untouched. It was generated by replaying the history against a recording schema, tests/Migration/schema.json is that replay's output, and SquashedSchemaTest fails if the two ever part. Every step in it is guarded, so it brings up an empty database, completes a half-upgraded one and does nothing on a current one — there is no floor on the version an instance may upgrade from. |
CoreRequestBuilder reimplementing the builder's helpers |
36 methods gone — eleven with no caller, twenty-five identical to the builder's — and 84 call sites now say $qb->limitToId($id). The file lost 450 lines. |
| Typedefs nothing checked | jsconfig.json plus npm run typecheck, which found a placeholder relationship missing two fields the server always sends, and a pagination cursor typed as a number while ids are strings. |
| The hand-rolled HTTP request model | Deleted. Requests go to IClient directly, and the signing string was pinned by a test run against both trees before and after — byte for byte identical. |
| Real defects found on the way | An instance's local flag was bound as a string because a parameter type was passed to the wrong function. Profile.vue's "User not found" panel could never appear because one query was being asked twice under two names. And GET /@{username}/outbox was shadowed by its own POST, because two route attributes on one method registered under one name — the endpoint a remote server fetches an outbox from was served by the registration the documentation calls not implemented. Pinning the federation wire found five more: a delivery that signed one path and sent to another, a non-default port silently dropped when fetching a remote object, a ?tag[]= in an id that was a TypeError, a report forward whose digest covered different bytes from the ones sent, and a webfinger response with no subject writing a garbage account name. |
- Collapse the two cache-actor joins, with a database to check against.
- Finish the l10n round trip — a Transifex round trip, not a code change.
- Web Push and a streaming API, so mobile clients stop polling.
# The support floors, and the places that should agree with them
grep -oE '(php|nextcloud) min-version="[^"]+" max-version="[^"]+"' appinfo/info.xml
grep -nE '"php": |platform' composer.json | head -3
grep -rn 'php min-version\|nextcloud-version-matrix' .github/workflows/*.yml | wc -l
# Sizes
find lib -name '*.php' | wc -l && find lib -name '*.php' -exec cat {} + | wc -l
find src -type f \( -name '*.js' -o -name '*.vue' \) | wc -l
# Classes from outside OCP\ (expect only docblock prose)
grep -rn 'OC\\DB\\QueryBuilder\\QueryBuilder\|OC::\$server\|OC\\SystemConfig\|Doctrine\\DBAL\\Query' lib --include='*.php'
# Untyped properties (expect 0)
grep -rnE '^\s*(var|private|protected|public|static|readonly)(\s+(static|readonly))*\s+\$\w+' lib --include='*.php' | wc -l
# Deprecated server config APIs (expect 0)
grep -rn 'config->getAppValue\|config->setAppValue\|config->getUserValue\|config->setUserValue' lib --include='*.php' | wc -l
# @deprecated methods that still have callers
grep -rn '@deprecated' lib --include='*.php' | wc -l
grep -rn 'leftJoinStreamAction(' lib --include='*.php' | grep -v 'function ' | wc -l
# Constructor promotion adoption
python3 - <<'PY'
import re, glob
p = n = 0
for f in glob.glob('lib/**/*.php', recursive=True):
for m in re.finditer(r'function\s+__construct\s*\((.*?)\)\s*[:{]', open(f).read(), re.S):
a = m.group(1)
if re.search(r'\b(private|protected|public|readonly)\s', a): p += 1
elif a.strip(): n += 1
print('promoted', p, 'of', p + n)
PY
# Custom Local API routes with no caller in src/
python3 - <<'PY'
import re, glob
routes = [m.groups() for l in open('appinfo/routes.php')
if (m := re.search(r"'name' => 'Local#(\w+)'.*?'url' => '([^']+)'", l))]
src = ''.join(open(p, errors='replace').read() for p in glob.glob('src/**/*', recursive=True)
if p.endswith(('.js', '.vue')))
for name, url in routes:
if url.split('{')[0].rstrip('/').lstrip('/') not in src:
print('uncalled:', url)
PY
# Data providers that are not static (expect 0). Matching on the name alone
# also finds methods like getDatabaseProvider(), so ask which methods the
# #[DataProvider] attributes actually name.
python3 - <<'PY'
import re, glob
named = set()
for f in glob.glob('tests/**/*.php', recursive=True):
named |= set(re.findall(r"#\[DataProvider\('(\w+)'\)\]", open(f).read()))
for f in glob.glob('tests/**/*.php', recursive=True):
for m in re.finditer(r'public (static )?function (\w+)\(', open(f).read()):
if m.group(2) in named and not m.group(1):
print('not static:', f, m.group(2))
PY
# ESLint rules this codebase does not adopt
grep -c "': 'off'" eslint.config.mjs
# Translation coverage
grep -rhoE "\b[tn]\('social',\s*'[^']*'" src | sort -u | wc -l
python3 -c "import json,glob; print(sorted((len(json.load(open(f))['translations']), f) for f in glob.glob('l10n/*.json'))[-3:])"
# Suites
composer test:unit && composer psalm && composer lint \
&& vendor/bin/php-cs-fixer fix --dry-run \
&& npm run lint && npm run stylelint && npm test && npm run build