Files
crewli/dev-docs/WS-3-LINT-BASELINE-AUDIT-2026-04-29.md
bert.hausmans f44bb969c9 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>
2026-04-29 11:15:16 +02:00

21 KiB
Raw Permalink Blame History

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 anys. 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):

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-1ms-1
  • pl-4ps-4
  • ml-automs-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:

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 anys 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.