feat(quality-config): adopt Nextcloud's coding standard — stricter, never different - #375
Merged
Merged
Conversation
…ion, and a CI/CD page that states where we diverge from Nextcloud Measured across all 18 core apps on canonical <app>@development, 2026-08-12: psalm.xml, phpstan.neon, playwright.config.ts and code-quality.yml each have EIGHTEEN distinct variants; eslint.config.js has 17; phpmd.xml 15. Even NamedParametersSniff.php — a custom RULE, not a setting — has six versions, so the same named rule enforces six different things. launchpad's is a superset that knows about Entity magic accessors; opencatalogi's is a 231-line stub with none of the scoping logic that calls addWarning() where the others call addError(); doriath ships two of the three sniffs not at all, including the one that blocks \OC::$server, removed in NC 34. quality-config/ is now the single source for phpcs, phpmd, phpstan and the three custom sniffs, shipped inside the existing conduction/hydra-gates package so `composer install` is the pull and a laptop behaves like CI. Two mechanics were MEASURED against phpcs 3.13.6 and 4.0.4 before anything was written, and the first result was a failing positive control: - Rules and custom sniffs DO inherit through <rule ref>, including sniff files at paths relative to the central ruleset. The first fixture registered 1 sniff instead of 2 and looked like proof this cannot work; the fixture was wrong — a custom sniff must sit at <Standard>/Sniffs/<Category>/<Name>Sniff.php. - <file> must stay in the app stub. phpcs resolves it relative to the ruleset that DECLARES it and it propagates through <rule ref>, so a <file>lib</file> here resolves to quality-config/lib inside vendor/ and every consumer dies with exit 3. psalm.xml is deliberately NOT stubbed: Psalm has no config inheritance. The frontend configs are templates, not live imports: ESLint flat config resolves plugins relative to the config file, so they need an npm channel (@conduction/nextcloud-vue, which all 18 apps already depend on). Both are written up rather than half-wired. docs/WayOfWork/ci-cd.md is new and states the divergences plainly — 4 spaces vs Nextcloud's tabs, next-line vs same-line braces, `(int) $x` vs `(int)$x` — plus the ones that are accidents rather than choices: - cs:check / cs:fix are nextcloud/coding-standard's script names and are aliases for PHPCS here, while 17 apps carry nextcloud/coding-standard in require-dev with no .php-cs-fixer config and no invocation anywhere. - No app ships an .editorconfig, so an editor configured from Nextcloud work defaults to tabs, which PHPCS then rejects. - info.xml declares NC 32-34 while nextcloud/ocp is pinned ^31 in 15 apps and nothing is tested on 34 — which is why the NC 34 removal of \OC::$server needed a hand-written sniff: the analyser was looking at NC 31. - 13 apps run stylelint with an UNQUOTED glob, so the shell expands src/**/ one level deep and nested components are silently unlinted. - No occ integrity:sign-app step, so published packages carry no appinfo/signature.json. - .prettierrc in 14 apps, prettier a dependency in none. Four claims in the existing pages were wrong and are corrected: PHPCS is not PSR-12, the pipeline is 18 job groups not four, Hydra is not "coming soon", and `channel: dev` development builds do exist (verified GitHub-prerelease-only — that path never POSTs to apps.nextcloud.com).
…entral ruleset unparseable `--warning-severity=0` in a <!-- --> block is illegal XML. phpcs exits 3 with "Ruleset is not valid ... Double hyphen within comment" BEFORE registering a single sniff, so every consuming app would have gone red on a comment. Caught by running the real config through a real stub from a real vendor/ tree in php:8.3-cli, which is also now the evidence that the mechanism works: the custom sniffs load from vendor/, and on a tab-indented fixture calling \OC::$server->getConfig() with positional arguments all three fire — NoLegacyServerAccessors, NamedParameters, and the 4-space ScopeIndent rule that is this fleet's deliberate divergence from Nextcloud core.
…emantics Policy change: Conduction code must PASS nextcloud/coding-standard unchanged. We may be stricter, never different. The previous version of this directory argued the opposite — that 4 spaces was a deliberate divergence worth documenting — and that framing is now wrong. Measured: php-cs-fixer was run over openregister's 1,427 files, then the old PEAR ruleset over the RESULT. 111,932 findings, 111,747 of them auto-fixable formatting — this file arguing with the formatter. Every whitespace, brace, indent and alignment sniff is removed; the same measurement now yields 185, all semantic (181 @SPEC warnings, 2 line-length, 2 SPDX end-char). The named-parameter and legacy-accessor sniffs fire ZERO times, which is what stricter-but-compatible looks like when it is true rather than assumed. The sharpest case: Squiz.ControlStructures.ElseIfDeclaration FORBIDS the elseif keyword that Nextcloud's elseif fixer REQUIRES. Two tools cannot both be right about one token, so only one gets an opinion about tokens. Docblock PRESENCE is semantic and stays; docblock LAYOUT is phpdoc_align's. The alignment codes are excluded individually rather than by dropping the sniff — losing the presence requirement would be a real regression. Each excluded code was observed firing, not guessed. tests/compatibility.sh makes this permanent: format a fixture with php-cs-fixer, run PHPCS over the result, fail on any formatting sniff. Its positive control asserts the fixer actually changed the fixture first — otherwise a clean PHPCS pass proves nothing. Also adds Nextcloud's .editorconfig verbatim (no fleet app had one, so editors defaulted to whatever the developer last worked in) and a .php-cs-fixer.dist.php stub.
…e-divergence framing The page previously argued that 4 spaces was a considered divergence worth documenting. The policy is the opposite: we may be stricter than Nextcloud, never different, so a difference is a defect rather than a dialect. Rewrites the formatting section around the measurement that settled it — all 1,427 openregister files failing nextcloud/coding-standard, and the 111,932 -> 185 drop once PHPCS stops arguing with php-cs-fixer — and around the tool split that makes the policy structural: conduction/coding-standard EXTENDS Nextcloud's Config and cannot override it, PHPCS keeps only semantics. Adds the two traps an adopting app hits first: cs:check/cs:fix used to be PHPCS aliases under Nextcloud's names, and a .php-cs-fixer.dist.php missing its autoloader reports a fatal as zero files needing changes.
…livery is not a publish Both composer packages are now on Packagist, which deletes a 'repositories' block from all 18 apps — a per-app file, and per-app files are what drift. The block also carried no-api:true, so composer cloned the whole .github repo, docs site included, on every install. The section exists mostly to record a verification trap found by walking into it. Packagist creates a push webhook on submission, so it LOOKS wired. Measured: a real push to conduction/coding-standard delivered 202 OK and ten minutes later dev-main still pointed at the previous commit. Three ways to misread that as success — a ping delivery is not a push delivery, 202 means queued not published, and package.time is the CREATION timestamp and never moves. The only honest check is whether the source reference matches git HEAD. The actual missing piece is the Packagist GitHub App, not installed on the org. It cannot be installed through the API — GitHub Apps require a browser consent flow — so there is no gh command for it.
rubenvdlinde
added a commit
to ConductionNL/nextcloud-app-template
that referenced
this pull request
Aug 12, 2026
This repo pinned hydra-gates-ref: v1.3.0. gate-65, added in ConductionNL/.github#375, flags it, and it is right to: a pin is a silent expiry date. 22 repos once sat on v1.0.1 while 16 gates were dead fleet-wide and every one reported PASS (.github#159); a default later flipped at @main then reached those same old runners and turned them red on gates they had no subject matter for (#173). Both directions come from the two halves of one system moving independently. The shared workflow is consumed at @main, so the gate package must be too — then a gate fix reaches this repo with no commit in this repo. conduction/hydra-gates is still constrained to dev-feat/centralised-quality-config in composer.json, which gate-65 also flags, correctly. That one becomes ^1.0 when #375 merges.
Centralising configuration does not stop it drifting. The 18 mutually different
psalm.xml files measured on 2026-08-12 were all copies of something that had been
shared once. What stops recurrence is a gate that fails when an app walks away
from the centre, or freezes itself against it.
Eleven rules, each one a defect that was LIVE in the fleet rather than a
preference. Measured against all 18 core apps at development:
cs-script-wired-to-phpcs 36 (2 scripts x 18 apps)
no-php-cs-fixer-config 18
phpcs-not-centralised 18
phpcs-declares-formatting-sniffs 18
no-editorconfig 18
coding-standard-not-required 18
nextcloud-coding-standard-declared-directly 17
local-custom-sniffs 16
stylelint-glob-unquoted 13
ocp-below-declared-minimum 13
The last figure cross-checks an independent count made three hours earlier: 15
apps pin nextcloud/ocp ^31 while declaring NC 32-34, minus docudesk and launchpad
on dev-master, which track the tip and are correctly not flagged.
NO PINNING is rule 10 and applies to all three shared things — the gate package,
the coding standard, and the shared workflow. floats and is fine; an exact
version, a dev-branch constraint, or a hydra-gates-ref that is not main, is not.
The rule found its first real target immediately: this fleet's own app template,
pinned at hydra-gates-ref v1.3.0.
23-assertion self-test, discovered automatically by tests/run-helper-suites.sh.
Every positive control is paired with a negative one, and three of them exist to
stop the rule over-firing: ^1.0 must not read as a pin, an explicit
hydra-gates-ref: main must not read as a pin, and a COMMENTED-OUT rule or pin
must not read as live — the gate-64 defect, where grepping for a string matches
it inside every comment.
The checker's terminal 'checked N rule(s)' line is required by the runner. A
crashed checker and a clean repo are otherwise the same silence.
…e way Nextcloud does Two changes, both to follow Nextcloud's own release template rather than run beside it. 1. THE VERSION NOW LIVES IN THE REPOSITORY. It used to exist only inside the built package: sed rewrote appinfo/info.xml in the copy under package/, and the committed file kept whatever stale number was last hand-edited. That is incompatible with Nextcloud's appstore-build-publish template, which reads the version FROM info.xml and asserts the tag matches it. Following Nextcloud means the repository is the source of truth, so the bump is committed first and the tag is cut from that commit — the tag then names a tree whose info.xml says what the tag says. Retires "Version Is Not In The Repository" from release-process.md. Guarded both ways: no commit when the number has not moved (a re-run must not create an empty commit and re-tag it), and a failed push is a HARD STOP with a diagnosable message. Releasing past it would put the old version number inside a package labelled with the new one. 2. occ integrity:sign-app — THE GAP. Every release this fleet has ever published went out WITHOUT appinfo/signature.json. We signed the tarball, which is what the App Store API authenticates the upload with, and stopped there. Nextcloud's integrity checker verifies something else entirely: a per-file manifest INSIDE the app directory. Without it, `occ integrity:check-app` reports the app as unverifiable, and an administrator running an integrity check sees our apps as the ones that cannot be vouched for. The server that provides occ is fetched at the version the app DECLARES it needs, read from info.xml rather than hardcoded — the same principle as icewind1991/nextcloud-version-matrix. The release cannot drift from the app's stated support range because it is computed from it. POSITIVE CONTROL, because integrity:sign-app exits 0 on several paths that produce no manifest: the step asserts signature.json exists AND that it hashes more than zero files. An empty manifest verifies nothing and would ship looking exactly like a signed package.
Found by running the new signing step against larpingapp rather than reading it.
It resolved min-version 8 and died on a null download URL, because info.xml
carries min-version on <php> too and <php> comes first.
Measured across all 18 apps, the unanchored form returned:
13 apps -> 8 from <php min-version="8.3"/>
portaliq -> 28 from a <nextcloud> element, but the WRONG one
The first is loud: updates.nextcloud.com has no NC 8, so the release stops. The
second is not. It resolves a real server and signs the app against a Nextcloud
four majors below the one it supports — a plausible wrong answer, which is the
worse kind.
Verified end to end afterwards on larpingapp's actual tree: min-version 32 ->
nextcloud-32.0.13 -> `occ integrity:sign-app` exit 0, appinfo/signature.json
121 KB hashing 619 files with both signature and certificate present. That is the
artefact every release this fleet has published so far has been missing.
rubenvdlinde
added a commit
to ConductionNL/nextcloud-app-template
that referenced
this pull request
Aug 12, 2026
ConductionNL/.github#375 is merged and tagged v1.7.0, so the temporary dev-feat/centralised-quality-config constraint can go — along with the VCS repositories block, since both packages are on Packagist now. gate-65 reported two findings on this branch and now reports none: 15 rules checked, zero deviations. The gate found its own author's pins first, which is the behaviour you want from it.
rubenvdlinde
added a commit
that referenced
this pull request
Aug 12, 2026
…check Nextcloud ships phpunit-mariadb, phpunit-mysql, phpunit-oci and phpunit-sqlite as separate workflow templates, because database-portability bugs are invisible on a single backend. This fleet ran PostgreSQL only. The `database` input already accepted sqlite, pgsql and mysql, but the phpunit job wired exactly one of them: the postgres service was keyed off `inputs.database`, and the install block had a pgsql branch with everything else falling through to a bare `--database <x>` that works for sqlite and cannot work for mysql — no host, no port, no credentials, and no service container to connect to. So "mysql" was a value the input accepted and the job could not honour. WHAT CHANGED - `database-test-matrix`, a JSON array, joins the phpunit matrix beside php-version and nextcloud-ref. - A mysql:8.4 service sits beside postgres:16. Both are keyed off matrix.database rather than inputs.database, and a service whose image evaluates to an empty string is not started — which is how one job definition serves several backends. - The install block is a case statement with real host/port/credentials per backend, and an explicit failure on an unknown one. - pdo_mysql and mysqli join the extension list unconditionally. Conditional extensions would mean a mysql leg silently falling through and testing sqlite while its name said mysql. INERT UNTIL AN APP OPTS IN. The default is an empty array, which collapses to `[inputs.database]` — today's dimensions exactly. Both sides of that comparison go through fromJSON on purpose: a string test against '[]' accepts '[ ]' and '[""]' and then resolves to an EMPTY MATRIX VECTOR, and an empty vector does not skip a job, it deletes it from the run with no row, no annotation and no trace. Measured on this repo's own self-test for frontend-checks: 19 jobs instead of 21. Verified here against all three spellings of empty plus a real three-backend array. POSITIVE CONTROL IN THE JOB. `maintenance:install` can exit 0 having fallen back to another backend, and a suite passing on sqlite while its leg is labelled mysql is worse than no leg at all — it is a green cell asserting something about a database it never touched. The step reads `occ config:system:get dbtype` back and fails if it does not match the leg's own name. That completes the five checks Nextcloud runs and we did not: info.xml XSD (#383), app:check-code and REUSE (this branch), integrity:sign-app (#375), and multi-database PHPUnit.
rubenvdlinde
added a commit
that referenced
this pull request
Aug 12, 2026
…, multi-DB PHPUnit (#385) * feat(quality): adopt occ app:check-code and REUSE, the last two Nextcloud checks Nextcloud runs five checks this fleet did not. #383 added the first (info.xml against the App Store XSD). This adds two more; multi-database PHPUnit is the remaining one and is a larger change to the phpunit job, tracked separately. occ app:check-code ------------------ Nextcloud's own tool for private and deprecated API usage. Our closest equivalent was ONE hand-written PHPCS sniff covering ONE symbol — \OC::$server, removed in NC 34 — written by hand precisely because static analysis could not see the removal while nextcloud/ocp sat a major below the declared min-version. app:check-code is the general form of that sniff: it knows the whole private surface of the server it ships with, needs no per-symbol maintenance, and moves forward when Nextcloud does. The server is fetched at the version the app DECLARES, anchored to the <nextcloud> element. Unanchored, the pattern returns 8 for thirteen fleet apps (from <php min-version="8.3"/>) and a plausible-but-wrong 28 for a fourteenth. REUSE ----- Nextcloud runs reuse.yml on every app. We had a PHPCS sniff checking @license and @copyright in PHP docblocks — a proxy for one file type that says nothing about images, JSON, YAML, or the LICENSES/ directory REUSE requires. It also closes a loop the coding-standard migration opened: the SPDX InvalidEndChar exception in the shared ruleset exists because a trailing full stop turns a machine-parsed SPDX expression into an invalid one. That exception is only worth having if something actually parses those expressions. BOTH ARE NON-BLOCKING BY DEFAULT, and that is a decision rather than timidity. Every app here reaches into server internals somewhere — OCA\DAV, OC_App and Doctrine\DBAL are all ignored by name in phpstan-base.neon — and only 2 of the 4 apps sampled ship a REUSE.toml at all. A gate that is red on arrival is a gate nobody turns on: that is how openregister ended up with an E2E job that had never once succeeded. Each carries a `*-blocking` input to flip per app once its findings are worked down. app:check-code carries a positive control. occ exits 0 on paths that inspect nothing — an app directory it cannot see, a command that bailed on the environment check — so the step asserts the log contains a recognisable verdict before treating any exit code as evidence. A silent exit 0 must not read as "no private API usage". * feat(quality): multi-database PHPUnit — the fifth and last Nextcloud check Nextcloud ships phpunit-mariadb, phpunit-mysql, phpunit-oci and phpunit-sqlite as separate workflow templates, because database-portability bugs are invisible on a single backend. This fleet ran PostgreSQL only. The `database` input already accepted sqlite, pgsql and mysql, but the phpunit job wired exactly one of them: the postgres service was keyed off `inputs.database`, and the install block had a pgsql branch with everything else falling through to a bare `--database <x>` that works for sqlite and cannot work for mysql — no host, no port, no credentials, and no service container to connect to. So "mysql" was a value the input accepted and the job could not honour. WHAT CHANGED - `database-test-matrix`, a JSON array, joins the phpunit matrix beside php-version and nextcloud-ref. - A mysql:8.4 service sits beside postgres:16. Both are keyed off matrix.database rather than inputs.database, and a service whose image evaluates to an empty string is not started — which is how one job definition serves several backends. - The install block is a case statement with real host/port/credentials per backend, and an explicit failure on an unknown one. - pdo_mysql and mysqli join the extension list unconditionally. Conditional extensions would mean a mysql leg silently falling through and testing sqlite while its name said mysql. INERT UNTIL AN APP OPTS IN. The default is an empty array, which collapses to `[inputs.database]` — today's dimensions exactly. Both sides of that comparison go through fromJSON on purpose: a string test against '[]' accepts '[ ]' and '[""]' and then resolves to an EMPTY MATRIX VECTOR, and an empty vector does not skip a job, it deletes it from the run with no row, no annotation and no trace. Measured on this repo's own self-test for frontend-checks: 19 jobs instead of 21. Verified here against all three spellings of empty plus a real three-backend array. POSITIVE CONTROL IN THE JOB. `maintenance:install` can exit 0 having fallen back to another backend, and a suite passing on sqlite while its leg is labelled mysql is worse than no leg at all — it is a green cell asserting something about a database it never touched. The step reads `occ config:system:get dbtype` back and fails if it does not match the leg's own name. That completes the five checks Nextcloud runs and we did not: info.xml XSD (#383), app:check-code and REUSE (this branch), integrity:sign-app (#375), and multi-database PHPUnit.
rubenvdlinde
added a commit
to ConductionNL/nextcloud-app-template
that referenced
this pull request
Aug 13, 2026
…the fleet migration recipe (#142) * chore: wire up conduction/coding-standard, .editorconfig, NC 34 and the stylelint glob Config only. The reformat is the NEXT commit on purpose, so that .git-blame-ignore-revs can name a commit that contains nothing but whitespace. * style: reformat with nextcloud/coding-standard — whitespace only, no behaviour change Applied by `composer cs:fix`. Tabs, same-line braces, `(int)$x`, `'a' . 'b'`, ordered imports — Nextcloud's dialect, which this app now passes unchanged. Isolated from the configuration change on purpose so .git-blame-ignore-revs can name a commit that touches nothing but formatting. Reviewing it line by line is not a useful activity; the useful review is the previous commit. * chore: ignore the reformat commit in git blame 96b2382 touches ~22 files and changes no behaviour. Without this, every line it reflowed attributes to it and the real author is one --skip away. GitHub honours the file automatically; locally it needs `git config blame.ignoreRevsFile .git-blame-ignore-revs` once. * fix: unpin hydra-gates-ref — the gates are consumed at the tip This repo pinned hydra-gates-ref: v1.3.0. gate-65, added in ConductionNL/.github#375, flags it, and it is right to: a pin is a silent expiry date. 22 repos once sat on v1.0.1 while 16 gates were dead fleet-wide and every one reported PASS (.github#159); a default later flipped at @main then reached those same old runners and turned them red on gates they had no subject matter for (#173). Both directions come from the two halves of one system moving independently. The shared workflow is consumed at @main, so the gate package must be too — then a gate fix reaches this repo with no commit in this repo. conduction/hydra-gates is still constrained to dev-feat/centralised-quality-config in composer.json, which gate-65 also flags, correctly. That one becomes ^1.0 when #375 merges. * chore: consume conduction/hydra-gates at ^1.0, not a branch ConductionNL/.github#375 is merged and tagged v1.7.0, so the temporary dev-feat/centralised-quality-config constraint can go — along with the VCS repositories block, since both packages are on Packagist now. gate-65 reported two findings on this branch and now reports none: 15 rules checked, zero deviations. The gate found its own author's pins first, which is the behaviour you want from it. * fix(ci): resync composer.lock and test the whole declared NC range composer.lock pinned conduction/hydra-gates to dev-feat/centralised-quality-config while composer.json requires ^1.0, so the lock did not satisfy composer.json. `composer install` therefore exited 4 ('lock file is not up to date') in EVERY PHP job — before any tool ran. That is the single cause of all 14 red checks on this PR, including Security and License, which touch no linter at all. Regenerated with a targeted update: hydra-gates dev-branch -> v1.7.1. coding-standard stays v1.0.0, nextcloud/ocp stays v34.0.2. `composer validate --no-check-all --no-check-publish` now passes. Deliberately WITHOUT --ignore-platform-reqs: that flag also discards the `config.platform.php = 8.3` override and lets the solver pick symfony/string v8.1.2, which requires php >=8.4.1 — a lock that cannot install on the PHP 8.3 test leg. The platform override alone is enough. nextcloud-test-refs now covers 32-34, the full range info.xml declares. stable34 stays element 0 because newman/playwright/journeydoc read [0] as their single server. * fix(appinfo): put info.xml elements in the order the XSD requires The shared workflow gained an 'info.xml lint' job that validates against appstore's own info.xsd. It reported 5 errors here, none of them introduced by this PR — appinfo/info.xml is byte-identical to development apart from min-version 28 -> 32. Three were pure element ORDER, and order is not cosmetic: <info>, <dependencies> and <repair-steps> are all modelled as xs:sequence, so a correct element in the wrong position is a hard validation error. <dependencies> php now precedes nextcloud <info> repair-steps now precedes settings, navigations follows it <repair-steps> install now follows post-migration Reordering carries no runtime meaning — Nextcloud resolves all of these by element name — so this is only what the schema demands. Note the third error was MASKED by the second: fixing the top-level order is what first let the validator descend into <repair-steps> and reach post-migration at all. Verified with lxml against the exact XSD the job fetches, with the pre-fix file as a positive control: it reproduces CI's five errors line-for-line, and the fixed file is down to three. The remaining three are one root cause and are NOT fixed here: the app id 'app-template' contains a hyphen, which appstore's pattern '[a-z]+[a-z0-9_]*[a-z0-9]+' rejects (and which the navigation <route> pattern rejects for the same reason). That is a 235-occurrence / 84-file rename covering the runtime APP_ID constant, DI service ids, routes and e2e URLs — far outside this PR. 'info.xml lint' therefore stays RED, pre-existing. * fix(ci): update the vendored coverage-guard to the merge-base-comparing version PHPUnit (PHP 8.3, NC stable34) was the only one of the six legs to fail, and it failed with the suite GREEN — 'OK (20 tests, 54 assertions)', printed twice. It is the single leg the coverage ratchet runs on, because the shared workflow gates those steps on php-version == inputs.php-version AND nextcloud-ref == fromJSON(nextcloud-test-refs)[0]. The actual error: scripts/coverage-guard.php predates merge-base comparison. This repo shipped the 59-line copy, md5 493a42ed, which is byte-identical to openregister@MAIN — the stale branch. The current script is 265 lines, md5 3184a800, identical on portaliq, openregister and decidesk @development, and app-neutral. Installed verbatim. CI's exact probe now passes locally: php scripts/coverage-guard.php --capabilities | grep -qx against -> against / update-baseline / capabilities Attribution: this is caused BY this PR, not pre-existing. Turning on enable-coverage-guard is what makes these steps run at all — on development both coverage jobs report 'skipped'. The PR enabled the ratchet while shipping a script too old to satisfy it. Under --against the committed .coverage-baseline (23.55) is informational only; the measured merge-base value is the floor. * fix(appinfo): rename the app id from `app-template` to `apptemplate` The App Store's own `info.xsd` rejects the hyphen. On PR #142 the `info.xml lint` job failed with three errors from one cause: appinfo/info.xml:4: Element 'id': 'app-template' is not accepted by the pattern '[a-z]+[a-z0-9_]*[a-z0-9]+' appinfo/info.xml:66: Element 'id': same appinfo/info.xml:68: Element 'route': 'app-template.dashboard.page' is not accepted by '[0-9a-zA-Z_]+(\.[0-9a-zA-Z_]+){2}' `apptemplate` satisfies both patterns. `app_template` would too, but every one of the 18 core Conduction apps is a single lowercase word, and this repo is the cookie-cutter every new app is generated from — so the separator-free form is the one worth seeding. The app id is NOT the repository name. `nextcloud-app-template` stays exactly as it is: clone paths, remotes, `ConductionNL/…` and `codeberg.org/…` URLs, the docs hostname `app-template.conduction.nl` (`docs/static/CNAME`, the `cname:` input of `documentation.yml`, `docusaurus.config.js`'s `url`), and the archived change docs under `openspec/changes/archive/`. The PHP namespace `AppTemplate` also stays: `info.xml` declares it explicitly via `<namespace>`, which is what Nextcloud reads first. Moved with the id: - `Application::APP_ID`, `SettingsSection::getID()` + its `imagePath()` app name, `AdminSettings::getSection()`, the deep-link registration - `MetricsController::METRIC_PREFIX` — a Prometheus name derived from the id - the navigation `<id>` and `<route>` (`apptemplate.dashboard.page`, which still resolves to `dashboard#page` in `appinfo/routes.php`) - `webpack.config.js`'s `appId`, so the emitted bundles are `apptemplate-*.js` — the names `Util::addScript($appId, $appId . '-main')` asks for; and the dashboard widget id in `src/exampleWidget.js`, which must equal `ExampleWidget::getId()` - every `t('apptemplate', …)` / `loadState` / `register` l10n domain, every `/apps/apptemplate/…` URL, the `#apptemplate-settings` mount id and the `.apptemplate-admin-settings` CSS prefix - `app-name:` / `app-id:` in the GitHub + Forgejo workflows, the `custom_apps/apptemplate` mount path in `openspec/config.yaml`, and the `dev-link` symlink in the Makefile — Nextcloud loads an app from `custom_apps/<app-id>`, so a stale path means the app silently never loads - `package.json`, `package-lock.json`, `composer.json`, `phpcs.xml`, `eslint.config.js`, `openspec/app-config.json` - the canonical specs under `openspec/specs/` and the two unarchived changes, which assert the id in URLs and metric names Two files are named after the id and were renamed, not just edited: - `lib/Settings/app_template_register.json` -> `apptemplate_register.json`. `SettingsService` builds the path as `getAppPath(APP_ID) . '/lib/Settings/' . APP_ID . '_register.json'`, which resolved to `app-template_register.json` — a file that never existed. The `is_file()` guard turned that into `$data = []`, so the register import ran on an empty payload and logged success. The rename makes the derived path hit the file for the first time. - `tests/integration/app-template.postman_collection.json` -> `apptemplate.postman_collection.json` (Newman globs the directory). `tests/validate-register.js` derived the PHP namespace by camel-casing the `<id>`, which gave `OCA\AppTemplate` only because the old id had a hyphen to split on; under `apptemplate` it would have produced `OCA\Apptemplate` and mis-resolved every `requires:` class reference. It now reads `<namespace>` first and falls back to the camel-case, matching `App::buildAppNamespace()`. Verified: `xmllint`-equivalent XSD validation against the same schema the CI job fetches fails with those three errors on the parent commit and passes here; `git grep app-template` leaves only repository references, the docs slug, and the archived changes; PHP lint clean (28 files); PHPCS 0 errors / 34 warnings, identical to the parent commit; PHPUnit 20/20 (54 assertions) inside a Nextcloud 34 container; `npm run build`, `lint`, `stylelint`, `check:specs` and `check:manifest` all pass. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The policy
"Stricter" means adding a rule Nextcloud has no opinion on. It never means giving one of their rules a different value.
We failed it completely
Measured 2026-08-12,
nextcloud/coding-standard(php-cs-fixer 3.95) over openregister'slib/: 1,427 of 1,427 files fail.curly_braces_positionindentation_typephpdoc_alignbinary_operator_spacescast_spacesconcat_spaceNot "stricter" — a different dialect.
The split
conduction/coding-standard— new repo,v1.0.0quality-config/— this PRquality-config/Conduction\CodingStandard\Configextends Nextcloud's and merges a privateADDITIONSontoparent::getRules(). Its invariant suite fails the build ifADDITIONSshares one key with the parent set, if a parent rule is dropped, or if a parent rule's value changed. The policy is enforced by inheritance, not by review. 13/13 pass, each with a positive control.ADDITIONSis empty — a result, not an omission. Every rule we want beyond Nextcloud's is semantic, and php-cs-fixer cannot express any of them.Why PHPCS had to be cut back
Two formatters with overlapping jurisdiction make an app unfixable:
cs:fixandphpcsdemand opposite things. Running the old ruleset over php-cs-fixer-formatted code gave 111,932 findings, 111,747 auto-fixable formatting:That last line is the whole problem in miniature: Squiz forbids the
elseifkeyword Nextcloud'selseiffixer requires.After stripping every formatting sniff, the same measurement yields 185 findings, all semantic — 181
@spec, 2 line-length, 2 SPDX. The named-parameter and legacy-accessor sniffs fire zero times: stricter-but-compatible, demonstrated rather than assumed.Docblock presence stays, docblock layout goes to
phpdoc_align— alignment codes excluded individually rather than by dropping the sniff, since losing the presence requirement would be a real regression. Each excluded code was observed firing, not guessed.Proven end-to-end
nextcloud-app-template#141, in
php:8.3-cli, installing fromvendor/:Three custom sniffs register and fire from the vendored package. That PR is the recipe the other 18 apps follow.
What's in this PR
quality-config/phpcs.xml— semantic-only rulesetquality-config/phpcs-custom-sniffs/— three sniffs, canonicallylaunchpad's NamedParameters (a 525-line superset;opencatalogi's 231-line copy callsaddWarning()where the rest calladdError()) +openregister's SpecTag and NoLegacyServerAccessorsquality-config/phpmd.xml,phpmd-unusedparams.xml,phpstan-base.neonquality-config/editorconfig— Nextcloud's, verbatim. No fleet app had one, so editors defaulted to whatever the developer last worked inquality-config/tests/compatibility.sh— format a fixture, run PHPCS over the result, fail on any formatting sniff. Its positive control asserts the fixer actually changed the fixture first, because otherwise a clean PHPCS pass proves nothingquality-config/stubs/— what an app's files becomedocs/WayOfWork/ci-cd.md+ four corrections to existing pagesMechanics measured, not assumed
Against phpcs 3.13.6 and 4.0.4:
<rule ref>, including sniff paths relative to the central ruleset. The first fixture registered 1 sniff instead of 2 and looked like proof this cannot work — the fixture was wrong (a sniff must sit at<Standard>/Sniffs/<Category>/<Name>Sniff.php).<file>must stay in the app stub — phpcs resolves it relative to the declaring ruleset, so a central one becomesquality-config/libinsidevendor/and every consumer exits 3.--inside an XML comment makes a ruleset unparseable, before any sniff registers. Caught by running it; it would have turned 18 apps red on a comment..php-cs-fixer.dist.phpwithoutrequire vendor/autoload.phpfatals, and in--format=jsonthat fatal is reported as zero files needing changes. It reads exactly like a clean tree.Blast radius
Zero until an app adopts it.
quality.ymlis unchanged; no app's files change in this PR.Deliberately not here
psalm.xml— Psalm has no config inheritance. Needs a generator or a drift gate.quality-config/frontend/— templates only. ESLint flat config resolves plugins relative to the config file, so a config invendor/cannot reachnode_modules. Frontend is being homed in@conduction/nextcloud-vue, which all 18 apps already depend on.Follow-ups
Roll to 18 apps ·
quality-config-driftgate · frontend homing · NC 34 forocp+ PHPUnit +psalm.xml phpVersion· Psalm to strictest level · quote the stylelint globs (13 apps lint one directory deep) · wire Prettier · align frontend versions · adopt the five Nextcloud checks we lack (info.xml XSD,app:check-code,integrity:sign-app, REUSE, multi-DB PHPUnit) · chain Nextcloud's release template · register both packages on Packagist.