# Capability Spec: macos-contacts **Package**: `@suaveplan/macos-contacts` **Status**: ACCEPTED (originating change `migrate-macos-contacts`, archived 2026-05-20) > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Stable error code registry The package SHALL expose `MACOS_CONTACTS_CODES` built via `defineCodes("MACOS_CONTACTS", [...])` and a `MacOSContactsError extends SuaveplanError` with optional `query` / `groupName` / `contactId` discriminants. Every failure path in the package SHALL raise a `MacOSContactsError` (never a bare `Error` or `HttpError`). #### Scenario: every code is constructible - **WHEN** the test instantiates `new MacOSContactsError(code, "msg", {...})` for every member of `MACOS_CONTACTS_CODES` - **THEN** the resulting error narrows `code` to the namespaced string `"MACOS_CONTACTS.*"` and exposes the discriminants #### Scenario: every code is thrown from production - **WHEN** `openspec-conformance` greps `src/` for every code in the registry - **THEN** each code appears in at least one non-test source file **Verified by:** `src/error-codes/error-codes.test.ts::"every registered code constructs a MacOSContactsError with namespaced code"` AND `src/error-codes/error-codes.test.ts::"every code exposes its discriminants on the instance"` --- ### Requirement: Zod input validation at every boundary The package SHALL validate every public input via Zod before any side effect. Boundary failures SHALL raise `MacOSContactsError(INVALID_INPUT)` with the `ZodError` chained as `cause` and the offending field listed in the message. #### Scenario: invalid bridge config - **WHEN** `new MacOSContactsBridge({ timeoutMs: -1 })` is constructed - **THEN** a `MacOSContactsError` with `code === "MACOS_CONTACTS.INVALID_INPUT"` is thrown #### Scenario: parseOrThrow wraps ZodError - **WHEN** `parseOrThrow(searchQuerySchema, "", "label")` is called - **THEN** it throws `MacOSContactsError(INVALID_INPUT)` with the `ZodError` as `cause` **Verified by:** `src/schemas/schemas.test.ts::"parseOrThrow throws MacOSContactsError(INVALID_INPUT) with cause"` AND `src/bridge/bridge.test.ts::"constructor rejects invalid timeoutMs"` --- ### Requirement: OTel instruments emitted from production paths The package SHALL expose `getMacOSContactsInstruments()` (lazy global-`MeterProvider` bind) and `resetMacOSContactsInstruments()`. The bundle SHALL expose 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 public bridge method SHALL emit the corresponding counter (with `outcome` attribute) and histogram. #### Scenario: bundle is cached - **WHEN** `getMacOSContactsInstruments()` is called twice without a reset - **THEN** the second call returns the same object reference #### Scenario: every counter and histogram fires - **WHEN** a `MacOSContactsBridge` runs `getAllContacts`, `searchContacts`, `getContactById`, `getGroups`, `getGroupMembers`, and `sync` with a fake runner against a real in-memory `MeterProvider` - **THEN** every counter records at least one increment and both histograms record at least one sample #### Scenario: failure counters split on outcome - **WHEN** the fake runner throws an error matching the permission regex - **THEN** the relevant counter records a data point with `outcome: "permission_denied"` **Verified by:** `src/otel/instruments.test.ts::"getMacOSContactsInstruments returns the same bundle on subsequent calls"` AND `src/otel/instruments.test.ts::"every counter and histogram fires from the bridge"` AND `src/otel/instruments.test.ts::"failure path emits outcome=permission_denied"` --- ### Requirement: Injectable AppleScript runner seam The package SHALL expose an `AppleScriptRunner` interface (`{ run(script: string, signal?: AbortSignal): Promise }`) and a `createDefaultAppleScriptRunner({ timeoutMs })` factory that returns a runner backed by `node:child_process.execFile` against the platform `osascript` binary. The `MacOSContactsBridge` constructor SHALL accept an `AppleScriptRunner` injection so tests run on any platform without macOS. #### Scenario: bridge defaults to the platform runner - **WHEN** `new MacOSContactsBridge({ timeoutMs: 5000 })` is constructed without an explicit runner - **THEN** the bridge holds an instance produced by `createDefaultAppleScriptRunner` #### Scenario: bridge accepts an injected runner - **WHEN** `new MacOSContactsBridge({}, { runner: fakeRunner })` is constructed - **THEN** every subsequent call routes through `fakeRunner.run` **Verified by:** `src/bridge/bridge.test.ts::"constructor defaults to the platform runner"` AND `src/bridge/bridge.test.ts::"constructor accepts an injected runner"` AND `src/executor/executor.test.ts::"default runner rejects when osascript fails"` --- ### Requirement: Injectable Clock for sync timestamps The package SHALL use an injectable `Clock` (from `@suaveplan/types`) for `SyncResult.syncedAt`. No production path SHALL call `Date.now()` or `new Date()` directly; the default `Clock` is `Date.now`. #### Scenario: injected clock is honoured - **WHEN** a `MacOSContactsBridge` is constructed with `{ clock: () => 1_700_000_000_000 }` and `sync()` is awaited - **THEN** `result.syncedAt.getTime() === 1_700_000_000_000` **Verified by:** `src/bridge/bridge.test.ts::"sync uses the injected clock for syncedAt"` --- ### Requirement: ContactParser free-function surface The package SHALL expose `parseContacts`, `parseGroups`, and `normalizePhone` as standalone functions (not static methods on a class). Each function SHALL preserve upstream behaviour verbatim, including empty-input guards and malformed-block / malformed-line skipping. #### Scenario: parseContacts handles a fully populated block - **WHEN** `parseContacts` is called with a single contact block carrying phones, emails, and addresses - **THEN** the result is a one-element array with every field populated #### Scenario: parseContacts skips malformed blocks - **WHEN** `parseContacts` is called with input containing one valid block and one malformed block - **THEN** the result contains only the valid block #### Scenario: normalizePhone normalises to E.164-style format - **WHEN** `normalizePhone("(555) 123-4567")` is invoked - **THEN** the result is `"+15551234567"` **Verified by:** `src/parser/parser.test.ts::"parseContacts parses a single contact from raw output"` AND `src/parser/parser.test.ts::"parseContacts skips malformed contact blocks"` AND `src/parser/parser.test.ts::"normalizePhone strips formatting and adds +"` --- ### Requirement: AppleScript template builders The package SHALL expose four standalone template builders (`getAllContactsScript`, `searchContactsScript`, `getGroupsScript`, `getGroupMembersScript`). The two that accept user-supplied input SHALL escape embedded `"` characters before splicing them into AppleScript string literals. #### Scenario: searchContactsScript escapes double quotes - **WHEN** `searchContactsScript('John "Jack"')` is invoked - **THEN** the returned script contains `\"Jack\"` (the escaped form) and not the raw embedded `"` #### Scenario: getGroupMembersScript escapes double quotes - **WHEN** `getGroupMembersScript('"Family"')` is invoked - **THEN** the returned script contains the escaped form **Verified by:** `src/applescript/applescript.test.ts::"searchContactsScript escapes double quotes in the query"` AND `src/applescript/applescript.test.ts::"getGroupMembersScript escapes double quotes in the group name"` --- ### Requirement: Bridge — getAllContacts `MacOSContactsBridge.getAllContacts()` SHALL invoke the injected runner with the `getAllContactsScript()` payload and return the parsed `MacOSContact[]`. Runner failures SHALL be mapped through the error-mapping rule (permission patterns → `CONTACTS_PERMISSION_DENIED`, otherwise → `CONTACTS_ACCESS_FAILED`) and re-raised as `MacOSContactsError`. #### Scenario: success path - **WHEN** the runner returns the canonical fixture - **THEN** `getAllContacts()` resolves to the parsed contact list #### Scenario: permission denial - **WHEN** the runner rejects with `Error("not authorized")` - **THEN** `getAllContacts()` rejects with `MacOSContactsError(CONTACTS_PERMISSION_DENIED)` **Verified by:** `src/bridge/bridge.test.ts::"getAllContacts parses contacts from the runner"` AND `src/bridge/bridge.test.ts::"getAllContacts maps permission-denial errors"` --- ### Requirement: Bridge — searchContacts `MacOSContactsBridge.searchContacts(query)` SHALL short-circuit with `[]` when `query` is empty or whitespace. Otherwise it SHALL invoke the runner with `searchContactsScript(query)` and parse the result. Runner failures SHALL include the `query` discriminant on the raised `MacOSContactsError`. #### Scenario: empty query short-circuits - **WHEN** `searchContacts("")` or `searchContacts(" ")` is awaited - **THEN** the result is `[]` and the runner is not invoked #### Scenario: failure carries the query discriminant - **WHEN** the runner rejects and the query is `"Alice"` - **THEN** the raised `MacOSContactsError.query === "Alice"` **Verified by:** `src/bridge/bridge.test.ts::"searchContacts short-circuits on empty query"` AND `src/bridge/bridge.test.ts::"searchContacts attaches the query discriminant on failure"` --- ### Requirement: Bridge — getContactById `MacOSContactsBridge.getContactById(id)` SHALL short-circuit with `null` when `id` is empty or whitespace. Otherwise it SHALL fetch all contacts and return the first matching record or `null`. #### Scenario: empty id short-circuits - **WHEN** `getContactById("")` is awaited - **THEN** the result is `null` and the runner is not invoked #### Scenario: unknown id returns null - **WHEN** `getContactById("not-real")` is awaited and the runner returns a list without that id - **THEN** the result is `null` **Verified by:** `src/bridge/bridge.test.ts::"getContactById short-circuits on empty id"` AND `src/bridge/bridge.test.ts::"getContactById returns null when no contact matches"` AND `src/bridge/bridge.test.ts::"getContactById returns the matching contact"` --- ### Requirement: Bridge — getGroups + getGroupMembers `MacOSContactsBridge.getGroups()` SHALL invoke the runner with `getGroupsScript()` and parse the result via `parseGroups`. `MacOSContactsBridge.getGroupMembers(groupName)` SHALL short-circuit with `[]` when `groupName` is empty, otherwise invoke the runner with `getGroupMembersScript(groupName)` and parse via `parseContacts`. Failures SHALL include the `groupName` discriminant. #### Scenario: getGroups parses runner output - **WHEN** the runner returns the groups fixture - **THEN** `getGroups()` resolves to the parsed `ContactGroup[]` #### Scenario: getGroupMembers short-circuits on empty group name - **WHEN** `getGroupMembers("")` is awaited - **THEN** the result is `[]` #### Scenario: getGroupMembers attaches the group-name discriminant on failure - **WHEN** the runner rejects and the group name is `"Family"` - **THEN** the raised `MacOSContactsError.groupName === "Family"` **Verified by:** `src/bridge/bridge.test.ts::"getGroups parses groups from the runner"` AND `src/bridge/bridge.test.ts::"getGroupMembers short-circuits on empty group name"` AND `src/bridge/bridge.test.ts::"getGroupMembers attaches the groupName discriminant on failure"` --- ### Requirement: Bridge — sync never throws `MacOSContactsBridge.sync()` SHALL return a `SyncResult` even on failure: contacts are populated on success, `errors` carries a `SyncError` (with `code` set to the same string used by the error-mapping rule) on failure, and `syncedAt` reflects the injected `Clock`. #### Scenario: success path - **WHEN** the runner returns the canonical fixture - **THEN** `sync()` resolves with `contacts.length > 0`, `errors === []`, and `syncedAt` reflecting the clock #### Scenario: failure path - **WHEN** the runner rejects with a generic error - **THEN** `sync()` resolves with `contacts === []`, `errors[0].code === "MACOS_CONTACTS.CONTACTS_ACCESS_FAILED"`, and `total === 0` **Verified by:** `src/bridge/bridge.test.ts::"sync returns the contacts on success"` AND `src/bridge/bridge.test.ts::"sync captures errors instead of throwing"` AND `src/bridge/bridge.test.ts::"sync uses the injected clock for syncedAt"` --- ### Requirement: Bridge — isAvailable never throws `MacOSContactsBridge.isAvailable()` SHALL probe the runner with a harmless script and return `true` when stdout is non-empty, `false` otherwise. Runner exceptions SHALL be swallowed and reported as `false`. #### Scenario: runner success - **WHEN** the runner resolves with `"Finder"` - **THEN** `isAvailable()` resolves to `true` #### Scenario: runner failure - **WHEN** the runner rejects - **THEN** `isAvailable()` resolves to `false` **Verified by:** `src/bridge/bridge.test.ts::"isAvailable returns true when the runner resolves"` AND `src/bridge/bridge.test.ts::"isAvailable returns false when the runner rejects"` --- ### Requirement: Default platform runner `createDefaultAppleScriptRunner({ timeoutMs })` SHALL return an `AppleScriptRunner` that shells out to the platform `osascript` binary via `node:child_process.execFile`, threads `AbortSignal` to abort the child, applies the supplied timeout, and resolves with stdout. Non-zero exit SHALL reject so the bridge can map the failure. #### Scenario: missing binary surfaces as access-failed - **WHEN** the default runner is invoked with a deliberately-failing script - **THEN** it rejects, and the bridge wraps the rejection as `MacOSContactsError` #### Scenario: AbortSignal aborts the child - **WHEN** the default runner is invoked with a pre-aborted `AbortSignal` - **THEN** it rejects **Verified by:** `src/executor/executor.test.ts::"default runner rejects when osascript fails"` AND `src/executor/executor.test.ts::"default runner honours AbortSignal"` --- ### Requirement: Barrel and subpath-export hygiene Every feature directory under `src/` SHALL expose its symbols via an `index.ts` barrel using **explicit named re-exports** (no `export *`). The `package.json` SHALL declare one subpath export per feature module so consumers can tree-shake. #### Scenario: every feature index.ts uses named exports - **WHEN** the spec-conformance test walks `src/` and matches `^\s*export\s+(type\s+)?\*\s+from` - **THEN** zero offenders are reported #### Scenario: every subpath export resolves to a real source file - **WHEN** the spec-conformance test walks the `exports` map and `stat()`s each `.//.ts` source file - **THEN** every subpath resolves **Verified by:** `src/macos-contacts.spec-conformance.test.ts::"every feature index.ts uses explicit named exports"` AND `src/macos-contacts.spec-conformance.test.ts::"every declared subpath export resolves to a real source file"` --- ### Requirement: Test-runner import hygiene Every test file SHALL import runner primitives from `@suaveplan/testing/runner`. Direct imports from `vitest` or `bun:test` are banned (§7). #### Scenario: no direct vitest / bun:test imports - **WHEN** the spec-conformance test greps every `*.test.ts` for `from "vitest"` or `from "bun:test"` - **THEN** zero offenders are reported **Verified by:** `src/macos-contacts.spec-conformance.test.ts::"no test file imports from vitest or bun:test directly"`