# Phase 13: Real Lint Gate (ESLint) - Pattern Map

**Mapped:** 2026-06-11
**Files analyzed:** 8 (3 new config files + 3 modified package.json/ci.yml + 2 source-fix files)
**Analogs found:** 5 / 8 (3 config files are greenfield with no in-repo analog)

---

## File Classification

| New/Modified File                                       | Role      | Data Flow        | Closest Analog                                                          | Match Quality |
| ------------------------------------------------------- | --------- | ---------------- | ----------------------------------------------------------------------- | ------------- |
| `eslint.config.js` (root)                               | config    | —                | none                                                                    | greenfield    |
| `.prettierrc` (root)                                    | config    | —                | none                                                                    | greenfield    |
| `.prettierignore` (root)                                | config    | —                | none                                                                    | greenfield    |
| `package.json` (root)                                   | config    | —                | existing `lint`/`typecheck` scripts (lines 11-12)                       | exact         |
| `apps/api/package.json`                                 | config    | —                | existing `typecheck` script (line 13)                                   | exact         |
| `apps/pwa/package.json`                                 | config    | —                | existing `typecheck` script (line 11)                                   | exact         |
| `.gitea/workflows/ci.yml`                               | config    | —                | existing `Lint` + `Typecheck` steps (lines 31-36)                       | exact         |
| `apps/api/src/broker/*.ts` (violation fixes)            | service   | batch            | existing setInterval+.catch pattern in same files                       | exact         |
| `apps/pwa/src/components/EventForm.tsx` (violation fix) | component | request-response | existing `eslint-disable-next-line` + justification comment at line 274 | exact         |

---

## Pattern Assignments

### `eslint.config.js` (root) — GREENFIELD

No analog exists in the codebase. Use the full skeleton from RESEARCH.md Pattern 1 verbatim. Key constraints from the codebase:

- Root `package.json` has `"type": "module"` absent (checked: it does NOT have `type: module`). However both `apps/api` and `apps/pwa` do (`"type": "module"`). The flat config file must be `eslint.config.js` (ESM); since root has no `type: module`, use `.mjs` extension OR add `"type": "module"` to root `package.json` alongside the new scripts. Prefer `eslint.config.js` + add `"type": "module"` to root — the root has no CJS code to break.
- `apps/api/tsconfig.json` excludes `"tests"` — add `apps/api/tests/**/*.ts` to the `disableTypeChecked` override block alongside the named config files.
- All three worker files (`outboxWorker.ts`, `poller.ts`, `reminderScheduler.ts`) use `setInterval(() => { runX().catch(...) }, N)` — the `.catch()` chain correctly handles the promise. If `no-floating-promises` fires on the `.catch()` return value itself, wrap with `void`: `void runX().catch(...)`.

**Config files that need `disableTypeChecked` override** (confirmed outside all tsconfig `include` arrays):

- `apps/api/drizzle.config.ts`
- `apps/api/vitest.config.ts`
- `apps/pwa/vite.config.ts`
- `apps/pwa/vitest.config.ts`
- `apps/pwa/playwright.config.ts`
- `apps/api/tests/**/*.ts` (excluded from `apps/api/tsconfig.json`)

---

### `.prettierrc` / `.prettierignore` — GREENFIELD

No analog exists. Use RESEARCH.md Prettier Configuration section verbatim.

Codebase style observation: all existing source files use single quotes (confirmed by broker file imports like `import { z } from 'zod'`). Set `"singleQuote": true`.

---

### `package.json` (root) — adding `format` / `format:check` scripts

**Analog:** the existing `lint` and `typecheck` script wiring in the same file.

**Existing pattern** (`/home/luc/Projects/familysync/package.json`, lines 11-12):

```json
"lint": "pnpm -r --if-present lint",
"typecheck": "pnpm -r typecheck"
```

**Apply the same style** for new scripts:

```json
"format":       "prettier --write .",
"format:check": "prettier --check ."
```

Note: root `package.json` currently has no `"type": "module"`. Adding it is required so `eslint.config.js` (ESM) is parsed as ESM by Node. Root has no `index.js` or other CJS entry points that would break.

