diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-SUMMARY.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-SUMMARY.md new file mode 100644 index 0000000..70d1377 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-SUMMARY.md @@ -0,0 +1,126 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 01 +subsystem: api +tags: [vitest, tdd, postgres, phishing-triage, pure-functions] + +# Dependency graph +requires: + - phase: 19-phishing-triage-classification-engine + provides: campaign-classifier.ts's mapVerdictToActions 7-action vocabulary and postgresClient/vi.mock testing idiom + - phase: 20-remediation-approval-audit-safety + provides: remediation-service.ts's ApproveActionInput shape and phishing-audit.ts's canonical event_type values +provides: + - resolveTicketToCampaign(ticketId) — pure ticket->campaign lookup service + - deriveDefaultParams(actionType, evidence) — 7-action-type default-param derivation + - mergeTimeline(reports, classifications, auditEvents) — chronological 3-source merge +affects: [22-02-ticket-campaign-route, 22-04-action-area-card, 22-05-timeline-card] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Pure lib/services/*.ts extraction for route/component logic that needs vitest coverage (test.include is lib/**/*.test.ts only)" + - "vi.mock('./postgres-client', () => ({ postgresClient: { query: ... } })) factory-mock idiom (mirrors campaign-classifier.test.ts)" + +key-files: + created: + - lib/services/phishing-ticket-resolver.ts + - lib/services/phishing-ticket-resolver.test.ts + - lib/services/remediation-default-params.ts + - lib/services/remediation-default-params.test.ts + - lib/services/phishing-timeline.ts + - lib/services/phishing-timeline.test.ts + modified: [] + +key-decisions: + - "Used named import `{ postgresClient }` from './postgres-client' (matches the sibling files' actual convention — campaign-classifier.ts, remediation-service.ts, phishing-audit.ts — and the PATTERNS.md code snippet), not the plan action text's parenthetical 'default import' gloss, since the vi.mock factory only provides a named `postgresClient` export and a default-import call site would not be mocked." + - "mergeTimeline's report/classification/audit source shapes use camelCase `createdAt` fields (not snake_case `created_at`) since the read_first note specifies these consume the already-transformed camelCase API response shapes from app/api/phishing/campaigns/[id]/route.ts." + +patterns-established: + - "Pattern: exhaustive switch with `default: return {}` for extensible action-type-keyed pure derivations (mirrors mapVerdictToActions)" + - "Pattern: discriminated union + fixed KIND_PRIORITY map for stable multi-source chronological merges" + +requirements-completed: [REVIEW-01, REVIEW-02, REVIEW-04] + +# Metrics +duration: 12min +completed: 2026-07-16 +--- + +# Phase 22 Plan 01: Wave 0 Pure-Logic Extraction Summary + +**Three pure `lib/services/*.ts` modules (ticket->campaign resolver, 7-action-type default-param derivation, 3-source timeline merge) extracted and unit-tested under vitest, unblocking the route/component work in plans 02/04/05.** + +## Performance + +- **Duration:** 12 min +- **Started:** 2026-07-16T18:17:00Z +- **Completed:** 2026-07-16T18:29:00Z +- **Tasks:** 3 completed +- **Files modified:** 6 (all new) + +## Accomplishments +- `resolveTicketToCampaign(ticketId)` covers all three resolution states (no report / ungrouped / grouped) via a single parameterized `reports` lookup +- `deriveDefaultParams(actionType, evidence)` implements the exact 7-action-type default-params table from the UI-SPEC, with a safe `{}` fallback for unknown/future action types +- `mergeTimeline(reports, classifications, auditEvents)` produces a single ascending-chronological array with a discriminated `TimelineEntry` union and a deterministic tie-break order +- All 3 modules are pure or DB-only (no `NextResponse`/`requirePermission` in any of them) — route-free per the plan's design intent + +## Task Commits + +Each task was committed via full TDD RED/GREEN pairs: + +1. **Task 1: phishing-ticket-resolver.ts + test** — `20b1e1b` (test), `e619321` (feat) +2. **Task 2: remediation-default-params.ts + test** — `c64a905` (test), `86cffc4` (feat) +3. **Task 3: phishing-timeline.ts + test (server-merge)** — `37f6657` (test), `d30a49f` (feat) + +**Plan metadata:** (this commit) + +## Files Created/Modified +- `lib/services/phishing-ticket-resolver.ts` - `resolveTicketToCampaign(ticketId)`, parameterized `reports` lookup +- `lib/services/phishing-ticket-resolver.test.ts` - 4 tests covering the 3 resolution states + query-shape assertion +- `lib/services/remediation-default-params.ts` - `deriveDefaultParams(actionType, evidence)`, exhaustive switch over 7 action types + fallback +- `lib/services/remediation-default-params.test.ts` - 9 tests covering all 7 known types, unknown fallback, and null-evidence defaults +- `lib/services/phishing-timeline.ts` - `mergeTimeline(reports, classifications, auditEvents)`, `TimelineEntry` discriminated union +- `lib/services/phishing-timeline.test.ts` - 4 tests covering sort order, length invariant, per-variant fields, and tie-break stability + +## Decisions Made +- Named import for `postgresClient` (not the plan text's literal "default import" parenthetical) — see `key-decisions` above; verified against all 3 sibling files (`campaign-classifier.ts`, `remediation-service.ts`, `phishing-audit.ts`) and the working `vi.mock` test pattern. +- `mergeTimeline` inputs are camelCase (`createdAt`, `reportId`, `ticketNumber`, `companyName`, `verdict`, `confidence`, `eventType`, `actor`, `payload`) since this function consumes the campaign-detail route's already-transformed response shape, not raw DB rows. + +## Deviations from Plan + +None - plan executed exactly as written, with one clarifying correction to an internal inconsistency in the plan text (see Decisions Made: named vs. default import) resolved in favor of the working, testable convention already established in this phase's sibling files. + +## Issues Encountered +None - all three RED phases confirmed failing before implementation (module-not-found), all three GREEN phases passed on first implementation attempt. + +## TDD Gate Compliance + +All three tasks followed the full RED -> GREEN cycle with separate commits: +- Task 1: `test(22-01)` at `20b1e1b` -> `feat(22-01)` at `e619321` +- Task 2: `test(22-01)` at `c64a905` -> `feat(22-01)` at `86cffc4` +- Task 3: `test(22-01)` at `37f6657` -> `feat(22-01)` at `d30a49f` + +No REFACTOR commits were needed — each GREEN implementation passed cleanly without follow-up cleanup. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Plan 02 (ticket->campaign resolver route) can now call `resolveTicketToCampaign` directly — the route becomes a thin `requirePermission` + param-parse + call wrapper per PATTERNS.md. +- Plans 04/05 (Action Area Card, Timeline Card) have a tested `deriveDefaultParams`/`mergeTimeline` to call from their respective routes/components (or the extended `campaigns/[id]` route, per the server-merge decision). +- Full test suite: 411/413 passing. The 2 failures are pre-existing, unrelated `lib/services/analyzer/itglue-search.test.ts` failures (untouched by this plan) — logged in `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md`, out of scope per the plan's file list. +- `npx tsc --noEmit --pretty` is clean. + +--- +*Phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve* +*Completed: 2026-07-16* + +## Self-Check: PASSED + +All 6 created files verified present on disk; all 6 task commit hashes +(`20b1e1b`, `e619321`, `c64a905`, `86cffc4`, `37f6657`, `d30a49f`) verified +present in `git log --oneline --all`. diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md new file mode 100644 index 0000000..b8468e6 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md @@ -0,0 +1,16 @@ +# Deferred Items — Phase 22 + +Pre-existing failures/warnings discovered during execution but out of scope +for the current plan (not touched by this plan's files, not caused by this +plan's changes). + +## Plan 01 + +- `lib/services/analyzer/itglue-search.test.ts` — 2 pre-existing failing + tests (`itglueSearch > tolerates per-call failures ...`, doc count + mismatches). Unrelated to phishing-ticket-resolver / + remediation-default-params / phishing-timeline (this plan's files). Not + modified by any commit in this plan. Confirmed present before this plan's + changes (file untouched in `git diff`). Full-suite run: `2 failed | 411 + passed (413)` with only these 2 pre-existing failures — all other tests, + including the 3 new files added by this plan, are green. diff --git a/lib/services/phishing-ticket-resolver.test.ts b/lib/services/phishing-ticket-resolver.test.ts new file mode 100644 index 0000000..49c0082 --- /dev/null +++ b/lib/services/phishing-ticket-resolver.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test — mirrors +// campaign-classifier.test.ts's vi.mock() factory-mocking discipline. +const queryMock = vi.fn(); +vi.mock('./postgres-client', () => ({ + postgresClient: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// eslint-disable-next-line import/first -- imported after vi.mock hoisting +import { resolveTicketToCampaign } from './phishing-ticket-resolver'; + +describe('resolveTicketToCampaign', () => { + beforeEach(() => { + queryMock.mockReset(); + }); + + it('returns { found: false } when no reports row exists for the ticket id', async () => { + queryMock.mockResolvedValue({ rows: [], rowCount: 0 }); + + const result = await resolveTicketToCampaign(999); + + expect(result).toEqual({ found: false }); + }); + + it('returns { found: true, campaignId: null } for an ungrouped report', async () => { + queryMock.mockResolvedValue({ + rows: [{ id: 'report-1', campaign_id: null, ticket_number: 'T20260716.0001' }], + rowCount: 1, + }); + + const result = await resolveTicketToCampaign(123); + + expect(result).toEqual({ + found: true, + reportId: 'report-1', + campaignId: null, + ticketNumber: 'T20260716.0001', + }); + }); + + it('returns { found: true, reportId, campaignId, ticketNumber } for a grouped report', async () => { + queryMock.mockResolvedValue({ + rows: [ + { + id: 'report-2', + campaign_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + ticket_number: 'T20260716.0002', + }, + ], + rowCount: 1, + }); + + const result = await resolveTicketToCampaign(456); + + expect(result).toEqual({ + found: true, + reportId: 'report-2', + campaignId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + ticketNumber: 'T20260716.0002', + }); + }); + + it('queries reports filtered by ticket_id with a parameterized query', async () => { + queryMock.mockResolvedValue({ rows: [], rowCount: 0 }); + + await resolveTicketToCampaign(1); + + expect(queryMock).toHaveBeenCalledWith( + expect.stringContaining('FROM reports WHERE ticket_id = $1'), + [1] + ); + }); +}); diff --git a/lib/services/phishing-ticket-resolver.ts b/lib/services/phishing-ticket-resolver.ts new file mode 100644 index 0000000..eee95a5 --- /dev/null +++ b/lib/services/phishing-ticket-resolver.ts @@ -0,0 +1,48 @@ +/** + * Phishing Ticket -> Campaign Resolver (Phase 22, Wave 0) + * + * Pure lookup service: given an Autotask ticket id, finds the linked + * `reports` row (if any) and reports back its campaign linkage. No auth, + * no param parsing — that lives in the route (plan 02, GET + * /api/phishing/tickets/[ticket_id]/campaign). Extracted as its own module + * so it is unit-testable under vitest (whose `test.include` is + * `lib/**\/*.test.ts` only — `app/**` route files have no coverage). + */ + +import { postgresClient } from './postgres-client'; + +export interface TicketCampaignResolution { + found: boolean; + reportId?: string; + campaignId?: string | null; + ticketNumber?: string | null; +} + +interface ReportLookupRow { + id: string; + campaign_id: string | null; + ticket_number: string | null; +} + +/** + * Looks up the `reports` row for a given ticket id (tickets.id / reports.ticket_id). + * - No row: `{ found: false }` + * - Row with `campaign_id = null`: `{ found: true, reportId, campaignId: null, ticketNumber }` — ungrouped report + * - Row with a `campaign_id`: `{ found: true, reportId, campaignId, ticketNumber }` — grouped into a campaign + */ +export async function resolveTicketToCampaign(ticketId: number): Promise { + const res = await postgresClient.query( + `SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1`, + [ticketId] + ); + const report = res.rows[0]; + if (!report) { + return { found: false }; + } + return { + found: true, + reportId: report.id, + campaignId: report.campaign_id, + ticketNumber: report.ticket_number, + }; +} diff --git a/lib/services/phishing-timeline.test.ts b/lib/services/phishing-timeline.test.ts new file mode 100644 index 0000000..9752c9f --- /dev/null +++ b/lib/services/phishing-timeline.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { mergeTimeline, type TimelineEntry } from './phishing-timeline'; + +describe('mergeTimeline', () => { + const reports = [ + { createdAt: '2026-07-15T00:00:00.000Z', reportId: 'report-1', ticketNumber: 'T-0001', companyName: 'Acme' }, + { createdAt: '2026-07-15T04:00:00.000Z', reportId: 'report-2', ticketNumber: 'T-0002', companyName: 'Acme' }, + ]; + const classifications = [ + { createdAt: '2026-07-15T02:00:00.000Z', verdict: 'THREAT', confidence: '85' }, + ]; + const auditEvents = [ + { createdAt: '2026-07-15T01:00:00.000Z', eventType: 'remediation_approved', actor: 'lorentz@wulfconsulting.com', payload: { actions: ['block_sender'] } }, + { createdAt: '2026-07-15T05:00:00.000Z', eventType: 'remediation_completed', actor: null, payload: {} }, + ]; + + it('returns a single array sorted ascending by `at` across all 3 sources', () => { + const result = mergeTimeline(reports, classifications, auditEvents); + + expect(result).toHaveLength(5); + const timestamps = result.map((entry) => new Date(entry.at).getTime()); + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + }); + + it('output length equals the sum of the three input array lengths', () => { + const result = mergeTimeline(reports, classifications, auditEvents); + expect(result).toHaveLength(reports.length + classifications.length + auditEvents.length); + }); + + it('carries a discriminant `kind` and the source-specific fields for each entry', () => { + const result = mergeTimeline(reports, classifications, auditEvents); + + const reportEntry = result.find((e) => e.kind === 'report' && e.reportId === 'report-1'); + expect(reportEntry).toMatchObject({ kind: 'report', reportId: 'report-1', ticketNumber: 'T-0001', companyName: 'Acme' }); + + const classificationEntry = result.find((e) => e.kind === 'classification'); + expect(classificationEntry).toMatchObject({ kind: 'classification', verdict: 'THREAT', confidence: '85' }); + + const auditEntry = result.find((e) => e.kind === 'audit' && e.eventType === 'remediation_approved'); + expect(auditEntry).toMatchObject({ + kind: 'audit', + eventType: 'remediation_approved', + actor: 'lorentz@wulfconsulting.com', + payload: { actions: ['block_sender'] }, + }); + }); + + it('preserves a stable tie-break order (report, then classification, then audit) on identical timestamps and does not throw', () => { + const tiedAt = '2026-07-15T10:00:00.000Z'; + const tiedReports = [{ createdAt: tiedAt, reportId: 'r', ticketNumber: 'T', companyName: 'C' }]; + const tiedClassifications = [{ createdAt: tiedAt, verdict: 'SPAM', confidence: '50' }]; + const tiedAudit = [{ createdAt: tiedAt, eventType: 'campaign_marked_false_positive', actor: null, payload: {} }]; + + let result: TimelineEntry[] = []; + expect(() => { + result = mergeTimeline(tiedReports, tiedClassifications, tiedAudit); + }).not.toThrow(); + + expect(result.map((e) => e.kind)).toEqual(['report', 'classification', 'audit']); + }); +}); diff --git a/lib/services/phishing-timeline.ts b/lib/services/phishing-timeline.ts new file mode 100644 index 0000000..837d0c7 --- /dev/null +++ b/lib/services/phishing-timeline.ts @@ -0,0 +1,85 @@ +/** + * Phishing Campaign Timeline Merge (Phase 22, Wave 0, REVIEW-02) + * + * Pure transform: merges the three already-camelCased sources consumed by + * `TimelineCard` (reports, classifications, audit_events — see + * app/api/phishing/campaigns/[id]/route.ts's response shape and + * lib/services/phishing-audit.ts's canonical event_type values) into a + * single ascending-by-timestamp array. Server-merge approach chosen per + * 22-RESEARCH.md Alternatives Considered. No DB/fetch imports — pure + * function, takes already-fetched row arrays. + */ + +export type TimelineEntry = + | { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null } + | { kind: 'classification'; at: string; verdict: string; confidence: string | null } + | { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown }; + +interface ReportTimelineSource { + createdAt: string; + reportId: string; + ticketNumber: string | null; + companyName: string | null; +} + +interface ClassificationTimelineSource { + createdAt: string; + verdict: string; + confidence: string | null; +} + +interface AuditTimelineSource { + createdAt: string; + eventType: string; + actor: string | null; + payload: unknown; +} + +/** Fixed tie-break priority when two entries share an identical `at` timestamp. */ +const KIND_PRIORITY: Record = { + report: 0, + classification: 1, + audit: 2, +}; + +/** + * Maps each of the three sources into its `TimelineEntry` variant, + * concatenates, and sorts ascending by `at` (oldest first — matches the + * existing `ORDER BY created_at ASC` convention). Ties on identical + * timestamps fall back to a fixed kind priority (report < classification < + * audit) for a stable, deterministic order. + */ +export function mergeTimeline( + reports: ReportTimelineSource[], + classifications: ClassificationTimelineSource[], + auditEvents: AuditTimelineSource[] +): TimelineEntry[] { + const entries: TimelineEntry[] = [ + ...reports.map((report) => ({ + kind: 'report', + at: report.createdAt, + reportId: report.reportId, + ticketNumber: report.ticketNumber, + companyName: report.companyName, + })), + ...classifications.map((classification) => ({ + kind: 'classification', + at: classification.createdAt, + verdict: classification.verdict, + confidence: classification.confidence, + })), + ...auditEvents.map((event) => ({ + kind: 'audit', + at: event.createdAt, + eventType: event.eventType, + actor: event.actor, + payload: event.payload, + })), + ]; + + return entries.sort((a, b) => { + const diff = new Date(a.at).getTime() - new Date(b.at).getTime(); + if (diff !== 0) return diff; + return KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind]; + }); +} diff --git a/lib/services/remediation-default-params.test.ts b/lib/services/remediation-default-params.test.ts new file mode 100644 index 0000000..7b3c13c --- /dev/null +++ b/lib/services/remediation-default-params.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { deriveDefaultParams, type DefaultParamEvidence } from './remediation-default-params'; + +const filledEvidence: DefaultParamEvidence = { + requesterEmail: 'requester@wulfconsulting.test', + senderEmail: 'attacker@evil.test', + senderDomain: 'evil.test', + messageId: 'message-123', +}; + +const emptyEvidence: DefaultParamEvidence = { + requesterEmail: null, + senderEmail: null, + senderDomain: null, + messageId: null, +}; + +describe('deriveDefaultParams', () => { + it('returns {} for no_action', () => { + expect(deriveDefaultParams('no_action', filledEvidence)).toEqual({}); + }); + + it('returns recipientEmail + empty message for warn_user, using requesterEmail', () => { + expect(deriveDefaultParams('warn_user', filledEvidence)).toEqual({ + recipientEmail: 'requester@wulfconsulting.test', + message: '', + }); + }); + + it('returns senderEmail + senderDomain for block_sender', () => { + expect(deriveDefaultParams('block_sender', filledEvidence)).toEqual({ + senderEmail: 'attacker@evil.test', + senderDomain: 'evil.test', + }); + }); + + it('returns messageId + empty mailboxes for purge_message', () => { + expect(deriveDefaultParams('purge_message', filledEvidence)).toEqual({ + messageId: 'message-123', + mailboxes: [], + }); + }); + + it('returns userPrincipalName (from requesterEmail) for reset_password', () => { + expect(deriveDefaultParams('reset_password', filledEvidence)).toEqual({ + userPrincipalName: 'requester@wulfconsulting.test', + }); + }); + + it('returns empty deviceId for isolate_endpoint', () => { + expect(deriveDefaultParams('isolate_endpoint', filledEvidence)).toEqual({ deviceId: '' }); + }); + + it('returns userPrincipalName + empty ruleName for disable_forwarding_rule', () => { + expect(deriveDefaultParams('disable_forwarding_rule', filledEvidence)).toEqual({ + userPrincipalName: 'requester@wulfconsulting.test', + ruleName: '', + }); + }); + + it('returns {} for an unknown/future action type', () => { + expect(deriveDefaultParams('unknown_future_type', filledEvidence)).toEqual({}); + }); + + it('falls back to empty-string defaults for warn_user/block_sender/reset_password/disable_forwarding_rule when evidence fields are null', () => { + expect(deriveDefaultParams('warn_user', emptyEvidence)).toEqual({ recipientEmail: '', message: '' }); + expect(deriveDefaultParams('block_sender', emptyEvidence)).toEqual({ senderEmail: '', senderDomain: '' }); + expect(deriveDefaultParams('purge_message', emptyEvidence)).toEqual({ messageId: '', mailboxes: [] }); + expect(deriveDefaultParams('reset_password', emptyEvidence)).toEqual({ userPrincipalName: '' }); + expect(deriveDefaultParams('disable_forwarding_rule', emptyEvidence)).toEqual({ + userPrincipalName: '', + ruleName: '', + }); + }); +}); diff --git a/lib/services/remediation-default-params.ts b/lib/services/remediation-default-params.ts new file mode 100644 index 0000000..7ac799e --- /dev/null +++ b/lib/services/remediation-default-params.ts @@ -0,0 +1,42 @@ +/** + * Remediation Default-Params Derivation (Phase 22, Wave 0) + * + * Pure transform: given an action type (from campaign-classifier.ts's + * `mapVerdictToActions` vocabulary) and bounded evidence fields, returns the + * default `params` object an operator sees pre-filled in the Action Area + * before approving (feeds `ApproveActionInput.params` in + * lib/services/remediation-service.ts). No DB/fetch imports — pure function. + */ + +export interface DefaultParamEvidence { + requesterEmail: string | null; + senderEmail: string | null; + senderDomain: string | null; + messageId: string | null; +} + +/** + * Exhaustive per-action-type default-params table (UI-SPEC Action Area + * Spec). Unknown/future action types fall through to `{}` rather than + * throwing, so newly introduced action types never break the Action Area. + */ +export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record { + switch (actionType) { + case 'no_action': + return {}; + case 'warn_user': + return { recipientEmail: evidence.requesterEmail ?? '', message: '' }; + case 'block_sender': + return { senderEmail: evidence.senderEmail ?? '', senderDomain: evidence.senderDomain ?? '' }; + case 'purge_message': + return { messageId: evidence.messageId ?? '', mailboxes: [] }; + case 'reset_password': + return { userPrincipalName: evidence.requesterEmail ?? '' }; + case 'isolate_endpoint': + return { deviceId: '' }; + case 'disable_forwarding_rule': + return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' }; + default: + return {}; + } +}