Initial Commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,303 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: macos-contacts (migrate-macos-contacts)
|
||||
|
||||
**Package**: `@suaveplan/macos-contacts`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
68
openspec/archive/2026-05-20-migrate-macos-contacts/tasks.md
Normal file
68
openspec/archive/2026-05-20-migrate-macos-contacts/tasks.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Tasks — migrate @suaveplan/macos-contacts
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/macos-contacts.md` end-to-end
|
||||
- [x] 1.2 Read upstream source under `../suaveplan/packages/universal/macos-contacts/src/`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 0a, 0c, 6a, 7, 13, 13b, 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [x] 1.6 Draft `specs/macos-contacts/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/contacts/macos-contacts/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`, `tests/setup/polyfills.ts`
|
||||
- [x] 2.2 `src/error-codes/error-codes.ts` — `MACOS_CONTACTS_CODES` via `defineCodes`, `MacOSContactsError extends SuaveplanError<MacOSContactsErrorCode>` with `query` / `groupName` / `contactId` discriminants
|
||||
- [x] 2.3 `src/otel/instruments.ts` — `getMacOSContactsInstruments` / `resetMacOSContactsInstruments` with six counters + two histograms
|
||||
- [x] 2.4 `src/schemas/schemas.ts` — `macOSContactsConfigSchema`, `searchQuerySchema`, `groupNameSchema`, `contactIdSchema`, `parseOrThrow` helper
|
||||
- [x] 2.5 `src/types/macos-contacts.types.ts` — entity types preserved verbatim
|
||||
- [x] 2.6 `src/parser/parser.ts` — `parseContacts`, `parseGroups`, `normalizePhone` as free functions
|
||||
- [x] 2.7 `src/applescript/applescript.ts` — four template builders preserved verbatim
|
||||
- [x] 2.8 `src/executor/executor.ts` — `AppleScriptRunner` interface + `createDefaultAppleScriptRunner` `execFile`-based impl, threads `AbortSignal`
|
||||
- [x] 2.9 `src/bridge/bridge.ts` — `MacOSContactsBridge` using injected runner + clock, error mapping, OTel emission
|
||||
- [x] 2.10 `src/index.ts` — explicit named re-exports from every feature barrel; side-effect import of error-codes module
|
||||
- [x] 2.11 Every feature `index.ts` uses explicit named re-exports (§6a)
|
||||
- [x] 2.12 `package.json` subpath exports for every feature module
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/error-codes/error-codes.test.ts` — every code constructs; discriminants flow through
|
||||
- [x] 3.2 `src/otel/instruments.test.ts` — every counter + histogram fires via real `MeterProvider`
|
||||
- [x] 3.3 `src/schemas/schemas.test.ts` — every schema accepts canonical input, rejects malformed input with `MacOSContactsError(INVALID_INPUT)`
|
||||
- [x] 3.4 `src/parser/parser.test.ts` — every parser branch including malformed-skip and empty-input
|
||||
- [x] 3.5 `src/applescript/applescript.test.ts` — every template builder including `"` escape
|
||||
- [x] 3.6 `src/executor/executor.test.ts` — failure path of `createDefaultAppleScriptRunner` via deliberately-failing script; conditionally runs the success path on platforms with `osascript`
|
||||
- [x] 3.7 `src/bridge/bridge.test.ts` — every method with an injected fake runner; error mapping; sync swallows errors; isAvailable swallows errors
|
||||
- [x] 3.8 `src/macos-contacts.spec-conformance.test.ts` — pins barrel shape, subpath exports, error-code wiring, no direct `vitest` imports
|
||||
- [x] 3.9 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 README links every co-located `.md` under `## Modules`
|
||||
- [x] 4.3 Update `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0 errors / 0 warnings across the monorepo
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/macos-contacts` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/macos-contacts` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/macos-contacts` — exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/macos-contacts` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded; `MacOSContactsError extends SuaveplanError<MacOSContactsErrorCode>` narrows `code`
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary (bridge constructor + `searchContacts` + `getContactById` + `getGroupMembers`)
|
||||
- [x] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
|
||||
- [x] 6.4 Strict-mode behaviors tested both on and off (default deduplicate-by-channel + opt-out, default clock + injected clock)
|
||||
- [x] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [x] 6.6 Lifecycle dedup tested where claimed (instruments bundle cached across `getMacOSContactsInstruments` calls)
|
||||
- [x] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/macos-contacts/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/migrate-macos-contacts/` → `openspec/archive/2026-05-20-migrate-macos-contacts/`
|
||||
- [x] 7.3 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user