---

### `apps/api/package.json` — adding `lint` script

**Analog:** the existing `typecheck` script in the same file (`/home/luc/Projects/familysync/apps/api/package.json`, line 13):

```json
"typecheck": "tsc --noEmit"
```

**Pattern to mirror** — same position in the scripts block, same style:

```json
"lint": "eslint src/ tests/ --max-warnings 0"
```

Mirror pattern: single command, no wrapper, scope by directory. The `tests/` directory is listed here even though it is excluded from the tsconfig — the ESLint config will apply `disableTypeChecked` for those files so the linter won't fail on project-inclusion errors.

---

### `apps/pwa/package.json` — adding `lint` script

**Analog:** the existing `typecheck` script in the same file (`/home/luc/Projects/familysync/apps/pwa/package.json`, line 11):

```json
"typecheck": "tsc --noEmit && tsc --project tsconfig.e2e.json --noEmit"
```

**Pattern to mirror:**

```json
"lint": "eslint src/ e2e/ --max-warnings 0"
```

The `e2e/` directory is covered by `tsconfig.e2e.json`, which `projectService: true` discovers automatically — no special override needed for e2e specs.

---

### `.gitea/workflows/ci.yml` — adding `Format check` step

**Analog:** the existing `Lint` and `Typecheck` steps in the `fast-checks` job (`/home/luc/Projects/familysync/.gitea/workflows/ci.yml`, lines 31-36):

```yaml
- name: Lint
  run: pnpm lint

- name: Typecheck
  run: pnpm typecheck
```

**New step — insert between `Lint` and `Typecheck`** (per RESEARCH ordering: lint → format:check → typecheck → tests):

```yaml
- name: Lint
  run: pnpm lint

- name: Format check
  run: pnpm format:check

- name: Typecheck
  run: pnpm typecheck
```

Also remove the now-stale comment above the `Lint` step (lines 28-30):

```yaml
# lint is currently a no-op: no package defines a `lint` script and ESLint is
# not installed. `pnpm -r lint` prints ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT but
# exits 0, so this step passes. Wiring lint is out of this phase's scope.
```

---

### `apps/api/src/broker/*.ts` — `no-floating-promises` / `no-unsafe-*` violation fixes

**Analog:** the existing setInterval worker pattern in the same files. All three workers (`outboxWorker.ts`, `poller.ts`, `reminderScheduler.ts`) already use the same correct structure:

**Existing correct pattern** (`outboxWorker.ts`, lines 781-787):

```ts
export function startOutboxWorker(): void {
  setInterval(() => {
    runOutboxDrain().catch((err: unknown) => {
      console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
    });
  }, 15 * 1000);
}
```

This pattern (inner `.catch()` inside `setInterval` arrow) is already correct for `no-floating-promises` — the promise is handled. However `no-floating-promises` may fire on the `.catch()` return value itself (the chained promise is also a Promise). If so, the fix is:

```ts
setInterval(() => {
  void runOutboxDrain().catch((err: unknown) => {
    console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
  });
}, 15 * 1000);
```

The `void` operator here is legitimate: it signals intentional fire-and-forget within a `setInterval` that handles its own scheduling. This is NOT masking a bug.

**For `no-unsafe-member-access` on ical.js in `sync.ts`** (`apps/api/src/broker/sync.ts`, lines 99-125): the existing pattern uses `as string | null` casts on `getFirstPropertyValue()` returns. If the rule fires, the correct response is a targeted disable WITH the existing comment pattern:

```ts
// ical.js getFirstPropertyValue returns a union type; cast to ICAL.Time for date handling
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null;
```

The file already has an inline comment at line 99 explaining the cast — the justification comment is already there. The `eslint-disable-next-line` line is the only addition required per D-13-06.

---

### `apps/pwa/src/components/EventForm.tsx` — `no-explicit-any` violation fix

**Analog:** the file already has an `eslint-disable-next-line` with justification comment at line 274 (the line immediately before the violation):

