diff --git a/docs/housekeeping.md b/docs/housekeeping.md index 217dfb5..e7687d3 100644 --- a/docs/housekeeping.md +++ b/docs/housekeeping.md @@ -26,21 +26,7 @@ Switching from plain JSON schema to `Type.Object({ error: Type.String() })` adde ## 3. Consolidate error message formats across error paths — DONE -**Status**: Completed. The `api-client.ts` / `error-handling.ts` refactor split the -fetch pipeline into `fetchSafe` and `fetchProne` and routed every message through -the file-private `formatErrorMessage` table so both paths share one canonical -format per (verbosity × category). Failure construction lives in two free -functions (`networkFailure`, `responseFailure`) that `fetchProne` calls inline. -Verbose-mode HTTP failure messages always carry the failing URL; safe mode -strips it. - ---- - -## 5. Split `navigate.spec.ts` into multiple files — DONE - -**Status**: Completed. `test/integration/navigate.spec.ts` was split into 10 -focused spec files following `docs/integration-test-split.md`. Shared fixtures -live in `test/test-schemas.ts`. +**Status**: Completed. The `api-client.ts` / `error-handling.ts` refactor consolidated the fetch pipeline into a single `ApiClient.fetchResource` method that returns a `{ resource | failure, baseURL }` discriminated result. Each `Failure` variant is produced by its own builder in `error-handling.ts` (`uriExpansionFailure`, `networkFailure`, `unmappedStatusFailure`, `invalidJsonFailure`, `invalidStructureFailure`, `responseFailure`), with verbosity-aware message formatting inlined per builder. The `Failure → Error` conversion used by safe-link callers is centralised in `failureToError`. --- @@ -56,6 +42,13 @@ schema directly. --- +## 5. Split `navigate.spec.ts` into multiple files — DONE + +**Status**: Completed. `test/integration/navigate.spec.ts` was split into 10 +focused spec files. Shared fixtures live in `test/test-schemas.ts`. + +--- + ## 6. Normalize error message for primitive inputs to `navigate()` — DONE **Status**: Resolved. Guard moved into `getOwningClient` in `src/runtime-metadata.ts` @@ -71,17 +64,16 @@ protected at the WeakMap boundary. ## 7. Petshop-fixture prone-link smoke test (post-split note) -**Source**: `docs/integration-test-split.md` consolidation review **Location**: `test/integration/error-handling.spec.ts` (`prone links (expected errors)` describe) **Status**: Resolved during the integration test split. -The plan flagged the petshop-fixture rows 1177/1195 as consolidation candidates -(redundant with the synthetic `errorApi` rows 1084/1115). The split kept **one** -of them — the success case — as a single "real-API-shape" smoke test against -the example `petshopApi`. The 404 duplicate (old line 1195) was dropped -outright. Rationale: the synthetic API already proves the prone-link contract -in detail, and the smoke test is enough to guard against drift between the -example API definition and the prone-link runtime path. +Petshop-fixture rows 1177/1195 are consolidation candidates (redundant with +the synthetic `errorApi` rows 1084/1115). The split kept **one** of them — the +success case — as a single "real-API-shape" smoke test against the example +`petshopApi`. The 404 duplicate (old line 1195) was dropped outright. Rationale: +the synthetic API already proves the prone-link contract in detail, and the smoke +test is enough to guard against drift between the example API definition and the +prone-link runtime path. --- @@ -107,6 +99,18 @@ behaviour. --- +## 9. `Merge` type uses broad `S extends object` check — RESOLVED + +**Source**: Critical review (Type System concern #3) +**Location**: `src/type-system.ts` (`MergeInner`) +**Resolution**: `MergeInner` object branch was tightened from `S extends object` +to `S extends Record`. This prevents Date, Function, RegExp, +and other non-plain-object types from being recursively merged. A type-level +regression test in `test/unit/type-system.test.ts` verifies that a Date field +passes through `MergeInner` without losing its instance methods. + +--- + ## 10. `defineLinks` should reject terminal-array link paths up front — DONE **Status**: Resolved (FINDING-02). `defineLinks` now rejects link paths whose @@ -189,6 +193,26 @@ lowest-risk cleanup if the cast is kept at one central helper. --- +## 12. Expand `ProblemSchema` to fully represent RFC 7807 + +**Source**: Code review quick-fixes (inline TODO removed) +**Location**: `examples/petshop-api.ts` (`ProblemSchema`) +**Effort**: ~5 min + +Expand `ProblemSchema` to fully represent RFC 7807 (title, type, status, detail, instance) — currently minimal stub. The schema only defines `title: Type.String()` today. All RFC 7807 fields should be added (with optional where appropriate) so the example accurately models a real problem detail response. + +```typescript +export const ProblemSchema = Type.Object({ + title: Type.String(), + type: Type.Optional(Type.String()), + status: Type.Optional(Type.Number()), + detail: Type.Optional(Type.String()), + instance: Type.Optional(Type.String()), +}); +``` + +--- + ## 13. CI does not run `tsconfig.typecheck.json` (covers `test/`, `examples/`) **Source**: PR #80 round-3 peer review observation @@ -214,18 +238,6 @@ The file is missing a trailing newline. Apply when next touched. --- -## 9. `Merge` type uses broad `S extends object` check — RESOLVED - -**Source**: Critical review (Type System concern #3) -**Location**: `src/type-system.ts` (`MergeInner`) -**Resolution**: `MergeInner` object branch was tightened from `S extends object` -to `S extends Record`. This prevents Date, Function, RegExp, -and other non-plain-object types from being recursively merged. A type-level -regression test in `test/unit/type-system.test.ts` verifies that a Date field -passes through `MergeInner` without losing its instance methods. - ---- - ## 15. `tsc --noEmit` silently skips `examples/` and `test/` **Source**: `feat/bff-showcase` session friction log F-5 @@ -233,23 +245,3 @@ passes through `MergeInner` without losing its instance methods. **Effort**: ~5 min The default `tsconfig.json` excludes `examples/` and `test/`, so `npx tsc --noEmit` passes even when those trees have real type errors. The correct invocation is `npx tsc --noEmit -p tsconfig.typecheck.json`. Add a one-liner to AGENTS.md under "Running checks" so contributors don't have to discover this the hard way. - ---- - -## 12. Expand `ProblemSchema` to fully represent RFC 7807 - -**Source**: Code review quick-fixes (inline TODO removed) -**Location**: `examples/petshop-api.ts` (`ProblemSchema`) -**Effort**: ~5 min - -Expand `ProblemSchema` to fully represent RFC 7807 (title, type, status, detail, instance) — currently minimal stub. The schema only defines `title: Type.String()` today. All RFC 7807 fields should be added (with optional where appropriate) so the example accurately models a real problem detail response. - -```typescript -export const ProblemSchema = Type.Object({ - title: Type.String(), - type: Type.Optional(Type.String()), - status: Type.Optional(Type.Number()), - detail: Type.Optional(Type.String()), - instance: Type.Optional(Type.String()), -}); -``` \ No newline at end of file diff --git a/docs/pipeline-refactor-plan-alt.md b/docs/pipeline-refactor-plan-alt.md deleted file mode 100644 index 8562bfb..0000000 --- a/docs/pipeline-refactor-plan-alt.md +++ /dev/null @@ -1,354 +0,0 @@ -# Pipeline refactor plan (alternative): single pipeline + boundary converter - -> **Status:** Proposed — alternative to `pipeline-refactor-plan.md`. -> **Scope:** `src/api-client.ts` + `src/error-handling.ts` only. No public API changes. No test assertion changes. -> **Relationship to other plans:** This is a lower-ceremony alternative to `pipeline-refactor-plan.md`. Both plans target the same duplication (`fetchSafe` + `fetchProne`) and preserve the same 22 pinned error strings. The difference is how much machinery they introduce to get there. - -## Motivation - -Same starting observation as the other plan: `fetchSafe` and `fetchProne` duplicate the fetch → parse → validate sequence, and the only real difference is how failures are *presented* (throw vs. tuple). - -**The deeper observation this plan leans on:** every piece of information a safe-link thrown `Error` needs is already carried by the `Failure` object that prone links produce. Specifically — - -- **Safe verbose network** (`'Connection refused'`) = the raw fetch Error, stored as `failure.cause` in `networkFailure` verbose mode. -- **Safe verbose parse** (`'Unexpected token in JSON'`) = the raw `SyntaxError`, stored as `failure.cause` in `invalidJsonFailure` verbose mode. -- **Safe verbose validate** (`/Response validation failed.*\/pets: Expected array/`) = the Error thrown by `validateResource`, stored as `failure.cause` in `invalidStructureFailure` verbose mode. (Confirmed by `test/integration/error-handling.spec.ts:184` — the prone tests *already* assert `error!.cause!.message` contains `'Response validation failed'`.) -- **Safe verbose HTTP** (`'HTTP 404: Not Found (url)'`) = `failure.message` (same `unmappedStatus` wording the prone path produces). -- **Safe mode HTTP / network** (`'HTTP 404 error'`, `'Network error'`) = `failure.message` (prone safe-mode formatting is identical). -- **Safe mode validate** (`'Response validation failed'`) = single hardcoded literal; `cause` is dropped in safe mode, so this is the one case that needs fresh construction. - -Because `Failure` already has everything we need, **there is no reason to introduce an intermediate `PipelineOutcome` type.** The prone pipeline becomes the only pipeline; safe links just convert the `Failure` back to an `Error` at the boundary. - -## Design - -### Shape - -1. **`ApiClient.fetchResource`** — the existing `fetchProne` body, renamed, with one change: the `rememberLinks` call is hoisted out into `resolve`. Signature returns `[resource, null] | [null, Failure]` for every link. -2. **`failureToError`** — a single pure function in `error-handling.ts`. ~15 lines. Switches over `failure.reason` and returns either `failure.cause`, `new Error(failure.message)`, or a hardcoded safe-mode literal. -3. **`resolve`** — calls `fetchResource` unconditionally, hydrates links on whatever resource was returned (success body or typed error body), then dispatches: prone links return the tuple, safe links throw `failureToError(failure)` on failure or return the resource on success. - -### What gets deleted - -- `fetchSafe` (14 lines) -- `deliverSuccess` (10 lines) -- `toFailure` (13 lines) — its one responsibility (hydrating the mapped error resource's links) moves into `resolve` as two lines of direct access to `failure.resource` / `failure.kind`. -- `httpErrorMessage` export (no remaining caller — safe-link HTTP errors now flow through `failure.message`) - -### What gets renamed / kept - -- `fetchProne` → `fetchResource`. Body unchanged *except* the trailing `rememberLinks(resource, resourceDef, extractBaseURL(url), this)` is deleted and the `resourceDef` parameter is no longer needed here (resolved in `resolve`). -- `validateResource` stays as a private method on `ApiClient`. No reason to hoist it. -- `responseFailure` stays as an exported function in `error-handling.ts`. No rename, no wrapper type. -- All four `Failure` builders (`networkFailure`, `unmappedStatusFailure`, `invalidJsonFailure`, `invalidStructureFailure`) stay unchanged. - -### What gets added - -- `failureToError` in `error-handling.ts` (~15 lines including doc comment). -- Rewritten `resolve` body (~20 lines). -- Class-header doc update on `ApiClient` capturing the new design intent. - -That is the entire surface of the refactor: **one new function, one rewritten method, four deletions.** - -## Code sketches - -### `failureToError` (error-handling.ts) - -```ts -/** - * Converts a `Failure` back to a JavaScript `Error` for safe links. - * - * Safe links run through the same prone-flavored pipeline as prone links; when - * the pipeline produces a `Failure` instead of a resource, `resolve` calls this - * to reshape it into the thrown `Error` that safe-link callers expect. - * - * Every case except safe-mode validate is recoverable directly from the - * `Failure` object: - * - verbose network / parse / validate → `failure.cause` is the original - * thrown Error (captured at the failure site and preserved in verbose mode) - * - safe-mode network / parse / HTTP / unmappedStatus → `failure.message` - * already carries the safe-flavored wording the prone pipeline produced - * - safe-mode validate → cause is dropped in safe mode, so we reconstruct - * the pinned literal `'Response validation failed'` directly - * - * Safe links never have a declared `expect`, so only the `'unexpected'` branch - * of `Failure` can reach here; the opening guard is defensive only. - * - * @internal - */ -export function failureToError( - failure: Failure, - verbosity: Verbosity, -): Error { - if (failure.kind !== 'unexpected') return new Error(failure.message); - switch (failure.reason) { - case 'network': - return verbosity === 'safe' ? new Error('Network error') : failure.cause!; - case 'unmappedStatus': - return new Error(failure.message); - case 'invalidJson': - return verbosity === 'safe' ? new Error(failure.message) : failure.cause!; - case 'invalidStructure': - return verbosity === 'safe' - ? new Error('Response validation failed') - : failure.cause!; - } -} -``` - -### `ApiClient.fetchResource` (api-client.ts) - -Identical to the current `fetchProne` **except** the trailing `rememberLinks` call is removed (hoisted into `resolve`). The three local `try`/`catch` sites remain as-is — they're the clearest expression of "catch errors at the step where they happen and turn them into Failures." - -```ts -/** - * Unified fetch pipeline for every link. Body-level failures never throw — - * transport errors, non-OK responses, malformed JSON, and schema mismatches - * all become `Failure` variants in the returned tuple. Safe links convert - * the returned Failure back to a thrown Error in `resolve`; prone links - * return it unchanged. - * - * Three local try/catch sites, each handling one specific class of failure: - * - * - transport errors from `doFetch` → `networkFailure` (`reason: 'network'`) - * - non-OK responses → `responseFailure` (expected case or `'unmappedStatus'`) - * - `response.json()` throws on a 2xx body → `invalidJsonFailure` - * - schema validation of a 2xx body fails → `invalidStructureFailure` - * - * `rememberLinks` is NOT called here — the caller (`resolve`) handles - * hydration because it has the single decision point for whether to hydrate - * the success body or a typed-error body. - */ -private async fetchResource( - url: string, - linkDef: LinkDefinition, - navigable: unknown, -): Promise<[unknown, null] | [null, Failure]> { - const resourceDef = this.requireResourceDef(linkDef.to); - - let response: Response; - try { - response = await this.doFetch(url, linkDef.to, navigable); - } catch (err) { - return [null, networkFailure(this.errorVerbosity, url, err as Error)]; - } - - if (!response.ok) { - return [null, await responseFailure( - this.errorVerbosity, url, response, linkDef, this.apiDef, - )]; - } - - let resource: unknown; - try { - resource = await response.json(); - } catch (err) { - return [null, invalidJsonFailure( - this.errorVerbosity, url, response, linkDef.to, err as Error, - )]; - } - try { - this.validateResource(resource, resourceDef, url); - } catch (err) { - return [null, invalidStructureFailure( - this.errorVerbosity, url, response, linkDef.to, err as Error, - )]; - } - return [resource, null]; -} -``` - -### Rewritten `resolve` (api-client.ts) - -```ts -/** - * Resolves a link on a navigable object. - * - * Runs the unified fetch pipeline (`fetchResource`), hydrates links on - * whatever resource was returned (success body *or* typed error body, since - * both carry followable links), then dispatches based on the link shape: - * - * - **Prone links** (with `expect`): return the `[resource, failure]` tuple - * directly. The typed error body's links are already hydrated above. - * - **Safe links** (no `expect`): return the resource on success, or throw - * `failureToError(failure, verbosity)` on failure. The converter maps each - * `Failure` reason back to the thrown-Error shape safe links advertise. - * - * Pre-pipeline programming errors (unknown link name, missing resource - * definition, invalid URI-template params) still propagate verbatim — they - * indicate caller bugs, not server or network failures. - * - * The `as any` at the end is unavoidable: TypeScript cannot narrow the - * conditional return type from inside this generic body. - */ -async resolve( - navigable: Navigable, - linkName?: string, - params?: Static, -): Promise : ResourceOrFailure> { - const link = recallLink(navigable, linkName); - const url = this.expandUrl(link, params); - const linkDef = link.linkDef; - const resourceDef = this.requireResourceDef(linkDef.to); - const baseURL = extractBaseURL(url); - - const [resource, failure] = await this.fetchResource(url, linkDef, navigable); - - // Hydrate links on whatever resource we received — success body OR - // typed-error body. Unexpected failures have no resource to hydrate. - if (resource !== null) { - rememberLinks(resource, resourceDef, baseURL, this); - } else if (failure!.kind !== 'unexpected') { - rememberLinks(failure!.resource, this.apiDef[failure!.kind], baseURL, this); - } - - // Dispatch by link shape. - if (linkDef.expect) { - return [resource, failure] as any; // prone: tuple - } - if (failure) { - throw failureToError(failure, this.errorVerbosity); // safe: throw - } - return resource as any; // safe: resource -} -``` - -### Class-header doc (api-client.ts) - -``` -Design intent: api-client.ts is deliberately thin on error handling. Error -classification, verbosity, message formatting, parse recovery, and schema -validation all live in error-handling.ts. The fetch → parse → validate flow -lives in the single `fetchResource` method, which always returns a -`[resource, Failure]` tuple. `resolve` runs that pipeline once per navigation, -hydrates links on whatever resource came back, and dispatches to the link -shape — returning the tuple for prone links, throwing `failureToError(...)` -for safe-link failures. If you find yourself adding a second fetch method, a -`verbosity === 'safe'` check, or error-message concatenation in here, the -right home is almost certainly `error-handling.ts`. -``` - -## Why this beats the other plan - -| Aspect | `pipeline-refactor-plan.md` | This plan | -|---|---|---| -| New types introduced | `PipelineOutcome`, `FailureOutcome` | **none** | -| New functions introduced | `runPipeline`, `toSafeError`, `toProneFailure`, `classifyHttpStatus`, `validateResourceOrThrow` | **`failureToError`** (1) | -| Fetch methods | `runPipeline` (new) | `fetchResource` (renamed `fetchProne`) | -| Mutable `step` variable + `response!` non-null assertion | yes | no | -| `hydrateWith` layering dance between `error-handling.ts` and `api-client.ts` | yes | no — `resolve` reads `failure.resource` / `failure.kind` directly | -| Switch statements projecting failure info | 2 (`toSafeError` + `toProneFailure`) | 1 (`failureToError`) | -| Methods deleted from `ApiClient` | 5 (`fetchSafe`, `fetchProne`, `deliverSuccess`, `toFailure`, `validateResource`) | 3 (`fetchSafe`, `deliverSuccess`, `toFailure`) | -| Exports added/removed in `error-handling.ts` | +4 / −2 | +1 / −1 | -| Pipeline shape | one big try/catch + mutable `step` | three local try/catches (clearer: each handler sits next to its step) | - -The critical structural difference: `pipeline-refactor-plan.md` introduces a 5-variant intermediate type (`PipelineOutcome`) so that *both* safe and prone can project from a common representation. This plan observes that `Failure` is already that common representation — safe links don't need a separate intermediate, they just need a converter to turn `Failure` back into a thrown Error. - -## Line-count estimate - -| File | Before | After | Delta | -|---|---|---|---| -| `src/api-client.ts` | 305 | ~260 | **−45** | -| `src/error-handling.ts` | 399 | ~395 | **−4** | -| **Total** | **704** | **~655** | **−49** | - -Net negative ~50 lines (vs. the other plan's net +26). The difference comes from not introducing `PipelineOutcome`, `FailureOutcome`, `runPipeline`, `toSafeError`, `toProneFailure`, `classifyHttpStatus`, or `validateResourceOrThrow`. - -## Test-assertion impact - -**All 22 pinned error strings preserved.** Walked case-by-case against `test/integration/error-handling.spec.ts` and `test/integration/error-verbosity.spec.ts`: - -| Scenario | Assertion | New path | Result | -|---|---|---|---| -| Safe verbose HTTP | `'HTTP 404: Not Found (url)'` | `failure.message` from `unmappedStatusFailure` verbose | ✓ | -| Safe verbose network | `'Connection refused'` | `failure.cause` from `networkFailure` verbose | ✓ | -| Safe verbose parse | `'Unexpected token in JSON'` | `failure.cause` from `invalidJsonFailure` verbose | ✓ | -| Safe verbose validate | `/Response validation failed.*\/pets: Expected array/` | `failure.cause` from `invalidStructureFailure` verbose (thrown by `validateResource`) | ✓ | -| Safe safe HTTP 500 | `'HTTP 500 error'` | `failure.message` from `unmappedStatusFailure` safe | ✓ | -| Safe safe HTTP 404 | `'HTTP 404 error'` | `failure.message` from `unmappedStatusFailure` safe | ✓ | -| Safe safe network | `'Network error'` | literal in `failureToError` safe branch | ✓ | -| Safe safe validate | `'Response validation failed'` | literal in `failureToError` safe branch | ✓ | -| Prone verbose mapped | `'HTTP 404: Not Found (url)'` | unchanged — `createExpectedFailure` → `mappedStatus` | ✓ | -| Prone verbose parse | `'Failed to parse JSON when target was expected (url)'` | unchanged — `invalidJsonFailure` verbose | ✓ | -| Prone verbose validate | `'Validation of target failed (url)'` | unchanged — `invalidStructureFailure` verbose | ✓ | -| Prone verbose unmapped | `'HTTP 403: Forbidden (url)'` | unchanged — `unmappedStatusFailure` | ✓ | -| Prone verbose mapped-parse-fail | `'Failed to parse JSON when notFound was expected (url)'` | unchanged — `createExpectedFailure` → `invalidJsonFailure` | ✓ | -| Prone verbose mapped-validate-fail | `'Validation of notFound failed (url)'` | unchanged — `createExpectedFailure` → `invalidStructureFailure` | ✓ | -| Prone verbose network | `'Network error: Network failure (url)'` | unchanged — `networkFailure` | ✓ | -| Prone safe network | `'Network error'` | unchanged — `networkFailure` safe | ✓ | -| Prone safe unmapped | `'HTTP 403 error'` | unchanged — `unmappedStatusFailure` safe | ✓ | -| Prone safe mapped | `'HTTP 404'` | unchanged — `createExpectedFailure` → `mappedStatus` safe | ✓ | -| Prone safe parse | `'HTTP 404: Response parse error'` | unchanged — `invalidJsonFailure` safe | ✓ | -| Prone safe validate | `'HTTP 404: Response validation error'` | unchanged — `invalidStructureFailure` safe | ✓ | -| Headers exposed (verbose) | `Headers` instance with values | unchanged — `makeResponseInfo` verbose | ✓ | -| Headers stripped (safe) | empty `Headers` | unchanged — `makeResponseInfo` safe | ✓ | -| `error.cause.message` contains `'Response validation failed'` (prone) | `cause` is thrown Error | unchanged — `invalidStructureFailure` stores `err` as `cause` in verbose | ✓ | - -### One behavior change in untested territory - -**Safe-mode safe-link parse errors** are the sole case with a visible behavior change, and there is **no pinned test** for this scenario. - -- **Before:** `fetchSafe` → `deliverSuccess` → raw `response.json()` — the underlying `SyntaxError` propagates unmasked (leaking something like `'Unexpected token < in JSON'`). -- **After:** `failureToError` safe-mode `invalidJson` returns `new Error(failure.message)` = `'HTTP 200: Response parse error'` (the prone safe-mode wording). - -This is arguably an *improvement*: safe mode's stated purpose is to mask internal details, and leaking a raw `SyntaxError` contradicts that. No tests break because no test pins this path. **Flag it in the PR description** so reviewers can confirm they're happy with it before landing. - -## Internal-export audit - -**Removed** from `error-handling.ts`: -- `httpErrorMessage` — no remaining caller (safe-link HTTP errors now flow through `failure.message`). - -**Added** to `error-handling.ts`: -- `failureToError` — called by `ApiClient.resolve`. - -**Unchanged** exports: `Failure`, `ResponseInfo`, `ResourceOrFailure`, `networkFailure`, `unmappedStatusFailure`, `invalidJsonFailure`, `invalidStructureFailure`, `responseFailure`. - -**Pre-implementation check:** grep `test/` for direct imports of `httpErrorMessage`. The `pipeline-refactor-plan.md` audit confirmed none exist; re-verify before deleting the export. - -## Implementation plan - -1. **Audit** `test/` for direct imports of `httpErrorMessage`. Confirm none. (Also confirm no test imports `fetchSafe` / `fetchProne` / `deliverSuccess` / `toFailure` / `validateResource` — they're private methods, so imports are unlikely, but the audit costs nothing.) -2. **Add** `failureToError` to `error-handling.ts`. -3. **Delete** `httpErrorMessage` export from `error-handling.ts`. -4. **Rename** `ApiClient.fetchProne` → `ApiClient.fetchResource`. Delete its trailing `rememberLinks` call. Update its doc comment to reflect that it's now the single unified pipeline. -5. **Rewrite** `ApiClient.resolve` per the sketch above. -6. **Delete** from `ApiClient`: `fetchSafe`, `deliverSuccess`, `toFailure`. -7. **Update** the `ApiClient` class-header comment with the new design-intent paragraph. -8. **Update** `AGENTS.md` to reflect the new shape — single `fetchResource` method, `failureToError` converter, the "one pipeline, dispatch at the boundary" story. -9. **Update** `docs/how-it-works.md` if it references any deleted methods. -10. **Run** `npm run test:coverage`. Expect all green, all four coverage metrics at 100%. -11. **Commit** as a single refactor commit — this is a cohesive reshape, not separable. - -## Risks and mitigations - -| Risk | Mitigation | -|---|---| -| Pinned error strings break | All 22 walked through above — each preserved by construction. | -| 100% coverage breaks | Every branch of `failureToError` is exercised by existing integration tests (each `reason` value is hit by at least one safe-link error test). | -| Safe-mode safe-link parse behavior change | Untested by pinned strings; called out explicitly in the PR description. Safe-mode masking arguably *should* apply here, so the change is an improvement. | -| Test imports reach into deleted symbols | Audit step #1 confirms this before deletion. | -| `resolve` generic return type breaks | The `as any` at the end of `resolve` is unchanged in shape; the conditional return type is identical. | -| Concurrent prone requests test breaks (cross-talk regression guard) | The unified pipeline has no shared mutable state — each call gets its own local `response` and destructured tuple. | - -## What this plan explicitly does NOT do - -- **No public API changes.** `navigate`, `linkTo`, `Failure`, `ResponseInfo`, and `ResourceOrFailure` are untouched. -- **No new files in `src/`.** Everything lives in the existing `api-client.ts` and `error-handling.ts`. -- **No new classes or types.** One new free function. -- **No message-wording unification** between safe-link and prone-link paths. (See the follow-up note below — this is a tempting further simplification but is explicitly out of scope for *this* refactor.) -- **No changes to `recallLink` / `expandUrl` flow.** They remain outside the pipeline because their failures are programming errors that propagate verbatim. -- **No changes to `FetchFactory` / `FetchContext`.** The fetch invocation flows through `this.doFetch` exactly as today. -- **No hoist of `validateResource` out of `ApiClient`.** It stays as a private method — one caller, no reason to move. - -## Possible follow-up (not part of this refactor) - -If there's appetite for touching a small number of pinned strings in a *separate* PR, the safe-link verbose wording (`'Response validation failed for url: ...'`) could be unified with prone wording (`'Validation of target failed (url)'`). That would collapse `failureToError` to: - -```ts -export function failureToError(failure: Failure): Error { - return failure.kind === 'unexpected' && failure.cause - ? failure.cause - : new Error(failure.message); -} -``` - -~4 lines. The cost is updating ~4 test assertions (safe-verbose validate, safe-verbose network, safe-verbose parse, safe-mode validate). The benefit is consistent wording across the whole library and a simpler converter. **Not in scope for this plan** — proposed as a follow-up once this refactor lands. diff --git a/docs/pipeline-refactor-plan.md b/docs/pipeline-refactor-plan.md deleted file mode 100644 index 80949b8..0000000 --- a/docs/pipeline-refactor-plan.md +++ /dev/null @@ -1,448 +0,0 @@ -# Pipeline refactor plan: unify safe & prone link flows - -> **Status:** Proposed — awaiting external review. -> **Scope:** `src/api-client.ts` + `src/error-handling.ts` only. No public API changes. No test assertion changes. -> **Related:** Supersedes `synthesis-error-handling.md` (which picked PR #74 as a base). This plan is an alternative that unifies the flow at a higher level. - -## Motivation - -`src/api-client.ts` currently has two near-identical fetch methods (`fetchSafe` and `fetchProne`) that duplicate the same fetch → parse → validate sequence in different shapes. `fetchProne` alone carries four `try`/`catch` blocks — one per failure class — plus an inline non-OK branch and two helper methods (`deliverSuccess`, `toFailure`). The synthesis plan (`.claude/plans/synthesis-error-handling.md`) proposed relocating helpers into `error-handling.ts` but **kept both methods**, leaving the flow duplicated line-for-line in two places. - -**The deeper observation:** the flow is *identical* for safe and prone links through fetch → parse → validate. What differs is only **how the outcome is presented** at the end: safe throws, prone returns a tuple. If the flow is written once and the presentation is done at the boundary, there is no duplication and there is exactly one `try`/`catch` in the entire file. - -## Design - -### Shape - -1. **`ApiClient.runPipeline`** — a single private method that fetches, parses, validates, and returns a `PipelineOutcome`. One `try`/`catch`. Uses a `step` variable to track which phase failed so the catch handler can classify the error. -2. **`PipelineOutcome`** — a discriminated union in `error-handling.ts` with five variants (`success`, `network`, `http-status`, `parse`, `validate`). Each variant carries exactly the fields the converters need. -3. **`toSafeError` / `toProneFailure`** — two pure converters in `error-handling.ts` that turn a `FailureOutcome` into an `Error` (safe) or a `Failure` (prone). All verbosity branching lives inside these converters. -4. **`resolve`** — becomes the only place that knows safe-vs-prone. After `runPipeline` returns, the success path is shared (hydrate via `rememberLinks`, return either the resource or a `[resource, null]` tuple) and the failure path is a two-line branch (throw vs. build tuple). - -### What gets deleted - -- `fetchSafe` (14 lines) -- `fetchProne` (30 lines) -- `deliverSuccess` (10 lines) -- `toFailure` (13 lines) -- `validateResource` (13 lines — hoisted to `error-handling.ts` as `validateResourceOrThrow`) -- `httpErrorMessage` export (becomes a file-private call to `formatErrorMessage` inside `toSafeError`) -- `responseFailure` export (replaced by file-private `classifyHttpStatus`) - -### What gets added - -- `PipelineOutcome` + `FailureOutcome` types in `error-handling.ts` -- `validateResourceOrThrow` in `error-handling.ts` -- `toSafeError` in `error-handling.ts` -- `toProneFailure` + file-private `classifyHttpStatus` in `error-handling.ts` -- `ApiClient.runPipeline` private method -- Rewritten `ApiClient.resolve` body (the new dispatch logic) -- Class-header doc comment on `ApiClient` stating the design intent (grafted from PR #76) - -## Code sketches - -### `ApiClient.runPipeline` (api-client.ts) - -```ts -/** - * Executes the fetch → parse → validate sequence against a single URL and - * returns a `PipelineOutcome`. Body-level failures are never thrown out of - * this method — they become `FailureOutcome` variants. The one surviving - * `try`/`catch` in `api-client.ts` lives here. - * - * The `step` variable tracks which phase is currently in progress so the - * catch handler can classify the thrown error without needing sentinel - * Error classes. `response` is hoisted above the try so the catch handler - * can include it on `parse`/`validate` failures. - */ -private async runPipeline( - url: string, - resourceName: string, - schema: TSchema, - navigable: unknown, -): Promise { - let step: 'fetch' | 'parse' | 'validate' = 'fetch'; - let response: Response | undefined; - try { - response = await this.doFetch(url, resourceName, navigable); - if (!response.ok) return { kind: 'http-status', response }; - step = 'parse'; - const resource = await response.json(); - step = 'validate'; - validateResourceOrThrow(resource, schema, url, this.errorVerbosity); - return { kind: 'success', resource }; - } catch (err) { - if (step === 'fetch') return { kind: 'network', error: err as Error }; - return { kind: step, response: response!, error: err as Error }; - } -} -``` - -**Design notes:** -- **One try/catch.** Not four, not two. -- **Non-OK is not an exception.** The non-OK branch returns directly from inside the try block — HTTP ≥ 400 is a normal server response, not a transport failure, so it doesn't pretend to be thrown. -- **`response!` is safe.** The assertion on the catch branch is provably correct: `step` is only ever `'parse'` or `'validate'` after `response = await this.doFetch(...)` has resolved. -- **No verbosity check.** The only place this method mentions verbosity is passing `this.errorVerbosity` to `validateResourceOrThrow`. - -### `PipelineOutcome` (error-handling.ts) - -```ts -/** - * Result of a single fetch-parse-validate pipeline run. Produced by - * `ApiClient.runPipeline`; consumed by `toSafeError` and `toProneFailure`. - * - * Each failure variant carries exactly the fields its converter needs — - * no optional fields, no non-null assertions at the use site, clean - * discriminated-union narrowing on `kind`. - * - * @internal - */ -export type PipelineOutcome = - | { readonly kind: 'success'; readonly resource: unknown } - | { readonly kind: 'network'; readonly error: Error } - | { readonly kind: 'http-status'; readonly response: Response } - | { readonly kind: 'parse'; readonly response: Response; readonly error: Error } - | { readonly kind: 'validate'; readonly response: Response; readonly error: Error }; - -/** All non-success variants — what the converters accept. @internal */ -export type FailureOutcome = Exclude; -``` - -### `toSafeError` (error-handling.ts) - -```ts -/** - * Converts a `FailureOutcome` to a JavaScript `Error` for safe links. - * - * Safe-link message contract (preserved verbatim from the pre-refactor code): - * - `network` verbose: the raw fetch Error (whatever the transport layer threw) - * - `network` safe: `'Network error'` - * - `http-status`: the formatted `unmappedStatus` message (same text as the prone path) - * - `parse`: the raw `SyntaxError` from `response.json()` (unchanged) - * - `validate`: the Error already thrown by `validateResourceOrThrow`, which - * formatted it in the safe-link format at throw time - * - * @internal - */ -export function toSafeError( - outcome: FailureOutcome, - verbosity: Verbosity, - url: string, -): Error { - switch (outcome.kind) { - case 'network': - return verbosity === 'safe' ? new Error('Network error') : outcome.error; - case 'http-status': - return new Error(formatErrorMessage(verbosity, { - kind: 'unmappedStatus', url, response: outcome.response, - })); - case 'parse': - case 'validate': - return outcome.error; - } -} -``` - -### `toProneFailure` (error-handling.ts) - -```ts -/** - * Converts a `FailureOutcome` to a `Failure` variant for prone links. - * - * Returns the failure plus an optional `hydrateWith` hint: when the - * caller must call `rememberLinks` on the failure's `resource` (because the - * failure is an expected typed error with an embedded resource shape), the - * `ResourceDefinition` to hydrate with is returned here. `api-client.ts` - * owns the `rememberLinks` call because `error-handling.ts` must not import - * `runtime-metadata.ts` — this hint is the one thread that crosses the layer. - * - * @internal - */ -export async function toProneFailure( - outcome: FailureOutcome, - verbosity: Verbosity, - url: string, - linkDef: LinkDefinition, - apiDef: ApiDefinition, -): Promise<{ - readonly failure: Failure; - readonly hydrateWith?: ResourceDefinition; -}> { - switch (outcome.kind) { - case 'network': - return { failure: networkFailure(verbosity, url, outcome.error) }; - case 'http-status': - return classifyHttpStatus(verbosity, url, outcome.response, linkDef, apiDef); - case 'parse': - return { - failure: invalidJsonFailure( - verbosity, url, outcome.response, linkDef.to, outcome.error, - ), - }; - case 'validate': - return { - failure: invalidStructureFailure( - verbosity, url, outcome.response, linkDef.to, outcome.error, - ), - }; - } -} - -/** - * Dispatches a non-OK response to either an expected Failure variant - * (if the status is in the link's `expect` map) or the `'unmappedStatus'` - * catch-all. Expected cases that fail to parse or validate fall back to - * `invalidJson` / `invalidStructure` unexpected variants, in which case - * there is nothing to hydrate and `hydrateWith` is omitted. - * - * Replaces the deleted `responseFailure` export. - */ -async function classifyHttpStatus( - verbosity: Verbosity, - url: string, - response: Response, - linkDef: LinkDefinition, - apiDef: ApiDefinition, -): Promise<{ - readonly failure: Failure; - readonly hydrateWith?: ResourceDefinition; -}> { - const errorName = linkDef.expect?.[response.status]; - if (!errorName) { - return { failure: unmappedStatusFailure(verbosity, url, response) }; - } - const errorDef = apiDef[errorName]; - const failure = await createExpectedFailure( - response, errorName, errorDef, verbosity, url, - ); - // createExpectedFailure falls back to invalidJson/invalidStructure on parse - // or schema failure; those variants have no `resource` to hydrate. - if (failure.kind === 'unexpected') return { failure }; - return { failure, hydrateWith: errorDef }; -} -``` - -### `validateResourceOrThrow` (error-handling.ts) - -```ts -/** - * Validates a parsed resource against its schema. Throws with the - * safe-link-formatted message on failure — i.e., the message contract - * that `fetchSafe` callers used to see directly. The prone path catches - * this throw inside `runPipeline` and `toProneFailure` reshapes it into an - * `invalidStructure` Failure whose `cause.message` contains the thrown text. - * - * Hoisted from `ApiClient.validateResource`; the body is identical. - * - * @internal - */ -export function validateResourceOrThrow( - resource: unknown, - schema: TSchema, - url: string, - verbosity: Verbosity, -): void { - if (verbosity === 'safe') { - if (!Value.Check(schema, resource)) { - throw new Error('Response validation failed'); - } - return; - } - const errors = [...Value.Errors(schema, resource)]; - if (errors.length > 0) { - const details = errors.map(e => `${e.path}: ${e.message}`).join(', '); - throw new Error(`Response validation failed for ${url}: ${details}`); - } -} -``` - -### Rewritten `resolve` (api-client.ts) - -```ts -/** - * Resolves a link on a navigable object. - * - * The pre-pipeline section handles programming errors (unknown link name, - * invalid URI-template params, missing resource definition) — these propagate - * verbatim rather than becoming Failures, because they indicate caller bugs, - * not server or network failures. - * - * The pipeline runs fetch → parse → validate and returns a `PipelineOutcome`. - * Safe and prone links share this entire flow; the only difference is what - * happens after the outcome lands: - * - * - **Success**: both paths call `rememberLinks` identically; they differ only - * in return shape (resource vs. `[resource, null]` tuple). - * - **Failure**: safe links throw the converted Error; prone links convert to - * a `Failure` variant, hydrate its embedded resource (if any) via - * `rememberLinks`, and return `[null, failure]`. - * - * The `as any` at the end is unavoidable: TypeScript cannot narrow the - * conditional return type from inside this generic body. - */ -async resolve( - navigable: Navigable, - linkName?: string, - params?: Static, -): Promise : ResourceOrFailure> { - // --- Pre-pipeline: programming errors propagate verbatim --- - const link = recallLink(navigable, linkName); - const url = this.expandUrl(link, params); - const resourceDef = this.requireResourceDef(link.linkDef.to); - const baseURL = extractBaseURL(url); - - // --- Pipeline: same flow for safe and prone --- - const outcome = await this.runPipeline( - url, link.linkDef.to, resourceDef.schema, navigable, - ); - - // --- Success path: shared hydration, return shape differs --- - if (outcome.kind === 'success') { - rememberLinks(outcome.resource, resourceDef, baseURL, this); - return (link.linkDef.expect ? [outcome.resource, null] : outcome.resource) as any; - } - - // --- Failure path: safe throws, prone returns tuple --- - if (!link.linkDef.expect) { - throw toSafeError(outcome, this.errorVerbosity, url); - } - const { failure, hydrateWith } = await toProneFailure( - outcome, this.errorVerbosity, url, link.linkDef, this.apiDef, - ); - if (hydrateWith) { - rememberLinks( - (failure as { resource: unknown }).resource, - hydrateWith, - baseURL, - this, - ); - } - return [null, failure] as any; -} -``` - -**Note on the `(failure as { resource: unknown })` cast:** TypeScript can't narrow `failure` through the truthiness of `hydrateWith`, so the cast is unavoidable here. It's the one place where a "layering leak" between `error-handling.ts` and `runtime-metadata.ts` is visible, and it's a single line. This is the same cast the synthesis plan acknowledged in its PR #73 critique; the difference is that here it appears *once* in `resolve` instead of being scattered across `fetchProne`. - -### Class-header doc (api-client.ts) - -Graft the synthesis-plan design-intent paragraph (adapted for the new shape): - -> **Design intent:** `api-client.ts` is deliberately kept thin on error-handling. Error classification, verbosity handling, message formatting, parse recovery and schema validation all live in `error-handling.ts`. The entire fetch → parse → validate flow lives in the single `runPipeline` method (one `try`/`catch`, tracked by a `step` variable); `resolve` dispatches the resulting outcome to the appropriate converter. If you find yourself adding a second `try`/`catch`, a `verbosity === 'safe'` check, or error-message string concatenation in here, the right home for it is almost certainly `error-handling.ts`. - -## Why this beats the synthesis plan - -| Aspect | Synthesis plan (PR #74 base) | This plan | -|---|---|---| -| Fetch methods | `fetchSafe` + `fetchProne` (flow duplicated in two bodies) | **One** `runPipeline` method | -| `try`/`catch` sites in api-client | 1 (transport in `fetchProne`) | 1 (inside `runPipeline`) | -| `verbosity === 'safe'` checks in api-client | 0 | 0 | -| Non-OK handling | Inlined in `fetchSafe` *and* in `classifyProneHttpError` call from `fetchProne` | Single `return { kind: 'http-status' }` in the pipeline | -| Shared success hydration | Duplicated in `deliverSuccess` + `fetchProne` | Single `rememberLinks` call in `resolve` | -| Shared pre-flight (`requireResourceDef`, `extractBaseURL`) | Called twice (once per fetch method) | Called once in `resolve` | -| Safe/prone divergence | Two full method bodies | Two `if` branches at the bottom of `resolve` | -| Methods deleted | `deliverSuccess`, `toFailure` (2) | `fetchSafe`, `fetchProne`, `deliverSuccess`, `toFailure`, `validateResource` (5) | -| New abstractions introduced | 4 free functions (`throwNetworkError`, `throwHttpError`, `validateResourceOrThrow`, `parseProneBody`) + `classifyProneHttpError` | 1 type (`PipelineOutcome`) + 3 functions (`validateResourceOrThrow`, `toSafeError`, `toProneFailure`) + 1 private helper (`classifyHttpStatus`) | - -The critical improvement: in the synthesis plan, the *flow* (fetch → parse → validate) is spelled out twice — once in `fetchSafe`, once in `fetchProne` — and an agent maintaining the code has to keep those two in sync. In this plan, the flow is spelled out **once**, and the safe/prone divergence is literally four lines at the bottom of `resolve`. - -## Line-count estimate - -| File | Before | After | Delta | -|---|---|---|---| -| `src/api-client.ts` | 305 | ~255 | **-50** | -| `src/error-handling.ts` | 399 | ~475 | **+76** | -| **Total** | **704** | **~730** | **+26** | - -Slightly net-positive (~+26 lines), similar to the synthesis plan's budget (~-4 lines). The extra lines buy structural consolidation — deleting five private methods, eliminating the flow duplication between `fetchSafe`/`fetchProne`, and reducing the api-client surface area to a single try/catch. - -## Test-assertion impact - -**All 22 pinned error strings survive unchanged.** Walked case by case: - -| Scenario | Current assertion | New code path | Result | -|---|---|---|---| -| Safe verbose HTTP | `'HTTP 404: Not Found (http://localhost:3000/safe)'` | `toSafeError` → `formatErrorMessage({kind:'unmappedStatus'})` verbose | ✓ | -| Safe verbose network | `'Connection refused'` | `toSafeError` → `outcome.error` (raw) | ✓ | -| Safe verbose parse | `'Unexpected token in JSON'` | `toSafeError` → `outcome.error` (raw SyntaxError) | ✓ | -| Safe verbose validate | `/Response validation failed.*\/pets: Expected array/` | `toSafeError` → `outcome.error` (thrown by `validateResourceOrThrow`) | ✓ | -| Safe safe HTTP | `'HTTP 500 error'`, `'HTTP 404 error'` | `toSafeError` → `formatErrorMessage` safe branch | ✓ | -| Safe safe network | `'Network error'` | `toSafeError` safe branch returns literal | ✓ | -| Safe safe validate | `'Response validation failed'` | `validateResourceOrThrow` safe branch throws literal | ✓ | -| Prone verbose mapped | `'HTTP 404: Not Found (http://localhost:3000/error-prone)'` | `classifyHttpStatus` → `createExpectedFailure` → `mappedStatus` verbose | ✓ | -| Prone verbose parse | `'Failed to parse JSON when target was expected (http://localhost:3000/error-prone)'` | `toProneFailure` → `invalidJsonFailure` → `invalidJson` verbose | ✓ | -| Prone verbose validate | `'Validation of target failed (http://localhost:3000/error-prone)'` | `toProneFailure` → `invalidStructureFailure` → `invalidStructure` verbose | ✓ | -| Prone verbose unmapped | `'HTTP 403: Forbidden (http://localhost:3000/error-prone)'` | `classifyHttpStatus` → `unmappedStatusFailure` | ✓ | -| Prone verbose mapped-parse-fail | `'Failed to parse JSON when notFound was expected (http://localhost:3000/error-prone)'` | `createExpectedFailure` → `invalidJsonFailure` | ✓ | -| Prone verbose mapped-validate-fail | `'Validation of notFound failed (http://localhost:3000/error-prone)'` | `createExpectedFailure` → `invalidStructureFailure` | ✓ | -| Prone verbose network | `'Network error: Network failure (http://localhost:3000/error-prone)'` | `toProneFailure` → `networkFailure` | ✓ | -| Prone safe network | `'Network error'` | `networkFailure` safe branch | ✓ | -| Prone safe unmapped | `'HTTP 403 error'` | `unmappedStatusFailure` safe branch | ✓ | -| Prone safe mapped | `'HTTP 404'` | `createExpectedFailure` → `mappedStatus` safe | ✓ | -| Prone safe parse | `'HTTP 404: Response parse error'` | `invalidJsonFailure` safe branch | ✓ | -| Prone safe validate | `'HTTP 404: Response validation error'` | `invalidStructureFailure` safe branch | ✓ | -| Headers exposed (verbose) | `Headers` instance with values | `makeResponseInfo` verbose | ✓ | -| Headers stripped (safe) | empty `Headers` | `makeResponseInfo` safe | ✓ | -| `error.cause.message` contains `'Response validation failed'` | `cause` is the thrown Error | `invalidStructureFailure` stores `outcome.error` as `cause` in verbose | ✓ | - -## Internal-export audit - -The refactor removes these `@internal` exports from `error-handling.ts`: -- `httpErrorMessage` — **inlined** into `toSafeError` -- `responseFailure` — **replaced** by `classifyHttpStatus` (file-private) - -These exports remain (unchanged): -- `Failure`, `ResponseInfo`, `ResourceOrFailure` — public API -- `networkFailure`, `unmappedStatusFailure`, `invalidJsonFailure`, `invalidStructureFailure` — called by the new converters - -These exports are added: -- `PipelineOutcome`, `FailureOutcome` — consumed by `toSafeError` / `toProneFailure` -- `validateResourceOrThrow` — called by `runPipeline` -- `toSafeError`, `toProneFailure` — called by `resolve` - -**Pre-implementation check:** audit `test/` for any direct imports of `httpErrorMessage` or `responseFailure`. If none, the exports can be deleted outright. If some, either migrate the tests to the new exports in the same commit or temporarily keep the old ones exported alongside. Grep-based audit finds no direct imports of these symbols in tests (they're exercised only through the public navigate/linkTo API). - -## Implementation plan - -1. **Audit `test/` for direct imports** of `httpErrorMessage` and `responseFailure`. Confirm none exist. If any exist, add them to the migration list. -2. **Add new types and functions to `error-handling.ts`**: - - `PipelineOutcome`, `FailureOutcome` types - - `validateResourceOrThrow` (copy body from `ApiClient.validateResource`) - - `toSafeError` - - `classifyHttpStatus` (file-private) - - `toProneFailure` -3. **Delete from `error-handling.ts`**: - - `httpErrorMessage` export (inline its single call into `toSafeError`) - - `responseFailure` export (replaced by `classifyHttpStatus`) -4. **Add `runPipeline` method** to `ApiClient`. -5. **Rewrite `ApiClient.resolve`** to use the new pipeline + converters (see sketch above). -6. **Delete from `ApiClient`**: - - `fetchSafe` - - `fetchProne` - - `deliverSuccess` - - `toFailure` - - `validateResource` -7. **Update the `ApiClient` class header comment** with the design-intent paragraph. -8. **Update `AGENTS.md`** to reflect the new shape (`runPipeline` method, `PipelineOutcome` type, the flow unification story). -9. **Update `docs/how-it-works.md`** if it references any deleted methods. -10. **Run `npm run test:coverage`**. Expect all green, all four coverage metrics at 100%. -11. **Commit** as a single refactor commit — this is a cohesive reshape, not separable. - -## Risks and mitigations - -| Risk | Mitigation | -|---|---| -| Pinned error strings break | All 22 walked through above — each preserved by construction | -| 100% coverage breaks | Every branch of `runPipeline`, `toSafeError`, `toProneFailure`, `classifyHttpStatus`, `validateResourceOrThrow` is exercised by existing integration tests (the tests exercise the same code paths, just via different method names) | -| Test imports reach into deleted symbols | Audited above — none do; if any turn up during implementation, either migrate or keep exports | -| `resolve` generic return type breaks | The `as any` at the end of `resolve` is unchanged from the current code; the conditional return type is the same | -| `response!` non-null assertion in `runPipeline` is unsound | Provably correct: `step` is only `'parse'` or `'validate'` after `response = await this.doFetch(...)` resolved. TypeScript just can't see through the mutation. | - -## What this plan explicitly does NOT do - -- **No public API changes.** `navigate`, `linkTo`, `Failure`, `ResponseInfo`, and `ResourceOrFailure` are untouched. -- **No new files in `src/`.** Everything lives in the existing `api-client.ts` and `error-handling.ts`. -- **No new classes.** `runPipeline` is a method on `ApiClient`; the converters are free functions. -- **No speculative abstractions.** The pipeline is a straight-line method body, not an array of `Step` callbacks. The "named steps" are tracked by a single `step` variable, not by a stateful runner object. -- **No changes to `recallLink` / `expandUrl` flow.** They remain outside the pipeline because their failures are programming errors that should propagate verbatim. -- **No changes to `FetchFactory` or `FetchContext`.** The fetch invocation flows through `this.doFetch` exactly as it does today. -- **No merging of safe-link and prone-link error messages.** The existing message divergence (e.g. safe-verbose-validate uses `'Response validation failed for url: details'` while prone-verbose-validate uses `'Validation of target failed (url)'`) is preserved verbatim — unifying the wording would break pinned tests and is orthogonal to the flow unification.