docs: WS-3 session 1b-i lint baseline audit report

Categorises remaining apps/app eslint problems after the Tier 1 +
Tier 2 autofix and whitespace cleanup. Six buckets:
- X. Tier 3 deferred (vite.config.ts, themeConfig.ts, vitest.config.ts) — 134
- A. Trivial-fix (second-pass autofix residue + unused imports) — 42
- B. Type safety (mostly no-explicit-any in vendored Vuexy code) — 34
- C. Style preference / rule-config — 8
- D. Vuetify / Vuexy idiom (ml-/pl- restricted-class) — 5
- E. Bug-shaped (security, isAxiosError, missing-return, fire-and-forget) — 8
Sum check: 134 + 42 + 34 + 8 + 5 + 8 = 231 ✓

Five open questions for Bert + Claude Chat decisions before 1b-ii:
- Should src/@core/** + src/@layouts/** be in ignorePatterns?
- vue/no-restricted-class regex scope (RTL-aware vs ml/pl only)?
- Stance on vue/prefer-true-attribute-shorthand?
- Bucket E.5 axios fire-and-forget pattern (void / .catch / disable)?
- Decoupling pnpm lint from --fix?

Lint baseline: 1451 → 231 this session, with Tier 3 (134) +
non-fixable (66) + second-pass-residue (31) deferred to 1b-ii.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 11:15:16 +02:00
parent 4976b4ebe0
commit f44bb969c9

View File

@@ -0,0 +1,521 @@
# WS-3 Lint baseline audit — apps/app
**Date:** 2026-04-29
**Source:** `pnpm exec eslint . -c .eslintrc.cjs --ext .ts,.js,.cjs,.vue,.tsx,.jsx --no-fix`
after WS-3 session 1b-i Tasks 1, 2, 3 (Tier 1 + Tier 2 autofix +
trailing-whitespace cleanup).
## Counts
| Stage | Total | Errors | Warnings | Fixable |
|-------|-------|--------|----------|---------|
| Pre-1b-i (main, dd8430f) | 1451 | 919 | 532 | 1370 |
| Post-Tier 1 (47bd533) | 422 | 408 | 14 | 354 |
| Post-Tier 2 (a7eaf0f) | 246 | 232 | 14 | 180 |
| Post-whitespace (4976b4e) | 231 | 217 | 14 | 165 |
Reduction: **1451 → 231** mechanical, **with Tier 3 deferred**.
The 165 still-fixable items break down as:
- **134 in Tier 3 files** (vite.config.ts, themeConfig.ts,
vitest.config.ts) — explicitly excluded from this session per
the risk-tier policy.
- **31 in Tier 1/2 paths** — second-pass-autofix residue (mainly
24 `indent` errors in one file, plus `lines-around-comment`
edge cases on Vue SFC `<script>`-tag-adjacent comments).
## Bucket summary
| Bucket | Count | Recommended disposition |
|--------|------:|-------------------------|
| X. Tier 3 deferred | 134 | 1b-ii, hand-reviewed per file |
| A. Trivial-fix | 42 | 1b-ii, mechanical (second-pass --fix or simple delete) |
| B. Type safety | 34 | 1b-ii, per-instance review |
| C. Style / rule-config | 8 | 1b-ii, rule-level decisions |
| D. Vuetify / Vuexy idiom | 5 | 1b-ii, per-pattern overrides |
| E. Bug-shaped | 8 | Investigate as bugs |
| **Sum** | **231** | |
Sum check: X(134) + A(42) + B(34) + C(8) + D(5) + E(8) = **231**
(matches total).
---
## Bucket X — Tier 3 deferred (134 items, all fixable)
All in three configuration files, all autofix-mechanical. Excluded
from this session per the risk-tier policy because the autofix could
in principle alter Vite plugin order, theme-token resolution, or
vitest setup-hook ordering.
```
apps/app/vite.config.ts — 91 items
58 @typescript-eslint/quotes
15 semi
15 @typescript-eslint/semi
2 arrow-parens
1 curly
apps/app/themeConfig.ts — 42 items
28 @typescript-eslint/quotes
7 semi
7 @typescript-eslint/semi
apps/app/vitest.config.ts — 1 item
1 lines-around-comment
```
**Recommendation for 1b-ii:** per-file diff review before applying
`--fix`. Focus areas:
- `vite.config.ts`: confirm no plugin-order changes (the autofix is
pure quotes/semi/arrow-parens; in principle no semantic effect).
Smoke `pnpm dev` and `pnpm build` after.
- `themeConfig.ts`: confirm no theme-token reordering. Visually
check the running app afterwards (login + dashboard).
- `vitest.config.ts`: trivial — single `lines-around-comment` fix.
---
## Bucket A — Trivial-fix (42 items)
### A.1 Second-pass autofix residue (31 items, all fixable)
Items where Tier 1 / Tier 2 `--fix` ran but ESLint's default 10-pass
cap left a cascade of dependent fixes unresolved. Re-running `--fix`
on the same file resolves them (verified in Task 3 on
`DefaultLayoutWithVerticalNav.vue`).
```
By rule:
24 indent (all in useTimeSlotDropdown.ts)
3 vue/max-attributes-per-line (App.vue — outside Tier 1 glob)
3 lines-around-comment (PortalLayout/PublicLayout/AppKpiCard — see A.2)
1 (no rule) (VNodeRenderer.tsx — eslint-disable comment that's now unused)
By file:
24 apps/app/src/composables/useTimeSlotDropdown.ts
3 apps/app/src/App.vue
1 apps/app/src/@layouts/components/VNodeRenderer.tsx
1 apps/app/src/components/AppKpiCard.vue
1 apps/app/src/layouts/PortalLayout.vue
1 apps/app/src/layouts/PublicLayout.vue
```
**Recommendation for 1b-ii:**
- Re-run `pnpm exec eslint <file> --fix` on each (single command per
file is safest) and verify `git diff --check` clean afterwards.
- For App.vue: it's at `src/App.vue`, missed by Tier 1's
`src/{components,pages,layouts,views}/**/*.vue` glob. Fix by
including `src/App.vue` in the Tier 1 paths in the next pass.
- For VNodeRenderer.tsx (`src/@layouts/`): vendored Vuexy code; the
fix is just removing an `eslint-disable` directive that's no longer
needed. Low risk.
### A.2 Vue-SFC `lines-around-comment` autofix gap (3 of the 31 above)
`PortalLayout.vue`, `PublicLayout.vue`, and `AppKpiCard.vue` show
`error Expected line before comment lines-around-comment` at line 2:1
because the comment block sits immediately after `<script setup
lang="ts">` on line 1. The rule has a fixer and the files were inside
the Tier 1 glob, but the fix did not apply — likely a vue-eslint-parser
boundary quirk (the script-tag opening doesn't register as a "block
start" the way a `{` does for `allowBlockStart: true`).
**Recommendation for 1b-ii:** either (a) hand-add a blank line before
the comment in each file, or (b) extend `.eslintrc.cjs` with an
overrides block that disables `lines-around-comment` for `*.vue` files
(or sets `allowBlockStart: true` more aggressively).
### A.3 Unused-imports / unused-vars (10 items, manual)
Trivial deletes. Each item below: file:line, the unused identifier,
and recommended action.
```
apps/app/src/components/layout/OrganisationSwitcher.vue:30:10
'toggleMenu' is defined but never used.
→ Delete the destructured `toggleMenu` from the line 30 destructure.
(Both unused-imports/no-unused-vars and @typescript-eslint/no-unused-vars
flag the same identifier — count of 2 above is the same identifier.)
apps/app/src/components/sections/CreateShiftDialog.vue:40:9
'scenario' is assigned a value but never used.
→ Delete the `const scenario = ...` line.
(Two-rule overlap — same identifier.)
apps/app/src/pages/events/[id]/time-slots/index.vue:242:27
'event' is defined but never used. (vue/no-unused-vars)
→ Replace with `_event` in the slot scope, or remove from destructure.
apps/app/src/pages/organisation/companies.vue:8:7
'authStore' is assigned a value but never used.
→ Delete `const authStore = useAuthStore()` on line 8.
(Two-rule overlap.)
apps/app/src/pages/platform/activity-log/index.vue:11:7
'searchDebounced' is assigned a value but never used.
→ Delete the line. (Two-rule overlap.)
apps/app/src/components/persons/PersonDetailPanel.vue:77:58
Unnecessary { after 'if' condition. (curly)
→ Strip the redundant brace block (one-statement if).
```
Note on the rule overlap: `unused-imports/no-unused-vars` and
`@typescript-eslint/no-unused-vars` both fire on the same identifier
in 4 of the 5 cases. Removing the identifier will resolve both per
report — the **42 Bucket A total** correctly counts these as 2 items
each (matching ESLint's actual emit). Effective unique deletes: 6.
---
## Bucket B — Type safety (34 items)
### B.1 `@typescript-eslint/no-explicit-any` (23 items)
Per CLAUDE.md frontend rule and `.eslintrc.cjs` line 62, this is
**erroneously committed** to `error` for the project. Yet 23 instances
exist, predominantly in `src/@core/` and `src/@layouts/` (the vendored
Vuexy code) and a few in our own files.
**Distribution:**
- `src/@core/**`: 13 (DropZone.vue, I18n.vue, Notifications.vue,
AppDateTimePicker.vue, createUrl.ts, useCookie.ts ×4,
apexCharConfig.ts ×3, types.ts)
- `src/@layouts/**`: 7 (index.ts ×1, types.ts ×6)
- `src/layouts/blank.vue`: 1 (line 9 — `ref<any>`)
- `src/layouts/default.vue`: 1 (line 21 — `ref<any>`)
- `src/layouts/components/NavSearchBar.vue`: 1
The 13 + 7 = 20 inside `@core` and `@layouts` are vendored Vuexy
template code. CLAUDE.md says: "Never: TypeScript `any` type" — but
this rule was clearly intended for OUR code, not the vendored copy
of Vuexy that we don't maintain.
**Recommendation for 1b-ii — judgment call needed:**
- **Option a** (preferred for honesty): add `src/@core/**` and
`src/@layouts/**` to `ignorePatterns` in `.eslintrc.cjs`. This is
the same pragma we already apply to `src/plugins/iconify/*.js`
(vendored). Reduces the 23 down to 3 (blank.vue, default.vue,
NavSearchBar.vue) — all `ref<any>` for the `AppLoadingIndicator`
template ref. Those 3 should be properly typed.
- **Option b**: keep the rule on, narrow the 20 vendored items to
proper types one by one. This is real work and risks breaking the
vendored components' contract.
- **Option c**: per-file `// eslint-disable-next-line` markers on the
vendored `any`s. Noisy but localised.
The `.eslintrc.cjs` already says (lines 60-62):
```
// Project rule (CLAUDE.md frontend rules): no `any`. Override the
// Vuexy reference (which sets this off) — Crewli's stricter posture.
'@typescript-eslint/no-explicit-any': 'error',
```
So the choice was deliberate when the rule was set. Option a is the
cleanest re-statement: "the ban applies to our code, not vendored."
### B.2 `@typescript-eslint/no-use-before-define` (7 items)
All in `src/components/shifts/ShiftDetailPanel.vue` (lines 249-261).
The pattern: handler functions defined at the top of `<script setup>`
reference `cancellingAssignment` and `isCancelDialogOpen` refs
declared further down in the file.
```
apps/app/src/components/shifts/ShiftDetailPanel.vue:249:3
'cancellingAssignment' was used before it was defined.
[...6 more in lines 250-261]
```
This is idiomatic Composition API: declare reactive state at the top,
then handlers, then watchers. The rule fires because handler
*functions* are hoisted but the `ref(...)` call results aren't.
**Recommendation for 1b-ii:** either (a) reorder the file to declare
the refs before the functions that reference them (mechanical, same
file only), or (b) add an override clause to `.eslintrc.cjs` allowing
this pattern in `<script setup>`. Option a is simpler and one-file.
### B.3 `@typescript-eslint/no-shadow` (1 item)
```
apps/app/src/stores/useImpersonationStore.ts:119:11
'stored' is already declared in the upper scope on line 18 column 9.
```
Local `stored` shadowing an outer `stored` — usually fine but
defensive practice is to rename. **Recommendation:** rename the
inner `stored` to `storedSnapshot` or similar.
### B.4 `camelcase` (3 items, same identifier)
```
apps/app/src/composables/api/useFormSchemas.ts:97:30
apps/app/src/composables/api/useFormSchemas.ts:99:17
apps/app/src/composables/api/useFormSchemas.ts:99:36
Identifier 'confirmed_name' is not in camel case.
```
Context (lines 95-100):
```ts
return useMutation({
mutationFn: async ({ id, confirmed_name }: { id: string; confirmed_name?: string }) => {
await apiClient.delete(`/organisations/${orgId.value}/forms/schemas/${id}`, {
params: confirmed_name ? { confirmed_name } : undefined,
})
},
```
`confirmed_name` is an **API query parameter** that the backend reads
in snake_case. CLAUDE.md says: "DB columns: snake_case" and
"TypeScript / JS variables: camelCase". The boundary is at the API
layer — the query-param key MUST be `confirmed_name` (snake) on the
wire. The local TypeScript variable `confirmed_name` matches the
wire format for symmetry (rather than `confirmedName` aliased).
**Recommendation for 1b-ii — judgment call:** rename the local
variable to `confirmedName` and keep the wire key snake_case
(`{ confirmed_name: confirmedName }`). Three rule violations resolved
with no behaviour change.
---
## Bucket C — Style / rule-config (8 items)
```
apps/app/src/components/form-failures/DismissFailureDialog.vue:43:3
sonarjs/prefer-single-boolean-return
"Replace this if-then-else flow by a single return statement."
apps/app/src/components/form-failures/FormFailureDetail.vue:44:5
no-void
"Expected 'undefined' and instead saw 'void'."
apps/app/src/components/sections/AssignShiftDialog.vue:41:3
sonarjs/prefer-single-boolean-return
apps/app/src/components/sections/SectionsShiftsPanel.vue:333:11
vue/prefer-true-attribute-shorthand
"Boolean prop with 'true' value should be written in shorthand form."
apps/app/src/components/shifts/AssignPersonDialog.vue:120:5
sonarjs/no-collapsible-if
"Merge this if statement with the nested one."
apps/app/src/components/shifts/AssignPersonDialog.vue:125:5
sonarjs/no-collapsible-if
apps/app/src/pages/events/[id]/settings/registration-fields.vue:335:13
vue/prefer-true-attribute-shorthand
apps/app/src/stores/useImpersonationStore.ts:105:12
sonarjs/no-collapsible-if
```
All readable-style preferences. **Recommendation for 1b-ii:** apply
the suggested rewrite per item; all are local single-file edits with
no behaviour change.
---
## Bucket D — Vuetify / Vuexy idiom (5 items)
```
apps/app/src/components/persons/PersonDetailPanel.vue:272:25
vue/no-restricted-class — "'ml-1' class is not allowed."
apps/app/src/components/sections/SectionsShiftsPanel.vue:357:25
vue/no-restricted-class — "'ml-1' class is not allowed."
apps/app/src/components/shifts/AssignPersonDialog.vue:461:29
vue/no-restricted-class — "'pl-4' class is not allowed."
apps/app/src/components/shifts/AssignPersonDialog.vue:475:23
vue/no-restricted-class — "'ml-auto' class is not allowed."
apps/app/src/components/shifts/AssignPersonDialog.vue:500:27
vue/no-restricted-class — "'ml-1' class is not allowed."
```
The rule (`.eslintrc.cjs` line 176) bans `m{l,r}-` and `p{l,r}-`
Tailwind utility-class names because the project is Vuetify-only
("Use Vuetify utility class … never custom CSS" per CLAUDE.md).
Vuetify's spacing utilities are `ms-*`, `me-*`, `ps-*`, `pe-*` (start/
end LTR-aware), or `mx-*`/`px-*` for both sides.
**Recommendation for 1b-ii:** rewrite the 5 occurrences:
- `ml-1``ms-1`
- `pl-4``ps-4`
- `ml-auto``ms-auto`
Vuetify supports both `m{l,r,s,e}-N` directly; the project preference
per the rule is the start/end form. Mechanical 5-line change across
3 files.
---
## Bucket E — Bug-shaped (8 items)
### E.1 `import/no-named-as-default-member` × 2 — `EventTabsNav.vue`
```
apps/app/src/components/events/EventTabsNav.vue:53:8
apps/app/src/components/events/EventTabsNav.vue:76:9
"Caution: `axios` also has a named export `isAxiosError`. Check if
you meant to write `import { isAxiosError } from 'axios'` instead."
```
The file accesses `axios.isAxiosError(...)` via the default export
namespace; ESLint flags that there's a named export of the same name.
Both forms work, but the named import is the modern axios pattern and
narrower (no full default-axios import overhead).
**Recommendation for 1b-ii:** change `import axios from 'axios'` to
`import { isAxiosError } from 'axios'` (or add the named import
alongside the default). Two-line edit. Zero behaviour change.
### E.2 `vue/component-api-style` × 1 — vitest spec
```
apps/app/src/composables/api/__tests__/useFormFailures.spec.ts:38:5
"Options API is not allowed in your project. `render` function is
part of the Options API. Use Composition API instead."
```
A test mock uses Options-API `render` function. CLAUDE.md frontend
rules: "Always `<script setup lang='ts'>` — never the Options API".
**Recommendation for 1b-ii:** rewrite the mock as Composition API,
or add a per-spec-file override.
### E.3 `vue/return-in-computed-property` × 1 — useTimeSlotDropdown
```
apps/app/src/composables/useTimeSlotDropdown.ts:80:32
"Expected to return a value in computed function."
```
A `computed` callback is missing a return path on at least one
branch — a real bug-class. **Recommendation for 1b-ii:** read line 80
of `useTimeSlotDropdown.ts` and ensure every code path returns.
### E.4 `vue/no-template-target-blank` × 1 — security
```
apps/app/src/pages/organisation/index.vue:342:21
"Using target='_blank' without rel='noopener noreferrer' is a
security risk."
```
External anchor with `target="_blank"` and no `rel="noopener"`
classic reverse-tabnabbing exposure. **Recommendation:** add
`rel="noopener noreferrer"` to the anchor.
### E.5 `promise/no-promise-in-callback` × 3 — `axios.ts`
```
apps/app/src/lib/axios.ts:42:12
apps/app/src/lib/axios.ts:61:7
apps/app/src/lib/axios.ts:73:7
"Avoid using promises inside of callbacks."
```
In each case the response interceptor reaches into a dynamic `import()`
to lazy-load a store, then chains `.then()`. Pattern:
```ts
import('@/stores/useImpersonationStore').then(({ useImpersonationStore }) => {
const impersonationStore = useImpersonationStore()
impersonationStore.clearState()
window.location.href = '/platform'
})
```
The lint rule fires because the interceptor itself is a callback and
the promise inside isn't returned/awaited. In practice the
interceptor doesn't need to wait for the store dynamic-import (the
side effect — set window.location — is fire-and-forget). It's not a
real bug, but the rule is correctly identifying that errors inside
those `.then()` blocks would be silently swallowed.
**Recommendation for 1b-ii — judgment call:**
- **Option a:** wrap each in a `void` to mark the fire-and-forget
intent: `void import('@/stores/...').then(...)`. Tells ESLint and
future-readers that the promise discard is intentional.
- **Option b:** add `.catch(err => console.error(...))` to each so
errors aren't swallowed.
- **Option c:** disable the rule for `lib/axios.ts` only — but loses
defence everywhere else.
Option b is most defensive; option a is most honest about current
behaviour. Both are 3-line single-file edits.
---
## Action plan for session 1b-ii
| Step | Bucket | Effort | Risk | Notes |
|------|--------|--------|------|-------|
| 1. Tier 3 hand-review + autofix | X | 1-2h | medium | Smoke `pnpm dev` + `pnpm build` after vite.config.ts |
| 2. Bucket A second-pass autofix on Tier 1/2 paths | A.1 | trivial | low | Rerun `--fix` on the 6 listed files; verify `git diff --check` |
| 3. Bucket A unused-imports/vars manual deletes | A.3 | trivial | low | 6 unique deletes |
| 4. Bucket A `lines-around-comment` Vue SFC fix | A.2 | trivial | low | Either hand-add blank lines or override rule for `*.vue` |
| 5. Bucket B `no-explicit-any` ignore-patterns decision | B.1 | trivial config | low | Likely add @core/** + @layouts/** to ignorePatterns |
| 6. Bucket B `no-explicit-any` real fixes (3 in our code) | B.1 | low | low | blank.vue / default.vue / NavSearchBar.vue refs |
| 7. Bucket B `no-use-before-define` reorder | B.2 | low | low | Reorder ShiftDetailPanel.vue script setup |
| 8. Bucket B `no-shadow` rename | B.3 | trivial | low | useImpersonationStore.ts |
| 9. Bucket B `camelcase` rename | B.4 | low | low | useFormSchemas.ts — 3-line edit |
| 10. Bucket C style fixes | C | low | low | 8 mechanical rewrites |
| 11. Bucket D Vuetify class renames | D | low | low | ml-/pl-/etc → ms-/ps-/etc, 5 occurrences |
| 12. Bucket E.1 isAxiosError import | E.1 | trivial | low | One file, two lines |
| 13. Bucket E.2 spec rewrite to Composition API | E.2 | low | low | Test only |
| 14. Bucket E.3 fix missing return in computed | E.3 | low | medium | Real semantic bug — verify with smoke |
| 15. Bucket E.4 security: add rel="noopener" | E.4 | trivial | low | One attribute add |
| 16. Bucket E.5 axios fire-and-forget pattern | E.5 | low | low | 3 spots, design choice between options a/b/c |
After 1b-ii completes, the lint count should drop from 231 to a
small number (single digits, mostly Bucket-B-judgment-call leftovers
if any — depending on the @core/@layouts ignorePatterns decision).
---
## Open questions for Bert + Claude Chat
1. **Vendored Vuexy code under `src/@core/` and `src/@layouts/`**
should it be included in `.eslintrc.cjs`'s `ignorePatterns`? 20 of
the 23 `no-explicit-any` items would silently disappear, plus a
small number of other rules. The trade-off: future drift in those
directories goes uncaught. Counter-trade-off: spending time
correcting `any`s in code we copy-pasted is low value and high
churn.
2. **`vue/no-restricted-class` regex `/^(p|m)(l|r)-/`** — the rule
bans both `mr-` and `ml-` (and `pr-`, `pl-`). Vuetify supports
`mr-N` natively (it's not just a Tailwind name); the migration
to `me-N` is for LTR/RTL flexibility. Confirm this is the
intended ban, or relax it to `pl/ml` only if RTL isn't a
project goal.
3. **Bucket C `vue/prefer-true-attribute-shorthand`** — the rule
prefers `<comp prop>` over `<comp :prop="true">` for boolean
props. Some teams find the explicit form clearer when it
distinguishes from omitted. Confirm preference.
4. **Bucket E.5 axios callback-promises** — pick option a/b/c per
above before 1b-ii starts. Option b (`.catch`) is the most
defensive but introduces error-handling code; option a (`void`)
is the most honest about today's behaviour.
5. **`pnpm lint` script in `package.json`** — currently aliased to
`eslint . --fix`. After 1b-ii lands a clean baseline, consider
splitting into `pnpm lint` (no-fix) and `pnpm lint:fix` (with
--fix). The current aliasing is what made the original 105
reference number wrong (someone ran `pnpm lint` and reported
the post-fix remainder).
---
## Artifacts
- `/tmp/eslint-pre.json` — pre-1b-i baseline (1451 problems)
- `/tmp/eslint-current.json` — post-Task-3 state (231 problems)
Both retained on the dev machine for 1b-ii reference. Not committed.