# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Generic rules These rules apply to every task in this project unless explicitly overridden. Bias: caution over speed on non-trivial work. Use judgment on trivial tasks. ### Rule 1 — Think Before Coding State assumptions explicitly. If uncertain, ask rather than guess. Present multiple interpretations when ambiguity exists. Push back when a simpler approach exists. Stop when confused. Name what's unclear. ### Rule 2 — Simplicity First Minimum code that solves the problem. Nothing speculative. No features beyond what was asked. No abstractions for single-use code. Test: would a senior engineer say this is overcomplicated? If yes, simplify. ### Rule 3 — Surgical Changes Touch only what you must. Clean up only your own mess. Don't "improve" adjacent code, comments, or formatting. Don't refactor what isn't broken. Match existing style. ### Rule 4 — Goal-Driven Execution Define success criteria. Loop until verified. Don't follow steps. Define success and iterate. Strong success criteria let you loop independently. ### Rule 5 — Use Claude only for judgment calls Use Claude for: classification, drafting, summarization, extraction. Do NOT use Claude for: routing, retries, deterministic transforms. If code can answer, code answers. ### Rule 6 — Token budgets are not advisory Budget the **work**, not the fixed context. This file plus the memory index already cost ~21,000 tokens before a single file is read, so a budget counting them is unspendable and trains you to ignore it. Per-task: 100,000 tokens of actual work. Per-session: 500,000. If approaching budget, summarize and start fresh. Surface the breach. Do not silently overrun. ### Rule 7 — Surface conflicts, don't average them If two patterns contradict, pick one (more recent / more tested). Explain why. Flag the other for cleanup. Don't blend conflicting patterns. ### Rule 8 — Read before you write Before adding code, read exports, immediate callers, shared utilities. "Looks orthogonal" is dangerous. If unsure why code is structured a way, ask. ### Rule 9 — Tests verify intent, not just behavior Tests must encode WHY behavior matters, not just WHAT it does. A test that can't fail when business logic changes is wrong. ### Rule 10 — Know where you are When you report a task as done, state what was done, what is verified and what is left. Don't continue from a state you can't describe back; if you lose track, stop and restate. ### Rule 11 — Match the codebase's conventions, even if you disagree Conformance > taste inside the codebase. If you genuinely think a convention is harmful, surface it. Don't fork silently. ### Rule 12 — Fail loud "Completed" is wrong if anything was skipped silently. "Tests pass" is wrong if any were skipped. Default to surfacing uncertainty, not hiding it. ### Rule 13 - OWASP security review Review **every** new and modified file (controller, Vue component, updated legacy links) for common vulnerabilities. This step must be re-run after any subsequent change request (e.g., adding features, fixing bugs, updating links) — not only on the initial pass. ### Checklist - **CSRF on state-changing endpoints.** Do **not** add a per-endpoint token. `CsrfProtectionListener` (`src/CoreBundle/EventListener/CsrfProtectionListener.php`) already guards every state-changing request whose route the router resolves — Symfony controllers, API Platform operations and the legacy AJAX endpoints under `public/main/inc/ajax/` alike — by verifying the request origin through `Sec-Fetch-Site`/`Origin`/`Referer`. The server emits nothing and the client sends nothing, so a new POST/PUT/PATCH/DELETE is protected as soon as its route exists; enforcement is on by default (`CHAMILO_CSRF_ENFORCE` in `config/services.yaml`). Verify these three things instead: the action is **not** a GET (the listener skips safe methods, so a sensitive GET has no protection at all — make it a POST, or give it its own token as `login_as` does); the operation does **not** declare `extraProperties: ['csrf' => false]`, which removes its only defense; and no route was added to the listener's `EXCLUDED_ROUTES`. Two categories stay outside the listener and keep their own token: legacy pages under `public/main/`, which the web server runs without a resolved route and which use `FormValidator` (whose token is only checked when the page called `protect()`), and Twig forms with `csrf_token()`. Some processors under `State/Gradebook/` and `State/AdminStatistics/` still emit and validate their own token — that is the superseded pattern, pending removal. Do not copy it. - **Broken access control.** Verify that `#[IsGranted(...)]` on the controller matches the legacy page's access checks. If the legacy page allowed both admins and session admins, use the `Expression` form. Verify that **every** destructive action inside the controller re-checks the role (e.g., session admins should not be able to delete sessions they don't manage just because they can reach the endpoint). For non-admin roles, filter actionable entity IDs to only those the current user is authorized to manage. - **SQL injection.** Never interpolate user input into DQL/SQL strings. Always use bound parameters (`:paramName` + `setParameter()`). The QueryBuilder already does this — verify no raw concatenation slipped in. Sort field values must use an allowlist mapping, never be passed directly into `orderBy()`. - **XSS.** Vue's template syntax (`{{ }}`) auto-escapes by default. Verify no `v-html` is used with user-supplied data. If linking to legacy PHP pages with query params built from data, ensure values are not attacker-controlled HTML. Check that dynamic `:href` bindings only interpolate integer IDs or known-safe strings. - **Open redirects.** If a controller builds a redirect URL using user-supplied or database values (e.g., a username), always `urlencode()` those values before embedding them in the URL. Verify that the Vue component does not use `window.location.href` with unsanitized query param values. - **Mass parameter manipulation.** Verify that array parameters from the client (e.g., `sessionIds[]`) are cast to safe types (`array_map('intval', ...)`) before use. Verify that a non-admin user cannot supply IDs of entities they do not own/manage. ### Rule 14 — Documentation for new/modified features Any new feature, and any substantially modified feature, must be documented in English in Chamilo's **docs** repository, under `3.x/en/` (the current version's English space). Where that repository is checked out differs per developer — it is **not** reliably a sibling of this one, so do not assume `../docs`. Recognise it by its contents: a `gitbook-docs.yaml` and a `CLAUDE.md` at the root, next to `1.11.x/`, `2.x/` and `3.x/` directories. It is usually added as an additional working directory for the session; if you cannot find it, ask for the path instead of guessing. **Work on the `all` branch.** It holds every documented version and language as `//` directories (e.g. `3.x/en/`, `3.x/fr/`, `2.x/es/`, `1.11.x/de/`), mapped to GitBook spaces by the root `gitbook-docs.yaml`. Branches named after a version or language (`3.x`, `3.x-fr`, `2.x`, ...) are read-only history: never check them out to edit. Before writing anything, **read that repository's own `CLAUDE.md`** — it defines the docs repo's own structure and conventions (space layout, `SUMMARY.md`, `.gitbook/assets/` per space, commit-message prefix) and takes precedence over guessing. If the docs repository is not accessible locally, write the documentation locally instead, as a `.md` file formatted as if it were being added to the docs repository (i.e. as it would appear under `3.x/en//...` on the `all` branch). Write it to a temporary directory outside this repository (e.g. the scratchpad) so it is never included in any commit — it must not be part of the code. As a final note once the feature is finished, tell the developer about the generated file's location and ask that it be uploaded manually on GitHub together with the PR (not committed as part of the code), so maintainers can merge it into the docs repository manually. ## Project Overview Chamilo LMS — an open-source e-learning platform built on **Symfony 7.4** (PHP 8.3/8.4/8.5) with a **Vue 3.5** frontend. Uses **API Platform 4.2** for REST/GraphQL APIs, **Doctrine ORM 3.3** for persistence, and **Webpack Encore 5** for asset compilation. ## Common Commands ### Frontend ```bash yarn install # Install JS dependencies yarn dev # Development build (Webpack Encore) yarn watch # Dev build with file watching yarn build # Production build ``` ### Backend ```bash composer install # Install PHP dependencies php bin/console doctrine:migrations:migrate # Run database migrations php bin/console doctrine:fixtures:load # Load test fixtures php bin/console cache:clear # Clear Symfony cache ``` ### Code Quality ```bash composer phpcs # Check PHP code style (ECS/Symfony standard) composer phpcs-fix # Auto-fix PHP code style composer phpstan # Static analysis (level 5) composer psalm # Type checking ``` **Both analysers need a warmed dev container first.** `psalm.xml` and `phpstan.neon` both read `var/cache/dev/Chamilo_KernelDevDebugContainer.xml`; without it Psalm aborts before analysing anything with `ConfigException: Container xml file(s) not found!`. This happens after every `composer install`, whose `post-install-cmd` wipes `var/cache`. All three parts matter: ```bash rm -rf var/cache/dev && APP_DEBUG=1 php -d memory_limit=-1 bin/console cache:warmup --env=dev ``` `-d memory_limit=-1` because the default 128M is not enough; `APP_DEBUG=1` because a non-debug warmup writes `Chamilo_KernelDevContainer` instead of the `...DevDebugContainer.xml` both tools look for; and `rm -rf` because the XML is only written when the container is *compiled* — if the `.php` already exists, the warmup will not regenerate it. **A Psalm error is not real until it survives `--no-cache`.** The cache in `var/cache/psalm` goes stale and keeps reporting issues the current code does not have — it has produced whole families of phantom `MethodSignatureMismatch` on classes implementing vendor interfaces, which no docblock can silence because the code was already correct. Before changing anything, or adding a `@psalm-suppress`, re-run just that file: ```bash vendor/bin/psalm --no-cache --no-diff --show-info=false --no-progress --output-format=text ``` If it is clean there, the fix is `rm -rf var/cache/psalm`, not an annotation. Same for stray `/opt/Psalmtemp_folder*` directories: Psalm leaves them behind and then reports errors from those copies as if they were project files — delete them. ### Testing ```bash php bin/phpunit # Run full test suite php bin/phpunit tests/CoreBundle/Path/To/Test.php # Run a single test file php bin/phpunit --filter testMethodName # Run a single test method ``` Tests use PHPUnit 9.6 with DAMA DoctrineTestBundle for transaction isolation. The test environment is configured via `.env.test` with `APP_ENV=test`. ### JavaScript Linting ```bash npx eslint assets/ # Lint JS/Vue files npx prettier --check . # Check formatting ``` ### Browser testing It is possible to test the application through the web, as admin, by calling locally: http://my.chamilo.net with credentials admin/admin. ### Documentation screenshots All of this happens **on the `all` branch** of the `docs` repository, like every other documentation change (see Rule 14); `3.x/en/` below is a **directory** on that branch. When you create or replace a screenshot for the English documentation (`3.x/en/.gitbook/assets/*.png`), also record how to reproduce it, in that space's screenshot catalogue: `3.x/en/.gitbook/assets/screenshot-catalogue.yaml`. Per screenshot, record the filename, the doc page it appears on, the URL, the account role needed, and any steps beyond plain navigation (opening a dialog, specific demo data, etc.). Why it matters: assets are **not** shared between GitBook spaces, so every translated space keeps its own copy of the same screenshot. Without the catalogue, whoever localises `3.x/fr/` has to re-derive the page, the role and the steps from the image alone. Check `all` first for both of these: - **If the catalogue does not exist**, the first screenshot you add creates it, on `all`. - **If `document-feature.md` (which produces these screenshots) is missing from `all`**, recover it from the `3.x` branch with `git show 3.x:.claude/commands/document-feature.md` and commit it to `all`; do not check out that branch to work from it. ### Playwright (browser automation tests) The browser-driven test suite uses **Playwright** with `playwright-bdd`, so `.feature` files stay plain Gherkin — only the step definitions are TypeScript. This is the only browser-driven suite: all new coverage goes here. - Feature files: `tests/playwright/features/*.feature` (organised by domain where it matters, e.g. `admin/`). - Step definitions: `tests/playwright/steps/common.steps.ts` — all steps, one shared file. - Config: `tests/playwright/playwright.config.ts`. Run the main suite (excludes the install/seed scenarios below, and every SpecialCase — see below): ```bash yarn test:playwright ``` **SpecialCase files** (`specialCase1PlatformSettings.feature`, `specialCase1Sessions.feature`, `specialCase2Registration.feature`, `specialCase2TeacherTools.feature`, `specialCase2TutorWorkflow.feature`, and any future `specialCaseN*.feature`) each derive from a real customer project and run **separately and single-threaded**, deliberately excluded from the command above. Every such file carries TWO tags: a file-specific one (`@specialcase1`, `@specialcase2`, ...) and the umbrella `@specialcases` — the main suite's own `--grep-invert` excludes the umbrella tag, so adding `specialCase3*.feature` later needs no change to that exclusion, just the same two tags on the new file(s). Run the whole SpecialCase chain together: ```bash yarn test:playwright:specialcases ``` Or just one group, for targeted local work (`:specialcase1`, `:specialcase2`, same pattern for a future `:specialcaseN`): ```bash yarn test:playwright:specialcase2 ``` Why they can't share the main parallel pool: within a group, a later file depends on state an earlier one creates (`specialCase1Sessions` needs the session extra fields and platform setting `specialCase1PlatformSettings` creates; `specialCase2TeacherTools`/`specialCase2TutorWorkflow` need the `parkur01` account `specialCase2Registration` creates) — only `--workers=1` plus `fullyParallel: false` (plain alphabetical file-path sort) guarantees that ordering, both within a group and across groups (`specialCase1*` sorts before `specialCase2*`, and so on). `specialCase1PlatformSettings` also mutates ~100 platform settings, several of them globally UI-visible on every page (a cookie banner that intercepts clicks, header changes), which would corrupt any file running concurrently with it. And every scenario across these files is `@long-scenario` (15-minute budget), which would starve other files of workers in the shared pool. **Shared-fixture teardown ownership**: when a fixture is created in one SpecialCase file and used by another (e.g. `parkur01`, created in `specialCase2Registration.feature`, used by both `specialCase2TutorWorkflow.feature` and `specialCase2TeacherTools.feature`), its final teardown must live in whichever file **actually runs last** — i.e. sorts last alphabetically among every file that touches it — never whichever file is last in the scenario's own narrative. Confirmed live the hard way: placing `parkur01`'s deletion in `specialCase2TeacherTools.feature` (the last file to narratively use the account) broke `specialCase2TutorWorkflow.feature` the moment it ran afterward, because `"TutorWorkflow"` sorts after `"TeacherTools"` alphabetically and found the account already gone. **Soft-delete vs. permanent delete, in a SpecialCase's own teardown**: the admin user list's plain "Delete" action only soft-deletes — the username/email stays permanently reserved (`USER_SOFT_DELETED = -2`; see the DQL gotchas section), so a teardown that only does this cannot be safely re-run against the same database (only ever an issue outside CI, e.g. repeated local dev runs against one shared box — a fresh CI database never collides). A SpecialCase teardown scenario that creates its own throwaway accounts should instead use the "Deleted users" tab (`/admin/user-list?view=deleted&keyword=...`) and its real "Delete permanently" action, so the account can be recreated identically on a later run. CI runs the whole SpecialCase chain (`yarn test:playwright:specialcases`) in its own job, in **parallel** with the main batch's job (not sequentially after it) — see `.github/workflows/playwright.yml`'s own top-of-file comment and the shared provisioning composite action at `.github/actions/setup-chamilo-playwright/action.yml` for the reasoning (wall-clock time ≈ max(batches) instead of their sum, and full environment isolation since each job installs its own fresh Chamilo). Adding a `specialCase3*.feature` group needs no workflow change at all, only the two tags above and a `:specialcase3` package.json script for local targeted runs. Run/debug interactively: ```bash yarn test:playwright:ui ``` One-time environment setup, in this order, before any course-tool feature will pass (creates the fixed test users and the `TEMP`/`TEMPPRIVATE` courses most scenarios assume exist): ```bash yarn test:playwright:seed # test users yarn test:playwright:seed-course # TEMP course yarn test:playwright:seed-private-course # TEMPPRIVATE course yarn test:playwright:seed-settings # settings some scenarios assume are enabled ``` The web installer itself is also covered (`yarn test:playwright:install`) — it's destructive (recreates the DB) and CI-only; never run it against a real environment. **Stale-cache trap:** after editing a `.feature` file or anything in `tests/playwright/steps/`, force-regenerate the compiled specs before trusting a run — `playwright-bdd`'s own auto-regen isn't always reliable: ```bash node_modules/.bin/bddgen --config=tests/playwright/playwright.config.ts ``` **When to write Playwright tests — mandatory rule:** Every new feature and every new interface added to an existing feature **must** be accompanied by a `.feature` file (or additions to an existing one) that covers all user interactions: create, read, edit, and delete at minimum. The `add-feature-test` skill (`/add-feature-test`) automates this — it explores the live UI to confirm real selectors before writing steps, since static code reading gets them wrong often enough to matter. - Place feature files in `tests/playwright/features/`, using a domain subfolder (e.g. `admin/`) where one already exists for that area. - Form inputs in Vue dialogs/forms **must** have a `name` attribute so steps can target them by name. - If an entity or page is accessible to more than one role, **run all scenarios once per role that has access**. Do not test only as admin if other roles can also interact with the feature. For access-restricted pages (e.g. admin-only), add a scenario verifying that non-privileged roles are denied access (redirected or do not see the management UI). - Login steps available: `I am a platform administrator` (admin), `I am a teacher` (mmosquera), `I am a student` (acostea), `I am an HR manager` (ptook), `I am a student boss` (abaggins), `I am an invitee` (bproudfoot), `I am logged as ""` (any other account, e.g. one just created in the scenario), `I am not logged` (explicit logout). Add a new named step to `common.steps.ts` whenever a test user with non-standard credentials is needed. - Each feature file must be self-contained: it creates all data it needs and deletes it at the end, leaving the database in the same state it found it — except the shared, one-time fixtures above (test users, `TEMP`/`TEMPPRIVATE` courses), which are meant to persist for the whole suite. - **Two SPA-timing traps that look identical to generic flakiness but have a definite root cause** — confirmed via `trace.zip`'s network log (not guessed), while stabilizing `createUser.feature`'s edit scenarios: 1. **Clicking into a client-side route change (`router.push`/``) never fires a real navigation event.** `"wait ... for the page to be loaded"` (`page.waitForLoadState("domcontentloaded"/"networkidle")`) resolves immediately regardless of whether the SPA has actually swapped routes — a script that clicks an edit-row icon then immediately checks `page.url()` after that wait can still show the OLD page's URL. Worse, if the old and new page happen to share a field `name` (e.g. a list page's own "Advanced search" filter having the same `name="email"` as the destination edit form), the next `"I fill in ..."` step silently fills the WRONG page's field instead of erroring. Fix: wait for an element unique to the destination page instead — `"I wait up to N seconds for the element {string} to appear"` with a selector/text only the target page has. 2. **A component whose `onMounted` async-fetches existing data (any edit form) has a fill-before-load race.** The static template (headings, labels) renders immediately on mount, before the fetch resolves; if a test fills a field in that window, the fetch's `.then()` handler overwrites the typed value with the loaded one moments later, and the eventual save submits the ORIGINAL data — no error, because nothing was actually wrong with it. Symptom: a "wrong value should be rejected" scenario times out waiting for a validation message that never appears, because the request that went out was valid. Confirmed by inspecting the actual submitted `multipart/form-data` in the trace's network log, not by re-reading the frontend code. Fix: assert the field already holds its expected LOADED value (`"the field {string} should have value {string}"`) before overwriting it — this doubles as proof the load finished. **No Behat.** Do not add Behat scenarios or restore `tests/behat/`. The old scenarios remain in git history and are still useful when writing new coverage for an area Behat once covered: `git show 98c77757ea6:tests/behat/features/.feature`, or `git ls-tree -r --name-only 98c77757ea6 tests/behat` for the full list (84 files at that commit). Treat anything found there as a **hint** of intended scenarios — never as a source of truth for selectors, since field names, button labels and dialog types have all rotted since. Verify everything against the live app. ## Architecture ### Backend Structure (`src/`) Three Symfony bundles: - **CoreBundle** — Main application logic (~215 entities, controllers, services, API resources, migrations) - **CourseBundle** — Course-specific entities and logic (entities prefixed with `C`, e.g., `CDocument`, `CCalendarEvent`) - **LtiBundle** — LTI (Learning Tools Interoperability) integration Standard Symfony patterns within each bundle: `Entity/`, `Repository/`, `Controller/`, `Service/`, `EventListener/`, `EventSubscriber/`, `Form/`, `Command/`. API Platform resources are defined via PHP attributes on entities. Custom API resources live in `src/CoreBundle/ApiResource/`. GraphQL queries/mutations are in `src/CoreBundle/GraphQL/`. Database migrations are in `src/CoreBundle/Migrations/Schema/`. ### Legacy Code `public/main/` contains legacy PHP code (pre-Symfony). `public/legacy.php` bootstraps legacy class autoloading via `composer.json` classmap entries. Legacy code is gradually being migrated to Symfony. ### Frontend Structure (`assets/`) Vue 3 SPA in `assets/vue/`: - **Entry point**: `main.js` — bootstraps Vue app with Pinia + Vuex stores, Vue Router, i18n, PrimeVue, and registers CRUD store modules per entity - **Router**: `router/index.js` — imports route modules per feature domain (documents, messages, courses, etc.) - **Views**: `views/` — page components organized by feature (e.g., `views/documents/`, `views/message/`) - **Components**: `components/` — reusable components, `components/basecomponents/` for shared UI primitives - **Composables**: `composables/` — Vue 3 composition API hooks - **Services**: `services/` — API client wrappers using Axios; `services/baseService.js` provides generic CRUD methods returning Hydra-format collections - **Store**: `store/` — Pinia stores (`securityStore.js`, `cidReq.js`, etc.) plus Vuex `modules/crud.js` for entity CRUD operations - **GraphQL**: `graphql/queries/` — GraphQL query definitions used via Apollo Client The API returns JSON-LD/Hydra format (`hydra:member`, `hydra:totalItems`, etc.). CSS uses **Tailwind CSS 3.4** with SCSS. Legacy pages also use Bootstrap 5 and jQuery. ### Key Patterns - **Entity IRIs**: The frontend references entities by their API Platform IRI (e.g., `/api/users/1`) rather than raw IDs - **CidReq**: Course/session/group context mirrors the backend's `CidReqListener` (see "Contextual roles" below) via the `useCidReqStore` Pinia store (`assets/vue/store/cidReq.js`). The router's global `beforeResolve` guard (`assets/vue/router/index.js`) reads `cid`/`sid`/`gid` from `route.query` on every navigation and calls `setCourseAndSessionById()`, which fetches the full `course`/`session` **API resources** (not raw IDs) and re-fetches the `ROLE_CURRENT_COURSE_*` contextual roles whenever the `cid:sid:gid` triple changes. Components read IDs defensively — `cidReqStore.course?.id ?? route.query.cid` — since the store may not be populated yet on first render. - **Resource system**: Content entities extend `AbstractResource` and use Chamilo's resource node/link system for access control and file management - **Services pattern (frontend)**: Each entity type has a service file that wraps `baseService` to interact with a specific API endpoint ## Configuration - Symfony config: `config/packages/` (doctrine, security, api_platform, messenger, etc.) - Environment variables: `.env` (copy from `.env.dist`), `.env.test` for tests - Webpack: `webpack.config.js` — entry points for `vue` (main SPA), `legacy_*` (legacy pages), style entries - ESLint: `eslint.config.mjs` (flat config, Vue 3 + Prettier) - Prettier: `.prettierrc.json` (120 width, no semicolons, single attribute per line) - PHP code style: `ecs.php` (Symfony standard + PSR-12) - PHPStan: `phpstan.neon` (level 5) ## Coding Conventions ### PHP (enforced by `vendor/bin/ecs check` and `vendor/bin/psalm`) - `declare(strict_types=1);` at the top of every file. - Yoda conditions: `'all' === $listType`, not `$listType === 'all'`. - String concatenation with **no spaces** around `.`: `'%'.$keyword.'%'`. - Short array syntax `[]`, trailing commas in multiline constructs. - No useless `else` — use early returns. No useless `return` at end of void methods. - No empty or superfluous phpdoc (tags that duplicate type hints). - Declare `void` return type on methods that return nothing. - Import classes via `use` statements — no inline `\Fully\Qualified\Name` in code. - Ordered class elements: constants → properties → constructor → public → protected → private. - Modern type casting: `(int)` not `intval()`. - `setParameter()` in QueryBuilder infers the binding type automatically for integers, string/int arrays, `DateTime` instances and managed entities, so passing those directly is **functionally** correct. However, inferring the type of an **entity object** forces Doctrine to resolve its metadata to extract the identifier — Psalm's `QueryBuilderSetParameter` flags this as a performance cost. So: **prefer passing the scalar identifier** of an entity, e.g. `->setParameter('course', (int) $course->getId())` (Core entities) or `(int) $entity->getIid()` (CourseBundle entities). Once the value is a cast scalar, **omit** the 3rd-arg type — inference is trivial and `Types::INTEGER` is redundant. Reserve the explicit 3rd arg (`Types::*` / `ParameterType::*`) for when you need a binding type different from the inferred one (e.g. forcing `Types::STRING` on a numeric string) or for genuinely non-scalar values. Note: some existing code still passes `..., Types::INTEGER` after a cast — harmless legacy redundancy, not a pattern to copy. - All methods must have return types; all parameters must have type hints. ### JavaScript / Vue - No semicolons, 120 char line width, Prettier formatting - Vue components use Composition API with `