Files
suaveplan/openspec/archive/2026-05-20-migrate-macos-contacts/proposal.md
2026-05-27 23:29:53 +01:00

87 lines
11 KiB
Markdown

# Migrate @suaveplan/macos-contacts
**Package**: `@suaveplan/macos-contacts`
**Status**: PROPOSED
**Type**: new-package
**Design**: (none — see `specs/macos-contacts/spec.md` for normative contract)
**Spec delta**: ./specs/macos-contacts/spec.md
## Why
`@suaveplan/macos-contacts` is the bridge between macOS Contacts.app and the genesis contacts pipeline (`@suaveplan/contacts`, `@suaveplan/api-plugin-contacts`, `@suaveplan/react-contacts`). Upstream lives at `../suaveplan/packages/universal/macos-contacts/` and is functional but enterprise-incomplete: bare `HttpError` with `unknown as HttpErrorCode` casts, four `@deprecated` static script-accessor methods, a static-only `ContactParser` class with a `biome-ignore` suppression, zero OTel instrumentation, hard-coded shell invocation (untestable without macOS), inline `new Date()` calls. This migration brings the package across at full enterprise quality with structured `MacOSContactsError` + `MACOS_CONTACTS_*` code registry, an injectable `AppleScriptRunner` seam (lets tests run on any platform without macOS), free-function `ContactParser` module, injectable `Clock` for sync timestamps, OTel counters and histograms across every public op, and Zod input validation at the boundary.
## What changes
- ADDED: 9 public exports — `MacOSContactsBridge`, `parseContacts` / `parseGroups` / `normalizePhone` (free-function ContactParser surface), `getAllContactsScript` / `searchContactsScript` / `getGroupsScript` / `getGroupMembersScript` (standalone AppleScript template builders), `MACOS_CONTACTS_CODES`, `MacOSContactsError`, `MacOSContactsErrorCode`, `MacOSContactsErrorOptions`, the OTel instruments bundle (`getMacOSContactsInstruments` / `resetMacOSContactsInstruments`), Zod schemas (`macOSContactsConfigSchema`, `searchQuerySchema`, `groupNameSchema`, `contactIdSchema`, `parseOrThrow`), the entity types (`MacOSContact`, `ContactPhone`, `ContactEmail`, `ContactAddress`, `ContactGroup`, `SyncResult`, `SyncError`, `MacOSContactsConfig`), and the runner seam (`AppleScriptRunner`, `createDefaultAppleScriptRunner`).
- ADDED: `MACOS_CONTACTS_CODES` registry via `defineCodes("MACOS_CONTACTS", [...])` with six stable codes — `INVALID_INPUT`, `CONTACTS_NOT_FOUND`, `CONTACTS_PERMISSION_DENIED`, `CONTACTS_ACCESS_FAILED`, `CONTACTS_PARSE_FAILED`, `CONTACTS_UNAVAILABLE`.
- ADDED: `MacOSContactsError extends SuaveplanError<MacOSContactsErrorCode>` with optional `query` / `groupName` / `contactId` discriminants. Replaces upstream's `HttpError(... code as unknown as HttpErrorCode)` cast.
- ADDED: OTel instruments bundle exposing six counters (`macos_contacts.list_total`, `macos_contacts.search_total`, `macos_contacts.get_total`, `macos_contacts.groups_list_total`, `macos_contacts.group_members_total`, `macos_contacts.sync_total`) and two histograms (`macos_contacts.applescript_duration_ms`, `macos_contacts.sync_duration_ms`); every counter partitions by `outcome` (`success` | `permission_denied` | `access_failed`).
- ADDED: `AppleScriptRunner` interface (`{ run(script: string, signal?: AbortSignal): Promise<string> }`) with a default `execFile`-based impl. Tests inject a fake runner that returns fixture strings, no macOS / no mocking framework required.
- ADDED: Injectable `Clock` (from `@suaveplan/types`) — replaces upstream `new Date()` in `SyncResult.syncedAt`.
- MODIFIED: `ContactParser` lowered from a `static`-only class with a biome suppression to a module of pure free functions (`parseContacts`, `parseGroups`, `normalizePhone`). Drops the `// biome-ignore lint/complexity/noStaticOnlyClass` comment outright.
- REMOVED: Upstream's four `@deprecated` `MacOSContactsBridge.*Script()` static methods. Callers use the standalone template-builder exports directly (already exported from upstream — verified in source).
## Out of scope
- Write-side operations (create / update / delete a contact via AppleScript). Upstream is read-only; this migration matches.
- macOS Contacts framework via native bindings (e.g. `node-mac-contacts`). Out of scope per upstream design — `osascript` is the supported path.
- A streaming / cursor interface for very large address books. Upstream returns the full list; this migration preserves the same surface.
## Source mapping
This change derives from `../suaveplan/enterprise/macos-contacts.md` and the upstream source under `../suaveplan/packages/universal/macos-contacts/src/`.
| Requirement | Source lines | Notes |
|---|---|---|
| Public surface preserved (bridge + parser + templates + types + error codes) | `src/index.ts:1-32` | verbatim; `ContactParser` static class lowered to free functions |
| `MacOSContact`, `ContactPhone`, `ContactEmail`, `ContactAddress`, `ContactGroup`, `MacOSContactsConfig`, `SyncResult`, `SyncError` types | `src/types/macos-contacts.types.ts:1-86` | verbatim shape; `CommandExecutor` replaced by typed `AppleScriptRunner` |
| `MACOS_CONTACTS_*` code registry (5 codes) | `src/error-codes.ts:19-75` | preserved; one new code (`INVALID_INPUT`) added for Zod boundary failures |
| Parse single contact block / multi-contact stream / empty input / malformed block | `src/core/contact-parser/contact-parser.test.ts:6-87` | preserved; tests rewritten against free functions |
| Parse groups + skip malformed lines + empty input | `src/core/contact-parser/contact-parser.test.ts:90-111` | verbatim |
| `normalizePhone` — strip non-digit, preserve `+`, NANP default, empty/non-digit guard | `src/core/contact-parser/contact-parser.test.ts:114-147` | verbatim |
| AppleScript builders — `getAllContactsScript`, `searchContactsScript` (with `"` escape), `getGroupsScript`, `getGroupMembersScript` (with `"` escape) | `src/core/contacts-bridge/applescript-templates.ts:48-101` | verbatim |
| `MacOSContactsBridge.getAllContacts` — parse osascript output to `MacOSContact[]` | `src/core/contacts-bridge/contacts-bridge.ts:106-115` | verbatim semantics; raw invocation via injectable runner |
| `MacOSContactsBridge.searchContacts(query)` — empty-query guard, errors mapped with `query` discriminant | `src/core/contacts-bridge/contacts-bridge.ts:119-132` | verbatim semantics; error becomes `MacOSContactsError` with `query` discriminant |
| `MacOSContactsBridge.getContactById(id)` — empty-id guard, returns `null` when unknown | `src/core/contacts-bridge/contacts-bridge.ts:136-143` | verbatim |
| `MacOSContactsBridge.getGroups()` — list contact groups | `src/core/contacts-bridge/contacts-bridge.ts:147-157` | verbatim |
| `MacOSContactsBridge.getGroupMembers(groupName)` — empty-name guard, members of named group | `src/core/contacts-bridge/contacts-bridge.ts:161-174` | verbatim |
| `MacOSContactsBridge.sync()` — capture errors in `SyncResult.errors`, never throw | `src/core/contacts-bridge/contacts-bridge.ts:178-207` | verbatim semantics; uses injectable `Clock` for `syncedAt` |
| `MacOSContactsBridge.isAvailable()` — probe osascript via `System Events`, never throw | `src/core/contacts-bridge/contacts-bridge.ts:92-101` | verbatim |
| Error mapping — permission patterns → `CONTACTS_PERMISSION_DENIED`, everything else → `CONTACTS_ACCESS_FAILED` | `src/core/contacts-bridge/contacts-bridge.ts:26-50` | verbatim regex; raises `MacOSContactsError` instead of `HttpError` |
| Universal-tier purity in everything except the default runner (which is a server-tier seam, isolated in its own module) | `enterprise/macos-contacts.md:L37` | verbatim |
| Zod schemas at every public input boundary | `enterprise/macos-contacts.md:L38` | verbatim |
| Deterministic behaviour — injectable `Clock`, no `new Date()` at module scope | `enterprise/macos-contacts.md:L40` | verbatim |
| OTel instrumentation via `@suaveplan/opentelemetry` for all public entrypoints | `enterprise/macos-contacts.md:L41` | verbatim |
| Structured errors extending `@suaveplan/error` with stable codes | `enterprise/macos-contacts.md:L42` | verbatim |
| 100% Istanbul coverage, no `it.skip`, no `any`, no `ts-ignore` | `enterprise/macos-contacts.md:L50-L60` | verbatim |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `INVALID_INPUT` code | Genesis-wide policy: Zod boundary failures raise a typed `MacOSContactsError`, never throw raw `ZodError`. Mirrors `@suaveplan/contacts`. |
| `AppleScriptRunner` injectable interface | Upstream's `CommandExecutor = (string) => Promise<string>` is a bare function alias; the genesis seam is a typed interface with a `run(script, signal?)` signature so future implementations (e.g. `node-mac-contacts`, FFI bindings) can plug in without surface drift. Also lets the bridge thread `AbortSignal` per genesis policy. |
| OTel counters partition by `outcome` | Genesis-wide observability rule: counters that wrap IO must split success vs typed failure modes so dashboards can distinguish "permission revoked in System Settings" from "transient osascript failure" without scraping logs. |
| `MacOSContactsError` carries `query` / `groupName` / `contactId` discriminants | Lets consumers dispatch on which validation failed without parsing messages. |
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/macos-contacts` to enumerate. The package is freshly migrated and has no in-repo consumers yet:
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/macos-contacts` returning only `@suaveplan/macos-contacts` itself).
Future consumers (planned but not yet migrated): `@suaveplan/api-plugin-contacts`, `@suaveplan/react-contacts`. They will be migrated under their own OpenSpec changes and will adopt the new `MacOSContactsError` / `AppleScriptRunner` surface at that time.
## Verification approach
- `src/error-codes/error-codes.test.ts` — registry shape, every code constructible, discriminants flow through
- `src/schemas/schemas.test.ts` — every schema accepts canonical input, rejects malformed input via `MacOSContactsError(INVALID_INPUT)`
- `src/otel/instruments.test.ts` — instruments observable via in-memory `MeterProvider`; every counter + histogram fires from a real bridge call
- `src/parser/parser.test.ts` — every parser branch (`parseContacts`, `parseGroups`, `normalizePhone`) including malformed-skip and empty-input guards
- `src/applescript/applescript.test.ts` — every template builder including `"` escaping
- `src/executor/executor.test.ts` — `createDefaultAppleScriptRunner` end-to-end against the real platform binary when available, asserting the failure path everywhere via a deliberately-failing script
- `src/bridge/bridge.test.ts` — every `MacOSContactsBridge` method with an injected fake runner; permission-pattern → `CONTACTS_PERMISSION_DENIED`, generic failure → `CONTACTS_ACCESS_FAILED`, sync swallows errors, isAvailable swallows errors; OTel counter + histogram emission asserted via real `MeterProvider`
- `src/macos-contacts.spec-conformance.test.ts` — pins barrel shape (§6a), subpath exports, error-code wiring, no direct `vitest` imports (§7)