Files
2026-05-27 23:29:53 +01:00

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 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<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 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 ./<feature>/<feature>.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"