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

420 lines
24 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency (add-currency)
**Package**: `@suaveplan/currency`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Universal-tier purity
The package SHALL ship as a universal-tier package — no imports from `node:*`,
`bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in
any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun,
modern browsers, and Web Workers. The `package.json` SHALL NOT declare a
`"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL
declare `"sideEffects": false`.
#### Scenario: no platform imports in src/
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/currency/src/` is run
- **THEN** the command produces zero matches
**Verified by:** `src/converter/converter.test.ts::"has no node/bun platform imports (tier purity)"` AND `src/rate-cache/rate-cache.test.ts::"has no node/bun platform imports (tier purity)"`
---
### Requirement: Stable CURRENCY_CODES error registry and CurrencyError class
The package SHALL declare an error-code registry via `defineCodes("CURRENCY", [...])`
containing exactly the codes `INVALID_CONFIG`, `INVALID_OPTIONS`,
`PROVIDER_UNAVAILABLE`, `UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`,
`TRIANGULATION_IMPOSSIBLE`, `ABORTED`. Every public error path SHALL throw a
`CurrencyError extends SuaveplanError<CurrencyErrorCode>` carrying one of those
codes. `CurrencyError` SHALL expose optional `from`, `to`, and `providerName`
discriminants for diagnostics. The package SHALL NOT throw raw `Error`
instances from any public method.
#### Scenario: every code is reachable from a call site
- **WHEN** the test suite exercises invalid configuration, invalid call-site
options, an all-providers-failing fallback chain, conversion for an
unregistered currency, a missing rate after fetch, a triangulation that
cannot be satisfied, and an aborted call
- **THEN** each path raises a `CurrencyError` whose `code` matches the expected
`CURRENCY.*` constant
#### Scenario: SuaveplanError contract preserved
- **WHEN** a `CurrencyError` is thrown with a `cause`
- **THEN** the resulting error is `instanceof SuaveplanError`, exposes the
original on `error.cause`, and the wrapped code remains stable
**Verified by:** `src/error-codes/error-codes.test.ts::"every CURRENCY.* code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"CurrencyError preserves cause and SuaveplanError contract"` AND `src/error-codes/error-codes.test.ts::"CurrencyError carries from, to, and providerName discriminants"`
---
### Requirement: Pluggable CurrencyProvider interface
The package SHALL declare a `CurrencyProvider` interface with `readonly name: string`,
`readonly supportsCrypto: boolean`, `readonly supportsFiat: boolean`,
optional `readonly supportsVirtual?: boolean`, and `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`.
The package SHALL ship an `InMemoryProvider` reference implementation (with
`setRates(base, rates)`, `setRate(from, to, rate)`) and a
`createStaticProvider(name, rateMap, options?)` factory returning a frozen
`CurrencyProvider`. Both reference providers SHALL honour
`RateFetchOptions.base`, `RateFetchOptions.targets`, and
`RateFetchOptions.signal` (raising `CurrencyError({ code: CURRENCY.ABORTED })`
when the signal has fired). When no rates are configured for the requested
base, both reference providers SHALL raise
`CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE, providerName })`.
#### Scenario: InMemoryProvider returns set rates
- **WHEN** `provider.setRates("USD", { EUR: 0.92, GBP: 0.79 })` is called and `provider.fetchRates({ base: "USD" })` is awaited
- **THEN** the result is `{ base: "USD", rates: { EUR: 0.92, GBP: 0.79 }, timestamp: <number> }`
#### Scenario: InMemoryProvider targets filter
- **WHEN** `provider.fetchRates({ base: "USD", targets: ["EUR"] })` is called against a provider that has both EUR and GBP rates
- **THEN** the result's `rates` map contains only `EUR`
#### Scenario: missing base raises PROVIDER_UNAVAILABLE
- **WHEN** `provider.fetchRates({ base: "ZZZ" })` is called against a provider with no `ZZZ` rates
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.PROVIDER_UNAVAILABLE"` and `providerName === provider.name`
#### Scenario: aborted fetch raises ABORTED
- **WHEN** `provider.fetchRates({ base: "USD", signal })` is called with an already-aborted `AbortSignal`
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
#### Scenario: createStaticProvider produces the same contract
- **WHEN** `createStaticProvider("static", { USD: { EUR: 0.92 } }).fetchRates({ base: "USD" })` is called
- **THEN** the resolved result has `base === "USD"` and `rates.EUR === 0.92`, and the provider exposes `name === "static"`
**Verified by:** `src/provider/provider.test.ts::"InMemoryProvider returns set rates"` AND `src/provider/provider.test.ts::"InMemoryProvider filters by targets"` AND `src/provider/provider.test.ts::"InMemoryProvider raises PROVIDER_UNAVAILABLE for missing base"` AND `src/provider/provider.test.ts::"InMemoryProvider raises ABORTED when signal already aborted"` AND `src/provider/provider.test.ts::"createStaticProvider returns the same contract"` AND `src/provider/provider.test.ts::"createStaticProvider raises PROVIDER_UNAVAILABLE for missing base"` AND `src/provider/provider.test.ts::"createStaticProvider raises ABORTED when signal already aborted"` AND `src/provider/provider.test.ts::"InMemoryProvider setRate adds a single pair"`
---
### Requirement: RateCache TTL eviction and triangulated lookup
The `RateCache` class SHALL store rate fetch results keyed by base currency
with a configurable TTL (default `3_600_000` ms). `get(base)` and `has(base)`
SHALL return undefined / false when the entry is expired and SHALL delete the
expired entry as a side effect. `getRate(from, to)` SHALL resolve to a number
when (a) `from === to` (returns `1`), (b) the cache has a direct entry for
`from` with a `to` rate, (c) the cache has an inverse entry for `to` with a
`from` rate (returns `1 / rate`), or (d) any cached non-expired base entry
contains both `from` and `to` (returns `toRate / fromRate`). Otherwise it
SHALL return `undefined`. `prune()` SHALL remove every expired entry and
return the count removed. `size` SHALL return the underlying Map size
(including expired entries). `getTimestamp(base)` SHALL return the underlying
`result.timestamp` when the entry is fresh and `undefined` otherwise.
#### Scenario: fresh entry round-trip
- **WHEN** `set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: <now> })` followed by `get("USD")` is called
- **THEN** the cached entry is returned with the same `base` / `rates` / `timestamp`
#### Scenario: expired entry returns undefined and is deleted
- **WHEN** an entry is stored with TTL 0 and `get(base)` is called after a microtask
- **THEN** `get` returns `undefined` and `size` drops by 1
#### Scenario: direct rate lookup
- **WHEN** `getRate("USD", "EUR")` is called against a cache with a `USD` base entry containing `EUR: 0.92`
- **THEN** the result is `0.92`
#### Scenario: inverse rate lookup
- **WHEN** `getRate("EUR", "USD")` is called against a cache that only has a `USD` base entry containing `EUR: 0.5`
- **THEN** the result is `2` (1 / 0.5)
#### Scenario: triangulated lookup via intermediate base
- **WHEN** `getRate("EUR", "JPY")` is called against a cache whose only entry is `USD` base with `{ EUR: 0.5, JPY: 100 }`
- **THEN** the result is `200` (100 / 0.5)
#### Scenario: identity rate
- **WHEN** `getRate("USD", "USD")` is called
- **THEN** the result is `1`
#### Scenario: no path returns undefined
- **WHEN** `getRate("EUR", "JPY")` is called against an empty cache
- **THEN** the result is `undefined`
#### Scenario: prune removes expired entries
- **WHEN** the cache has 3 entries, 2 of which have a past `expiresAt`, and `prune()` is called
- **THEN** `prune` returns `2` and `size` is `1`
#### Scenario: getTimestamp returns fresh timestamp
- **WHEN** an entry is stored with `timestamp: 1234` and `getTimestamp(base)` is called before expiry
- **THEN** the result is `1234`; after expiry, the result is `undefined`
#### Scenario: clear empties the cache
- **WHEN** `clear()` is called after storing one or more entries
- **THEN** `size` is `0` and every subsequent `get(base)` returns `undefined`
**Verified by:** `src/rate-cache/rate-cache.test.ts::"stores and retrieves a fresh entry"` AND `src/rate-cache/rate-cache.test.ts::"deletes expired entries on get"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns direct rate"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns inverse rate"` AND `src/rate-cache/rate-cache.test.ts::"getRate triangulates via cached base"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns 1 for identity pair"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns undefined when no path exists"` AND `src/rate-cache/rate-cache.test.ts::"prune removes expired entries and returns count"` AND `src/rate-cache/rate-cache.test.ts::"getTimestamp returns fresh timestamp and undefined when expired"` AND `src/rate-cache/rate-cache.test.ts::"clear empties the cache"` AND `src/rate-cache/rate-cache.test.ts::"has returns true for fresh, false for expired"` AND `src/rate-cache/rate-cache.test.ts::"getRate skips expired entries when triangulating"`
---
### Requirement: CurrencyConverter convert / convertAmount / getRate
The `CurrencyConverter` class SHALL expose `convert(money, to, options?)`,
`convertAmount(amount, from, to, options?)`, and `getRate(from, to, options?)`.
`convert` SHALL return a `ConversionResult` whose `amount: bigint` is the
target currency's minor-unit amount (`Math.round` of `majorAmount * rate *
minorUnitMultiplier(toExponent)`), `rate` is the multiplicative factor used,
`inverseRate` is `1 / rate`, `from` / `to` echo the call, `rateAgeMs` is
`Date.now() - <cache timestamp for from-base or now>`, `providerName` is
`"<identity>"` for `from === to`, `"<cache>"` when the rate was served from
the cache without a provider call, `"<virtual>"` when the rate came from the
virtual-currency seeding, and otherwise the `name` of the provider whose
fetch produced the cached entry, and `triangulatedThrough: string[] | null`
is the intermediate base currency when the cache triangulated, otherwise
`null`. `getRate` SHALL return `1` for identity calls; for non-identity calls
it SHALL first consult the cache (unless `options.fresh === true`), then walk
the fallback chain. `convertAmount` SHALL return `amount * rate`.
#### Scenario: identity conversion short-circuits
- **WHEN** `convert(Money.fromMinor(100n, "USD"), "USD")` is called
- **THEN** the result is `{ amount: 100n, rate: 1, inverseRate: 1, from: "USD", to: "USD", rateAgeMs: 0, providerName: "<identity>", triangulatedThrough: null }`
#### Scenario: direct conversion populates providerName
- **WHEN** the converter has a primary provider with `USD → EUR = 0.92` and `convert(Money.fromMajor("100", "USD"), "EUR")` is awaited
- **THEN** the result has `amount === 9200n`, `rate === 0.92`, `providerName === <primary provider name>`, `triangulatedThrough === null`
#### Scenario: cached subsequent call reports providerName "<cache>"
- **WHEN** the same conversion is awaited a second time within the TTL window
- **THEN** the second result's `providerName === "<cache>"`
#### Scenario: triangulated conversion reports the intermediary
- **WHEN** the cache is seeded with `USD → EUR = 0.5` and `USD → JPY = 100` and `convert(Money.fromMajor("10", "EUR"), "JPY")` is awaited
- **THEN** the result has `rate === 200`, `triangulatedThrough === ["USD"]`, `providerName === "<cache>"`
#### Scenario: fallback chain takes over when primary fails
- **WHEN** the primary provider throws and the fallback provider has `USD → EUR = 0.92` and `convert(...)` is awaited
- **THEN** the result's `providerName === <fallback provider name>` and the conversion succeeds
#### Scenario: every provider failing raises PROVIDER_UNAVAILABLE
- **WHEN** every provider in the chain throws on `fetchRates`
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.PROVIDER_UNAVAILABLE"`, `from === <source currency>`, and the underlying cause attached
#### Scenario: getRate fresh bypasses the cache
- **WHEN** `getRate("USD", "EUR", { fresh: true })` is called after a successful prior conversion
- **THEN** the provider's `fetchRates` is invoked again (provider hit count increments)
#### Scenario: convertAmount returns numeric product
- **WHEN** `convertAmount(250, "USD", "EUR")` is awaited against `USD → EUR = 0.92`
- **THEN** the result is `230` (250 * 0.92)
#### Scenario: zero-exponent target rounds correctly
- **WHEN** `convert(Money.fromMajor("10", "USD"), "JPY")` is awaited against `USD → JPY = 149.5`
- **THEN** the result has `amount === 1495n` (JPY exponent 0; major * rate rounded)
#### Scenario: missing rate after fetch raises RATE_UNAVAILABLE
- **WHEN** the converter is configured with a provider whose `fetchRates` returns rates that do not include the requested target and `getRate("USD", "ZZZ")` is awaited
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.RATE_UNAVAILABLE"`, `from === "USD"`, `to === "ZZZ"`
**Verified by:** `src/converter/converter.test.ts::"identity conversion short-circuits with providerName <identity>"` AND `src/converter/converter.test.ts::"direct conversion populates providerName from the primary"` AND `src/converter/converter.test.ts::"second conversion within TTL reports providerName <cache>"` AND `src/converter/converter.test.ts::"triangulated conversion reports the intermediary base"` AND `src/converter/converter.test.ts::"fallback chain takes over when primary throws"` AND `src/converter/converter.test.ts::"all providers failing raises PROVIDER_UNAVAILABLE with cause"` AND `src/converter/converter.test.ts::"getRate fresh bypasses the cache and re-fetches"` AND `src/converter/converter.test.ts::"convertAmount returns numeric product"` AND `src/converter/converter.test.ts::"zero-exponent target rounds correctly"` AND `src/converter/converter.test.ts::"missing rate after fetch raises RATE_UNAVAILABLE"`
---
### Requirement: Virtual currency seeding and runtime mutation
When `ConverterConfig.virtualCurrencies` is supplied the constructor SHALL
register each entry through `@suaveplan/money`'s `registerCurrency` and seed
its fixed rate (against `defaultBase`) into the cache. After every successful
provider fetch the seeded virtual rates SHALL be re-merged into the cache
entry for `defaultBase` so they survive refreshes. The converter SHALL expose
`setVirtualRate(code, rate)` and `updateVirtualRate(code, rate)` (aliases)
that update the in-memory virtual rate map and re-seed the cache; and
`getVirtualRates(): Readonly<Record<string, number>>` returning a defensive
copy.
#### Scenario: virtual currency is available immediately
- **WHEN** `new CurrencyConverter({ provider, virtualCurrencies: { GOLD: { rate: 100, name: "Gold", symbol: "🪙" } } })` is constructed and `convertAmount(1, "USD", "GOLD")` is awaited (with `defaultBase: "USD"`)
- **THEN** the result is `100` and no provider call is required
#### Scenario: virtual rates survive provider refresh
- **WHEN** the converter completes a `getRate("USD", "EUR", { fresh: true })` after `GOLD` was seeded, then `convertAmount(1, "USD", "GOLD")` is awaited
- **THEN** the result is still `100` (re-seeding fired after the fresh fetch)
#### Scenario: setVirtualRate updates a virtual rate
- **WHEN** `setVirtualRate("GOLD", 150)` is called and `convertAmount(1, "USD", "GOLD")` is re-awaited
- **THEN** the result is `150`
#### Scenario: updateVirtualRate is an alias
- **WHEN** `updateVirtualRate("GOLD", 200)` is called and `convertAmount(1, "USD", "GOLD")` is re-awaited
- **THEN** the result is `200`
#### Scenario: getVirtualRates returns a defensive copy
- **WHEN** `getVirtualRates()` is mutated by the caller
- **THEN** subsequent calls to `getVirtualRates()` return the original (unmodified) value
**Verified by:** `src/converter/converter.test.ts::"virtual currency is available immediately after construction"` AND `src/converter/converter.test.ts::"virtual rates survive a fresh provider refresh"` AND `src/converter/converter.test.ts::"setVirtualRate updates a virtual rate at runtime"` AND `src/converter/converter.test.ts::"updateVirtualRate is an alias for setVirtualRate"` AND `src/converter/converter.test.ts::"getVirtualRates returns a defensive copy"`
---
### Requirement: AbortSignal cancellation through the fallback chain
The `CurrencyConverter` SHALL honour `options.signal: AbortSignal` on every
public method (`convert`, `convertAmount`, `getRate`). When the signal is
already aborted at call time, the method SHALL raise
`CurrencyError({ code: CURRENCY.ABORTED })` without consulting any provider.
When the signal aborts between fallback attempts, the chain SHALL stop and
raise `CurrencyError({ code: CURRENCY.ABORTED })`. The same contract applies
to the reference providers' `fetchRates`.
#### Scenario: already-aborted signal raises ABORTED on convert
- **WHEN** `convert(money, "EUR", { signal: <aborted> })` is awaited
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"` and no provider call occurs
#### Scenario: signal aborted between fallback attempts
- **WHEN** the primary provider throws, an `AbortController.abort()` is scheduled via `queueMicrotask`, and `convert(...)` is awaited
- **THEN** the chain stops, the fallback provider is never called, and a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
#### Scenario: already-aborted signal raises ABORTED on convertAmount
- **WHEN** `convertAmount(1, "USD", "EUR", { signal: <aborted> })` is awaited
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
#### Scenario: already-aborted signal raises ABORTED on getRate
- **WHEN** `getRate("USD", "EUR", { signal: <aborted> })` is awaited
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
**Verified by:** `src/converter/converter.test.ts::"already-aborted signal raises ABORTED on convert"` AND `src/converter/converter.test.ts::"signal aborted between fallback attempts stops the chain"` AND `src/converter/converter.test.ts::"already-aborted signal raises ABORTED on convertAmount"` AND `src/converter/converter.test.ts::"already-aborted signal raises ABORTED on getRate"`
---
### Requirement: Zod-validated public boundary
The package SHALL export `ConverterConfigSchema`, `ConvertOptionsSchema`,
`VirtualCurrencyConfigSchema`, and `RateFetchOptionsSchema` Zod schemas
defining the accepted shape of each public option bundle. The
`CurrencyConverter` constructor SHALL validate its argument against
`ConverterConfigSchema` and raise
`CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure, with
`error.context.issues` carrying the Zod issue array. `convert`,
`convertAmount`, and `getRate` SHALL each validate their `options` argument
(when supplied) against `ConvertOptionsSchema` and raise
`CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure.
#### Scenario: invalid config rejected
- **WHEN** `new CurrencyConverter({ provider: null as never })` is constructed
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.INVALID_CONFIG"` and `context.issues` is a non-empty array
#### Scenario: invalid options rejected
- **WHEN** `converter.convert(money, "EUR", { rounding: "nope" as never })` is awaited
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.INVALID_OPTIONS"`
#### Scenario: invalid virtual currency rejected
- **WHEN** `new CurrencyConverter({ provider, virtualCurrencies: { GOLD: { rate: -1, name: "Gold", symbol: "🪙" } } })` is constructed
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.INVALID_CONFIG"`
#### Scenario: valid options accepted
- **WHEN** a well-formed `ConverterConfig` and well-formed `ConvertOptions` are passed
- **THEN** no validation error is raised and the call proceeds
#### Scenario: empty options accepted
- **WHEN** `convert(money, "EUR")` is called with no options
- **THEN** no validation error is raised
#### Scenario: parse helpers raise the same errors
- **WHEN** `parseConverterConfig({ provider: null as never })` and `parseConvertOptions({ rounding: "nope" as never })` are called
- **THEN** they raise `CurrencyError` with `INVALID_CONFIG` / `INVALID_OPTIONS` respectively
**Verified by:** `src/schemas/schemas.test.ts::"ConverterConfigSchema rejects null provider"` AND `src/schemas/schemas.test.ts::"ConvertOptionsSchema rejects unknown rounding"` AND `src/schemas/schemas.test.ts::"VirtualCurrencyConfigSchema rejects non-positive rate"` AND `src/schemas/schemas.test.ts::"ConverterConfigSchema accepts a well-formed config"` AND `src/schemas/schemas.test.ts::"ConvertOptionsSchema accepts an empty options object"` AND `src/schemas/schemas.test.ts::"RateFetchOptionsSchema accepts base, targets, and signal"` AND `src/schemas/schemas.test.ts::"parseConverterConfig raises INVALID_CONFIG"` AND `src/schemas/schemas.test.ts::"parseConvertOptions raises INVALID_OPTIONS"` AND `src/converter/converter.test.ts::"constructor raises INVALID_CONFIG when provider is null"` AND `src/converter/converter.test.ts::"convert raises INVALID_OPTIONS when options is malformed"`
---
### Requirement: CurrencyConverter setRate / clearCache / getCache management surface
The class SHALL expose `setRate(from, to, rate)` to insert a rate directly
into the underlying cache (merging into an existing entry when present),
`clearCache()` to empty the cache, and `getCache()` to return the underlying
`RateCache` for inspection.
#### Scenario: setRate inserts a new pair
- **WHEN** `converter.setRate("USD", "EUR", 0.92)` is called and `getRate("USD", "EUR")` is awaited
- **THEN** the result is `0.92`
#### Scenario: setRate merges with an existing entry
- **WHEN** `setRate("USD", "EUR", 0.92)` and then `setRate("USD", "GBP", 0.79)` are called
- **THEN** the cache entry for `USD` contains both `EUR: 0.92` and `GBP: 0.79`
#### Scenario: clearCache empties the cache
- **WHEN** `clearCache()` is called after a successful conversion
- **THEN** subsequent calls re-hit the provider
#### Scenario: getCache returns the inspectable instance
- **WHEN** `getCache()` is called
- **THEN** the result is an instance of `RateCache` and reflects the current `size`
**Verified by:** `src/converter/converter.test.ts::"setRate inserts a new pair into the cache"` AND `src/converter/converter.test.ts::"setRate merges with an existing base entry"` AND `src/converter/converter.test.ts::"clearCache empties the cache and re-hits the provider"` AND `src/converter/converter.test.ts::"getCache returns the inspectable RateCache instance"`
---
### Requirement: Sub-path exports per public module
The `package.json` SHALL expose six sub-path exports in addition to the root
`"."` export: `./converter/converter`, `./rate-cache/rate-cache`,
`./provider/provider`, `./error-codes/error-codes`, `./schemas/schemas`, and
`./types/common`. Every sub-path SHALL resolve to a `.d.ts` and a `.js` file
under `dist/`. Every feature-module `index.ts` barrel SHALL use explicit
named re-exports (no `export *`).
#### Scenario: sub-path exports declared
- **WHEN** the test reads `package.json`'s `exports` keys
- **THEN** the list includes `./converter/converter`, `./rate-cache/rate-cache`, `./provider/provider`, `./error-codes/error-codes`, `./schemas/schemas`, `./types/common`
#### Scenario: no `export *` in feature barrels
- **WHEN** `grep` is run for `export\s+\*\s+from` inside `src/**/index.ts` files (excluding the root `src/index.ts`)
- **THEN** the command produces zero matches
**Verified by:** `src/converter/converter.test.ts::"sub-path exports are declared in package.json"` AND `src/converter/converter.test.ts::"feature barrels use explicit named re-exports"`
<!-- markdownlint-enable MD024 -->