**Existing pattern** (`EventForm.tsx`, lines 271-275):

```ts
// (the API expand contract does not expose it in v1 — D-03). We cast to any to
// read it if a future API version adds it, and default to 'none' when not present
// (WR-03 v1 comment: occurrence edits whose recurrence is not in the cache default
// to 'none'; this will be addressed when the occurrence/expand contract is extended).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined;
```

The `eslint-disable-next-line` comment is already present in the source. This file requires no change for the `no-explicit-any` rule — the suppression is pre-existing and already justified. Verify the comment is on the line immediately before the `const derivedRecurrence` line and that no other `as any` casts exist in the file.

---

## Shared Patterns

### `typecheck` → `lint` script mirroring

**Source:** all three `package.json` files
**Apply to:** `apps/api/package.json`, `apps/pwa/package.json`

The `typecheck` scripts are the canonical pattern to copy for new per-package tool scripts:

- One command per script
- No wrapper (`pnpm run` prefix, extra flags)
- Directory-scoped (not `--ext`, not glob patterns)

### `eslint-disable-next-line` with justification comment

**Source:** `apps/pwa/src/components/EventForm.tsx` lines 271-275
**Apply to:** any suppression required in `apps/api/src/broker/sync.ts` or `apps/api/src/broker/expand.ts`

Pattern: multi-line comment block explaining WHY the rule is wrong here, immediately followed by `// eslint-disable-next-line <rule-name>`, immediately followed by the flagged line. No blank lines between comment, disable, and code.

### CI step insertion

**Source:** `.gitea/workflows/ci.yml` lines 31-38
**Apply to:** new `Format check` step

Pattern: `- name: Verb noun` (title case, imperative verb), `  run: pnpm <script>`. No `working-directory` override for root scripts. No `env:` block needed for lint/format.

---

## No Analog Found

| File               | Role   | Data Flow | Reason                                                      |
| ------------------ | ------ | --------- | ----------------------------------------------------------- |
| `eslint.config.js` | config | —         | First ESLint config in the repo; use RESEARCH.md skeleton   |
| `.prettierrc`      | config | —         | First Prettier config in the repo; use RESEARCH.md defaults |
| `.prettierignore`  | config | —         | First Prettier ignore in the repo; use RESEARCH.md content  |

---

## Metadata

**Analog search scope:** `/home/luc/Projects/familysync/package.json`, `apps/api/package.json`, `apps/pwa/package.json`, `.gitea/workflows/ci.yml`, `apps/api/src/broker/{outboxWorker,poller,reminderScheduler,sync}.ts`, `apps/pwa/src/components/EventForm.tsx`
**Files scanned:** 10
**Pattern extraction date:** 2026-06-11

---

## PATTERN MAPPING COMPLETE

**Phase:** 13 - Real Lint Gate (ESLint)
**Files classified:** 9
**Analogs found:** 6 / 9

### Coverage

- Files with exact analog: 6 (3 × package.json scripts, ci.yml step, broker setInterval pattern, EventForm disable pattern)
- Files with role-match analog: 0
- Files with no analog: 3 (eslint.config.js, .prettierrc, .prettierignore — greenfield)

### Key Patterns Identified

- All package-level tool scripts follow the `typecheck` pattern: single command, directory-scoped, no wrappers — copy exactly for `lint` scripts
- CI steps follow `- name: Verb noun` / `  run: pnpm <script>` — new `Format check` step slots between `Lint` and `Typecheck`
- All three broker workers already use `setInterval(() => { runX().catch(...) })` correctly; if `no-floating-promises` fires on the `.catch()` return, add `void` before `runX()` (legitimate signal, not a mask)
- `eslint-disable-next-line` suppression pattern is pre-established in EventForm.tsx with a multi-line justification block — use the same structure for any ical.js unsafe-access suppressions in broker files

### File Created

`/home/luc/Projects/familysync/.planning/phases/13-real-lint-gate-eslint/13-PATTERNS.md`

### Ready for Planning

Pattern mapping complete. Planner can now reference analog patterns in PLAN.md files.
