15 KiB
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<MacOSContactsErrorCode> 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 ofMACOS_CONTACTS_CODES - THEN the resulting error narrows
codeto the namespaced string"MACOS_CONTACTS.*"and exposes the discriminants
Scenario: every code is thrown from production
- WHEN
openspec-conformancegrepssrc/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
MacOSContactsErrorwithcode === "MACOS_CONTACTS.INVALID_INPUT"is thrown
Scenario: parseOrThrow wraps ZodError
- WHEN
parseOrThrow(searchQuerySchema, "", "label")is called - THEN it throws
MacOSContactsError(INVALID_INPUT)with theZodErrorascause
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
MacOSContactsBridgerunsgetAllContacts,searchContacts,getContactById,getGroups,getGroupMembers, andsyncwith a fake runner against a real in-memoryMeterProvider - 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<string> }) 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
MacOSContactsBridgeis constructed with{ clock: () => 1_700_000_000_000 }andsync()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
parseContactsis 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
parseContactsis 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 withMacOSContactsError(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("")orsearchContacts(" ")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
nulland 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 parsedContactGroup[]
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 withcontacts.length > 0,errors === [], andsyncedAtreflecting the clock
Scenario: failure path
- WHEN the runner rejects with a generic error
- THEN
sync()resolves withcontacts === [],errors[0].code === "MACOS_CONTACTS.CONTACTS_ACCESS_FAILED", andtotal === 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 totrue
Scenario: runner failure
- WHEN the runner rejects
- THEN
isAvailable()resolves tofalse
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
exportsmap andstat()s each./<feature>/<feature>.tssource 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.tsforfrom "vitest"orfrom "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"