12 KiB
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.jsonhas"type": "module"absent (checked: it does NOT havetype: module). However bothapps/apiandapps/pwado ("type": "module"). The flat config file must beeslint.config.js(ESM); since root has notype: module, use.mjsextension OR add"type": "module"to rootpackage.jsonalongside the new scripts. Prefereslint.config.js+ add"type": "module"to root — the root has no CJS code to break. apps/api/tsconfig.jsonexcludes"tests"— addapps/api/tests/**/*.tsto thedisableTypeCheckedoverride block alongside the named config files.- All three worker files (
outboxWorker.ts,poller.ts,reminderScheduler.ts) usesetInterval(() => { runX().catch(...) }, N)— the.catch()chain correctly handles the promise. Ifno-floating-promisesfires on the.catch()return value itself, wrap withvoid:void runX().catch(...).
Config files that need disableTypeChecked override (confirmed outside all tsconfig include arrays):
apps/api/drizzle.config.tsapps/api/vitest.config.tsapps/pwa/vite.config.tsapps/pwa/vitest.config.tsapps/pwa/playwright.config.tsapps/api/tests/**/*.ts(excluded fromapps/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):
"lint": "pnpm -r --if-present lint",
"typecheck": "pnpm -r typecheck"
Apply the same style for new scripts:
"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):
"typecheck": "tsc --noEmit"
Pattern to mirror — same position in the scripts block, same style:
"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):
"typecheck": "tsc --noEmit && tsc --project tsconfig.e2e.json --noEmit"
Pattern to mirror:
"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):
- 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):
- 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):
# 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):
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:
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:
// 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):
// (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 runprefix, 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
typecheckpattern: single command, directory-scoped, no wrappers — copy exactly forlintscripts - CI steps follow
- name: Verb noun/run: pnpm <script>— newFormat checkstep slots betweenLintandTypecheck - All three broker workers already use
setInterval(() => { runX().catch(...) })correctly; ifno-floating-promisesfires on the.catch()return, addvoidbeforerunX()(legitimate signal, not a mask) eslint-disable-next-linesuppression 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.