Release: merge development into beta - #647
Conversation
`fetchEngagement()` answered any failure with `catch { this.totalPoints = 0 }`,
so a learner whose engagement row could not be read was told they had zero
points — identical on screen to a learner who genuinely has none. On a tile
whose entire purpose is showing someone their own progress, that is the worst
available wrong answer.
The catch now records the error and the tile passes `:error` to CnStatsBlock,
which renders a dash and "Unavailable" instead of a number.
Two things deliberately left alone:
- The `else` branch still sets 0. An ABSENT engagement row really does mean no
points; only the failed read was lying.
- `fetchLevelName()`'s `catch { this.levelName = null }` stays. A level name
that will not resolve is legitimately absent — the tile just omits it — and
it is a secondary lookup that should not blank out a points total it already
read successfully.
The detail line (level, streak) is hidden while errored, so a stale level name
cannot sit under an "Unavailable" figure.
Verified against what is installed: `^2.19.0` ranged, `node_modules` holds
2.19.0, and that copy contains `hasError()` — without which `:error` would be
an inert attribute that renders nothing and still passes CI.
The manifest is data the renderer walks, not source the l10n extractor scans, so nav labels, page titles and descriptions looked up keys that were never in the catalogue. A missing key falls back to the English source, so a Dutch user reads English across almost the whole app. 427 strings covering admissions, curriculum, assessment, proctoring, portfolios, BSA, competencies and the BPV chain. Dutch education has its own vocabulary and the literal translation is usually the wrong one, so the domain terms are used: Learning plan template -> Sjabloon ontwikkelingsperspectief (OPP) Pass mark -> Cesuur Credits (EC) -> Studiepunten (EC) Proctoring -> Surveillance Prior learning records -> EVC-dossiers Assessment -> Toets Enrolment -> Inschrijving Learner -> Deelnemer Placement agreements -> Praktijkovereenkomsten (POK) Acronyms that ARE the Dutch term stay as they are: BPV, BSA, TLV, SWV, EC, POK, WOR, OSO. Expanding them would make the labels longer and less recognisable to the people who use them daily. Strings already Dutch are left alone on both sides: Instructieniveau, Praktijkovereenkomsten, Praktijkopleiders, Werkproces, Beoordeling, Leerbedrijf. Then `l10n:build`, because nl.json alone ships nothing: the frontend reads OC.L10N from l10n/nl.js and a raw .json is not served from an app directory. Verified: 0 manifest strings left without Dutch, keys DROPPED = 0 against the pre-change catalogues, nl.js registers under "learniq" with 1,376 keys and resolves "BPV placement" -> "BPV-plaats", check:l10n-js PASS.
…#637) Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.67.0 to 8.68.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/parser" dependency-version: 8.68.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@nextcloud/router](https://github.com/nextcloud-libraries/nextcloud-router) from 2.2.1 to 3.1.0. - [Release notes](https://github.com/nextcloud-libraries/nextcloud-router/releases) - [Changelog](https://github.com/nextcloud-libraries/nextcloud-router/blob/main/CHANGELOG.md) - [Commits](nextcloud-libraries/nextcloud-router@v2.2.1...v3.1.0) --- updated-dependencies: - dependency-name: "@nextcloud/router" dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ruben van der Linde <rubenvdlinde@gmail.com>
#652) The PHPUnit cell that runs the coverage ratchet fails with "scripts/coverage-guard.php predates merge-base comparison (it does not report the 'against' capability)". That message names a plausible cause rather than the measured one, but the underlying fact is simple: this copy is the 17,288-byte version and the workflow is written against the 35,733-byte canonical one, which additionally reports `deletion-neutral`. Without that capability, deleting well-tested dead code still reads as a coverage drop. Measured across the 21-app fleet: 8 apps already carry the canonical copy and their ratchets run and report normally; 11 carry a stale variant. This is vendored-copy drift, the same shape as the six drifted app-manifest schemas. Copied verbatim from ConductionNL/.github at quality-config/coverage-guard.php rather than patched locally. Reimplementing a gate has cost us before: a hand-rolled version found 1 finding where the real gate found 13.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-27 10:05 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-27 11:17 UTC
Download the full PDF report from the workflow artifacts.
* feat(listeners): defer the Enrolment progress roll-up (ADR-078) First leaf-app adoption of `ListenerDeferralService`. Until now only openregister and openregister-authz used it. `EnrolmentProgressRollupHandler` did its work INSIDE the write that triggered it: creating a LessonCompletion ran a read for the active Enrolment, an evaluation over the learner's lessons, and a second `saveObject()` — all before the original write returned. gate-61 refuses that shape without a deferral or a reasoned inline exception, and ADR-078 makes post-`*ed` work async by default. A roll-up is the clearest case for deferring: nothing reads `progressPercent` back in the same request, so inline bought only a slower LessonCompletion write. The listener now keeps its guards and defers; `EnrolmentProgressRollupJob` does the read, the evaluation and the write. DEDUPED per learner+course. A learner finishing ten lessons in one request owes ONE roll-up, not ten — the roll-up recomputes from scratch, so repeating it produces the same number nine times over. That is what makes deferring cheaper rather than merely later. ## Stubs, and why their parameter names matter learniq's tests map `OCA\OpenRegister\` at `tests/Stubs/`, so this needed stubs for `ListenerDeferralService`, `DeferredListenerContext`, `ActorForwardedJob` and `OrganisationService`. Their signatures are copied from canonical openregister rather than invented, and each says why in its docblock: these are called with PHP NAMED arguments, which bind by name, so a stub with a plausible-but-wrong parameter name makes an `Unknown named parameter` error invisible to psalm, phpstan and every mock-based test. `ActorForwardedJob::$logger` is `protected` for the same reason — a private stub would fail only at runtime. That is not hypothetical: openregister#2921, opened today, fixes three TMLO endpoints that answered 500 on every request for exactly this reason. ## Tests The listener's test now asserts what the listener does — that it defers, with the right entry and dedupe key, and writes NOTHING on the event path. The "no active Enrolment" arm moved to `EnrolmentProgressRollupJobTest`, where that decision now lives; leaving it behind would have asserted behaviour at a layer that no longer has it. The job's test adds an arm the inline version could not have: one failing entry must not discard the rest of the chunk. Entries are batched now, so an exception escaping the loop would drop every roll-up buffered after it — silently, because the job still succeeds from the queue's point of view. 965 tests / 4,373 assertions pass. gate-61 reports 0 failures on the touched registration. phpcs 0 errors, phpstan `[OK] No errors`. Two more listeners carry the same defect — EngagementSignalHandler and LearnerEngagementRollupHandler, both of which additionally run an UNBOUNDED findAll() on the write path. They follow in this branch. * feat(listeners): defer the LearnerEngagement roll-up and streak awards (ADR-078) Second of the three. `LearnerEngagementRollupHandler` did all of this inside the PointAward write that triggered it: a read, an evaluation, a `saveObject()`, then an UNBOUNDED `findAll()` over every active streak-milestone PointRule and a further `saveObject()` per crossed milestone. gate-61 named that unbounded read on the write path specifically. It has not become bounded — it has stopped being paid on every point a learner earns. Reading every active rule once per queued chunk is a different proposition from reading them on every award. ## The dedupe key carries `sourceKind`, and that is load-bearing A milestone bonus award has a recursion guard: its own roll-up must not re-check milestones. Deferring preserves that because `sourceKind` travels in the entry — but only if the dedupe key distinguishes the two kinds. Keyed on learner+tenant alone, a milestone award arriving FIRST in a request would swallow an ordinary award arriving second, and the ordinary award's milestone check — which the guard does not apply to — would be dropped silently along with it. The key is `learnerId|tenantId|sourceKind`, and a test pins it. ## Tests moved with the behaviour The four behavioural arms — recompute, streak crossing awards exactly once, the bonus award not re-triggering, no forward progress awarding nothing — are now `LearnerEngagementRollupJobTest`, driving the job through `runDeferred()` with the same fixtures. The listener keeps its schema guard and gains two arms of its own: that it defers with the right entry, and the dedupe-key one above.⚠️ Worth recording: after slimming the listener test, phpunit reported "OK (1 test)" — green, and wrong. My two new tests had not been inserted because the anchor I replaced on did not match. A passing run says nothing about tests that are not in the file; the count does. Checked, fixed, now 3. 967 tests / 4,382 assertions. phpcs 0 errors, phpstan `[OK] No errors`. * feat(listeners): defer the EngagementScore recompute and threshold checks (ADR-078) Third and last. `EngagementSignalHandler` did the most of the three inside the write that triggered it: recompute the EngagementScore, save it, then an UNBOUNDED `findAll()` over every active EngagementRiskThreshold and, per crossed threshold, further reads (cohort membership, open flags) and a flag write — all on every xAPI statement. xAPI statements arrive in volume. Paying a full threshold scan on each one is precisely the shape gate-61 exists to catch. The listener drops from 481 lines to 160: guards, then a defer. Nine private methods move verbatim into `EngagementSignalJob` — verbatim on purpose, so this change is "the work moved" and not "the work moved and quietly changed". Deduped per learner+course: a burst of statements for one learner on one course owes ONE recompute, since the score is recomputed from scratch. ## An invariant that had to follow the work `testConstructorHasNoAiOrHermiqDependency` asserts this path takes no AI or hermiq dependency. Left on the listener alone it would have kept passing while the job — where the work now is — grew one. It now asserts against `EngagementSignalJob`, and the listener keeps its own copy. A test that guards a property of some work has to move when the work does, or it guards an empty room. ## Tests Five behavioural arms (recompute, first crossing, no duplicate while open, relapse after resolution, cohort-scoped skip) move to `EngagementSignalJobTest` with their fixtures. The listener keeps its schema guard and gains one arm: it defers with the right entry and dedupe key, and writes nothing on the event path. 969 tests / 4,401 assertions. gate-61 0 failures. phpcs 0 errors, phpstan `[OK] No errors`. All three listeners gate-61 flagged are now deferred. Both unbounded write-path reads are off the write path. * fix(tests): the DeferredListenerContext stub must carry canonical parameter names learniq maps `OCA\OpenRegister\` at `tests/Stubs/`, so a unit run resolves the deferral classes to stubs while CI installs the real app and resolves them to openregister. The listeners call these with NAMED arguments, which bind by name, so a stub whose parameter names differ from canonical passes every local test and fails only in CI. The stub declared `__construct(array $entries = [])`; canonical is `__construct(?string $userId, ?string $orgUuid, array $entries)`. Eighteen job tests passed locally and all six PHPUnit cells failed with Argument #1 ($userId) must be of type ?string, array given Fixes, all measured: - the stub now mirrors canonical, getters included; - the three call sites pass named arguments, so the actor is visible at the point of use rather than implied; - `StubSignatureParityTest` compares each stub's parameter NAMES against the real class. It reflects the loaded class when openregister is installed (the path CI always takes) and falls back to reading canonical off disk locally. It cannot silently verify nothing: resolving to the stub under CI is an explicit failure, and a local checkout with no openregister nearby skips loudly instead of passing. Mutation-checked by reintroducing the exact bug above - the test fails naming `userId`; - psalm.xml allowlists the three ADR-078 deferral classes alongside the other cross-app OpenRegister types. Their absence, not the stub, was the psalm failure: psalm scans `lib` plus stubs and never sees the sibling app. check:strict exits 0; 964 unit tests, 4406 assertions. * test(deferral): cover the entry guards and per-entry catch the ratchet named The coverage guard failed this PR: "coverage of the code this change KEEPS or ADDS dropped by 7.32%" — 336/397 statements on the changed files against a base of 80/87 (91.95%). Moving the roll-up bodies out of the listeners and into jobs added 310 statements, and the branches that came with them were untested. Measured the same 61 uncovered statements locally (pcov in a php:8.3-cli container, since this machine has no coverage driver) and covered the ones that carry a contract: - **an entry missing an id is skipped** — the deferral buffer is fed by listeners, so a malformed entry is something that reaches the job, not something that cannot; - **one failing entry does not lose the rest of the chunk** — the reason the per-entry catch exists at all: a throw out of the loop would silently drop every later entry. Mutation-checked by narrowing the catch to a class nothing throws; the test errors; - **`isCrossed` for both metrics**, as data providers: strict comparison at the limit, and — the one that matters — a null/empty/unparseable `lastActivityAt` is NOT a crossing. "Infinitely stale" would have flagged every learner it touched; - **an unknown metric never crosses**, rather than falling through to whichever branch is last; - **`resolveMetricValue` picks its own metric's number**, days vs score; - **milestone rules with no `milestoneDays` or no id award nothing** — a milestone at day zero is crossed by every streak, and a bonus award referencing no rule is worse than no award; - **listener guards**: a non-ObjectCreatedEvent, and a payload with no learner/course, enqueue nothing. Changed-file coverage 84.63% -> 96.22%, above the 91.95% base. 996 tests, 4444 assertions; check:strict exits 0.
…e endpoint (#659) * feat(engagement): serve the learner's own points/level/streak from one endpoint The points tile was the last bespoke KPI widget in the app. It could not be a declared `type: 'stat'` tile because it needs a JOIN across two schemas — `learner-engagement.levelId` resolved against `engagement-level.name` — which no single aggregation config expresses. It also could not tell the truth. Fetching twice from the client meant the two halves failed independently: the level lookup's `catch { levelName = null }` rendered "has points, no level", which is exactly what a learner who has not reached a level looks like. A reader could not distinguish them. GET /apps/learniq/api/engagement/me returns the joined record, so the whole thing succeeds or fails as one: { totalPoints, levelName, currentStreakDays, summary } and the tile becomes config — `endpointSource` + `valueField` + a caption — with KpiPointsLevelWidget.vue deleted. `summary` is composed server-side on purpose. CnStatWidget's caption resolves a missing `{token}` to the empty string, so the obvious template `'{levelName} · {currentStreakDays}-day streak'` would show a learner with no level a leading orphan separator, and would say "0-day streak" where the old tile showed nothing. `levelName` and `currentStreakDays` are still returned separately — the summary is the display string, not a replacement for the data. It reuses the existing `{days}-day streak` catalogue entry, so every locale already translates it and no new source strings appear. A failed read returns 500 rather than zeros. Serving 0 for "the store did not answer" is the confident-zero bug this endpoint exists to remove: a mutation reintroducing it fails testAFailedReadIsAnErrorNotAConfidentZero, and a mutation letting a dangling levelId abort the request fails testADanglingLevelIdDoesNotFailTheRequest. Both were checked, not assumed. Also, in the files this touches: 66 `@spec`/`@e2e` tags pointed at openspec/changes/engagement-gamification/, archived on 2026-07-16, so every one of them dereferenced to nothing. Repointed to openspec/specs/engagement/. All 16 distinct anchors were verified to resolve against the canonical spec, with a control confirming the validator reports a fabricated anchor as dead — the first slugifier I wrote silently "found" four dead tags that were fine, because it dropped `/` and `.` instead of mapping them to `-`. The e2e now asserts the tile's VALUE, not just its presence. That was previously listed as an unmeasured gap and could not be closed while the tile swallowed its own failure; bound to one endpoint, a placeholder dash means the endpoint did not answer. composer check:strict exits 0; 967 tests, 4386 assertions; npm run lint and the webpack build are clean. * fix(e2e): match a digit, not a numeric shape, in the points tile The tile declares no `format`, so CnStatWidget formats through `Intl.NumberFormat(undefined, ...)` and the RUNNER locale chooses the group separator. Several locales use a non-breaking space, which the anchored [0-9.,]+ pattern would have rejected for a perfectly correct number — a locale-dependent red that says nothing about the endpoint. /\\d/ plus the existing "no placeholder dash" assertion still separates the three outcomes that matter: a value rendered, the loading placeholder, and the error state. * style(e2e): prettier * style(dashboard): prettier — the whole-repo format check, not just the file I touched CI runs `prettier --check "**/*.{js,ts,vue,css,scss}"`; I had only checked the spec file. Verified with that exact glob rather than per-file this time.
…ADR-099 §3) (#655) ADR-099 decision 3: "A directory under `lib/` holding exactly one class is not a namespace. It collapses into `Service/`." Eight concrete classes move, and every reference to them is rewritten (27 files): Analytics/EngagementScoreEvaluator CourseEvaluation/CourseQualityScoreEvaluator Engagement/PointEngagementEvaluator Payments/OrderTotalEvaluator PeerReview/PeerReviewAllocationService Progress/EnrolmentProgressEvaluator Release/LessonReleaseEvaluator StudyProgress/BsaProgressEvaluator Safe to move because nothing names them as a STRING: no FQCN appears in `info.xml`, the register JSON, or any config. They are DI-injected collaborators referenced through `use` statements, so the rename is mechanical and the autoloader fails loudly on a miss rather than silently. ## Two directories the rule should NOT have taken, and why **The three `Provides*` interfaces stay put** — `Bpv`, `Plagiarism`, `Proctoring`. Moving them broke a real test: SecureExamTestModeTest::testNoNewProctoringControllerServiceOrRouteExists "Learniq MUST NOT ship a Proctoring/TestMode service class" That invariant guards ADR-022's "No new PHP" decision — proctoring session and flag writes go through OpenRegister's generic object API, and the test globs `lib/Service/*Proctoring*.php` to keep it that way. An INTERFACE is not a service, but the glob is name-based and the ADR's rule is class-count-based, so the two collide. The test encodes a deliberate architectural decision and wins; the ADR needs an interface carve-out. **`Support/FleetAppId` stays put.** FIVE fleet apps keep that same shared helper at `lib/Support/FleetAppId.php` — openregister, openregister-authz, pipelinq, learniq, shillinq. `Support` is absent from the ADR's vocabulary list, but moving one app's copy while four keep theirs trades a tidiness gain for a cross-app inconsistency in the same class. The vocabulary should gain `Support`. `BackgroundJob`, `Portal`, `Sections` and `Settings` also hold one class each and are all in the ADR's allowed vocabulary, so they are untouched. ## Verification - Full suite 960 tests / 4,364 assertions, no failures (5 pre-existing skips). The proctoring invariant above is exactly why the suite was run before committing rather than after. - phpcs 0 errors, phpstan `[OK] No errors`, psalm 0 errors, after `composer dump-autoload` — a stale autoloader would have hidden every missed reference behind "class not found". - Grepped for surviving references to all eleven old namespaces: none. No gate enforces decision 3 yet, so this does not unblock anything — it is the decision applied by hand to one app. The other nineteen still drift.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ⏭️ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ⏭️ | ||||
| eslint | ⏭️ | ||||
| stylelint | ⏭️ | ||||
| build | ⏭️ | ||||
| composer | ⏭️ | ⏭️ | |||
| npm | ⏭️ | ⏭️ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ⏭️ | ||||
| REUSE | ⏭️ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-27 15:27 UTC
Download the full PDF report from the workflow artifacts.
Bumps [vue-router](https://github.com/vuejs/router) from 4.6.4 to 5.2.0. - [Release notes](https://github.com/vuejs/router/releases) - [Commits](vuejs/router@v4.6.4...v5.2.0) --- updated-dependencies: - dependency-name: vue-router dependency-version: 5.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ruben van der Linde <rubenvdlinde@gmail.com> Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-27 17:03 UTC
Download the full PDF report from the workflow artifacts.
The App Store keys everything on the app id, so renaming <id> to learniq made it a brand new store entry starting from nothing. The release workflow derives its version baseline from the git tags and info.xml of THIS repo, neither of which knows anything about what shipped as scholiq -- so the version line restarts below it. filinq was about to publish 0.0.40 while docudesk sits at 0.1.0-beta.3 on the store. The store has no version ordering rule (_check_permission validates existence and ownership only), so that uploads with a 200 and is then never offered to anyone already on the higher version. previous-app-id folds the old entry's published versions into the baseline, so the renamed app picks the line up instead of restarting it.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ⏭️ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ⏭️ | ||||
| eslint | ⏭️ | ||||
| stylelint | ⏭️ | ||||
| build | ⏭️ | ||||
| composer | ⏭️ | ⏭️ | |||
| npm | ⏭️ | ⏭️ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ⏭️ | ||||
| REUSE | ⏭️ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-27 18:21 UTC
Download the full PDF report from the workflow artifacts.
) vue/new-line-between-multi-line-property, LearniqDashboards.vue:540. One error, and it is the only thing standing between development and a green Vue Quality job -- the other 19 findings in that run are warnings. It reached development because every Code Quality run there since 09:54 was CANCELLED by the release loop's concurrency group rather than completing, so nothing reported the regression.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 648/648 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-27 20:45 UTC
Download the full PDF report from the workflow artifacts.
* feat(demo): generated demo data for every schema (ADR-111 rules 1-2)
This app declares schemas and shipped no demo data, so it opened on an empty
list: the person evaluating it had to author objects by hand against a schema
they did not know yet. Fleet-wide, 562 of 598 schemas were in that state.
🔴 GENERATED, NOT WRITTEN. Every value is derived from the schema that will
validate it — `enum` picks from the enum, `pattern` is satisfied, `format`
drives the shape, `minimum`/`maxLength` are honoured, `required` is always
populated. Hand-written demo data is wrong in a way nobody sees until the demo
(a status outside its own enum, a required field omitted) and it fails at
import, in front of whoever asked for the demo.
Produced and validated by the single copy that gate-99 also runs:
`vendor/conduction/hydra-gates/scripts/lib/generate_mock_register.py`.
Regenerate with that file; `--keep` preserves curated objects and tops up only
what is short.
🔴 IT DOES NOT INSTALL ITSELF (ADR-111 rule 3). `x-openregister.type: mock` is
imported ON DEMAND — sample data appearing on a production instance because
somebody upgraded is a data-integrity incident, not a convenience. Install it
with:
occ openregister:descriptors:list --app=<app> --import=<register>
The setup-wizard step that offers this during first-run install (ADR-111 rule 4)
follows once OpenRegister's shared installer lands — deliberately not twenty-one
copies of the same logic.
Verified: `--check` re-validates every object against its own schema with
jsonschema and reports clean.
* fix(demo): emit real MDI icon names, so gate-60 stops rejecting the demo data
gate-60 (icon-vocabulary) failed the very PR that added demo data: the generator
emitted `Voorbeeld Icon 1` for properties named `icon`, and an icon outside the
canonical vocabulary "renders blank wherever it is not aliased locally"
(ADR-077).
🔴 THE FIX READS THE GATE'S OWN VOCABULARY FILE, never a copy of it. Carrying a
second list here is precisely how a producer and its judge drift apart, which
this package fixed once already today.
🔴 AND IT READS THE VALUES, NOT THE KEYS. The first attempt emitted the semantic
key — `documentation` — because that is what the map is keyed by. The map is
`"documentation": "BookOpenVariantOutline"`, and gate-60 accepts the MDI name:
it rejected `documentation` in the same breath as the invented name it replaced,
"a kebab-case or lowercase spelling of an MDI name resolves to nothing". Half
reading a vocabulary is not reading it.
Verified on portaliq: gate-60 PASS, zero gate failures. All seven apps
regenerate with zero validation findings.
* fix(demo-data): attribute the descriptor to the app id, not the directory
`x-openregister.app` is what the descriptor inventory resolves a register to
an app by, and the generator was writing the CHECKOUT DIRECTORY name into it.
Five of eight apps already ship an `<id>` that differs from their directory —
larpinq, learniq, humaniq, decidiq, buildiq — so those descriptors named an
app that does not exist, and the `occ openregister:descriptors:list --app=…`
command printed in their own description resolved nothing. A cross-app id is
a runtime lookup: it does not error, it silently finds nobody.
The id now comes from `<id>` in appinfo/info.xml, the stated authority,
falling back to the directory name only when there is no info.xml. The output
file is renamed to match and the directory-named one removed, so exactly one
mock descriptor remains per app — two would import demo data twice.
Register slugs are untouched: those are frozen per app.
* fix(lint): blank line between multi-line properties in LearniqDashboards
`vue/new-line-between-multi-line-property` was erroring on development
itself, not on anything this branch changed — the file here is byte-identical
to development's. Every branch cut from it inherits a red lint-check, which
is how the failure arrived on a PR that only touches a JSON descriptor.
eslint --fix, one blank line. `eslint src` now reports 0 errors (the 19
remaining warnings are pre-existing and do not gate).
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 648/648 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-27 21:48 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 648/648 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-27 22:52 UTC
Download the full PDF report from the workflow artifacts.
The catch-all branch reported 'Nextcloud unreachable at $BASE' for every seeder exit code that is not 0 or 2. Exit 1 is what the seeder returns when it decides the instance is unreachable, and it is equally what node returns for ANY uncaught exception -- so a crash was reported as an outage. Observed 2026-08-27 on #661: the seeder logged 'Nextcloud 34.0.3 at http://localhost:8080 -- OK', imported 118 schemas and linked 118/118, then died inside node's bundled HTTP client with 'AssertionError: assert(!this.paused)' at Parser.finish (undici, node v24.19.0). CI reported 'Nextcloud unreachable' about a server that had just answered a hundred and eighteen requests. Probe status.php before naming a cause, and say which of the two it was.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 648/648 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-28 05:34 UTC
Download the full PDF report from the workflow artifacts.
…661) The App Store serves 0.2.0-beta.3 (scholiq) for this app, and the release workflow was computing a beta BELOW it. The baseline comes from this repo's latest stable tag and appinfo/info.xml, neither of which knows what was published while the fleet released from Codeberg -- that line had walked up a minor that only exists as prerelease tags, which the stable-tag filter cannot see. The store has no version ordering rule (_check_permission validates existence and ownership only), so a lower version uploads with a 200 and is then never offered to anyone already on the higher one. Setting the baseline to 0.2.0 makes the next beta 0.2.1-beta.<ts>, which clears it. Minor bump only -- the major is unchanged.
#675) Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
.github#597 set cancel-in-progress on the shared quality.yml, but a caller-level concurrency cancels the whole run before the called workflows setting can apply -- so that fix reached only the apps that declare no concurrency of their own. Measured 2026-08-28 over push runs on development since #597 merged: caller silent 0 of 11 cancelled caller says true 7 of 13 cancelled (54%) This repo is in the second group. pull_request keeps cancelling, where superseding really is correct.
* refactor(manifest): the flow pages are an index and a flow `flows` and `flow-detail` are deprecated aliases. `flows` predates named index sources: a flow lives in OpenRegister's native flow table rather than a register/schema pair, so an object-backed index had nothing to bind to and the list needed a page type of its own. `config.entitySource` closes that, so the list is an ordinary `index` and only the EDITOR still needs its own type, now named `flow`. Behaviour is unchanged - both aliases resolve to the same components. The list page's `_note` is updated in the same commit: it asserted the old rationale (that an index "cannot address" a flow), which this change disproves. `config.app` is untouched and is load-bearing: the editor stamps it on a flow created there and the index filters on it. Requires @conduction/nextcloud-vue 2.21, where a named source's columns and create button are actually read. * chore(deps): bump @conduction/nextcloud-vue to ^2.21.0 The flow pages need 2.21.0: earlier releases DECLARE a named index source's columns, create button and row actions without reading them, so the migrated page renders a columnless table with no working create action. The lock is the part that matters. CI installs with `npm ci`, which honours package-lock.json and ignores how permissive the caret is — bumping the range alone would change nothing about what actually installs. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Adds `learniq-compose.yaml` and a setup page describing it. The compose brings up Postgres and Nextcloud, installs openregister (required), thematiq and integriq (optional) and learniq from release tarballs, and enables them in dependency order. Nothing is bind-mounted: Nextcloud installs an app by deleting its directory and extracting an archive over it, so pointing that at a checkout deletes the working tree — measured on a development machine on 2026-08-27, where an app-store update fired on a container restart and removed every top-level file including .git. Release tarballs rather than a clone for a second reason: a tarball is a complete app carrying vendor/ and the built js/, and an app with no vendor/ does not fail loudly — it warns once and keeps loading, so it looks installed while every service needing a dependency is absent. The openregister dependency is not declared in appinfo/info.xml — no app in the fleet declares an <app> dependency — so the compose encodes what the manifest does not. Verified: docker compose config parses and interpolates; the same generated file was booted end to end for portaliq, which produced 17 registers, 86 schemas and 13 magic tables for its own register, with the portal content API returning a real site rather than an empty shell. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Two defects, both measured against a booted demo rather than inferred from
the code.
The verification step told you to run
curl -s -o /dev/null -w '%{http_code}' http://localhost:PORT/apps/APP/
and described a pass. That request is unauthenticated, and a Nextcloud app
page requires a login, so it prints 401 on a perfectly healthy demo. A reader
following the page would conclude the demo was broken. The command now sends
the demo credentials and says in words that a bare 401 is expected.
Thematiq additionally has no app route at all — it declares
<admin-section>theming</admin-section> — so /apps/thematiq/ answers 404 even
authenticated. Its page now opens Settings -> Administration -> Theming.
Verified on two booted demos: the corrected commands return 200.
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
learniq's 8 V3 findings, all four files. Not one was a carelessly
silenced test — every reason already existed and was good. None was
somewhere a tool could read it.
peer-and-self-assessment 3 reason in the file header
personal-timetable 3 reason in the file header
adaptive-release 1 reason in a comment above the test
report-card 1 reason in a comment above the test
The skip-discipline gate reads the Playwright REPORT, which is the only
place a run-time exclusion records a reason at all. A `describe.skip` and
a `test.fixme(name, fn)` both record NONE, so all eight classified as
"skip with no reason recorded" — indistinguishable from a test someone
silenced without saying why.
Nothing about what runs changes. Same skips, same conditions, same test
counts (3, 3, 3 and 9 listed before and after).
## Two placements, deliberately different
The file-level deferrals become a describe-level `test.skip(true, …)` —
that is the correct scope, because every test in those files is deferred
for the same reason.
The two single-test cases put the annotation INSIDE the test body. This
is not a style choice: report-card.spec.ts carries its own warning that
Declared as `test.fixme(name, fn)` — NOT a bare `test.fixme(true, …)`
statement, which applies to every test in the enclosing describe and
would quietly take the other scenarios down with it.
That is exactly right about a DESCRIBE-level statement, and exactly the
hazard to avoid. In the test body it scopes to that test alone, and
unlike `test.fixme(name, fn)` it records the reason. So the author's
constraint is preserved and the reason still reaches the report.
Both remain `fixme` rather than `skip`, keeping the property the author
wanted: a KNOWN failure rather than a pass.
Verified: npm ci rc=0, npm run lint rc=0 (0 errors), prettier clean on all
four, and `playwright test --list` compiles each file with its original
test count.
Part of ConductionNL/.github#609.
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
#686) * feat(setup): a wizard that offers the demo data this app already ships This app ships lib/Settings/*_mock_register.json - a dataset generated from its own schemas, conformant by construction, validated by the generator's --check - and had no way for an operator to reach it. There was no setup wizard at all. welcome -> demo-data -> done. Nothing app-specific is invented: the only action is the demo-data import the descriptor already supports. A wizard that asked questions the app does not act on would be worse than none, which is why there are no configuration steps here yet. completed is TRUE and the demo-data step is optional, so setup never gates the app. skip-demo-data records its outcome just as installing does: since nextcloud-vue 2.21 an OUTSTANDING OPTIONAL step opens the wizard over every page (nextcloud-vue#806), so a step that can never be marked done is a dialog that never closes - the defect buildiq was failing 37 E2E specs on. Verified: manifest validates against schema 2.26.0, gate-100 PASS, routes.php and both PHP files parse. The template was checked on launchpad against phpcs, phpstan, psalm and phpmd - all clean. * fix(setup): declare the endpoints' auth, and translate the wizard's strings Two gate findings on the previous push. gate-5 route-auth — status() and runAction() carried no auth attribute. The docblock said 'admin-only by Nextcloud's default for an un-attributed method', which is true and is not a declaration: the gate exists because a missing attribute silently makes an endpoint unreachable, and a comment cannot be checked by middleware. Both now carry #[AuthorizedAdminSetting(Application::APP_ID)], placed DIRECTLY above the declaration - gate-5 walks upward from the method and a long docblock between attribute and declaration costs the attribute its visibility, which the gate documents as a false FAIL it had to repair. gate-102 manifest-l10n-coverage — the wizard's title and body strings had no l10n/nl.json key, so a Dutch user would read them in English. Added, and the browser catalogue rebuilt where the app ships one: nl.json alone is not enough, because the browser reads nl.js. The catalogue edit is insertions only, proven against the same change applied structurally - an earlier attempt on another app re-serialised the whole file (410 lines) before being reverted. * fix(setup): authorize against the admin settings class, and test what it guards `AuthorizedAdminSetting` takes a `class-string<IDelegatedSettings>`, not an app id, so `Application::APP_ID` — a plain string — was rejected by phpstan. The apps where this shipped green (larpinq, shillinq) already pass their admin settings class; match them. gate-47 and the coverage ratchet were both right to fail this. The change adds an admin-authorized endpoint pair and ~364 lines of PHP with nothing behind them. Two assertions are worth naming: - a FAILED install must leave the step UNDECIDED. Recording the decision in the catch block would close the step for an operator who asked for demo data and received none. - the object count comes from the FILE, not the importer's reply, so the number reported is the number ASKED FOR. Both verified by mutation on openregister: reversing each behaviour fails exactly the test that claims to guard it. The e2e spec issues both calls from inside the logged-in admin page, which is the only place that middleware can be observed admitting a real session. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…licks (#689) The ADR-111 setup step is OPTIONAL, and CnAppRoot opens the non-gating wizard as a full modal mask while any optional non-info step is reported not-done — in every fresh browser context, so once per spec. Merging the setup wizard therefore turned this app's whole E2E suite red without touching a single spec: the call log reads "locator resolved to <button ...> - attempting click action" with <ol class="cn-wizard-dialog__progress"> named as the interceptor. The element was found; the click never landed. SKIPPED rather than installed, because recording the DECISION is what closes the wizard. Installing would push the app's demo dataset into every list the suite asserts on, which changes what the other specs measure. `demo-data-setup-step.spec.ts` exercises the install deliberately, in isolation. Uses the workflow's own exported credentials rather than this script's internals, and is tolerant of a non-200: an app whose wizard has no demo-data step answers 400, and that is not a seeding failure. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* fix(e2e): let the demo-data spec inherit this suite's auth The spec declared `storageState: tests/e2e/.auth/admin.json`, which this app does not write — `global-setup.ts` writes `test-results/.auth/admin.json`, and the config already points `use.storageState` there for every project. The override therefore replaced a working session with a path that does not exist, and the whole describe failed in 2ms on ENOENT before a single assertion ran. That declaration was carried over from openregister, whose default project authenticates with a Basic header instead and genuinely needs it. Everywhere else in this fleet the spec inherits the suite's own session, which is what the five apps it was proven on do. * style(e2e): drop the blank line the removal left behind --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…687) * feat(walkthrough): a first-visit tour built from this app's own menu ADR-043. The app had no walkthrough, so a new user arrived on a shell with no orientation at all. Every step targets a route READ FROM THIS MANIFEST, never invented. CnWalkthrough resolves target {kind: nav-item, ref} by querying [data-cn-route=<ref>], which CnAppNav emits from the ROUTE - so a tour pointing at a menu id that differs from its route still renders, as a centred anchorless coachmark that points at nothing. Verified: every ref resolves to a page this manifest declares. Where a menu label is a translation KEY rather than a sentence, the copy uses the key's last segment as a word. Printing launchpad.menu.dashboards at a user is worse than useless, and falling back to the route gives 'Click dashboard-detail in the menu' - an internal identifier read aloud. Verified: manifest validates against schema 2.26.0, every target ref resolves, and no raw translation key reaches the copy. * fix(walkthrough): point the tour at flows, and translate its copy gate-70 walkthrough-flows-stop - this app ships a flows page and no tour step targeted it. The gate's own measurement is why it exists: across 20 manifests, 12 apps shipped a flows page and exactly ONE tour mentioned it, so the automation surface was discoverable only by someone who already knew. Detected the way the gate detects it - type:'flows' OR the migrated form, an index over config.entitySource:'flows'. Matching on type alone would miss every app the flow-page migration touched, which is all of them. The step advances on route-match and sets allowManualNext. The same gate fails a step advancing only on object-created: that turns 'here is where flows live' into 'build an automation before you may continue', and a tour nobody can finish is worse than one that never mentions flows. gate-102 manifest-l10n-coverage - the tour's titles, bodies and tasks had no l10n/nl.json key. Added, and the browser catalogue rebuilt where the app ships one, because the browser reads nl.js rather than nl.json. * style(walkthrough): drop the em-dash from the flows step gate-96 (manifest-copy-style) fails this: voice.md section 8 is unambiguous — no em-dashes, Conduction strips them fleet-wide. The nl.json KEY is the English string, so it is renamed with it; leaving the key behind would orphan the Dutch translation of a string that still ships. The Dutch VALUE carried the same em-dash and gets the same treatment — the gate only counts manifest strings, but it is the same user-facing sentence. Note for a later sweep: this app's nl.json still holds 16 other em-dash strings from Vue copy. They are outside this gate's scope (it reads manifest strings) and outside this PR's. * fix(l10n): refresh the browser catalogue after the em-dash rename check:l10n-js reported 'Stale browser catalogue: l10n/nl.js'. Renaming the key in nl.json without nl.js leaves the two halves of the same catalogue disagreeing, and the runtime reads the .js one. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
2.22.1 carries the theme app-id fix (nextcloud-vue#840). CnAppRoot calls useScopedTheme() with no slug, so this app resolved theme tokens, the token-set catalogue and the contrast check through a hardcoded 'nldesign' app id. thematiq is renaming to 'thematiq', and every path in that composable degrades to default styling by design — so once a renamed build is installed this app would render unthemed with nothing in any log. The LOCK is what moves here. A caret range alone changes nothing, because npm ci installs what package-lock.json pins. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…licks (#703) @conduction/nextcloud-vue 2.22.x made the product walkthrough actually open. A `placement: "center"` welcome step used to be parked in `_pendingAutoTour` and never shown; the library now correctly starts it on any route. Its `cn-walkthrough__dim--full` layer is a `role="dialog" aria-modal="true"` overlay, so every spec that clicks behind it times out, and `getByRole('dialog')` resolves to the dim layer rather than the modal under test. The marker is per USER, not per test, so leaving it unseeded also makes the suite order-dependent: whichever spec runs first wears the tour. Seeded before the single storageState save, which is where this suite persists its authenticated state. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…xt (#701) gate-103 (locale-duplication) fails this app: 28 locale pairs are byte-for-byte copies of another language. They are not "untranslated" — they are WRONG, and a user who picks one is shown a language they did not choose. Identified by reading the content, not just the hashes. Six groups, one real translation each: * bs, cs, mk, sk, sl, sr == hr CROATIAN ("Konfiguriranje postavki") * be, uk == ru RUSSIAN ("Настройки Learniq") * ca == es SPANISH ("Configurar los ajustes") * sv == da DANISH ("Konfigurer appindstillingerne") * lb == de GERMAN ("App-Einstellungen konfigurieren") * rm == it ITALIAN ("Configura le impostazioni") Deleted rather than blanked, so those twelve fall back to English. English a Slovak reader can recognise as not-their-language beats Croatian they may mistake for it. Verified after the deletion: zero duplicate groups in both the .json and .js catalogues, 24 locales remain, and every kept bundle still parses. Noted while reading, NOT fixed here: the kept `hr` bundle still says "Postavke Scholiqa" — the pre-rename app name survives inside the translated strings. That is a separate change. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
#702) The first-visit walkthrough opens as a modal with a full-page dim inside role="dialog", and it does so in EVERY fresh browser context — once per spec, not once per suite. Merging it therefore turned specs red without touching them: the Playwright call log resolves the target button, attempts the click, and names the interceptor as <div class="cn-walkthrough__dim cn-walkthrough__dim--full"> learniq lost 7 specs to this and portaliq 2, all on development, all after the walkthrough landed. Marks the tour SEEN rather than disabling it. The manifest declares `walkthrough.version: 1` with `completionConfigKey: walkthrough_completed_version`, so storing that version is exactly what a real operator does by finishing or dismissing the tour once — the walkthrough itself stays shipped and testable. Same shape as the demo-data decision this seed already settles, and tolerant of a non-200 for the same reason: an app without a walkthrough answers 400 there, which is not a seeding failure. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
`prettier --check` failed on the walkthrough marker seeded earlier today. Purely a line-width wrap; the marker and its behaviour are unchanged. Length-dependent, which is why the identical insertion passed in shorter-named apps: 'cn-walkthrough-seen:learniq' pushes the call past the print width where a shorter app id does not. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…rds (#696) * feat(demo): generated demo data for every schema (ADR-111 rules 1-2) This app declares schemas and shipped no demo data, so it opened on an empty list: the person evaluating it had to author objects by hand against a schema they did not know yet. Fleet-wide, 562 of 598 schemas were in that state. 🔴 GENERATED, NOT WRITTEN. Every value is derived from the schema that will validate it — `enum` picks from the enum, `pattern` is satisfied, `format` drives the shape, `minimum`/`maxLength` are honoured, `required` is always populated. Hand-written demo data is wrong in a way nobody sees until the demo (a status outside its own enum, a required field omitted) and it fails at import, in front of whoever asked for the demo. Produced and validated by the single copy that gate-99 also runs: `vendor/conduction/hydra-gates/scripts/lib/generate_mock_register.py`. Regenerate with that file; `--keep` preserves curated objects and tops up only what is short. 🔴 IT DOES NOT INSTALL ITSELF (ADR-111 rule 3). `x-openregister.type: mock` is imported ON DEMAND — sample data appearing on a production instance because somebody upgraded is a data-integrity incident, not a convenience. Install it with: occ openregister:descriptors:list --app=<app> --import=<register> The setup-wizard step that offers this during first-run install (ADR-111 rule 4) follows once OpenRegister's shared installer lands — deliberately not twenty-one copies of the same logic. Verified: `--check` re-validates every object against its own schema with jsonschema and reports clean. * fix(demo): emit real MDI icon names, so gate-60 stops rejecting the demo data gate-60 (icon-vocabulary) failed the very PR that added demo data: the generator emitted `Voorbeeld Icon 1` for properties named `icon`, and an icon outside the canonical vocabulary "renders blank wherever it is not aliased locally" (ADR-077). 🔴 THE FIX READS THE GATE'S OWN VOCABULARY FILE, never a copy of it. Carrying a second list here is precisely how a producer and its judge drift apart, which this package fixed once already today. 🔴 AND IT READS THE VALUES, NOT THE KEYS. The first attempt emitted the semantic key — `documentation` — because that is what the map is keyed by. The map is `"documentation": "BookOpenVariantOutline"`, and gate-60 accepts the MDI name: it rejected `documentation` in the same breath as the invented name it replaced, "a kebab-case or lowercase spelling of an MDI name resolves to nothing". Half reading a vocabulary is not reading it. Verified on portaliq: gate-60 PASS, zero gate failures. All seven apps regenerate with zero validation findings. * fix(demo-data): attribute the descriptor to the app id, not the directory `x-openregister.app` is what the descriptor inventory resolves a register to an app by, and the generator was writing the CHECKOUT DIRECTORY name into it. Five of eight apps already ship an `<id>` that differs from their directory — larpinq, learniq, humaniq, decidiq, buildiq — so those descriptors named an app that does not exist, and the `occ openregister:descriptors:list --app=…` command printed in their own description resolved nothing. A cross-app id is a runtime lookup: it does not error, it silently finds nobody. The id now comes from `<id>` in appinfo/info.xml, the stated authority, falling back to the directory name only when there is no info.xml. The output file is renamed to match and the directory-named one removed, so exactly one mock descriptor remains per app — two would import demo data twice. Register slugs are untouched: those are frozen per app. * fix(lint): blank line between multi-line properties in LearniqDashboards `vue/new-line-between-multi-line-property` was erroring on development itself, not on anything this branch changed — the file here is byte-identical to development's. Every branch cut from it inherits a red lint-check, which is how the failure arrived on a PR that only touches a JSON descriptor. eslint --fix, one blank line. `eslint src` now reports 0 errors (the 19 remaining warnings are pre-existing and do not gate).
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Two independent faults, either of which alone stops the docs site updating. TRIGGER. This listened on a branch called `documentation`. Nobody has pushed to one since 2026-05-25, so every docs change merged to `development` passed review and published nothing. SECRETS. A reusable workflow receives no secrets by default. With none mapped, the callee's publish step finds CF_API_TOKEN empty and skips itself on its own guard, and the run finishes GREEN having changed nothing. Fixing only the trigger would have produced exactly that. The worker name is now pinned. Deriving it is the documented way to get a green run that reaches nobody: wrangler creates the derived worker and publishes there while the custom domains keep routing to the real one. Where the app was renamed, `canonical-host` turns the retired hostname from a second live copy of every page into a 301 to the same path on the current one. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
#695) * feat(demo): generated demo data for every schema (ADR-111 rules 1-2) This app declares schemas and shipped no demo data, so it opened on an empty list: the person evaluating it had to author objects by hand against a schema they did not know yet. Fleet-wide, 562 of 598 schemas were in that state. 🔴 GENERATED, NOT WRITTEN. Every value is derived from the schema that will validate it — `enum` picks from the enum, `pattern` is satisfied, `format` drives the shape, `minimum`/`maxLength` are honoured, `required` is always populated. Hand-written demo data is wrong in a way nobody sees until the demo (a status outside its own enum, a required field omitted) and it fails at import, in front of whoever asked for the demo. Produced and validated by the single copy that gate-99 also runs: `vendor/conduction/hydra-gates/scripts/lib/generate_mock_register.py`. Regenerate with that file; `--keep` preserves curated objects and tops up only what is short. 🔴 IT DOES NOT INSTALL ITSELF (ADR-111 rule 3). `x-openregister.type: mock` is imported ON DEMAND — sample data appearing on a production instance because somebody upgraded is a data-integrity incident, not a convenience. Install it with: occ openregister:descriptors:list --app=<app> --import=<register> The setup-wizard step that offers this during first-run install (ADR-111 rule 4) follows once OpenRegister's shared installer lands — deliberately not twenty-one copies of the same logic. Verified: `--check` re-validates every object against its own schema with jsonschema and reports clean. * fix(demo): emit real MDI icon names, so gate-60 stops rejecting the demo data gate-60 (icon-vocabulary) failed the very PR that added demo data: the generator emitted `Voorbeeld Icon 1` for properties named `icon`, and an icon outside the canonical vocabulary "renders blank wherever it is not aliased locally" (ADR-077). 🔴 THE FIX READS THE GATE'S OWN VOCABULARY FILE, never a copy of it. Carrying a second list here is precisely how a producer and its judge drift apart, which this package fixed once already today. 🔴 AND IT READS THE VALUES, NOT THE KEYS. The first attempt emitted the semantic key — `documentation` — because that is what the map is keyed by. The map is `"documentation": "BookOpenVariantOutline"`, and gate-60 accepts the MDI name: it rejected `documentation` in the same breath as the invented name it replaced, "a kebab-case or lowercase spelling of an MDI name resolves to nothing". Half reading a vocabulary is not reading it. Verified on portaliq: gate-60 PASS, zero gate failures. All seven apps regenerate with zero validation findings. * fix(demo-data): attribute the descriptor to the app id, not the directory `x-openregister.app` is what the descriptor inventory resolves a register to an app by, and the generator was writing the CHECKOUT DIRECTORY name into it. Five of eight apps already ship an `<id>` that differs from their directory — larpinq, learniq, humaniq, decidiq, buildiq — so those descriptors named an app that does not exist, and the `occ openregister:descriptors:list --app=…` command printed in their own description resolved nothing. A cross-app id is a runtime lookup: it does not error, it silently finds nobody. The id now comes from `<id>` in appinfo/info.xml, the stated authority, falling back to the directory name only when there is no info.xml. The output file is renamed to match and the directory-named one removed, so exactly one mock descriptor remains per app — two would import demo data twice. Register slugs are untouched: those are frozen per app. * feat(flows): give the flow-detail canvas its sidebar The manifest _note claimed the controls rendered in the NC app sidebar, but the sidebarComponent field it described did not exist. Every #/flows/:id -- and #/flows/new, the same route with the literal id -- drew a bare canvas: savable and runnable, but with no way to name, describe, trigger or step-edit the flow, because those controls all live in CnFlowSidebar. Mirrors pipelinq#1490. ADR-110 Decision 4. * test(stubs): assert OpenRegister stubs carry canonical parameter names learniq calls the stubbed OpenRegister classes with named arguments, which bind by name, so a stub whose parameter names drift from canonical passes every local unit run and fails only in CI -- where the real app is installed -- with an error naming the stub rather than the caller. DeferredListenerContext was stubbed __construct(array $entries) against a canonical __construct(?string $userId, ?string $orgUuid, array $entries). * docs(registry): name the flow page types development actually declares The comment named `flows` / `flow-detail`; development renamed them to an `index` with config.entitySource 'flows' and `flow" (#669), which the merge resolution above adopts. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
beta held 23 commit(s) development did not. Merged with -s ours: development's tree is kept BYTE FOR BYTE and only the ancestry is recorded. That is the payload -- without it the merge base never moves and the next development -> beta promotion conflicts on the version file exactly as before. 13 of 19 promotion PRs were CONFLICTING for this reason. Nothing is silently imported. What beta holds and development does not, and which this deliberately does NOT bring over: .forgejo/.beta-release-trigger .forgejo/issue_template/bug-report.yml .forgejo/issue_template/feature-request.yml .forgejo/issue_template/technical-task.yml .forgejo/issue_template/user-story.yml .forgejo/workflows/documentation.yml .forgejo/workflows/release-beta.yml .forgejo/workflows/release-stable.yml CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md SUPPORT.md Those are dead Forgejo/Codeberg CI (removed from development on 2026-08-24/25 by 'chore(ci): remove dead Forgejo/Codeberg CI configuration'), generated Docusaurus build output, and community-health files that never existed on development. Each can be added deliberately if wanted; resurrecting them as a side effect of a sync is how a merge silently undoes a decision.
…260830084323 chore(sync): carry beta back into development
…0841 chore(sync): record beta's ancestry on development
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ⏭️ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ⏭️ | ||||
| eslint | ⏭️ | ||||
| stylelint | ⏭️ | ||||
| build | ⏭️ | ||||
| composer | ⏭️ | ⏭️ | |||
| npm | ⏭️ | ⏭️ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ⏭️ | ||||
| REUSE | ⏭️ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-30 09:07 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 648/648 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-30 10:19 UTC
Download the full PDF report from the workflow artifacts.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.