Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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 -->