From e2df01c45d769f4106a2497ab744176a8813522b Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 4 Aug 2026 13:28:34 +0200 Subject: [PATCH 1/2] docs(agents): record learned dev-workflow facts in scoped AGENTS.md - root: the CI static gate runs four tools (rector and phpat were undocumented); captainhook host-php driver trap and its container fallback; the manual release order and its immutability point - tests: .env.test.local silently redirects bin/phpunit to the dev DB; real-env-var override; db_unittest volume seeding - src: DESC key parts defeat MIN/MAX loose scan (why Version20260704 recreated the indexes ASC); Settings/2FA endpoint response conventions and the authSource tri-state - frontend: popups inside a modal render inline+fixed, not body-portalled; MCP-playwright click focus artifact Signed-off-by: Sebastian Mendel --- AGENTS.md | 8 ++++++++ frontend/AGENTS.md | 8 ++++++++ src/AGENTS.md | 11 +++++++++++ tests/AGENTS.md | 12 ++++++++++++ 4 files changed, 39 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0327e7da5..83d6d5c18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,14 @@ This file explains repo-wide conventions and where to find scoped rules. - Lint/format: `composer cs-check` / `composer cs-fix` - Tests: `composer test` +The CI "Lint & Static Analysis" gate runs FOUR static tools, not two: phpstan, phpat (`bin/phpstan analyze -c config/quality/phpat.neon`), php-cs-fixer, and rector (`bin/rector process src --config=config/quality/rector.php --dry-run`). Run all four on changed PHP before pushing — rector is the one that gets forgotten. Tools live in `bin/` (composer bin-dir), not `vendor/bin`. When a change touches a base/parent method signature, run FULL-tree phpstan (`bin/phpstan analyze --no-progress`), not file-scoped — anonymous-class overrides in `tests/` break invisibly otherwise. + +The captainhook pre-commit hook runs the unit suite on the HOST php. A host without `pdo_mysql` fails with "could not find driver" — never bypass with `--no-verify`; either run the suite in the container (`docker compose --profile dev exec -T -e APP_ENV=test -e DATABASE_URL=mysql://unittest:unittest@db_unittest:3306/unittest app-dev bin/phpunit …`) or point `DATABASE_URL` at the unittest DB via a real env var. If phpat aborts with a Nette `ContainerLoader … .lock` error after a container run, it is a uid-split on `var/phpstan-phpat/` — remove that cache dir, it is not an architecture violation. Container and host runs regenerate `config/reference.php`; restore it (`git checkout -- config/reference.php`) before committing, never stage it. + +## Releases + +No workflow creates releases. `docker-publish.yml` builds images on tag push; `slsa-provenance.yml` runs on `release: published` and its asset upload makes the release IMMUTABLE — get the notes right before publishing. Order: verify main CI green → `git tag -s vX.Y.Z -m "vX.Y.Z"` → `git push origin vX.Y.Z` (Docker Publish runs) → `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file ` (SLSA runs). The metadata-action strips the leading `v`: images are `:6.0.0`/`:6.0`/`:6`. Credit contributors inline per change with `@mentions` (GitHub builds the Contributors row from body mentions); auto-generated notes miss direct pushes — cross-check via the compare API. Credit the human driving an agent-authored commit (committer / `Co-authored-by`), never a bot author. + ## Index of scoped AGENTS.md | Path | Purpose | diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index bb05d19f1..1c397fb09 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -96,3 +96,11 @@ See [`README.md`](README.md) for the full stack description. disk icon forces a save and row-leave shows the full error). Relation cells use `ChipSelect` (`src/lib/chipSelect.tsx`, an Ark Combobox) body-portalled (whitelist `data-chipselect-popup`) to escape the table scroll container + +## Popups inside a modal dialog + +A body-portalled combobox/popover popup is a SIBLING of an Ark Dialog's portal, so the dialog's modal focus trap makes it `inert` — it looks dead. Render the Positioner INLINE (no ``) with `positioning: {strategy: 'fixed'}`: it stays inside the dialog's DOM subtree (the trap only inerts siblings), and `fixed` still escapes `overflow` clipping. Portalling *into* the dialog breaks `openOnClick` (the combobox machine initialises without a present Positioner). The body-portal guidance elsewhere in this file applies OUTSIDE modals. + +## MCP-playwright clicks do not focus inputs here + +In an `mcp__playwright__*` session, clicking any input leaves `document.activeElement` on `#main-content` (the SPA autofocus wins the race), so `openOnClick` and option-click commits silently fail. This is a session artifact, not a component bug — the real e2e runner clicks fine. Verify comboboxes in MCP by TYPING (`fill`/`pressSequentially`), and trust the e2e suite for click flows; never "fix" the component off a failing MCP click without checking a known-green reference. diff --git a/src/AGENTS.md b/src/AGENTS.md index 32f48daeb..77c5d41e8 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -178,3 +178,14 @@ public function list(EntityManagerInterface $em): JsonResponse Migrating deploys: back up first (`mariadb-dump` via the URL user — root has no pw) and pre-flight each pending migration's columns. For local testing use the dev compose stack (`COMPOSE_PROFILES=dev`, port 8765, `APP_ENV=dev`) + +## Index direction vs. MIN/MAX loose scan + +MariaDB 10.11 cannot use a loose scan (`Using index for group-by`) for `MIN()`/`MAX()` over a `DESC` key part — a `(col, day DESC)` index turns `SELECT col, MAX(day) … GROUP BY col` into a full index scan (~240k rows, ~150 ms), while plain ASC `(col, day)` loose-scans in ~1 ms AND still serves `WHERE col=X ORDER BY day DESC` without filesort (backward scan). That is why `Version20260704_LastActivityIndexesAscLooseScan` recreated the three `idx_entries_*_day` indexes ASC. Before adding a `DESC` key part, check whether an aggregate reads the index; verify with a real `EXPLAIN`, not from docs or tickets. + +## Settings/2FA endpoint conventions + +- Write actions (SaveSettings, ChangePassword): `{"success": true}` / `{"success": false, "message": }`; HTTP 422 on validation failure, 403 on auth-source refusal. +- 2FA toggle actions (Start/ConfirmTotpEnrollment, DisableTwoFactor): `{"enabled": true|false, …}` — the toggle state, not generic success. +- Errors translate via `$this->translate()` (`messages` domain); DTO violations via the `validators` domain. Both need de/es/fr/ru entries — `debug:translation` does NOT detect `translate()`-wrapped strings. +- Admin `/user/save` `authSource` is TRI-STATE (`?string`, ADR-018 D1): `null` (legacy client) = no source change and never clears an existing local hash; `'ldap'` clears the hash; `'local'` sets/keeps a password (new local account without password → 422). Probe "has a local password" with `User::isLocalAccount()`, never `getPassword() === null` — `getPassword()` synthesises an LDAP signature and never returns null. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index b6e17ba4b..092038287 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -141,3 +141,15 @@ public function testSyncWithJiraHandlesApiError(): void so a validator's `find($loggedInUserId)` returns the CACHED entity — a raw `UPDATE users …` is invisible to it. Unit-test such validators (mock `find`) or operate on a non-logged-in entity + +## `.env.test.local` hijacks PHPUnit's database + +`make e2e-up` writes `.env.test.local` whose `DATABASE_URL` points at the dev `db` — Symfony dotenv loads it LAST for any `APP_ENV=test` process, so `bin/phpunit` silently runs against the (often migration-behind) dev DB and fails with `SQLSTATE 1054 Unknown column` on recently added columns. Do NOT delete the file (the e2e stack needs it); override with a REAL env var instead, which dotenv never overrides: + +```bash +docker compose --profile dev exec -T -e APP_ENV=test \ + -e 'DATABASE_URL=mysql://unittest:unittest@db_unittest:3306/unittest?serverVersion=mariadb-12.1.2&charset=utf8mb4' \ + app-dev bin/phpunit tests/... +``` + +`db_unittest` seeds `sql/unittest/00{1,2}_*.sql` only on FIRST volume init; `001_testtables.sql` is a gitignored artifact regenerated from `sql/full.sql`. A stale schema means: recreate the volume. From f21137fc97d5b0c2fa33df169ada2c1c874f15be Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 4 Aug 2026 13:57:44 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(agents):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20immutability=20as=20practice,=20StartTotpEnrollment=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartTotpEnrollment returns provisioningUri/secret (no enabled field) — verified in StartTotpEnrollmentAction; only confirm/disable carry the toggle state. Release immutability phrased as treat-as-immutable. Signed-off-by: Sebastian Mendel --- AGENTS.md | 2 +- src/AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 83d6d5c18..656960fc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ The captainhook pre-commit hook runs the unit suite on the HOST php. A host with ## Releases -No workflow creates releases. `docker-publish.yml` builds images on tag push; `slsa-provenance.yml` runs on `release: published` and its asset upload makes the release IMMUTABLE — get the notes right before publishing. Order: verify main CI green → `git tag -s vX.Y.Z -m "vX.Y.Z"` → `git push origin vX.Y.Z` (Docker Publish runs) → `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file ` (SLSA runs). The metadata-action strips the leading `v`: images are `:6.0.0`/`:6.0`/`:6`. Credit contributors inline per change with `@mentions` (GitHub builds the Contributors row from body mentions); auto-generated notes miss direct pushes — cross-check via the compare API. Credit the human driving an agent-authored commit (committer / `Co-authored-by`), never a bot author. +No workflow creates releases. `docker-publish.yml` builds images on tag push; `slsa-provenance.yml` runs on `release: published`; treat the release as immutable once its provenance asset is uploaded (the tag/assets are locked and the provenance attests the published state — do not rely on editing anything after publish), so get the notes right before publishing. Order: verify main CI green → `git tag -s vX.Y.Z -m "vX.Y.Z"` → `git push origin vX.Y.Z` (Docker Publish runs) → `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file ` (SLSA runs). The metadata-action strips the leading `v`: images are `:6.0.0`/`:6.0`/`:6`. Credit contributors inline per change with `@mentions` (GitHub builds the Contributors row from body mentions); auto-generated notes miss direct pushes — cross-check via the compare API. Credit the human driving an agent-authored commit (committer / `Co-authored-by`), never a bot author. ## Index of scoped AGENTS.md diff --git a/src/AGENTS.md b/src/AGENTS.md index 77c5d41e8..d41a2d748 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -186,6 +186,6 @@ MariaDB 10.11 cannot use a loose scan (`Using index for group-by`) for `MIN()`/` ## Settings/2FA endpoint conventions - Write actions (SaveSettings, ChangePassword): `{"success": true}` / `{"success": false, "message": }`; HTTP 422 on validation failure, 403 on auth-source refusal. -- 2FA toggle actions (Start/ConfirmTotpEnrollment, DisableTwoFactor): `{"enabled": true|false, …}` — the toggle state, not generic success. +- 2FA state-changing actions (ConfirmTotpEnrollment, DisableTwoFactor): `{"enabled": true|false, …}` — the toggle state, not generic success. StartTotpEnrollment is the exception: it returns the enrollment material (`provisioningUri`, `secret`), no `enabled` field. - Errors translate via `$this->translate()` (`messages` domain); DTO violations via the `validators` domain. Both need de/es/fr/ru entries — `debug:translation` does NOT detect `translate()`-wrapped strings. - Admin `/user/save` `authSource` is TRI-STATE (`?string`, ADR-018 D1): `null` (legacy client) = no source change and never clears an existing local hash; `'ldap'` clears the hash; `'local'` sets/keeps a password (new local account without password → 422). Probe "has a local password" with `User::isLocalAccount()`, never `getPassword() === null` — `getPassword()` synthesises an LDAP signature and never returns null.