Initial Commit
This commit is contained in:
436
openspec/archive/2026-05-25-enterprise-sms/specs/sms/spec.md
Normal file
436
openspec/archive/2026-05-25-enterprise-sms/specs/sms/spec.md
Normal file
@@ -0,0 +1,436 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: sms (sms)
|
||||
|
||||
**Package**: `@suaveplan/sms`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public package surface
|
||||
|
||||
The package SHALL export, from its root barrel `src/index.ts`, the
|
||||
full upstream surface: `SmsService`, the `SmsProvider` interface,
|
||||
`SmsMessage`, `SmsDeliveryReport`, `SmsDeliveryStatus`,
|
||||
`OptOutEntry`, `SmsTemplate`, `SmsEventMap`, the legacy `SmsError`
|
||||
class with its factory methods, `SmsErrorCode`, `SmsErrorContext`,
|
||||
`SmsErrorOptions`, `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
|
||||
`registerSmsErrorCodes`, `SMS_ERROR_METADATA`, and
|
||||
`SmsRegisteredErrorCode`. It SHALL additionally export the genesis
|
||||
additions: `SMS_CODES`, every leaf error class, every Zod schema,
|
||||
`TemplateRegistry`, `OptOutRegistry`, `RateLimiter`, `RetryPolicy`,
|
||||
`MessageScheduler`, `DeliveryTracker`, `InMemorySmsProvider`,
|
||||
`renderTemplate`, `isValidE164`, and the OTel `record*` helpers.
|
||||
|
||||
#### Scenario: every upstream symbol is re-exported
|
||||
|
||||
- **WHEN** a consumer imports from `@suaveplan/sms`
|
||||
- **THEN** `SmsService`, `SmsProvider` (type), `SmsMessage` (type),
|
||||
`SmsDeliveryReport` (type), `SmsDeliveryStatus` (type),
|
||||
`OptOutEntry` (type), `SmsTemplate` (type), `SmsEventMap` (type),
|
||||
`SmsError`, `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
|
||||
`registerSmsErrorCodes`, `SMS_ERROR_METADATA` are all defined
|
||||
|
||||
**Verified by:** `src/service/sms-service.test.ts::"re-exports the full upstream surface"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable SMS error-code registry
|
||||
|
||||
The package SHALL register exactly thirteen error codes via
|
||||
`defineCodes("SMS", [...])`: `INVALID_NUMBER`, `INVALID_CONFIG`,
|
||||
`PROVIDER_NOT_CONFIGURED`, `PROVIDER_ERROR`, `SEND_FAILED`,
|
||||
`TEMPLATE_NOT_FOUND`, `RECIPIENT_OPTED_OUT`, `RATE_LIMITED`,
|
||||
`FORBIDDEN`, `CONFIG_MISSING`, `SCHEDULE_CANCELLED`, `ABORTED`,
|
||||
`RECEIPT_NOT_FOUND`. Every code SHALL be reachable from at least
|
||||
one production call site and SHALL be asserted by a test that
|
||||
triggers the real call site.
|
||||
|
||||
#### Scenario: every code is reachable from a production call site
|
||||
|
||||
- **WHEN** the package is fully built
|
||||
- **THEN** for each of the thirteen codes there exists at least one
|
||||
test that triggers the production call site whose `throw` (or
|
||||
caught error) carries that code
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every SMS code is reachable from a production call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Legacy SmsError back-compat
|
||||
|
||||
The package SHALL preserve the upstream `SmsError` class with the
|
||||
exact `SmsErrorCode` union (`SMS_INVALID_PHONE_NUMBER`,
|
||||
`SMS_SEND_FAILED`, `SMS_TEMPLATE_NOT_FOUND`,
|
||||
`SMS_RECIPIENT_OPTED_OUT`, `SMS_PROVIDER_NOT_CONFIGURED`) and the
|
||||
five static factory methods (`invalidPhoneNumber`, `sendFailed`,
|
||||
`templateNotFound`, `recipientOptedOut`, `providerNotConfigured`)
|
||||
plus the `isSmsError` type guard. The legacy class SHALL also carry
|
||||
the same `SmsErrorContext` and `SmsErrorOptions` shapes.
|
||||
|
||||
#### Scenario: legacy factories return SmsError instances
|
||||
|
||||
- **WHEN** `SmsError.invalidPhoneNumber("xx")` is called
|
||||
- **THEN** the returned value is `instanceof SmsError`, `code` is
|
||||
`"SMS_INVALID_PHONE_NUMBER"`, and `SmsError.isSmsError(value)` is
|
||||
`true`
|
||||
|
||||
**Verified by:** `src/legacy-errors/legacy-errors.test.ts::"factory methods return the right code"` AND `src/legacy-errors/legacy-errors.test.ts::"isSmsError narrows the type"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Legacy metadata registration
|
||||
|
||||
The package SHALL ship `SMS_ERROR_METADATA` containing the six
|
||||
upstream metadata entries (`SMS_SEND_FAILED`, `SMS_PROVIDER_ERROR`,
|
||||
`SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`, `SMS_CONFIG_MISSING`,
|
||||
`SMS_FORBIDDEN`) with the same `httpStatus` / `severity` / `retry` /
|
||||
`userMessageKey` / `category` / `description` fields, and SHALL
|
||||
register every entry on the shared `ErrorCodes` singleton via
|
||||
`registerSmsErrorCodes()` at module load time. The function SHALL
|
||||
be idempotent.
|
||||
|
||||
#### Scenario: SMS_RATE_LIMITED maps to HTTP 429
|
||||
|
||||
- **WHEN** `SMS_ERROR_METADATA.SMS_RATE_LIMITED` is read after
|
||||
`registerSmsErrorCodes()` has run
|
||||
- **THEN** the entry has `httpStatus === 429`, `severity ===
|
||||
"warning"`, and `retry.retryable === true`
|
||||
|
||||
**Verified by:** `src/legacy-metadata/legacy-metadata.test.ts::"every metadata entry preserves the upstream shape"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: E.164 phone validation
|
||||
|
||||
`isValidE164(value)` SHALL return `true` only when `value` matches
|
||||
`/^\+[1-9]\d{1,14}$/`. The service SHALL reject every `send()` call
|
||||
whose `message.to` fails this check by throwing
|
||||
`SmsInvalidNumberError` (code `SMS.INVALID_NUMBER`).
|
||||
|
||||
#### Scenario: valid +15551234567 passes
|
||||
|
||||
- **WHEN** `isValidE164("+15551234567")` is called
|
||||
- **THEN** the return value is `true`
|
||||
|
||||
#### Scenario: missing leading + is rejected
|
||||
|
||||
- **WHEN** `isValidE164("15551234567")` is called
|
||||
- **THEN** the return value is `false`
|
||||
|
||||
#### Scenario: leading +0 is rejected
|
||||
|
||||
- **WHEN** `isValidE164("+0123456789")` is called
|
||||
- **THEN** the return value is `false`
|
||||
|
||||
**Verified by:** `src/validation/phone.test.ts::"accepts E.164 numbers"` AND `src/validation/phone.test.ts::"rejects non-E.164 inputs"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary on public inputs
|
||||
|
||||
`schemas/schemas.ts` SHALL export Zod schemas for `SmsMessage`,
|
||||
`SmsTemplate`, `SmsDeliveryReport`, and `SmsServiceOptions`. The
|
||||
parser helpers `parseSmsMessage` and `parseSmsTemplate` SHALL throw
|
||||
`SmsInvalidConfigError` (code `SMS.INVALID_CONFIG`) with
|
||||
`context.issues` containing the Zod issues on rejection.
|
||||
|
||||
#### Scenario: empty body is rejected
|
||||
|
||||
- **WHEN** `parseSmsMessage({ to: "+15551234567", body: "" })` is
|
||||
called
|
||||
- **THEN** the call throws `SmsInvalidConfigError` whose `code` is
|
||||
`SMS.INVALID_CONFIG` and whose `context.issues` is a non-empty
|
||||
array
|
||||
|
||||
**Verified by:** `src/schemas/schemas.test.ts::"rejects empty body"` AND `src/schemas/schemas.test.ts::"rejects non-E.164 to"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Template registry and render
|
||||
|
||||
`TemplateRegistry` SHALL provide `register(template)`,
|
||||
`get(id)`, `has(id)`, `unregister(id)`, and `list()`. `renderTemplate(template, vars, defaults?)` SHALL replace every `{{var}}` placeholder declared in `template.variables` with `vars[var] ?? defaults?.[var] ?? ""`.
|
||||
|
||||
#### Scenario: rendering substitutes declared variables
|
||||
|
||||
- **WHEN** `renderTemplate({ id: "x", body: "Hello {{name}}", variables: ["name"] }, { name: "Alice" })` is called
|
||||
- **THEN** the returned string is `"Hello Alice"`
|
||||
|
||||
#### Scenario: missing variables default to empty string
|
||||
|
||||
- **WHEN** `renderTemplate({ id: "x", body: "Hi {{name}}", variables: ["name"] }, {})` is called
|
||||
- **THEN** the returned string is `"Hi "`
|
||||
|
||||
#### Scenario: defaults map fills missing variables
|
||||
|
||||
- **WHEN** `renderTemplate({ id: "x", body: "Hi {{name}}", variables: ["name"] }, {}, { name: "World" })` is called
|
||||
- **THEN** the returned string is `"Hi World"`
|
||||
|
||||
**Verified by:** `src/templates/templates.test.ts::"substitutes declared variables"` AND `src/templates/templates.test.ts::"missing variables fall back to defaults then empty string"` AND `src/templates/templates.test.ts::"registry register/get/has/unregister/list"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Opt-out registry
|
||||
|
||||
`OptOutRegistry` SHALL provide `add(phoneNumber, channel?)`,
|
||||
`remove(phoneNumber)`, `has(phoneNumber)`, `list()`,
|
||||
`importEntries(entries)`, and `clear()`. `SmsService` SHALL reject
|
||||
every `send()` to an opted-out recipient with
|
||||
`SmsRecipientOptedOutError` (code `SMS.RECIPIENT_OPTED_OUT`).
|
||||
|
||||
#### Scenario: opt-out blocks send
|
||||
|
||||
- **WHEN** a recipient is added via `optOut(phone)` and `service.send({to: phone, ...})` is called
|
||||
- **THEN** the send throws `SmsRecipientOptedOutError`
|
||||
|
||||
#### Scenario: opt-in clears the registry entry
|
||||
|
||||
- **WHEN** `optIn(phone)` is called for an opted-out recipient
|
||||
- **THEN** `isOptedOut(phone)` returns `false`
|
||||
|
||||
**Verified by:** `src/opt-out/opt-out.test.ts::"add/remove/has/list/clear"` AND `src/service/sms-service.test.ts::"opt-out blocks send"` AND `src/service/sms-service.test.ts::"opt-in unblocks send"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Rate limiter
|
||||
|
||||
`RateLimiter` SHALL implement a token bucket with `capacity` and
|
||||
`refillPerSecond`, keyed by an arbitrary string key. `tryConsume(key)` SHALL return `true` when a token is available and `false` otherwise, refilling tokens against an injectable `Clock`. `SmsService` SHALL throw `SmsRateLimitedError` (code `SMS.RATE_LIMITED`) when the limiter rejects.
|
||||
|
||||
#### Scenario: bucket exhausts after capacity tokens
|
||||
|
||||
- **WHEN** `tryConsume("k")` is called `capacity + 1` times in a row without time advancing
|
||||
- **THEN** the first `capacity` calls return `true` and the last returns `false`
|
||||
|
||||
#### Scenario: time advances refill tokens
|
||||
|
||||
- **WHEN** the bucket is exhausted and the injected clock is advanced by `1000ms` with `refillPerSecond = 1`
|
||||
- **THEN** the next `tryConsume("k")` returns `true`
|
||||
|
||||
**Verified by:** `src/rate-limiter/rate-limiter.test.ts::"exhausts after capacity"` AND `src/rate-limiter/rate-limiter.test.ts::"refills on clock advance"` AND `src/service/sms-service.test.ts::"rate-limited recipient throws SmsRateLimitedError"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Retry policy
|
||||
|
||||
`RetryPolicy` SHALL retry a thrown attempt up to
|
||||
`maxAttempts` times with exponential backoff
|
||||
(`initialDelayMs * 2^(attempt - 1)`, capped at `maxDelayMs`) and
|
||||
full jitter. Each retry SHALL be awaited against an injectable
|
||||
`Timers.delay(ms, signal)`. An aborted retry SHALL throw
|
||||
`SmsAbortedError` (code `SMS.ABORTED`).
|
||||
|
||||
#### Scenario: succeeds on second attempt
|
||||
|
||||
- **WHEN** a callback fails once then succeeds
|
||||
- **THEN** the policy returns the success value and emits one retry event
|
||||
|
||||
#### Scenario: exhausts attempts and rethrows
|
||||
|
||||
- **WHEN** a callback fails on every attempt
|
||||
- **THEN** the policy rethrows the last error after `maxAttempts` invocations
|
||||
|
||||
#### Scenario: abort signal halts retries
|
||||
|
||||
- **WHEN** the signal aborts between attempts
|
||||
- **THEN** the policy throws `SmsAbortedError`
|
||||
|
||||
**Verified by:** `src/retry/retry.test.ts::"succeeds on second attempt"` AND `src/retry/retry.test.ts::"exhausts attempts"` AND `src/retry/retry.test.ts::"abort halts retries"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Message scheduler
|
||||
|
||||
`MessageScheduler.scheduleAt(date, fn)` SHALL invoke `fn` at the
|
||||
target time through an injectable `Timers.setTimeout`-like seam and
|
||||
return a `ScheduleHandle` with `cancel()`. A cancelled handle SHALL
|
||||
not invoke `fn`. `SmsService.scheduleAt(date, message)` SHALL emit
|
||||
`sms.scheduled` on the bus when the message is queued, and route
|
||||
the eventual send through the full pipeline. A cancelled schedule
|
||||
SHALL emit `sms.failed` with code `SMS.SCHEDULE_CANCELLED`.
|
||||
|
||||
#### Scenario: scheduled message fires at the target time
|
||||
|
||||
- **WHEN** a message is scheduled for `now + 100ms` and the injected timer fires
|
||||
- **THEN** the provider's `send` is invoked exactly once
|
||||
|
||||
#### Scenario: cancelled schedule does not fire
|
||||
|
||||
- **WHEN** `cancel()` is called before the timer fires
|
||||
- **THEN** the provider's `send` is not invoked
|
||||
|
||||
**Verified by:** `src/scheduler/scheduler.test.ts::"fires at the target time"` AND `src/scheduler/scheduler.test.ts::"cancel prevents firing"` AND `src/service/sms-service.test.ts::"scheduleAt routes through the pipeline"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Delivery tracker
|
||||
|
||||
`DeliveryTracker` SHALL accept incoming delivery receipts via
|
||||
`ingest(report)` and return the latest cached status via
|
||||
`getStatus(messageId)`. The cache SHALL be bounded by an LRU policy
|
||||
with configurable `maxEntries` (default `1024`). `lookupOrFetch(messageId, provider)` SHALL fall back to the provider's `getStatus`
|
||||
when no cached entry exists, caching the result. Cache miss with no
|
||||
provider SHALL throw `SmsReceiptNotFoundError` (code
|
||||
`SMS.RECEIPT_NOT_FOUND`).
|
||||
|
||||
#### Scenario: ingested receipt is retrievable
|
||||
|
||||
- **WHEN** a report with `messageId: "m1"` is ingested then
|
||||
`getStatus("m1")` is called
|
||||
- **THEN** the returned report deep-equals the ingested report
|
||||
|
||||
#### Scenario: LRU eviction drops the oldest entry
|
||||
|
||||
- **WHEN** `maxEntries = 2` and three reports `m1`, `m2`, `m3` are
|
||||
ingested in order
|
||||
- **THEN** `getStatus("m1")` returns `undefined`
|
||||
|
||||
**Verified by:** `src/delivery-tracker/delivery-tracker.test.ts::"ingest/getStatus round-trip"` AND `src/delivery-tracker/delivery-tracker.test.ts::"LRU eviction"` AND `src/delivery-tracker/delivery-tracker.test.ts::"lookupOrFetch falls back to provider"` AND `src/delivery-tracker/delivery-tracker.test.ts::"miss without provider throws receipt-not-found"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: In-memory provider
|
||||
|
||||
`InMemorySmsProvider` SHALL implement `SmsProvider` with `name` =
|
||||
`"in-memory"`, deliver every message synchronously into a `sent`
|
||||
log, and return a `SmsDeliveryReport` with `status` controlled by
|
||||
the constructor's `defaultStatus` (default `"sent"`). It SHALL
|
||||
expose `sent`, `clear()`, `setStatus(messageId, status)`, and
|
||||
`failOn(predicate, error)` so tests can simulate provider failures
|
||||
and status transitions without external services.
|
||||
|
||||
#### Scenario: send appends to the sent log
|
||||
|
||||
- **WHEN** `provider.send({ to: "+15551234567", body: "x" })` is
|
||||
called
|
||||
- **THEN** `provider.sent` has length 1 and the entry's `to` is
|
||||
`"+15551234567"`
|
||||
|
||||
#### Scenario: failOn triggers a thrown error
|
||||
|
||||
- **WHEN** `provider.failOn(msg => msg.to === "+15550000000", new Error("boom"))` is set and `send({to: "+15550000000", body: "x"})` is called
|
||||
- **THEN** the call rejects with the configured error
|
||||
|
||||
**Verified by:** `src/in-memory-provider/in-memory-provider.test.ts::"send appends to log"` AND `src/in-memory-provider/in-memory-provider.test.ts::"failOn triggers"` AND `src/in-memory-provider/in-memory-provider.test.ts::"setStatus transitions delivery state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instruments
|
||||
|
||||
The package SHALL register four OTel instruments on the
|
||||
`@suaveplan/sms` meter: counters `sms.sent_total`,
|
||||
`sms.failed_total`, `sms.retry_total`, and histogram
|
||||
`sms.send_duration_ms`. The module SHALL expose `record*` helpers
|
||||
and a `refreshSmsMeters()` re-bind for tests.
|
||||
|
||||
#### Scenario: successful send records sent_total + send_duration_ms
|
||||
|
||||
- **WHEN** `service.send(validMessage)` is called against an
|
||||
in-memory provider
|
||||
- **THEN** an in-memory metric exporter reads at least one data
|
||||
point on `sms.sent_total` and one on `sms.send_duration_ms`
|
||||
|
||||
**Verified by:** `src/otel/metrics.test.ts::"refresh re-binds against the global provider"` AND `src/service/sms-service.test.ts::"send records sent_total and send_duration_ms"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Typed EventBus + legacy on/off mirror
|
||||
|
||||
`SmsService` SHALL expose a typed `EventBus<SmsEventMap>` field
|
||||
named `bus`, AND a legacy `on/off/emit` surface that mirrors against
|
||||
the bus. Topics: `sms.sent`, `sms.delivered`, `sms.failed`,
|
||||
`sms.opted_out`, `sms.opted_in`, `sms.scheduled`, `sms.retry`,
|
||||
`sms.rate_limited`.
|
||||
|
||||
#### Scenario: legacy on/off mirrors the bus
|
||||
|
||||
- **WHEN** a listener is registered via `service.on("sms.sent", h)`
|
||||
and the service emits `sms.sent`
|
||||
- **THEN** the handler is invoked exactly once with the report
|
||||
|
||||
#### Scenario: bus.on receives the same payload
|
||||
|
||||
- **WHEN** `service.bus.on("sms.sent", h)` is registered before a
|
||||
send
|
||||
- **THEN** the bus handler is invoked with the same report payload
|
||||
|
||||
**Verified by:** `src/service/sms-service.test.ts::"legacy on/off mirrors the bus"` AND `src/service/sms-service.test.ts::"bus receives emitted reports"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Abort signal propagation
|
||||
|
||||
`SmsService.send(message, { signal })` SHALL propagate the abort
|
||||
signal through the rate limiter, retry policy, and provider. An
|
||||
abort BEFORE the provider call SHALL throw `SmsAbortedError`. An
|
||||
abort that lands inside a retry delay SHALL throw `SmsAbortedError`
|
||||
without invoking the next attempt.
|
||||
|
||||
#### Scenario: pre-aborted signal halts send immediately
|
||||
|
||||
- **WHEN** `service.send(msg, { signal: AbortSignal.abort() })` is
|
||||
called
|
||||
- **THEN** the call throws `SmsAbortedError` (code `SMS.ABORTED`)
|
||||
and the provider's `send` is not invoked
|
||||
|
||||
**Verified by:** `src/service/sms-service.test.ts::"pre-aborted signal halts send"` AND `src/retry/retry.test.ts::"abort halts retries"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Hono problem+json helper
|
||||
|
||||
`respondProblemJson(c, err)` SHALL serialise any `BaseError` /
|
||||
`HttpError` / `SuaveplanError` / unknown throwable into an RFC 7807
|
||||
`application/problem+json` response on the Hono `Context`-like
|
||||
surface. The helper SHALL preserve the `httpStatus` registered by
|
||||
`SMS_ERROR_METADATA` when the error's code is registered in the
|
||||
shared `ErrorCodes` singleton, and SHALL fall back to `500` for
|
||||
unknown errors.
|
||||
|
||||
#### Scenario: registered SMS code preserves its httpStatus
|
||||
|
||||
- **WHEN** `respondProblemJson(c, new SmsError({code: "SMS_RATE_LIMITED", message: "x"}))` is called
|
||||
- **THEN** the response body's `status` field is `429`
|
||||
|
||||
**Verified by:** `src/routes/problem-json.test.ts::"registered code preserves httpStatus"` AND `src/routes/problem-json.test.ts::"unknown error falls back to 500"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature
|
||||
|
||||
The `package.json` `exports` map SHALL expose a tree-shakeable
|
||||
subpath for every feature module: `./error-codes`,
|
||||
`./legacy-errors`, `./legacy-metadata`, `./schemas`,
|
||||
`./validation/phone`, `./templates`, `./opt-out`,
|
||||
`./rate-limiter`, `./retry`, `./scheduler`,
|
||||
`./delivery-tracker`, `./in-memory-provider`, `./service`,
|
||||
`./routes/problem-json`, `./otel`.
|
||||
|
||||
#### Scenario: feature subpaths resolve
|
||||
|
||||
- **WHEN** the package is fully built
|
||||
- **THEN** `Object.keys(pkg.exports).filter(k => k.startsWith("./")).length >= 15`
|
||||
|
||||
**Verified by:** `src/service/sms-service.test.ts::"feature subpaths are declared in package.json"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL not import any Node-only or Bun-only API
|
||||
(`node:*`, `bun:*`). It SHALL rely only on `globalThis.setTimeout`
|
||||
/ `clearTimeout` (universally available) via an injectable
|
||||
`Timers` seam.
|
||||
|
||||
#### Scenario: no node: / bun: imports in src/
|
||||
|
||||
- **WHEN** the repository is grepped for `from ['\"]node:|from ['\"]bun:`
|
||||
under `packages/communication/messaging/sms/src/`
|
||||
- **THEN** the grep yields zero matches
|
||||
|
||||
**Verified by:** `src/service/sms-service.test.ts::"no node: or bun: imports leak into src/"`
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user