diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b86a23bbe..3d70988940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,68 @@ jobs: with: message: Deployed ${{ github.sha }} to https://ForgeRock.github.io/ping-javascript-sdk/pr-${{ github.event.number }}/${{github.sha}} branch gh-pages in ForgeRock/ping-javascript-sdk + - name: Validate interface mapping + id: interface-mapping + continue-on-error: true + run: | + OUTPUT=$(pnpm mapping:validate 2>&1) + EXIT_CODE=$? + echo "$OUTPUT" + echo 'report<> "$GITHUB_OUTPUT" + echo "$OUTPUT" >> "$GITHUB_OUTPUT" + echo 'MAPPING_EOF' >> "$GITHUB_OUTPUT" + exit $EXIT_CODE + + - name: Find interface mapping comment + id: find-mapping-comment + if: always() + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: + + - name: Update interface mapping comment + if: always() && steps.interface-mapping.outcome == 'failure' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-mapping-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body: | + + ## Interface Mapping Out of Date + + The `interface_mapping.md` document is out of sync with the SDK exports. + +
+ Drift report + + ``` + ${{ steps.interface-mapping.outputs.report }} + ``` + +
+ + **To fix**, run: + ```bash + pnpm mapping:generate + ``` + Then commit the updated `interface_mapping.md`. + + - name: Update interface mapping comment (passing) + if: always() && steps.interface-mapping.outcome == 'success' && steps.find-mapping-comment.outputs.comment-id != '' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-mapping-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body: | + + ## Interface Mapping Up to Date + + The `interface_mapping.md` document is in sync with the SDK exports. + - name: Download baseline bundle sizes uses: dawidd6/action-download-artifact@v3 with: diff --git a/.gitignore b/.gitignore index b7ab27b789..afd11c3dc6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ out-tsc/ .swc .vite +# Lefthook +.lefthook-local.yml +.lefthook/ + # IDEs .vscode diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100755 index 35efa3f4dc..0000000000 --- a/.husky/commit-msg +++ /dev/null @@ -1 +0,0 @@ -pnpm run commitlint ${1} diff --git a/.husky/install.mjs b/.husky/install.mjs deleted file mode 100644 index 640eaa8d65..0000000000 --- a/.husky/install.mjs +++ /dev/null @@ -1,6 +0,0 @@ -// Skip Husky install in production and CI -if (process.env.NODE_ENV === 'production' || process.env.CI === 'true') { - process.exit(0); -} -const husky = (await import('husky')).default -console.log(husky()) diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index cb2c84d5c3..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm lint-staged diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index 3c2296efa8..0000000000 --- a/.husky/pre-push +++ /dev/null @@ -1 +0,0 @@ -# CI=true pnpm run nx affected:test && CI=true npx nx affected:e2e diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg deleted file mode 100755 index af230c12af..0000000000 --- a/.husky/prepare-commit-msg +++ /dev/null @@ -1,3 +0,0 @@ -if [ "$2" == "template" ]; then # Only run commitizen if no commit message was already provided. - exec < /dev/tty && pnpm run commit || true -fi diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000000..b1dcb3258c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,236 @@ +# Migration Guide: Legacy JavaScript SDK to Ping SDK + +This guide documents the conversion from `@forgerock/javascript-sdk` (legacy) to the newer Ping SDK packages. For the complete API-level mapping of every class, method, parameter, and return type, see [`interface_mapping.md`](./interface_mapping.md). + +## Package Dependencies + +| Legacy | New | Purpose | +| --------------------------- | --------------------------- | ------------------------------------------------------------------ | +| `@forgerock/javascript-sdk` | `@forgerock/journey-client` | Authentication tree/journey flows | +| `@forgerock/javascript-sdk` | `@forgerock/oidc-client` | OAuth2/OIDC token management, user info, logout | +| `@forgerock/javascript-sdk` | `@forgerock/sdk-types` | Shared types and enums | +| `@forgerock/javascript-sdk` | `@forgerock/device-client` | Device profile & management (OATH, Push, WebAuthn, Bound, Profile) | +| `@forgerock/ping-protect` | `@forgerock/protect` | PingOne Protect/Signals integration | + +--- + +## SDK Initialization & Configuration + +The legacy SDK uses a global static `Config.set()`. The new SDK uses **async factory functions** that each return an independent client instance. + +| Legacy | New | Notes | +| ----------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------ | +| `import { Config } from '@forgerock/javascript-sdk'` | `import { journey } from '@forgerock/journey-client'` | Journey client factory | +| — | `import { oidc } from '@forgerock/oidc-client'` | OIDC client factory (separate package) | +| `Config.set({ clientId, redirectUri, scope, serverConfig, realmPath, tree })` | `const journeyClient = await journey({ config })` | Async initialization; config per-client, not global | +| `TokenStorage.get()` | `const tokens = await oidcClient.token.get()` | Now part of oidcClient; check errors with `if ('error' in tokens)` | + +### Wellknown Configuration + +Both clients require only the OIDC wellknown endpoint URL. All other configuration (baseUrl, paths, realm) is derived automatically: + +```typescript +import type { JourneyClientConfig } from '@forgerock/journey-client/types'; + +// Shared config — journey-client only needs serverConfig.wellknown +const config: JourneyClientConfig = { + serverConfig: { + wellknown: 'https://am.example.com/am/oauth2/alpha/.well-known/openid-configuration', + }, +}; + +// Journey client — for authentication tree flows +const journeyClient = await journey({ config }); + +// OIDC client — for token management, user info, logout +const oidcClient = await oidc({ + config: { + ...config, + clientId: 'my-app', + redirectUri: `${window.location.origin}/callback`, + scope: 'openid profile', + }, +}); +``` + +--- + +## Authentication & Journey Flow + +| Legacy Method | New Method | Notes | +| -------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ | +| `FRAuth.start({ tree: 'Login' })` | `journeyClient.start({ journey: 'Login' })` | Tree name passed per-call via `journey` param (not in config) | +| `FRAuth.next(step, { tree: 'Login' })` | `journeyClient.next(step)` | Tree is set at `start()`, not repeated on `next()` | +| `FRAuth.redirect(step)` | `await journeyClient.redirect(step)` | Now async. Step stored in `sessionStorage` (was `localStorage`) | +| `FRAuth.resume(resumeUrl)` | `await journeyClient.resume(resumeUrl)` | Previous step retrieved from `sessionStorage` (was `localStorage`) | +| No equivalent | `await journeyClient.terminate()` | **New.** Ends the AM session via `/sessions` endpoint | + +### Before/After: Login Flow + +**Legacy:** + +```typescript +import { FRAuth, StepType } from '@forgerock/javascript-sdk'; + +Config.set({ + serverConfig: { baseUrl: 'https://am.example.com/am' }, + realmPath: 'alpha', + tree: 'Login', +}); + +let step = await FRAuth.start(); +while (step.type === StepType.Step) { + // ... handle callbacks ... + step = await FRAuth.next(step); +} +if (step.type === StepType.LoginSuccess) { + console.log('Session token:', step.getSessionToken()); +} +``` + +**New:** + +```typescript +import { journey } from '@forgerock/journey-client'; + +const journeyClient = await journey({ + config: { + serverConfig: { + wellknown: 'https://am.example.com/am/oauth2/alpha/.well-known/openid-configuration', + }, + }, +}); + +let result = await journeyClient.start({ journey: 'Login' }); +while (result.type === 'Step') { + // ... handle callbacks ... + result = await journeyClient.next(result); +} +if ('error' in result) { + console.error('Journey error:', result); +} else if (result.type === 'LoginSuccess') { + console.log('Session token:', result.getSessionToken()); +} +``` + +--- + +## User Management + +| Legacy | New | Notes | +| -------------------------------------- | -------------------------------- | -------------------------------------------------------- | +| `UserManager.getCurrentUser()` | `await oidcClient.user.info()` | Returns typed `UserInfoResponse` or `GenericError` | +| `FRUser.logout({ logoutRedirectUri })` | `await oidcClient.user.logout()` | Revokes tokens + ends session. Returns structured result | + +--- + +## Token Management & OAuth Flow + +| Legacy | New | Notes | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `TokenManager.getTokens({ forceRenew: true })` | `await oidcClient.token.get({ forceRenew: true, backgroundRenew: true })` | Single call with auto-renewal. Returns tokens or error | +| `TokenManager.getTokens()` then manual code+state | `await oidcClient.authorize.background()` then `await oidcClient.token.exchange(code, state)` | Two-step when you need explicit control | +| `TokenManager.deleteTokens()` | `await oidcClient.token.revoke()` | Revokes remotely AND deletes locally | +| `TokenStorage.get()` | `await oidcClient.token.get()` | Auto-retrieves from storage; check `'error' in tokens` | +| `TokenStorage.set(tokens)` | Handled automatically by `oidcClient.token.exchange()` | Tokens stored after exchange | +| `TokenStorage.remove()` | `await oidcClient.token.revoke()` | Combined revoke + delete | + +### Token Type Change + +| Legacy | New | Change | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `Tokens { accessToken?, idToken?, refreshToken?, tokenExpiry? }` | `OauthTokens { accessToken, idToken, refreshToken?, expiresAt?, expiryTimestamp? }` | `accessToken` and `idToken` now required. `tokenExpiry` renamed to `expiryTimestamp` | + +--- + +## Callback & WebAuthn + +| Legacy | New | Notes | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------- | +| `import { CallbackType } from '@forgerock/javascript-sdk'` | `import { callbackType } from '@forgerock/journey-client'` | PascalCase enum → camelCase object | +| `CallbackType.RedirectCallback` | `callbackType.RedirectCallback` | Values remain the same strings | +| `import { FRCallback } from '@forgerock/javascript-sdk'` | `import { BaseCallback } from '@forgerock/journey-client/types'` | Base class renamed | +| `import { NameCallback, ... } from '@forgerock/javascript-sdk'` | `import { NameCallback, ... } from '@forgerock/journey-client/types'` | Same class names, different import path | +| `import { FRWebAuthn, WebAuthnStepType } from '@forgerock/javascript-sdk'` | `import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | `FRWebAuthn` → `WebAuthn`, submodule import | + +--- + +## Step Types + +| Legacy | New | Notes | +| --------------------------------- | ----------------------------------- | ----------------------------------------------------- | +| `FRStep` (class instance) | `JourneyStep` (object type) | Cannot use `instanceof`; use `result.type === 'Step'` | +| `FRLoginSuccess` (class instance) | `JourneyLoginSuccess` (object type) | Use `result.type === 'LoginSuccess'` | +| `FRLoginFailure` (class instance) | `JourneyLoginFailure` (object type) | Use `result.type === 'LoginFailure'` | + +All step methods (`getCallbackOfType`, `getDescription`, `getHeader`, `getStage`, `getSessionToken`, etc.) remain identical. + +--- + +## HTTP Client + +The legacy `HttpClient` is removed. It provided auto bearer-token injection, 401 token refresh, and policy advice handling. In the new SDK, manage tokens manually: + +```typescript +const tokens = await oidcClient.token.get({ backgroundRenew: true }); +if ('error' in tokens) { + throw new Error('No valid tokens'); +} + +const response = await fetch('https://api.example.com/resource', { + method: 'GET', + headers: { Authorization: `Bearer ${tokens.accessToken}` }, +}); +``` + +--- + +## Error Handling Pattern + +The new SDK returns error objects instead of throwing exceptions: + +| Legacy | New | +| ------------------------------------------------ | ------------------------------------------------------------------------ | +| `try { ... } catch (err) { console.error(err) }` | `if ('error' in result) { console.error(result.error, result.message) }` | + +**Legacy:** + +```typescript +try { + const user = await UserManager.getCurrentUser(); + setUser(user); +} catch (err) { + console.error(`Error: get current user; ${err}`); + setUser({}); +} +``` + +**New:** + +```typescript +const user = await oidcClient.user.info(); +if ('error' in user) { + console.error('Error getting user:', user); + setUser({}); +} else { + setUser(user); +} +``` + +--- + +## Summary of Key Changes + +1. **Async-First Initialization**: SDK clients initialized asynchronously with factory functions (`journey()`, `oidc()`) +2. **Instance-Based APIs**: Methods called on client instances, not static classes +3. **Separated Packages**: Journey auth (`@forgerock/journey-client`), OIDC (`@forgerock/oidc-client`), Device (`@forgerock/device-client`), Protect (`@forgerock/protect`) +4. **Explicit Error Handling**: Response objects contain `{ error }` property instead of throwing exceptions +5. **Wellknown-Based Discovery**: Only `serverConfig.wellknown` is required; all paths derived automatically +6. **No Built-in HTTP Client**: Protected API requests require manual token retrieval and header management +7. **Config Fixed at Creation**: Per-call config overrides removed; create separate client instances for different configs + +--- + +## Further Reference + +For the complete API-level mapping (every class, method, parameter change, return type change, and behavioral note), see [`interface_mapping.md`](./interface_mapping.md). diff --git a/docs/superpowers/specs/2026-04-08-interface-mapping-generator-design.md b/docs/superpowers/specs/2026-04-08-interface-mapping-generator-design.md new file mode 100644 index 0000000000..b998ed5e7d --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-interface-mapping-generator-design.md @@ -0,0 +1,230 @@ +# Interface Mapping Generator — Design Spec + +**Date:** 2026-04-08 +**Status:** Draft +**Package:** `tools/interface-mapping-validator` (evolution, not new package) +**Depends on:** Interface Mapping Validator (already implemented) + +## Problem + +The interface mapping validator can detect drift, but the Quick Reference (Section 0), Package Mapping (Section 1), and Callback Type Mapping (Section 5) tables still require manual authoring. These tables are derivable from a small mapping config + auto-discovery of callback exports. + +## Solution + +Add a generator mode (`--generate`) that produces Sections 0, 1, and 5 from: +1. A TypeScript mapping config defining legacy → new symbol connections +2. Auto-discovered callback exports from `@forgerock/journey-client/types` +3. Auto-discovered 1:1 name matches between legacy and new SDK exports + +Sections 2-4, 6-20 remain hand-authored and are never modified by the generator. + +## Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Config format | TypeScript (`mapping-config.ts`) | Type-checked, no extra dependencies | +| Behavioral notes | In config for Section 0; deeper sections hand-written | One-liner notes belong with mapping data | +| Output strategy | Replace sections 0, 1, 5 in-place | No markers needed; identify sections by heading | + +## New Files + +``` +tools/interface-mapping-validator/src/ +├── mapping-config.ts # Symbol mapping data +├── generator.ts # Pure function: config + exports → table strings +├── generator.test.ts # Tests for generator +├── writer.ts # Replace sections in markdown +└── writer.test.ts # Tests for writer +``` + +## Modified Files + +``` +tools/interface-mapping-validator/src/ +├── main.ts # Add --generate flag +├── types.ts # Add SymbolMapping type +``` + +## Module Design + +### 1. Mapping Config (`mapping-config.ts`) + +**Type definitions (added to `types.ts`):** + +```typescript +export type RenamedMapping = { + readonly new: string; + readonly package: string; + readonly type?: boolean; + readonly note?: string; +}; + +export type RemovedMapping = { + readonly status: 'removed'; + readonly note: string; +}; + +export type InternalMapping = { + readonly status: 'internal'; + readonly note: string; +}; + +export type SymbolMapping = RenamedMapping | RemovedMapping | InternalMapping; +``` + +**Config structure:** + +```typescript +export const SYMBOL_MAP: Record = { + // Renamed/moved symbols + FRAuth: { new: 'journey', package: '@forgerock/journey-client', note: 'factory returns `JourneyClient`' }, + FRStep: { new: 'JourneyStep', package: '@forgerock/journey-client/types', type: true }, + FRCallback: { new: 'BaseCallback', package: '@forgerock/journey-client/types' }, + FRLoginSuccess: { new: 'JourneyLoginSuccess', package: '@forgerock/journey-client/types', type: true }, + FRLoginFailure: { new: 'JourneyLoginFailure', package: '@forgerock/journey-client/types', type: true }, + // ... etc + + // Removed symbols + Config: { status: 'removed', note: 'pass config to `journey()` / `oidc()` factory params' }, + HttpClient: { status: 'removed', note: 'use `fetch` + manual `Authorization` header' }, + + // Internal symbols (not re-exported) + WebAuthnOutcome: { status: 'internal', note: 'internal to webauthn module' }, +}; +``` + +**Callbacks are NOT in the config.** They are auto-discovered from `@forgerock/journey-client/types` exports ending in `Callback`. The generator produces their rows automatically since the names match 1:1 between legacy and new SDK. + +### 2. Generator (`generator.ts`) + +Pure function. No file I/O. + +**Input:** +- `legacy: LegacyExport[]` — from the legacy extractor +- `newSdk: NewSdkExport[]` — from the new SDK extractor +- `config: Record` — the mapping config + +**Output:** +```typescript +type GeneratedSections = { + quickReference: string; // Complete markdown table for Section 0 + packageMapping: string; // Complete markdown table for Section 1 + callbackMapping: string; // Complete markdown table for Section 5 + unmapped: string[]; // Legacy symbols not in config and not auto-matched +}; +``` + +**Logic for Section 0 (Quick Reference):** + +For each legacy export symbol, in this priority order: +1. **Config lookup:** If symbol is in `SYMBOL_MAP`: + - `RenamedMapping` → `| \`Symbol\` | \`import { new } from 'package'\` note |` + - `RemovedMapping` → `| \`Symbol\` | Removed — note |` + - `InternalMapping` → `| \`Symbol\` | Not exported — note |` +2. **Auto-match:** If symbol name exists as a new SDK export (1:1 name match) → generate row with the discovered import path +3. **Callback auto-match:** If symbol ends with `Callback` and exists in `@forgerock/journey-client/types` → generate row +4. **Unmapped:** Symbol goes into the `unmapped` list (reported as an error) + +The table is sorted: renamed/moved first (alphabetical), then removed, then internal. + +**Logic for Section 1 (Package Mapping):** + +Generated from the same config data, grouped by target package. Each row shows the full legacy import and the full new import: + +``` +| `import { FRAuth } from '@forgerock/javascript-sdk'` | `import { journey } from '@forgerock/journey-client'` | Authentication flow | +``` + +Only `RenamedMapping` entries appear here. Removed and internal symbols are excluded. + +**Logic for Section 5 (Callback Type Mapping):** + +Auto-generated from new SDK exports where: +- `importPath === '@forgerock/journey-client/types'` +- `symbol.endsWith('Callback')` + +Each callback gets a row: +``` +| `import { NameCallback } from '@forgerock/javascript-sdk'` | `import { NameCallback } from '@forgerock/journey-client/types'` | None | +``` + +The "Method Changes" column defaults to "None". Callbacks with method changes can optionally be listed in a separate config if needed in the future. + +### 3. Writer (`writer.ts`) + +Takes the existing `interface_mapping.md` content and replaces specific sections. + +**Input:** +- `content: string` — current markdown file content +- `sections: Record` — map of section heading → new content + +**Output:** +- `string` — updated markdown + +**Section identification:** Matches headings by pattern: +- Section 0: `## 0. Quick Reference` +- Section 1: `## 1. Package Mapping` +- Section 5: heading `### Callback Type Mapping` (sub-heading within Section 5) + +**Replacement boundary:** From the heading line to the next `---` horizontal rule, next same-or-higher-level heading, or end of file — whichever comes first. The heading itself is preserved; only the content between the heading and the boundary is replaced. + +For Section 5's callback table specifically: the heading `### Callback Type Mapping` and the content up to the next heading or `---` is replaced. The surrounding Section 5 content (Base Class Change table, BaseCallback Methods table) is preserved. + +**Safety:** Content outside the three target sections is preserved byte-for-byte. The writer is a pure function (string in, string out) — file I/O is handled by `main.ts`. + +### 4. CLI Changes (`main.ts`) + +New `--generate` flag: + +```bash +pnpm tsx tools/interface-mapping-validator/src/main.ts --generate +``` + +Behavior: +1. Run extractors (legacy, new-sdk) +2. Import mapping config +3. Run generator → get three section strings + unmapped list +4. If unmapped symbols exist, print them and exit with code 1 +5. Run writer to replace sections in `interface_mapping.md` +6. Run validator on the result to catch any remaining issues +7. Print report + +The existing `--fix` and default (validate-only) modes are unchanged. + +### 5. Updated Validator + +The differ gains awareness of the mapping config for better error messages: +- "Legacy symbol `X` is not in SYMBOL_MAP and has no 1:1 name match" (instead of generic "undocumented") +- Config entries referencing symbols not in the legacy SDK are flagged as stale config + +This is a nice-to-have, not blocking. The validator continues to work as before even without this change. + +## Testing Strategy + +**generator.test.ts:** +- Config with renamed symbol → correct Quick Reference row +- Config with removed symbol → "Removed — note" row +- Config with internal symbol → "Not exported — note" row +- Auto-matched callback → correct Callback Mapping row +- 1:1 name match (no config entry) → auto-generated row +- Unmapped symbol → appears in unmapped list +- Section 1 groups by package correctly +- Type imports use `import type` syntax + +**writer.test.ts:** +- Replaces Section 0 content, preserves everything else +- Replaces Section 5 callback table, preserves surrounding Section 5 content +- Handles missing sections gracefully +- Round-trip safe: non-target content preserved byte-for-byte + +**mapping-config.ts validation (integration test):** +- Every key in SYMBOL_MAP exists in legacy SDK exports +- Every `RenamedMapping.package` is a valid new SDK import path +- Every `RenamedMapping.new` exists as an export at the specified package + +## Out of Scope + +- Generating behavioral notes for deeper sections (2-4, 6-20) +- Generating code examples +- Detecting renames automatically (the config IS the rename database) +- Callback-specific method change notes (defaults to "None") diff --git a/interface_mapping.md b/interface_mapping.md new file mode 100644 index 0000000000..18c7b3107f --- /dev/null +++ b/interface_mapping.md @@ -0,0 +1,1102 @@ +--- +document: interface_mapping +version: '1.0' +last_updated: '2026-04-06' +legacy_sdk: '@forgerock/javascript-sdk' +legacy_source: '.opensource/forgerock-javascript-sdk/packages/javascript-sdk/src' +new_packages: + journey: '@forgerock/journey-client' + oidc: '@forgerock/oidc-client' + device: '@forgerock/device-client' + protect: '@forgerock/protect' + types: '@forgerock/sdk-types' + utilities: '@forgerock/sdk-utilities' + logger: '@forgerock/sdk-logger' + storage: '@forgerock/storage' +--- + +# Interface Mapping: Legacy SDK → Ping SDK + +This document maps every public interface from the legacy `@forgerock/javascript-sdk` to its equivalent in the new Ping SDK. It is designed for both human developers performing migrations and AI coding assistants needing structured context for automated refactoring. + +## Table of Contents + +0. [Quick Reference](#0-quick-reference) +1. [Package Mapping](#1-package-mapping) +2. [Configuration](#2-configuration) +3. [Authentication Flow](#3-authentication-flow) +4. [Step Types](#4-step-types) +5. [Callbacks](#5-callbacks) +6. [Callback Type Enum](#6-callback-type-enum) +7. [Token Management](#7-token-management) +8. [OAuth2 Client](#8-oauth2-client) +9. [User Management](#9-user-management) +10. [Session Management](#10-session-management) +11. [HTTP Client](#11-http-client) +12. [WebAuthn](#12-webauthn) +13. [QR Code](#13-qr-code) +14. [Recovery Codes](#14-recovery-codes) +15. [Policy](#15-policy) +16. [Device](#16-device) +17. [Protect](#17-protect) +18. [Error Handling Patterns](#18-error-handling-patterns) +19. [Type Exports](#19-type-exports) +20. [Removed / Deprecated APIs](#20-removed--deprecated-apis) + +--- + +## 0. Quick Reference + +Flat lookup table for AI context injection. Every legacy symbol → new import in one line. + +| Legacy Symbol | New SDK Equivalent | +| ---------------------------------- | ------------------------------------------------------------------------------------------------- | +| `AuthResponse` | `import type { AuthResponse } from '@forgerock/journey-client/types'` | +| `Callback` | `import type { Callback } from '@forgerock/sdk-types'` — AM callback interface | +| `ConfigOptions` | Removed — use factory params on `journey()` / `oidc()` instead | +| `FailureDetail` | `import type { FailureDetail } from '@forgerock/journey-client/types'` | +| `FRCallbackFactory` | Removed — custom callback factories not supported | +| `FRStepHandler` | Removed — step handling is internal to JourneyClient | +| `GetAuthorizationUrlOptions` | `import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'` | +| `GetOAuth2TokensOptions` | Removed — use `oidcClient.token.get()` params instead | +| `GetTokensOptions` | Removed — use `oidcClient.token.get()` params instead | +| `IdPValue` | `import type { IdPValue } from '@forgerock/journey-client/types'` | +| `LoggerFunctions` | Removed — use `CustomLogger` from `@forgerock/sdk-logger` instead | +| `MessageCreator` | `import type { MessageCreator } from '@forgerock/journey-client/policy'` | +| `NameValue` | `import type { NameValue } from '@forgerock/sdk-types'` | +| `OAuth2Tokens` | `import type { Tokens } from '@forgerock/sdk-types'` — renamed to `Tokens` | +| `PolicyRequirement` | `import type { PolicyRequirement } from '@forgerock/sdk-types'` | +| `ProcessedPropertyError` | `import type { ProcessedPropertyError } from '@forgerock/journey-client/policy'` | +| `RelyingParty` | `import type { RelyingParty } from '@forgerock/journey-client/webauthn'` | +| `Step` | `import type { Step } from '@forgerock/sdk-types'` — AM step response interface | +| `StepDetail` | `import type { StepDetail } from '@forgerock/sdk-types'` | +| `Tokens` | `import type { Tokens } from '@forgerock/sdk-types'` | +| `ValidConfigOptions` | Removed — config is encapsulated in client instances | +| `WebAuthnAuthenticationMetadata` | `import type { WebAuthnAuthenticationMetadata } from '@forgerock/journey-client/webauthn'` | +| `WebAuthnCallbacks` | `import type { WebAuthnCallbacks } from '@forgerock/journey-client/webauthn'` | +| `WebAuthnRegistrationMetadata` | `import type { WebAuthnRegistrationMetadata } from '@forgerock/journey-client/webauthn'` | +| `defaultMessageCreator` | Not exported — internal to `@forgerock/journey-client/policy` | +| `AttributeInputCallback` | `import { AttributeInputCallback } from '@forgerock/journey-client/types'` | +| `Auth` | Removed — no replacement needed | +| `CallbackType` | `import { callbackType } from '@forgerock/journey-client'` | +| `ChoiceCallback` | `import { ChoiceCallback } from '@forgerock/journey-client/types'` | +| `Config` | Removed — pass config to `journey()` / `oidc()` factory params | +| `ConfirmationCallback` | `import { ConfirmationCallback } from '@forgerock/journey-client/types'` | +| `Deferred` | Removed — use native `Promise` constructor | +| `deviceClient` | `import { deviceClient } from '@forgerock/device-client'` | +| `DeviceProfileCallback` | `import { DeviceProfileCallback } from '@forgerock/journey-client/types'` | +| `ErrorCode` | Removed — use `GenericError.type` instead | +| `FRAuth` | `import { journey } from '@forgerock/journey-client'` — factory returns `JourneyClient` | +| `FRCallback` | `import { BaseCallback } from '@forgerock/journey-client/types'` | +| `FRDevice` | `import { deviceClient } from '@forgerock/device-client'` | +| `FRLoginFailure` | `import type { JourneyLoginFailure } from '@forgerock/journey-client/types'` | +| `FRLoginSuccess` | `import type { JourneyLoginSuccess } from '@forgerock/journey-client/types'` | +| `FRPolicy` | `import { Policy } from '@forgerock/journey-client/policy'` | +| `FRQRCode` | `import { QRCode } from '@forgerock/journey-client/qr-code'` | +| `FRRecoveryCodes` | `import { RecoveryCodes } from '@forgerock/journey-client/recovery-codes'` | +| `FRStep` | `import type { JourneyStep } from '@forgerock/journey-client/types'` | +| `FRUser` | `import { oidc } from '@forgerock/oidc-client'` — `oidcClient.user.logout()` | +| `FRWebAuthn` | `import { WebAuthn } from '@forgerock/journey-client/webauthn'` | +| `HiddenValueCallback` | `import { HiddenValueCallback } from '@forgerock/journey-client/types'` | +| `HttpClient` | Removed — use `fetch` + manual `Authorization` header | +| `KbaCreateCallback` | `import { KbaCreateCallback } from '@forgerock/journey-client/types'` | +| `LocalStorage` | Removed — use `@forgerock/storage` or native APIs | +| `MetadataCallback` | `import { MetadataCallback } from '@forgerock/journey-client/types'` | +| `NameCallback` | `import { NameCallback } from '@forgerock/journey-client/types'` | +| `OAuth2Client` | `import { oidc } from '@forgerock/oidc-client'` — `oidcClient.authorize.*` / `oidcClient.token.*` | +| `PasswordCallback` | `import { PasswordCallback } from '@forgerock/journey-client/types'` | +| `PingOneProtectEvaluationCallback` | `import { PingOneProtectEvaluationCallback } from '@forgerock/journey-client/types'` | +| `PingOneProtectInitializeCallback` | `import { PingOneProtectInitializeCallback } from '@forgerock/journey-client/types'` | +| `PKCE` | Removed — handled internally by `@forgerock/oidc-client` | +| `PolicyKey` | `import { PolicyKey } from '@forgerock/sdk-types'` | +| `PollingWaitCallback` | `import { PollingWaitCallback } from '@forgerock/journey-client/types'` | +| `ReCaptchaCallback` | `import { ReCaptchaCallback } from '@forgerock/journey-client/types'` | +| `ReCaptchaEnterpriseCallback` | `import { ReCaptchaEnterpriseCallback } from '@forgerock/journey-client/types'` | +| `RedirectCallback` | `import { RedirectCallback } from '@forgerock/journey-client/types'` | +| `ResponseType` | `import type { ResponseType } from '@forgerock/sdk-types'` | +| `SelectIdPCallback` | `import { SelectIdPCallback } from '@forgerock/journey-client/types'` | +| `SessionManager` | `journeyClient.terminate()` — method on JourneyClient, not a standalone import | +| `StepOptions` | Removed — per-call config overrides removed; use factory params | +| `StepType` | `import type { StepType } from '@forgerock/journey-client/types'` | +| `SuspendedTextOutputCallback` | `import { SuspendedTextOutputCallback } from '@forgerock/journey-client/types'` | +| `TermsAndConditionsCallback` | `import { TermsAndConditionsCallback } from '@forgerock/journey-client/types'` | +| `TextInputCallback` | `import { TextInputCallback } from '@forgerock/journey-client/types'` | +| `TextOutputCallback` | `import { TextOutputCallback } from '@forgerock/journey-client/types'` | +| `TokenManager` | `import { oidc } from '@forgerock/oidc-client'` — `oidcClient.token.*` | +| `TokenStorage` | `import { oidc } from '@forgerock/oidc-client'` — `oidcClient.token.*` | +| `UserManager` | `import { oidc } from '@forgerock/oidc-client'` — `oidcClient.user.info()` | +| `ValidatedCreatePasswordCallback` | `import { ValidatedCreatePasswordCallback } from '@forgerock/journey-client/types'` | +| `ValidatedCreateUsernameCallback` | `import { ValidatedCreateUsernameCallback } from '@forgerock/journey-client/types'` | +| `WebAuthnOutcome` | Not exported — internal to webauthn module | +| `WebAuthnOutcomeType` | Not exported — internal to webauthn module | +| `WebAuthnStepType` | `import { WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | +| `@forgerock/ping-protect` | `@forgerock/protect` — PingOne Protect/Signals integration | + +--- + +## 1. Package Mapping + +The legacy SDK is a single package. The new SDK splits concerns across multiple focused packages. + +| Legacy Import | New Import | Notes | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------ | +| `import type { AuthResponse } from '@forgerock/javascript-sdk'` | `import type { AuthResponse } from '@forgerock/journey-client/types'` | AuthResponse | +| `import type { Callback } from '@forgerock/javascript-sdk'` | `import type { Callback } from '@forgerock/sdk-types'` | Callback | +| `import type { FailureDetail } from '@forgerock/javascript-sdk'` | `import type { FailureDetail } from '@forgerock/journey-client/types'` | FailureDetail | +| `import type { GetAuthorizationUrlOptions } from '@forgerock/javascript-sdk'` | `import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'` | GetAuthorizationUrlOptions | +| `import type { IdPValue } from '@forgerock/javascript-sdk'` | `import type { IdPValue } from '@forgerock/journey-client/types'` | IdPValue | +| `import type { MessageCreator } from '@forgerock/javascript-sdk'` | `import type { MessageCreator } from '@forgerock/journey-client/policy'` | MessageCreator | +| `import type { NameValue } from '@forgerock/javascript-sdk'` | `import type { NameValue } from '@forgerock/sdk-types'` | NameValue | +| `import type { OAuth2Tokens } from '@forgerock/javascript-sdk'` | `import type { Tokens } from '@forgerock/sdk-types'` | OAuth2Tokens | +| `import type { PolicyRequirement } from '@forgerock/javascript-sdk'` | `import type { PolicyRequirement } from '@forgerock/sdk-types'` | PolicyRequirement | +| `import type { ProcessedPropertyError } from '@forgerock/javascript-sdk'` | `import type { ProcessedPropertyError } from '@forgerock/journey-client/policy'` | ProcessedPropertyError | +| `import type { RelyingParty } from '@forgerock/javascript-sdk'` | `import type { RelyingParty } from '@forgerock/journey-client/webauthn'` | RelyingParty | +| `import type { Step } from '@forgerock/javascript-sdk'` | `import type { Step } from '@forgerock/sdk-types'` | Step | +| `import type { StepDetail } from '@forgerock/javascript-sdk'` | `import type { StepDetail } from '@forgerock/sdk-types'` | StepDetail | +| `import type { Tokens } from '@forgerock/javascript-sdk'` | `import type { Tokens } from '@forgerock/sdk-types'` | Tokens | +| `import type { WebAuthnAuthenticationMetadata } from '@forgerock/javascript-sdk'` | `import type { WebAuthnAuthenticationMetadata } from '@forgerock/journey-client/webauthn'` | WebAuthnAuthenticationMetadata | +| `import type { WebAuthnCallbacks } from '@forgerock/javascript-sdk'` | `import type { WebAuthnCallbacks } from '@forgerock/journey-client/webauthn'` | WebAuthnCallbacks | +| `import type { WebAuthnRegistrationMetadata } from '@forgerock/javascript-sdk'` | `import type { WebAuthnRegistrationMetadata } from '@forgerock/journey-client/webauthn'` | WebAuthnRegistrationMetadata | +| `import { CallbackType } from '@forgerock/javascript-sdk'` | `import { callbackType } from '@forgerock/journey-client'` | CallbackType | +| `import { deviceClient } from '@forgerock/javascript-sdk'` | `import { deviceClient } from '@forgerock/device-client'` | deviceClient | +| `import { FRAuth } from '@forgerock/javascript-sdk'` | `import { journey } from '@forgerock/journey-client'` | FRAuth | +| `import { FRCallback } from '@forgerock/javascript-sdk'` | `import { BaseCallback } from '@forgerock/journey-client/types'` | FRCallback | +| `import { FRDevice } from '@forgerock/javascript-sdk'` | `import { deviceClient } from '@forgerock/device-client'` | FRDevice | +| `import type { FRLoginFailure } from '@forgerock/javascript-sdk'` | `import type { JourneyLoginFailure } from '@forgerock/journey-client/types'` | FRLoginFailure | +| `import type { FRLoginSuccess } from '@forgerock/javascript-sdk'` | `import type { JourneyLoginSuccess } from '@forgerock/journey-client/types'` | FRLoginSuccess | +| `import { FRPolicy } from '@forgerock/javascript-sdk'` | `import { Policy } from '@forgerock/journey-client/policy'` | FRPolicy | +| `import { FRQRCode } from '@forgerock/javascript-sdk'` | `import { QRCode } from '@forgerock/journey-client/qr-code'` | FRQRCode | +| `import { FRRecoveryCodes } from '@forgerock/javascript-sdk'` | `import { RecoveryCodes } from '@forgerock/journey-client/recovery-codes'` | FRRecoveryCodes | +| `import type { FRStep } from '@forgerock/javascript-sdk'` | `import type { JourneyStep } from '@forgerock/journey-client/types'` | FRStep | +| `import { FRUser } from '@forgerock/javascript-sdk'` | `import { oidc } from '@forgerock/oidc-client'` | FRUser | +| `import { FRWebAuthn } from '@forgerock/javascript-sdk'` | `import { WebAuthn } from '@forgerock/journey-client/webauthn'` | FRWebAuthn | +| `import { OAuth2Client } from '@forgerock/javascript-sdk'` | `import { oidc } from '@forgerock/oidc-client'` | OAuth2Client | +| `import { PolicyKey } from '@forgerock/javascript-sdk'` | `import { PolicyKey } from '@forgerock/sdk-types'` | PolicyKey | +| `import type { ResponseType } from '@forgerock/javascript-sdk'` | `import type { ResponseType } from '@forgerock/sdk-types'` | ResponseType | +| `import type { StepType } from '@forgerock/javascript-sdk'` | `import type { StepType } from '@forgerock/journey-client/types'` | StepType | +| `import { TokenManager } from '@forgerock/javascript-sdk'` | `import { oidc } from '@forgerock/oidc-client'` | TokenManager | +| `import { TokenStorage } from '@forgerock/javascript-sdk'` | `import { oidc } from '@forgerock/oidc-client'` | TokenStorage | +| `import { UserManager } from '@forgerock/javascript-sdk'` | `import { oidc } from '@forgerock/oidc-client'` | UserManager | +| `import { WebAuthnStepType } from '@forgerock/javascript-sdk'` | `import { WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | WebAuthnStepType | + +--- + +## 2. Configuration + +### Architecture Change + +| Aspect | Legacy | New | +| --------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| Pattern | Global static `Config.set(options)` | Per-client config via factory function params | +| Discovery | Manual `serverConfig.baseUrl` + optional `paths` | Automatic via `serverConfig.wellknown` endpoint | +| Scope | Shared global state | Each client instance independently configured | +| Async | `Config.set()` is sync; `Config.setAsync()` is async | Factory functions (`journey()`, `oidc()`) are always async | + +### Config Class Methods + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------- | +| `Config.set(options: ConfigOptions): void` | `journey({ config })` / `oidc({ config })` | `void` → `Promise` / OIDC client object | Config is passed as factory param, not set globally | +| `Config.setAsync(options: AsyncConfigOptions): Promise` | `journey({ config })` / `oidc({ config })` | Same | Wellknown is now the default and only discovery path | +| `Config.get(options?: ConfigOptions): ValidConfigOptions` | No equivalent | — | Config is encapsulated in the client instance; not retrievable externally | + +### ConfigOptions Property Mapping + +| Legacy Property | Journey Client | OIDC Client | Notes | +| --------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------- | +| `serverConfig.baseUrl: string` | Derived from `serverConfig.wellknown` | Derived from `serverConfig.wellknown` | No longer manually specified | +| `serverConfig.paths?: CustomPathConfig` | Derived from wellknown response | Derived from wellknown response | No longer manually specified | +| `serverConfig.timeout?: number` | Accepted but ignored (warning logged) | `serverConfig.timeout?: number` | Only used by oidc-client | +| `serverConfig.wellknown?: string` (AsyncServerConfig) | `serverConfig.wellknown: string` **(required)** | `serverConfig.wellknown: string` **(required)** | Now the primary and only server config | +| `clientId?: string` | Accepted but ignored (warning logged) | `clientId: string` **(required)** | Only needed for OIDC operations | +| `redirectUri?: string` | Accepted but ignored (warning logged) | `redirectUri: string` **(required)** | Only needed for OIDC operations | +| `scope?: string` | Accepted but ignored (warning logged) | `scope: string` **(required)** | Only needed for OIDC operations | +| `realmPath?: string` | Derived from wellknown URL | Derived from wellknown URL | No longer manually specified | +| `tree?: string` | Passed to `journeyClient.start({ journey: 'treeName' })` | N/A | Tree specified per-call, not in config | +| `tokenStore?: TokenStoreObject \| 'sessionStorage' \| 'localStorage'` | Accepted but ignored | `storage?: Partial` param on `oidc()` | Storage config is a separate factory param | +| `middleware?: RequestMiddleware[]` | `requestMiddleware?: RequestMiddleware[]` param on `journey()` | `requestMiddleware?: RequestMiddleware[]` param on `oidc()` | Separate factory param, not in config object | +| `callbackFactory?: FRCallbackFactory` | Accepted but ignored (warning logged) | N/A | Custom callback factories not supported | +| `oauthThreshold?: number` | Accepted but ignored | `oauthThreshold?: number` in `OidcConfig` | Default: 30000ms (30 seconds) | +| `logLevel?: LogLevel` | `logger?: { level: LogLevel }` param on `journey()` | `logger?: { level: LogLevel }` param on `oidc()` | Separate factory param with custom logger support | +| `logger?: LoggerFunctions` | `logger?: { custom?: CustomLogger }` param on `journey()` | `logger?: { custom?: CustomLogger }` param on `oidc()` | Interface changed | +| `platformHeader?: boolean` | Accepted but ignored (warning logged) | N/A | Removed | +| `prefix?: string` | Accepted but ignored (warning logged) | `storage?.prefix` on `oidc()` | Moved to storage config | +| `type?: string` | Accepted but ignored (warning logged) | N/A | Removed | +| `responseType?: ResponseType` | N/A | `responseType?: ResponseType` in `OidcConfig` | Defaults to `'code'` | + +### Before/After: Configuration + +**Legacy:** + +```typescript +import { Config } from '@forgerock/javascript-sdk'; + +Config.set({ + clientId: 'my-app', + redirectUri: `${window.location.origin}/callback`, + scope: 'openid profile', + serverConfig: { + baseUrl: 'https://am.example.com/am', + timeout: 5000, + }, + realmPath: 'alpha', + tree: 'Login', +}); +``` + +**New:** + +```typescript +import { journey } from '@forgerock/journey-client'; +import { oidc } from '@forgerock/oidc-client'; + +const config = { + serverConfig: { + wellknown: 'https://am.example.com/am/oauth2/alpha/.well-known/openid-configuration', + }, +}; + +const journeyClient = await journey({ config }); +const oidcClient = await oidc({ + config: { + ...config, + clientId: 'my-app', + redirectUri: `${window.location.origin}/callback`, + scope: 'openid profile', + }, +}); +``` + +--- + +## 3. Authentication Flow + +### FRAuth → JourneyClient + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `FRAuth.start(options?: StepOptions): Promise` | `journeyClient.start(options?: StartParam): Promise` | Return type now includes `GenericError` in union | Tree name passed via `options.journey` instead of `Config.set({ tree })` or `options.tree` | +| `FRAuth.next(step?: FRStep, options?: StepOptions): Promise` | `journeyClient.next(step: JourneyStep, options?: NextOptions): Promise` | Return type now includes `GenericError` in union | `step` param is now required (not optional). `options` simplified to `{ query?: Record }` | +| `FRAuth.redirect(step: FRStep): void` | `journeyClient.redirect(step: JourneyStep): Promise` | `void` → `Promise` | Now async. Step stored in `sessionStorage` (was `localStorage`) | +| `FRAuth.resume(url: string, options?: StepOptions): Promise` | `journeyClient.resume(url: string, options?: ResumeOptions): Promise` | Return type now includes `GenericError` in union | Previous step retrieved from `sessionStorage` (was `localStorage`) | +| No equivalent | `journeyClient.terminate(options?: { query?: Record }): Promise` | — | **New method.** Ends the authentication session via `/sessions` endpoint | + +### StartParam (replaces StepOptions for start) + +| Legacy Property | New Property | Notes | +| ---------------------------------------- | ---------------------------------------- | ---------------------------------------------- | +| `options.tree` or `Config.set({ tree })` | `options.journey: string` | Renamed. Passed per-call, not in global config | +| `options.query?: StringDict` | `options.query?: Record` | Type changed from `StringDict` to `Record` | +| All other `ConfigOptions` properties | Not applicable | Per-call config overrides removed | + +### Per-Call Config Override Removal + +> **Breaking Change:** In the legacy SDK, every call to `FRAuth.next()` accepted a full `StepOptions` (extending `ConfigOptions`), allowing per-call overrides of `tree`, `serverConfig`, `middleware`, etc. In the new SDK, config is **fixed at client creation time**. Only `query` parameters can vary per-call via `NextOptions` or `ResumeOptions`. Apps that dynamically switch trees or servers mid-flow must create separate `JourneyClient` instances. + +### resume() URL Parameter Parsing + +The legacy `FRAuth.resume()` automatically parses 10+ URL parameters from the redirect URL and conditionally adjusts behavior. The new `journeyClient.resume()` handles a subset of these: + +| URL Parameter | Legacy Behavior | New Behavior | +| ------------------------------------ | -------------------------------------------- | ------------------------------------------------------ | +| `code` | Extracted, passed as query param to `next()` | Same — extracted and passed through | +| `state` | Extracted, passed as query param | Same | +| `form_post_entry` | Extracted, triggers previous step retrieval | Same | +| `responsekey` | Extracted, triggers previous step retrieval | Same | +| `error`, `errorCode`, `errorMessage` | Extracted, passed as query params | **Not parsed** — check return value for `GenericError` | +| `suspendedId` | Extracted; skips previous step retrieval | **Not parsed** — handle suspended flows manually | +| `RelayState` | Extracted for SAML flows | **Not parsed** | +| `nonce`, `scope` | Extracted, passed as query params | **Not parsed** | +| `authIndexValue` | Used as fallback tree name | **Not parsed** — pass tree via `options.journey` | + +> **Migration note:** If your app relies on `suspendedId`, `RelayState`, or `authIndexValue` URL parameters being auto-parsed, you must extract them manually from the URL and pass them via `options.query` in the new SDK. + +### Before/After: Authentication Flow + +**Legacy:** + +```typescript +import { FRAuth, StepType } from '@forgerock/javascript-sdk'; + +// Start a journey +let step = await FRAuth.start({ tree: 'Login' }); + +while (step.type === StepType.Step) { + // ... handle callbacks ... + step = await FRAuth.next(step, { tree: 'Login' }); +} + +if (step.type === StepType.LoginSuccess) { + const token = step.getSessionToken(); +} +``` + +**New:** + +```typescript +import { journey } from '@forgerock/journey-client'; +import type { StepType } from '@forgerock/journey-client/types'; + +const journeyClient = await journey({ + config: { + serverConfig: { + wellknown: 'https://am.example.com/am/oauth2/alpha/.well-known/openid-configuration', + }, + }, +}); + +let result = await journeyClient.start({ journey: 'Login' }); + +while (result.type === 'Step') { + // ... handle callbacks ... + result = await journeyClient.next(result); +} + +if ('error' in result) { + console.error('Journey error:', result); +} else if (result.type === 'LoginSuccess') { + const token = result.getSessionToken(); +} +``` + +--- + +## 4. Step Types + +### Class → Object Type Change + +The legacy SDK uses class instances created via `new FRStep(payload)`. The new SDK uses plain objects with methods, created internally by `createJourneyObject()`. This means `instanceof` checks no longer work — use the `type` discriminant instead. + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| -------------------------------------------------- | --------------------------------- | --------------------------------- | ------------------------------------------------------------- | +| `new FRStep(payload: Step)` class instance | `JourneyStep` object type | Class → plain object with methods | Cannot use `instanceof FRStep`; use `result.type === 'Step'` | +| `new FRLoginSuccess(payload: Step)` class instance | `JourneyLoginSuccess` object type | Class → plain object with methods | Cannot use `instanceof`; use `result.type === 'LoginSuccess'` | +| `new FRLoginFailure(payload: Step)` class instance | `JourneyLoginFailure` object type | Class → plain object with methods | Cannot use `instanceof`; use `result.type === 'LoginFailure'` | + +### JourneyStep Methods (unchanged signatures) + +| Method | Legacy (`FRStep`) | New (`JourneyStep`) | Notes | +| ------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------- | +| `callbacks` | `FRCallback[]` | `BaseCallback[]` | Base class renamed | +| `payload` | `Step` | `Step` | Same interface | +| `getCallbackOfType(type)` | `getCallbackOfType(type: CallbackType): T` | `getCallbackOfType(type: CallbackType): T` | Generic constraint changed | +| `getCallbacksOfType(type)` | `getCallbacksOfType(type: CallbackType): T[]` | `getCallbacksOfType(type: CallbackType): T[]` | Generic constraint changed | +| `setCallbackValue(type, value)` | Same | Same | No change | +| `getDescription()` | Same | Same | No change | +| `getHeader()` | Same | Same | No change | +| `getStage()` | Same | Same | No change | + +### JourneyLoginSuccess Methods (unchanged signatures) + +| Method | Notes | +| ---------------------------------------- | --------- | +| `getRealm(): string \| undefined` | No change | +| `getSessionToken(): string \| undefined` | No change | +| `getSuccessUrl(): string \| undefined` | No change | + +### JourneyLoginFailure Methods (unchanged signatures) + +| Method | Notes | +| -------------------------------------------------------------------------------- | --------- | +| `getCode(): number` | No change | +| `getDetail(): FailureDetail \| undefined` | No change | +| `getMessage(): string \| undefined` | No change | +| `getReason(): string \| undefined` | No change | +| `getProcessedMessage(messageCreator?: MessageCreator): ProcessedPropertyError[]` | No change | + +### StepType Enum + +| Legacy | New | Notes | +| ------------------------------------------ | ---------------- | -------------------------------------------- | +| `StepType.Step` (`'Step'`) | `'Step'` | Same values, now from `@forgerock/sdk-types` | +| `StepType.LoginSuccess` (`'LoginSuccess'`) | `'LoginSuccess'` | Same | +| `StepType.LoginFailure` (`'LoginFailure'`) | `'LoginFailure'` | Same | + +--- + +## 5. Callbacks + +### Base Class Change + +| Legacy | New | Notes | +| ---------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------- | +| `import FRCallback from '@forgerock/javascript-sdk'` | `import { BaseCallback } from '@forgerock/journey-client/types'` | Renamed: `FRCallback` → `BaseCallback` | + +### BaseCallback Methods (identical to FRCallback) + +| Method | Signature | Notes | +| ---------------------------------------- | ------------------------------------------------------------------ | --------- | +| `getType()` | `(): CallbackType` | No change | +| `getInputValue(selector?)` | `(selector: number \| string = 0): unknown` | No change | +| `setInputValue(value, selector?)` | `(value: unknown, selector: number \| string \| RegExp = 0): void` | No change | +| `getOutputValue(selector?)` | `(selector: number \| string = 0): unknown` | No change | +| `getOutputByName(name, defaultValue)` | `(name: string, defaultValue: T): T` | No change | + +### Callback Type Mapping + +All callback classes retain the same name and method signatures. Only the import path and base class change. + +| Legacy Import | New Import | Notes | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ----- | +| `import { Callback } from '@forgerock/javascript-sdk'` | `import { Callback } from '@forgerock/journey-client/types'` | None | +| `import { AttributeInputCallback } from '@forgerock/javascript-sdk'` | `import { AttributeInputCallback } from '@forgerock/journey-client/types'` | None | +| `import { BaseCallback } from '@forgerock/javascript-sdk'` | `import { BaseCallback } from '@forgerock/journey-client/types'` | None | +| `import { ChoiceCallback } from '@forgerock/javascript-sdk'` | `import { ChoiceCallback } from '@forgerock/journey-client/types'` | None | +| `import { ConfirmationCallback } from '@forgerock/javascript-sdk'` | `import { ConfirmationCallback } from '@forgerock/journey-client/types'` | None | +| `import { DeviceProfileCallback } from '@forgerock/javascript-sdk'` | `import { DeviceProfileCallback } from '@forgerock/journey-client/types'` | None | +| `import { createCallback } from '@forgerock/javascript-sdk'` | `import { createCallback } from '@forgerock/journey-client/types'` | None | +| `import { HiddenValueCallback } from '@forgerock/javascript-sdk'` | `import { HiddenValueCallback } from '@forgerock/journey-client/types'` | None | +| `import { KbaCreateCallback } from '@forgerock/javascript-sdk'` | `import { KbaCreateCallback } from '@forgerock/journey-client/types'` | None | +| `import { MetadataCallback } from '@forgerock/javascript-sdk'` | `import { MetadataCallback } from '@forgerock/journey-client/types'` | None | +| `import { NameCallback } from '@forgerock/javascript-sdk'` | `import { NameCallback } from '@forgerock/journey-client/types'` | None | +| `import { PasswordCallback } from '@forgerock/javascript-sdk'` | `import { PasswordCallback } from '@forgerock/journey-client/types'` | None | +| `import { PingOneProtectEvaluationCallback } from '@forgerock/javascript-sdk'` | `import { PingOneProtectEvaluationCallback } from '@forgerock/journey-client/types'` | None | +| `import { PingOneProtectInitializeCallback } from '@forgerock/javascript-sdk'` | `import { PingOneProtectInitializeCallback } from '@forgerock/journey-client/types'` | None | +| `import { PollingWaitCallback } from '@forgerock/javascript-sdk'` | `import { PollingWaitCallback } from '@forgerock/journey-client/types'` | None | +| `import { ReCaptchaCallback } from '@forgerock/javascript-sdk'` | `import { ReCaptchaCallback } from '@forgerock/journey-client/types'` | None | +| `import { ReCaptchaEnterpriseCallback } from '@forgerock/javascript-sdk'` | `import { ReCaptchaEnterpriseCallback } from '@forgerock/journey-client/types'` | None | +| `import { RedirectCallback } from '@forgerock/javascript-sdk'` | `import { RedirectCallback } from '@forgerock/journey-client/types'` | None | +| `import { SelectIdPCallback } from '@forgerock/javascript-sdk'` | `import { SelectIdPCallback } from '@forgerock/journey-client/types'` | None | +| `import { SuspendedTextOutputCallback } from '@forgerock/javascript-sdk'` | `import { SuspendedTextOutputCallback } from '@forgerock/journey-client/types'` | None | +| `import { TextInputCallback } from '@forgerock/javascript-sdk'` | `import { TextInputCallback } from '@forgerock/journey-client/types'` | None | +| `import { TextOutputCallback } from '@forgerock/javascript-sdk'` | `import { TextOutputCallback } from '@forgerock/journey-client/types'` | None | +| `import { TermsAndConditionsCallback } from '@forgerock/javascript-sdk'` | `import { TermsAndConditionsCallback } from '@forgerock/journey-client/types'` | None | +| `import { ValidatedCreatePasswordCallback } from '@forgerock/javascript-sdk'` | `import { ValidatedCreatePasswordCallback } from '@forgerock/journey-client/types'` | None | +| `import { ValidatedCreateUsernameCallback } from '@forgerock/javascript-sdk'` | `import { ValidatedCreateUsernameCallback } from '@forgerock/journey-client/types'` | None | + +### Callback Factory + +| Legacy | New | Notes | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------- | +| `import type { FRCallbackFactory } from '@forgerock/javascript-sdk'` | `import type { CallbackFactory } from '@forgerock/journey-client/types'` | Type renamed: `FRCallbackFactory` → `CallbackFactory` | +| `createCallback(callback: Callback): FRCallback` | `createCallback(callback: Callback): BaseCallback` | Return type changed from `FRCallback` to `BaseCallback` | + +--- + +## 6. Callback Type Enum + +The legacy SDK uses a TypeScript `enum`. The new SDK uses a plain object (`const` assertion). + +| Legacy | New | Notes | +| ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------- | +| `import { CallbackType } from '@forgerock/javascript-sdk'` | `import { callbackType } from '@forgerock/journey-client'` | PascalCase enum → camelCase object | + +### Value Mapping + +All values remain the same strings. Only the access pattern changes: + +| Legacy | New | +| ----------------------------------------------- | ----------------------------------------------- | +| `CallbackType.NameCallback` | `callbackType.NameCallback` | +| `CallbackType.PasswordCallback` | `callbackType.PasswordCallback` | +| `CallbackType.ChoiceCallback` | `callbackType.ChoiceCallback` | +| `CallbackType.TextInputCallback` | `callbackType.TextInputCallback` | +| `CallbackType.TextOutputCallback` | `callbackType.TextOutputCallback` | +| `CallbackType.ConfirmationCallback` | `callbackType.ConfirmationCallback` | +| `CallbackType.HiddenValueCallback` | `callbackType.HiddenValueCallback` | +| `CallbackType.RedirectCallback` | `callbackType.RedirectCallback` | +| `CallbackType.MetadataCallback` | `callbackType.MetadataCallback` | +| `CallbackType.BooleanAttributeInputCallback` | `callbackType.BooleanAttributeInputCallback` | +| `CallbackType.NumberAttributeInputCallback` | `callbackType.NumberAttributeInputCallback` | +| `CallbackType.StringAttributeInputCallback` | `callbackType.StringAttributeInputCallback` | +| `CallbackType.ValidatedCreateUsernameCallback` | `callbackType.ValidatedCreateUsernameCallback` | +| `CallbackType.ValidatedCreatePasswordCallback` | `callbackType.ValidatedCreatePasswordCallback` | +| `CallbackType.SelectIdPCallback` | `callbackType.SelectIdPCallback` | +| `CallbackType.TermsAndConditionsCallback` | `callbackType.TermsAndConditionsCallback` | +| `CallbackType.KbaCreateCallback` | `callbackType.KbaCreateCallback` | +| `CallbackType.DeviceProfileCallback` | `callbackType.DeviceProfileCallback` | +| `CallbackType.ReCaptchaCallback` | `callbackType.ReCaptchaCallback` | +| `CallbackType.ReCaptchaEnterpriseCallback` | `callbackType.ReCaptchaEnterpriseCallback` | +| `CallbackType.PingOneProtectInitializeCallback` | `callbackType.PingOneProtectInitializeCallback` | +| `CallbackType.PingOneProtectEvaluationCallback` | `callbackType.PingOneProtectEvaluationCallback` | +| `CallbackType.PollingWaitCallback` | `callbackType.PollingWaitCallback` | +| `CallbackType.SuspendedTextOutputCallback` | `callbackType.SuspendedTextOutputCallback` | + +### Before/After: Callback Handling + +**Legacy:** + +```typescript +import { CallbackType, FRStep } from '@forgerock/javascript-sdk'; +import type { FRCallback } from '@forgerock/javascript-sdk'; + +if (step.type === 'Step') { + const nameCb = step.getCallbackOfType(CallbackType.NameCallback); + nameCb.setName('demo'); +} +``` + +**New:** + +```typescript +import { callbackType } from '@forgerock/journey-client'; +import type { NameCallback } from '@forgerock/journey-client/types'; + +if (result.type === 'Step') { + const nameCb = result.getCallbackOfType(callbackType.NameCallback); + nameCb.setName('demo'); +} +``` + +--- + +## 7. Token Management + +### TokenManager + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `TokenManager.getTokens(options?: GetTokensOptions): Promise` | `oidcClient.token.get(options?: GetTokensOptions): Promise` | `OAuth2Tokens \| void` → `OauthTokens \| TokenExchangeErrorResponse \| AuthorizationError \| GenericError` | Returns error objects instead of throwing. Check with `'error' in result` | +| `TokenManager.deleteTokens(): Promise` | `oidcClient.token.revoke(): Promise` | `void` → result object | Revokes remotely AND deletes locally. Returns result instead of throwing | + +### TokenManager.getTokens Options + +| Legacy Option | New Option | Notes | +| ---------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `forceRenew?: boolean` | `forceRenew?: boolean` | Same behavior | +| `login?: 'embedded' \| 'redirect'` | Removed | Login mode determined by client usage pattern | +| `skipBackgroundRequest?: boolean` | `backgroundRenew?: boolean` | **Inverted semantics**: legacy `skipBackgroundRequest: true` = new `backgroundRenew: false` | +| `query?: StringDict` | `authorizeOptions?: GetAuthorizationUrlOptions` | Authorization customization moved to nested options | + +### TokenStorage + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------- | +| `TokenStorage.get(): Promise` | `oidcClient.token.get()` | `Tokens \| void` → `OauthTokens \| GenericError \| ...` | Retrieves from storage with optional auto-renewal | +| `TokenStorage.set(tokens: Tokens): Promise` | Handled internally by `oidcClient.token.exchange()` | — | Tokens are stored automatically after exchange | +| `TokenStorage.remove(): Promise` | `oidcClient.token.revoke()` | `void` → result object | Revokes remotely and removes locally | + +### Token Type Change + +| Legacy Type | New Type | Changes | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `Tokens { accessToken?, idToken?, refreshToken?, tokenExpiry? }` | `OauthTokens { accessToken, idToken, refreshToken?, expiresAt?, expiryTimestamp? }` | `accessToken` and `idToken` now required. `tokenExpiry` → `expiryTimestamp` | + +### Token Refresh Flow Comparison + +The token refresh behavior changed significantly between SDKs: + +**Legacy flow (`TokenManager.getTokens()`):** + +1. Check if tokens exist in storage +2. If `forceRenew: false` and tokens are not expired → return stored tokens +3. If tokens will expire within `oauthThreshold` (default: 30s) → attempt silent refresh +4. If `skipBackgroundRequest: false` (default) → attempt iframe-based silent auth (`prompt=none`) +5. If iframe fails with "allowed error" (consent required, CORS, timeout) → fall back to redirect +6. If `skipBackgroundRequest: true` → skip iframe, go straight to redirect-based auth + +**New flow (`oidcClient.token.get()`):** + +1. Check if tokens exist in storage +2. If error in stored tokens → return `state_error` +3. If `forceRenew: false` and tokens are not within `oauthThreshold` → return stored tokens +4. If `backgroundRenew: false` and `forceRenew: false` → return tokens (even if expired) or `state_error` +5. If `backgroundRenew: true` or `forceRenew: true` → attempt iframe-based silent auth +6. On success → revoke old tokens, store new tokens, return new tokens +7. On failure → return `AuthorizationError` or `GenericError` + +**Key differences:** + +- Legacy throws errors; new SDK returns them as objects +- Legacy has redirect fallback built in; new SDK does not auto-redirect +- `skipBackgroundRequest: true` (legacy) = `backgroundRenew: false` (new) — **inverted boolean** +- New SDK auto-revokes old tokens before storing new ones during background renewal + +### Before/After: Token Management + +**Legacy:** + +```typescript +import { TokenManager, TokenStorage } from '@forgerock/javascript-sdk'; + +try { + const tokens = await TokenManager.getTokens({ forceRenew: true }); + console.log(tokens?.accessToken); +} catch (err) { + console.error('Token error:', err); +} + +// Direct storage access +const stored = await TokenStorage.get(); +``` + +**New:** + +```typescript +// Tokens managed through oidcClient +const tokens = await oidcClient.token.get({ forceRenew: true, backgroundRenew: true }); + +if ('error' in tokens) { + console.error('Token error:', tokens); +} else { + console.log(tokens.accessToken); +} +``` + +--- + +## 8. OAuth2 Client + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------ | +| `OAuth2Client.createAuthorizeUrl(options: GetAuthorizationUrlOptions): Promise` | `oidcClient.authorize.url(options?: GetAuthorizationUrlOptions): Promise` | `string` → `string \| GenericError` | Returns error instead of throwing. PKCE handled internally | +| `OAuth2Client.getAuthCodeByIframe(options: GetAuthorizationUrlOptions): Promise` | `oidcClient.authorize.background(options?: GetAuthorizationUrlOptions): Promise` | `string` (URL) → `{ code, state }` or error object | Returns parsed code+state instead of raw URL. Uses `prompt: 'none'` internally | +| `OAuth2Client.getOAuth2Tokens(options: GetOAuth2TokensOptions): Promise` | `oidcClient.token.exchange(code: string, state: string, options?: Partial): Promise` | `OAuth2Tokens` → union with error types | Separate `code` and `state` params (not in options object). Auto-stores tokens | +| `OAuth2Client.getUserInfo(options?: ConfigOptions): Promise` | `oidcClient.user.info(): Promise` | `unknown` → `GenericError \| UserInfoResponse` | No config param needed. Token retrieved from storage automatically | +| `OAuth2Client.endSession(options?: LogoutOptions): Promise` | `oidcClient.user.logout(): Promise` | `Response \| void` → structured result | Revokes tokens, clears storage, and ends session in one call | +| `OAuth2Client.revokeToken(options?: ConfigOptions): Promise` | `oidcClient.token.revoke(): Promise` | `Response` → structured result | Also removes tokens from local storage | +| `ResponseType` enum | `ResponseType` from `@forgerock/sdk-types` | Same values | Import path changed | + +### Before/After: OAuth2 Flow + +**Legacy:** + +```typescript +import { OAuth2Client, TokenStorage } from '@forgerock/javascript-sdk'; + +try { + const urlWithCode = await OAuth2Client.getAuthCodeByIframe({ ...options }); + const url = new URL(urlWithCode); + const code = url.searchParams.get('code'); + const tokens = await OAuth2Client.getOAuth2Tokens({ + authorizationCode: code, + verifier, + }); + await TokenStorage.set(tokens); +} catch (err) { + console.error(err); +} +``` + +**New:** + +```typescript +const authResult = await oidcClient.authorize.background(); +if ('error' in authResult) { + console.error(authResult); +} else { + const tokens = await oidcClient.token.exchange(authResult.code, authResult.state); + if ('error' in tokens) { + console.error(tokens); + } + // Tokens are auto-stored +} +``` + +--- + +## 9. User Management + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------- | +| `UserManager.getCurrentUser(options?: ConfigOptions): Promise` | `oidcClient.user.info(): Promise` | `unknown` → typed response | No config param. Returns error object instead of throwing | +| `FRUser.logout(options?: LogoutOptions): Promise` | `oidcClient.user.logout(): Promise` | `void` → structured result | Combines revoke + delete + end session. Returns result instead of throwing | +| `FRUser.login(handler, options)` | No equivalent | — | Was never implemented in legacy SDK (`throw new Error('not implemented')`) | + +### Logout Orchestration Comparison + +**Legacy `FRUser.logout()` flow:** + +1. Call `SessionManager.logout()` to destroy AM session (if sessions endpoint configured) +2. Call `OAuth2Client.revokeToken()` to revoke the access token +3. Call `TokenStorage.remove()` to clear local tokens +4. Call `OAuth2Client.endSession()` with `idToken` to end the OIDC session +5. Each step is "best effort" — errors are logged but not thrown, so logout continues even if individual steps fail +6. `logoutRedirectUri` controls post-logout redirect; `redirect: false` explicitly disables the end-session redirect + +**New `oidcClient.user.logout()` flow:** + +1. Revoke access token via revocation endpoint +2. End OIDC session via end-session endpoint +3. Remove tokens from local storage +4. Returns structured `LogoutSuccessResult` or `LogoutErrorResult` + +**Key differences:** + +- Legacy has separate `logoutRedirectUri` and `redirect` options; new SDK does not expose redirect control on logout +- Legacy silently swallows per-step errors; new SDK returns detailed error results for each sub-operation +- AM session destruction (`SessionManager.logout()`) is NOT part of `oidcClient.user.logout()` — combine with `journeyClient.terminate()` for full logout + +### Before/After: User Info & Logout + +**Legacy:** + +```typescript +import { UserManager, FRUser } from '@forgerock/javascript-sdk'; + +try { + const user = await UserManager.getCurrentUser(); + console.log(user); +} catch (err) { + console.error('Error getting user:', err); +} + +try { + await FRUser.logout({ logoutRedirectUri: '/signoff' }); +} catch (err) { + console.error('Logout error:', err); +} +``` + +**New:** + +```typescript +const user = await oidcClient.user.info(); +if ('error' in user) { + console.error('Error getting user:', user); +} else { + console.log(user); +} + +const logoutResult = await oidcClient.user.logout(); +if ('error' in logoutResult) { + console.error('Logout error:', logoutResult); +} +``` + +--- + +## 10. Session Management + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------- | -------------------------------------------------------------------- | +| `SessionManager.logout(options?: ConfigOptions): Promise` | `journeyClient.terminate(options?: { query?: Record }): Promise` | `Response` → `void \| GenericError` | Calls `/sessions` endpoint. Returns error object instead of throwing | + +> **Note:** `SessionManager.logout()` in the legacy SDK destroys the AM session. `journeyClient.terminate()` serves the same purpose. For full logout (revoke tokens + end OIDC session + destroy AM session), combine `oidcClient.user.logout()` with `journeyClient.terminate()`. + +--- + +## 11. HTTP Client + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| -------------------------------------------------------------------------- | ------------------------ | ------------------ | ------------------------------------- | +| `HttpClient.request(options: HttpClientRequestOptions): Promise` | **No direct equivalent** | — | Must manually manage tokens and fetch | + +### Migration Pattern + +The legacy `HttpClient` provided several advanced features that have no equivalent in the new SDK. You must implement these yourself: + +### Capabilities Lost + +| Legacy `HttpClient` Feature | Migration Approach | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| **Auto bearer token injection** — Automatically added `Authorization: Bearer` header | Manually call `oidcClient.token.get()` and set the header | +| **401 token refresh** — On 401 response, auto-refreshed tokens and retried the request | Implement retry logic: on 401, call `oidcClient.token.get({ forceRenew: true })` and retry | +| **Policy advice parsing (IG)** — Parsed `Advices` from Identity Gateway 401/redirect responses | Not supported. If using IG-protected resources, parse advice manually | +| **Policy advice parsing (REST)** — Parsed `AuthenticateToServiceConditionAdvice` and `TransactionConditionAdvice` from AM REST responses | Not supported. Parse AM authorization advice manually | +| **Automatic authorization tree execution** — Ran additional auth trees to satisfy policy advice | Must implement the full advice → auth tree → retry flow manually | +| **Request timeout** — Configurable via `serverConfig.timeout` | Use `AbortController` with `setTimeout` for fetch timeout | +| **Middleware integration** — Request middleware received `ActionTypes` context | Apply your own middleware pattern to fetch calls | + +**Legacy:** + +```typescript +import { HttpClient } from '@forgerock/javascript-sdk'; + +const response = await HttpClient.request({ + url: 'https://api.example.com/resource', + init: { method: 'GET' }, + authorization: { handleStep: myStepHandler }, +}); +``` + +**New:** + +```typescript +const tokens = await oidcClient.token.get({ backgroundRenew: true }); + +if ('error' in tokens) { + throw new Error('No valid tokens'); +} + +const response = await fetch('https://api.example.com/resource', { + method: 'GET', + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, +}); +``` + +--- + +## 12. WebAuthn + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------- | +| `import { FRWebAuthn, WebAuthnStepType } from '@forgerock/javascript-sdk'` | `import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | — | Class renamed `FRWebAuthn` → `WebAuthn`. Import path changed to submodule | +| `FRWebAuthn.getWebAuthnStepType(step: FRStep): WebAuthnStepType` | `WebAuthn.getWebAuthnStepType(step: JourneyStep): WebAuthnStepType` | Same | Step type changed to `JourneyStep` | +| `FRWebAuthn.authenticate(step: FRStep, optionsTransformer?): Promise` | `WebAuthn.authenticate(step: JourneyStep): Promise` | `FRStep` → `void` | Mutates step in-place instead of returning it | +| `FRWebAuthn.register(step: FRStep, deviceName?): Promise` | `WebAuthn.register(step: JourneyStep): Promise` | `FRStep` → `void` | Mutates step in-place instead of returning it. Device name not passed here | +| `FRWebAuthn.isWebAuthnSupported(): boolean` | No equivalent exported | — | Check `window.PublicKeyCredential` directly | +| `FRWebAuthn.isConditionalMediationSupported(): Promise` | No equivalent exported | — | Check `PublicKeyCredential.isConditionalMediationAvailable()` directly | +| `FRWebAuthn.getCallbacks(step): WebAuthnCallbacks` | Not exported as public API | — | Internal to `WebAuthn.authenticate/register` | +| `FRWebAuthn.getMetadataCallback(step)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getOutcomeCallback(step)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getTextOutputCallback(step)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getAuthenticationCredential(options)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getAuthenticationOutcome(credential)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getRegistrationCredential(options)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getRegistrationOutcome(credential)` | Not exported as public API | — | Internal | +| `FRWebAuthn.createAuthenticationPublicKey(metadata)` | Not exported as public API | — | Internal | +| `FRWebAuthn.createRegistrationPublicKey(metadata)` | Not exported as public API | — | Internal | + +### WebAuthn Enums + +| Legacy | New | Notes | +| --------------------------------- | --------------------------------- | --------------------------- | +| `WebAuthnStepType.None` | `WebAuthnStepType.None` | Same | +| `WebAuthnStepType.Registration` | `WebAuthnStepType.Registration` | Same | +| `WebAuthnStepType.Authentication` | `WebAuthnStepType.Authentication` | Same | +| `WebAuthnOutcome` | Not exported | Internal to WebAuthn module | +| `WebAuthnOutcomeType` | Not exported | Internal to WebAuthn module | + +### Before/After: WebAuthn + +**Legacy:** + +```typescript +import { FRWebAuthn, WebAuthnStepType } from '@forgerock/javascript-sdk'; + +const type = FRWebAuthn.getWebAuthnStepType(step); +if (type === WebAuthnStepType.Authentication) { + step = await FRWebAuthn.authenticate(step); +} else if (type === WebAuthnStepType.Registration) { + step = await FRWebAuthn.register(step, 'My Device'); +} +``` + +**New:** + +```typescript +import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'; + +const type = WebAuthn.getWebAuthnStepType(step); +if (type === WebAuthnStepType.Authentication) { + await WebAuthn.authenticate(step); // Mutates step in place +} else if (type === WebAuthnStepType.Registration) { + await WebAuthn.register(step); // Mutates step in place +} +``` + +--- + +## 13. QR Code + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ------------------------------------------------------ | ------------------------------------------------------------ | ------------------ | ---------------------------------------------------------------- | +| `import { FRQRCode } from '@forgerock/javascript-sdk'` | `import { QRCode } from '@forgerock/journey-client/qr-code'` | — | Class renamed `FRQRCode` → `QRCode`. **Subpath import required** | +| `FRQRCode.isQRCodeStep(step: FRStep): boolean` | `QRCode.isQRCodeStep(step: JourneyStep): boolean` | Same | Step param type changed | +| `FRQRCode.getQRCodeData(step: FRStep): QRCodeData` | `QRCode.getQRCodeData(step: JourneyStep): QRCodeData` | Same | Step param type changed | + +--- + +## 14. Recovery Codes + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------ | +| `import { FRRecoveryCodes } from '@forgerock/javascript-sdk'` | `import { RecoveryCodes } from '@forgerock/journey-client/recovery-codes'` | — | Class renamed `FRRecoveryCodes` → `RecoveryCodes`. **Subpath import required** | +| `FRRecoveryCodes.isDisplayStep(step: FRStep): boolean` | `RecoveryCodes.isDisplayStep(step: JourneyStep): boolean` | Same | Step param type changed | +| `FRRecoveryCodes.getCodes(step: FRStep): string[]` | `RecoveryCodes.getCodes(step: JourneyStep): string[]` | Same | Step param type changed | +| `FRRecoveryCodes.getDeviceName(step: FRStep): string` | `RecoveryCodes.getDeviceName(step: JourneyStep): string` | Same | Step param type changed | + +--- + +## 15. Policy + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `import { FRPolicy, PolicyKey } from '@forgerock/javascript-sdk'` | `import { Policy } from '@forgerock/journey-client/policy'` + `import { PolicyKey } from '@forgerock/sdk-types'` | — | Class renamed `FRPolicy` → `Policy`. **Subpath import required** | +| `FRPolicy.parseErrors(err, messageCreator?)` | `Policy.parseErrors(err, messageCreator?)` | Same | Same signature | +| `FRPolicy.parseFailedPolicyRequirement(failedPolicy, messageCreator?)` | `Policy.parseFailedPolicyRequirement(failedPolicy, messageCreator?)` | Same | Same signature | +| `FRPolicy.parsePolicyRequirement(property, policy, messageCreator?)` | `Policy.parsePolicyRequirement(property, policy, messageCreator?)` | Same | Same signature | +| `import { defaultMessageCreator } from '@forgerock/javascript-sdk'` | Internal to `@forgerock/journey-client/policy` module | Same | **Not publicly re-exported.** `Policy` class uses it internally. To provide custom messages, pass a `MessageCreator` to `Policy.parseErrors()` | + +--- + +## 16. Device + +### FRDevice (Device Profile Collection) + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ---------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------- | +| `import { FRDevice } from '@forgerock/javascript-sdk'` | Device profile functionality via `@forgerock/device-client` | — | Class-based → factory function | +| `new FRDevice(config?).getProfile({ location, metadata })` | `deviceClient(config).profile.get(query)` | `DeviceProfileData` → `ProfileDevice[] \| { error }` | Returns error object instead of throwing | + +### deviceClient (Device CRUD Operations) + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ---------------------------------------------------------- | --------------------------------------------------------- | ------------------ | --------------------------------------- | +| `import { deviceClient } from '@forgerock/javascript-sdk'` | `import { deviceClient } from '@forgerock/device-client'` | — | Same factory pattern, different package | +| `deviceClient(config).oath.get(query)` | `deviceClient(config).oath.get(query)` | Same | — | +| `deviceClient(config).oath.delete(query)` | `deviceClient(config).oath.delete(query)` | Same | — | +| `deviceClient(config).push.get(query)` | `deviceClient(config).push.get(query)` | Same | — | +| `deviceClient(config).push.delete(query)` | `deviceClient(config).push.delete(query)` | Same | — | +| `deviceClient(config).webAuthn.get(query)` | `deviceClient(config).webAuthn.get(query)` | Same | — | +| `deviceClient(config).webAuthn.update(query)` | `deviceClient(config).webAuthn.update(query)` | Same | — | +| `deviceClient(config).webAuthn.delete(query)` | `deviceClient(config).webAuthn.delete(query)` | Same | — | +| `deviceClient(config).bound.get(query)` | `deviceClient(config).bound.get(query)` | Same | — | +| `deviceClient(config).bound.delete(query)` | `deviceClient(config).bound.delete(query)` | Same | — | +| `deviceClient(config).profile.get(query)` | `deviceClient(config).profile.get(query)` | Same | — | +| `deviceClient(config).profile.delete(query)` | `deviceClient(config).profile.delete(query)` | Same | — | + +--- + +## 17. Protect + +| Legacy API | New API | Return Type Change | Behavioral Notes | +| --------------------------------------------------- | ---------------------------------------------- | ------------------------------ | -------------------- | +| `import { protect } from '@forgerock/ping-protect'` | `import { protect } from '@forgerock/protect'` | — | Package renamed only | +| `protect(options).start()` | `protect(options).start()` | `Promise` | Same | +| `protect(options).getData()` | `protect(options).getData()` | `Promise` | Same | +| `protect(options).pauseBehavioralData()` | `protect(options).pauseBehavioralData()` | `void \| { error }` | Same | +| `protect(options).resumeBehavioralData()` | `protect(options).resumeBehavioralData()` | `void \| { error }` | Same | + +### Protect Config Changes + +| Legacy Property | New Property | Notes | +| ----------------- | --------------- | ----------------- | +| `envId: string` | `envId: string` | Same (required) | +| All other options | Same | No config changes | + +--- + +## 18. Error Handling Patterns + +### Fundamental Pattern Change + +| Aspect | Legacy | New | +| --------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Error mechanism | `throw new Error(...)` caught via `try/catch` | Return `GenericError` object in union type | +| Detection | `catch (err) { ... }` | `if ('error' in result) { ... }` | +| Error info | `err.message: string` | `result.error: string`, `result.message?: string`, `result.type: ErrorType`, `result.status?: number \| string`, `result.code?: string \| number` | + +### GenericError Shape + +```typescript +interface GenericError { + error: string; // Error identifier + message?: string; // Human-readable description + type: ErrorType; // Categorized error type + status?: number | string; // HTTP status code (when applicable) + code?: string | number; // Error code (when applicable) +} +``` + +### Error Types + +| Error Type | When Used | +| ------------------- | --------------------------------------- | +| `'argument_error'` | Invalid arguments passed to SDK methods | +| `'auth_error'` | Authentication/authorization failures | +| `'davinci_error'` | DaVinci-specific errors | +| `'fido_error'` | WebAuthn/FIDO errors | +| `'exchange_error'` | Token exchange failures | +| `'internal_error'` | Internal SDK errors | +| `'network_error'` | Network/fetch failures | +| `'parse_error'` | Response parsing failures | +| `'state_error'` | Invalid state (e.g., no tokens found) | +| `'unknown_error'` | Unclassified errors | +| `'wellknown_error'` | Wellknown endpoint errors | + +### Before/After: Error Handling + +**Legacy:** + +```typescript +try { + const user = await UserManager.getCurrentUser(); + setUser(user); +} catch (err) { + console.error(`Error: get current user; ${err}`); + setUser({}); +} +``` + +**New:** + +```typescript +const user = await oidcClient.user.info(); +if ('error' in user) { + console.error('Error getting user:', user.error, user.message); + setUser({}); +} else { + setUser(user); +} +``` + +--- + +## 19. Type Exports + +| Legacy Type Export | New Location | Notes | +| -------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `AuthResponse` | `@forgerock/sdk-types` | Same interface | +| `Callback` | `@forgerock/sdk-types` | Same interface | +| `CallbackType` (type) | `@forgerock/sdk-types` | Same type | +| `ConfigOptions` | No direct equivalent | Config split across `JourneyClientConfig` and `OidcConfig` | +| `FailureDetail` | `@forgerock/sdk-types` | Same interface | +| `FRCallbackFactory` | `CallbackFactory` from `@forgerock/journey-client/types` | Renamed | +| `FRStepHandler` | No equivalent | Removed | +| `GetAuthorizationUrlOptions` | `@forgerock/sdk-types` | Same interface | +| `GetOAuth2TokensOptions` | No direct equivalent | Replaced by `token.exchange(code, state)` params | +| `GetTokensOptions` | `@forgerock/oidc-client` (from `client.types.ts`) | Different shape: `{ authorizeOptions?, forceRenew?, backgroundRenew?, storageOptions? }` | +| `IdPValue` | `@forgerock/journey-client/types` (via `SelectIdPCallback`) | Same interface | +| `LoggerFunctions` | `CustomLogger` from `@forgerock/sdk-logger` | Interface changed | +| `MessageCreator` | `@forgerock/journey-client/types` | Same interface | +| `NameValue` | `@forgerock/sdk-types` | Same interface | +| `OAuth2Tokens` | `OauthTokens` from `@forgerock/oidc-client` | Renamed, shape changed (see Token section) | +| `PolicyRequirement` | `@forgerock/sdk-types` | Same interface | +| `ProcessedPropertyError` | `@forgerock/journey-client/types` | Same interface | +| `RelyingParty` | `@forgerock/journey-client/webauthn` (via interfaces) | Same interface | +| `Step` | `@forgerock/sdk-types` | Same interface | +| `StepDetail` | `@forgerock/sdk-types` | Same interface | +| `StepOptions` | No direct equivalent | Replaced by `StartParam`, `NextOptions`, `ResumeOptions` | +| `Tokens` | `OauthTokens` from `@forgerock/oidc-client` | Renamed and restructured | +| `ValidConfigOptions` | No equivalent | Removed (config is encapsulated) | +| `WebAuthnAuthenticationMetadata` | `@forgerock/journey-client/webauthn` (via interfaces) | Same interface | +| `WebAuthnCallbacks` | Not exported | Internal to WebAuthn module | +| `WebAuthnRegistrationMetadata` | `@forgerock/journey-client/webauthn` (via interfaces) | Same interface | + +--- + +## 20. Removed / Deprecated APIs + +These legacy exports have no equivalent in the new SDK: + +| Legacy Export | Status | Migration Path | +| ----------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Auth` | Removed | Internal authentication logic. No public replacement needed | +| `Deferred` | Removed | Use native `Promise` constructor or equivalent | +| `PKCE` | Removed | PKCE is handled internally by `@forgerock/oidc-client`. Legacy utility methods (`PKCE.createState()`, `PKCE.createVerifier()`, `PKCE.createChallenge(verifier)`) have no public replacement — use `crypto.randomUUID()` for state and `crypto.subtle` for PKCE if needed | +| `LocalStorage` | Removed | Use `@forgerock/storage` package or native `localStorage`/`sessionStorage` | +| `ErrorCode` | Removed | Replaced by `GenericError.type` error classification (see [Error Handling](#18-error-handling-patterns)) | +| `HttpClient` | Removed | Use `fetch` + manual `Authorization` header (see [HTTP Client](#11-http-client)) | +| `Config` (static class) | Removed | Config passed as params to factory functions | +| `FRUser.login()` | Removed | Was never implemented in legacy SDK | +| `WebAuthnOutcome` | Not exported | Internal to `@forgerock/journey-client/webauthn` | +| `WebAuthnOutcomeType` | Not exported | Internal to `@forgerock/journey-client/webauthn` | +| `PolicyKey` | Available | `import { PolicyKey } from '@forgerock/sdk-types'` | +| `defaultMessageCreator` | Internal | Not publicly re-exported. Used internally by `Policy` class in `@forgerock/journey-client/policy`. Pass a custom `MessageCreator` to `Policy.parseErrors()` instead | + +### Token Vault + +The legacy `@forgerock/token-vault` package provided advanced token security via Service Worker interception and iframe-based origin isolation. It included: + +- `client(config)` — Main factory for creating a Token Vault client +- `register.interceptor()` — Service Worker registration for transparent token injection +- `register.proxy(element, config)` — iframe proxy for cross-origin token storage +- `register.store()` — Custom token store implementation for SDK integration + +> **Migration note:** Token Vault is not yet available in the new SDK. If you depend on Service Worker-based token isolation, continue using the legacy `@forgerock/token-vault` or implement custom token security. + +### Key Behavioral Removals + +| Legacy Behavior | New Approach | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Global config via `Config.set()` | Each client manages its own config independently | +| Automatic PKCE challenge generation in `OAuth2Client` | `@forgerock/oidc-client` handles PKCE internally | +| `HttpClient` auto-injecting bearer tokens and refreshing on 401 | Manually get tokens, add `Authorization` header, handle 401 yourself | +| Token stored in `localStorage` by default | OIDC client uses `localStorage` by default; journey client step storage uses `sessionStorage` | +| Per-call config overrides via `StepOptions` | **Major change:** Config is fixed at client creation time. Legacy apps that passed different `tree`, `serverConfig`, or `middleware` per-call must create separate client instances. Only `query` params can vary per-call | +| `FRUser.logout()` silently swallows errors per-step | `oidcClient.user.logout()` returns structured `LogoutErrorResult` with per-operation error details | +| `FRAuth.resume()` auto-parses 10+ URL params (suspendedId, RelayState, etc.) | `journeyClient.resume()` only parses `code`, `state`, `form_post_entry`, `responsekey`. Other params must be extracted manually | diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000000..cd8a8dfc18 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,39 @@ +pre-commit: + commands: + nx-sync: + run: pnpm nx sync + nx-check: + run: pnpm nx affected -t typecheck lint build + stage_fixed: true + format: + run: pnpm nx format:write + stage_fixed: true + interface-mapping: + glob: >- + {tools/interface-mapping-validator/**/*.ts, + interface_mapping.md, + packages/*/src/index.ts, + packages/*/src/types.ts, + packages/*/package.json, + packages/sdk-effects/*/src/index.ts, + packages/sdk-effects/*/package.json} + run: > + pnpm tsx tools/interface-mapping-validator/src/main.ts || + (echo "" && + echo "Interface mapping is out of sync." && + echo "Run: pnpm mapping:generate" && + exit 1) + +commit-msg: + commands: + commitlint: + run: pnpm run commitlint {1} + +prepare-commit-msg: + commands: + commitizen: + interactive: true + run: | + if [ "$2" = "template" ]; then + exec < /dev/tty && pnpm run commit || true + fi diff --git a/nx.json b/nx.json index d2f5805f60..060d49ca6a 100644 --- a/nx.json +++ b/nx.json @@ -3,7 +3,7 @@ "default": ["{projectRoot}/**/*", "sharedGlobals"], "sharedGlobals": [ "!{workspaceRoot}/.github/pull_request_template.md", - "!{workspaceRoot}/.husky/*", + "!{workspaceRoot}/lefthook.yml", "!{workspaceRoot}/.verdaccio/*", "!{workspaceRoot}/renovate.json", "!{projectRoot}/LICENSE", @@ -118,7 +118,8 @@ "options": { "targetName": "nxLint" }, - "include": ["e2e/**/**/*", "packages/**/**/*", "tools/**/**/*"] + "include": ["e2e/**/**/*", "packages/**/**/*", "tools/**/**/*"], + "exclude": ["tools/**/fixtures/**/*"] }, { "plugin": "@nx/vite/plugin", diff --git a/package.json b/package.json index 50662075de..cadcb8affd 100644 --- a/package.json +++ b/package.json @@ -25,25 +25,19 @@ "format": "pnpm nx format:write", "generate-docs": "typedoc", "lint": "nx affected --target=lint", + "mapping:validate": "pnpm tsx tools/interface-mapping-validator/src/main.ts", + "mapping:generate": "pnpm tsx tools/interface-mapping-validator/src/main.ts --generate", "local-release": "pnpm ts-node tools/release/release.ts", "nx": "nx", "postinstall": "ts-patch install", "preinstall": "npx only-allow pnpm", - "prepare": "node .husky/install.mjs", + "prepare": "lefthook install", "serve": "nx serve", "test": "CI=true nx affected:test", "test:e2e": "CI=true nx affected:e2e", "verdaccio": "nx local-registry", "watch": "nx vite:watch-deps" }, - "lint-staged": { - "*": [ - "pnpm nx sync", - "pnpm nx affected -t typecheck lint build", - "pnpm nx format:write", - "git add" - ] - }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" @@ -96,11 +90,10 @@ "eslint-plugin-playwright": "^2.0.0", "eslint-plugin-prettier": "^5.2.3", "fast-check": "^4.0.0", - "husky": "^9.0.0", + "@evilmartians/lefthook": "^2.1.4", "jiti": "2.6.1", "jsdom": "27.1.0", "jsonc-eslint-parser": "^2.1.0", - "lint-staged": "^15.0.0", "madge": "8.0.0", "nx": "22.3.3", "pkg-pr-new": "^0.0.60", diff --git a/packages/journey-client/package.json b/packages/journey-client/package.json index 465ad7c889..f6f4357ab8 100644 --- a/packages/journey-client/package.json +++ b/packages/journey-client/package.json @@ -35,7 +35,9 @@ "@forgerock/sdk-utilities": "workspace:*", "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "vitest": "catalog:vitest", + "vitest-canvas-mock": "catalog:vitest" }, "devDependencies": { "@vitest/coverage-v8": "catalog:vitest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 788a4afc5a..9b97ef96ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@eslint/js': specifier: ~9.39.0 version: 9.39.1 + '@evilmartians/lefthook': + specifier: ^2.1.4 + version: 2.1.5 '@nx/devkit': specifier: 22.3.3 version: 22.3.3(nx@22.3.3(@swc-node/register@1.10.10(@swc/core@1.11.21(@swc/helpers@0.5.17))(@swc/types@0.1.25)(typescript@5.8.3))(@swc/core@1.11.21(@swc/helpers@0.5.17))) @@ -195,9 +198,6 @@ importers: fast-check: specifier: ^4.0.0 version: 4.3.0 - husky: - specifier: ^9.0.0 - version: 9.1.7 jiti: specifier: 2.6.1 version: 2.6.1 @@ -207,9 +207,6 @@ importers: jsonc-eslint-parser: specifier: ^2.1.0 version: 2.4.1 - lint-staged: - specifier: ^15.0.0 - version: 15.5.2 madge: specifier: 8.0.0 version: 8.0.0(typescript@5.8.3) @@ -577,6 +574,19 @@ importers: specifier: 4.20.6 version: 4.20.6 + tools/interface-mapping-validator: + dependencies: + ts-morph: + specifier: ^25.0.0 + version: 25.0.1 + devDependencies: + '@forgerock/javascript-sdk': + specifier: 4.9.0 + version: 4.9.0 + vitest: + specifier: catalog:vitest + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.1.0)(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + tools/release: dependencies: '@effect/platform': @@ -1933,6 +1943,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@evilmartians/lefthook@2.1.5': + resolution: {integrity: sha512-svJSugMv1M0g7JeD1EqGcVQtF4pk1HkFZ8h7hfygXDyMNmo2UyQteqr6b9ljkS936SvH61IJOBjllt/EH3c7gw==} + cpu: [x64, arm64, ia32] + os: [darwin, linux, win32] + hasBin: true + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -1940,6 +1956,9 @@ packages: '@forgerock/javascript-sdk@4.7.0': resolution: {integrity: sha512-0wpy2/ii9F9yKI3r+huqQtp6bVAeajf2+Llq25dvkfxQX19FKKi9KPPMF7JTVti6heYHyo36lxweB7xerB5UTQ==} + '@forgerock/javascript-sdk@4.9.0': + resolution: {integrity: sha512-xNE4LMIFYvvPpsq04RqSGnZ+zT+LjOdHePPj6K4uukqewBaZMDmynozkOTtCFmWui7gqANuhf2arbdo3J5uTVQ==} + '@gerrit0/mini-shiki@1.27.2': resolution: {integrity: sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og==} @@ -3017,6 +3036,9 @@ packages: resolution: {integrity: sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==} engines: {node: '>=18'} + '@ts-morph/common@0.26.1': + resolution: {integrity: sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==} + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -3631,10 +3653,6 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} - ansi-escapes@7.2.0: - resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} - engines: {node: '>=18'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4044,10 +4062,6 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - cli-spinners@2.6.1: resolution: {integrity: sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==} engines: {node: '>=6'} @@ -4056,10 +4070,6 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-truncate@4.0.0: - resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} - engines: {node: '>=18'} - cli-width@3.0.0: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} @@ -4088,6 +4098,9 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + collect-v8-coverage@1.0.3: resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} @@ -4119,10 +4132,6 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4597,9 +4606,6 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4650,10 +4656,6 @@ packages: engines: {node: '>=4'} hasBin: true - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -4875,9 +4877,6 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -4893,10 +4892,6 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - exit-x@0.2.2: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} @@ -5185,10 +5180,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -5216,10 +5207,6 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -5439,15 +5426,6 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -5593,14 +5571,6 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@4.0.0: - resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} - engines: {node: '>=12'} - - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-generator-fn@2.1.0: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} @@ -5689,10 +5659,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -6025,10 +5991,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -6039,15 +6001,6 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - lint-staged@15.5.2: - resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} - engines: {node: '>=18.12.0'} - hasBin: true - - listr2@8.3.3: - resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} - engines: {node: '>=18.0.0'} - loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -6125,10 +6078,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - longest@2.0.1: resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} engines: {node: '>=0.10.0'} @@ -6281,14 +6230,6 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -6493,10 +6434,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nx@22.3.3: resolution: {integrity: sha512-pOxtKWUfvf0oD8Geqs8D89Q2xpstRTaSY+F6Ut/Wd0GnEjUjO32SS1ymAM6WggGPHDZN4qpNrd5cfIxQmAbRLg==} hasBin: true @@ -6556,14 +6493,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -6686,6 +6615,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -6706,10 +6638,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -6761,11 +6689,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} @@ -7091,10 +7014,6 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - rettime@0.7.0: resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} @@ -7102,9 +7021,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -7296,14 +7212,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - slice-ansi@5.0.0: - resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} - engines: {node: '>=12'} - - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - slow-redact@0.3.2: resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} @@ -7422,10 +7330,6 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -7475,10 +7379,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -7694,6 +7594,9 @@ packages: resolution: {integrity: sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw==} engines: {node: '>=18'} + ts-morph@25.0.1: + resolution: {integrity: sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -8171,10 +8074,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -9752,6 +9651,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@evilmartians/lefthook@2.1.5': {} + '@fastify/busboy@2.1.1': {} '@forgerock/javascript-sdk@4.7.0': @@ -9762,6 +9663,14 @@ snapshots: - react - react-redux + '@forgerock/javascript-sdk@4.9.0': + dependencies: + '@reduxjs/toolkit': 2.10.1 + immer: 10.2.0 + transitivePeerDependencies: + - react + - react-redux + '@gerrit0/mini-shiki@1.27.2': dependencies: '@shikijs/engine-oniguruma': 1.29.2 @@ -10998,6 +10907,12 @@ snapshots: '@ts-graphviz/ast': 2.0.7 '@ts-graphviz/common': 2.1.5 + '@ts-morph/common@0.26.1': + dependencies: + fast-glob: 3.3.3 + minimatch: 9.0.5 + path-browserify: 1.0.1 + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -11855,10 +11770,6 @@ snapshots: dependencies: type-fest: 0.21.3 - ansi-escapes@7.2.0: - dependencies: - environment: 1.1.0 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -12319,19 +12230,10 @@ snapshots: dependencies: restore-cursor: 3.1.0 - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - cli-spinners@2.6.1: {} cli-spinners@2.9.2: {} - cli-truncate@4.0.0: - dependencies: - slice-ansi: 5.0.0 - string-width: 7.2.0 - cli-width@3.0.0: {} cli-width@4.1.0: {} @@ -12354,6 +12256,8 @@ snapshots: co@4.6.0: {} + code-block-writer@13.0.3: {} + collect-v8-coverage@1.0.3: {} color-convert@1.9.3: @@ -12381,8 +12285,6 @@ snapshots: commander@12.1.0: {} - commander@13.1.0: {} - commander@2.20.3: {} commander@6.2.1: {} @@ -12834,8 +12736,6 @@ snapshots: emittery@0.13.1: {} - emoji-regex@10.6.0: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -12875,8 +12775,6 @@ snapshots: envinfo@7.15.0: {} - environment@1.1.0: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -13224,8 +13122,6 @@ snapshots: eventemitter3@4.0.7: {} - eventemitter3@5.0.1: {} - events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -13256,18 +13152,6 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - exit-x@0.2.2: {} expand-tilde@2.0.2: @@ -13630,8 +13514,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -13664,8 +13546,6 @@ snapshots: get-stream@6.0.1: {} - get-stream@8.0.1: {} - get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -13948,10 +13828,6 @@ snapshots: human-signals@2.1.0: {} - human-signals@5.0.0: {} - - husky@9.1.7: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -14107,12 +13983,6 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@4.0.0: {} - - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.4.0 - is-generator-fn@2.1.0: {} is-generator-function@1.1.2: @@ -14177,8 +14047,6 @@ snapshots: is-stream@2.0.1: {} - is-stream@3.0.0: {} - is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -14703,8 +14571,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lilconfig@3.1.3: {} - lines-and-columns@1.2.4: {} lines-and-columns@2.0.3: {} @@ -14713,30 +14579,6 @@ snapshots: dependencies: uc.micro: 2.1.0 - lint-staged@15.5.2: - dependencies: - chalk: 5.6.2 - commander: 13.1.0 - debug: 4.4.3 - execa: 8.0.1 - lilconfig: 3.1.3 - listr2: 8.3.3 - micromatch: 4.0.8 - pidtree: 0.6.0 - string-argv: 0.3.2 - yaml: 2.8.1 - transitivePeerDependencies: - - supports-color - - listr2@8.3.3: - dependencies: - cli-truncate: 4.0.0 - colorette: 2.0.20 - eventemitter3: 5.0.1 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 9.0.2 - loader-runner@4.3.1: {} locate-path@5.0.0: @@ -14796,14 +14638,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - log-update@6.1.0: - dependencies: - ansi-escapes: 7.2.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.1.2 - wrap-ansi: 9.0.2 - longest@2.0.1: {} loupe@3.2.1: {} @@ -14933,10 +14767,6 @@ snapshots: mimic-fn@2.1.0: {} - mimic-fn@4.0.0: {} - - mimic-function@5.0.1: {} - mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -15136,10 +14966,6 @@ snapshots: dependencies: path-key: 3.1.1 - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - nx@22.3.3(@swc-node/register@1.10.10(@swc/core@1.11.21(@swc/helpers@0.5.17))(@swc/types@0.1.25)(typescript@5.8.3))(@swc/core@1.11.21(@swc/helpers@0.5.17)): dependencies: '@napi-rs/wasm-runtime': 0.2.4 @@ -15244,14 +15070,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -15391,6 +15209,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-exists@5.0.0: {} @@ -15401,8 +15221,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-scurry@1.11.1: @@ -15440,8 +15258,6 @@ snapshots: picomatch@4.0.3: {} - pidtree@0.6.0: {} - pify@3.0.0: {} pify@4.0.1: {} @@ -15808,17 +15624,10 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - rettime@0.7.0: {} reusify@1.1.0: {} - rfdc@1.4.1: {} - rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -16083,16 +15892,6 @@ snapshots: slash@3.0.0: {} - slice-ansi@5.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 4.0.0 - - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - slow-redact@0.3.2: {} sonic-boom@3.8.1: @@ -16224,12 +16023,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 - string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -16288,8 +16081,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-final-newline@3.0.0: {} - strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -16503,6 +16294,11 @@ snapshots: '@ts-graphviz/common': 2.1.5 '@ts-graphviz/core': 2.0.7 + ts-morph@25.0.1: + dependencies: + '@ts-morph/common': 0.26.1 + code-block-writer: 13.0.3 + ts-node@10.9.2(@swc/core@1.11.21(@swc/helpers@0.5.17))(@types/node@24.9.2)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -17131,12 +16927,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.1.2 - wrappy@1.0.2: {} write-file-atomic@5.0.1: diff --git a/tools/interface-mapping-validator/README.md b/tools/interface-mapping-validator/README.md new file mode 100644 index 0000000000..6aec2070b8 --- /dev/null +++ b/tools/interface-mapping-validator/README.md @@ -0,0 +1,134 @@ +# Interface Mapping Validator & Generator + +Validates and generates the `interface_mapping.md` document that maps every public symbol from the legacy `@forgerock/javascript-sdk` to its equivalent in the new Ping SDK packages. + +## Quick Start + +```bash +# Validate — check for drift between the doc and actual SDK exports +pnpm tsx tools/interface-mapping-validator/src/main.ts + +# Generate — rebuild Sections 0, 1, and 5 from the mapping config +pnpm tsx tools/interface-mapping-validator/src/main.ts --generate + +# Fix — patch existing tables (add missing rows, remove stale rows) +pnpm tsx tools/interface-mapping-validator/src/main.ts --fix +``` + +## How It Works + +The tool has three modes: + +### Validate (default) + +Extracts the public API surface from both SDKs using [ts-morph](https://ts-morph.com/), parses the existing `interface_mapping.md` tables, and reports drift: + +- **Undocumented legacy symbols** — in the legacy SDK but missing from the doc +- **Stale entries** — in the doc but no longer in the legacy SDK +- **Invalid import paths** — documented paths that don't match real package exports +- **Missing/stale callbacks** — callback types out of sync with `@forgerock/journey-client/types` +- **Undocumented new exports** — new SDK symbols not referenced in the doc (warnings) + +Exit code `0` if no errors, `1` if errors found. + +### Generate (`--generate`) + +Produces three sections of `interface_mapping.md` from a [mapping config](#mapping-config) combined with auto-discovered exports: + +| Section | Source | +|---|---| +| **0. Quick Reference** | `SYMBOL_MAP` config + auto-discovered callbacks + 1:1 name matches | +| **1. Package Mapping** | Renamed symbols from `SYMBOL_MAP`, grouped by target package | +| **5. Callback Type Mapping** | Auto-discovered from `@forgerock/journey-client/types` exports | + +All other sections (2–4, 6–20) are **never modified** — they contain hand-written behavioral notes, code examples, and migration guidance. + +If any legacy symbol is missing from both the config and auto-discovery, generation aborts with a list of unmapped symbols. + +### Fix (`--fix`) + +Patches the existing tables in-place: adds missing rows, removes stale rows, corrects import paths. Does not regenerate from scratch — use `--generate` for that. + +## Mapping Config + +The mapping config at `src/mapping-config.ts` is the source of truth for legacy → new symbol connections. It exports: + +### `SYMBOL_MAP` + +Maps each legacy symbol to one of three categories: + +```typescript +// Renamed or moved to a new package +FRAuth: { + new: 'journey', + package: '@forgerock/journey-client', + note: 'factory returns `JourneyClient`', +} + +// Intentionally removed with no replacement +Config: { + status: 'removed', + note: 'pass config to `journey()` / `oidc()` factory params', +} + +// Exists but not publicly exported +WebAuthnOutcome: { + status: 'internal', + note: 'internal to webauthn module', +} +``` + +**Callbacks are omitted** from the config. Any symbol ending in `Callback` that exists in `@forgerock/journey-client/types` is auto-discovered and mapped automatically. + +### `PACKAGE_MAP` + +Maps package-level renames (not symbol-level): + +```typescript +'@forgerock/ping-protect': { + new: '@forgerock/protect', + note: 'PingOne Protect/Signals integration', +} +``` + +## Adding a New Symbol + +When a new symbol is added to either SDK: + +1. Run `pnpm tsx tools/interface-mapping-validator/src/main.ts` to see the drift report +2. If the symbol is a callback with the same name in both SDKs, it's auto-discovered — nothing to do +3. Otherwise, add an entry to `SYMBOL_MAP` in `src/mapping-config.ts` +4. Run `pnpm tsx tools/interface-mapping-validator/src/main.ts --generate` to regenerate + +## Architecture + +``` +src/ +├── extractors/ +│ ├── legacy.ts # Parse legacy SDK dist/index.d.ts → symbol list +│ ├── new-sdk.ts # Parse new SDK package.json exports + barrel files → symbol list +│ └── markdown.ts # Parse interface_mapping.md tables → documented mappings +├── mapping-config.ts # Symbol mapping data (the "rename database") +├── generator.ts # Config + exports → markdown table strings +├── writer.ts # Replace sections in markdown, preserve everything else +├── differ.ts # Compare extracted vs. documented → findings +├── fixer.ts # Apply findings as patches to markdown +├── reporter.ts # Format findings for stdout +├── types.ts # Shared type definitions +├── config.ts # Constants (package paths, section names) +└── main.ts # CLI entry point +``` + +## Testing + +```bash +# Run all tests (92 tests) +cd tools/interface-mapping-validator && npx vitest run + +# Run specific module tests +npx vitest run src/generator.test.ts +npx vitest run src/writer.test.ts +npx vitest run src/integration.test.ts +``` + +The integration tests run against the real workspace data — they verify that the mapping config covers all legacy symbols and that generation produces valid output. diff --git a/tools/interface-mapping-validator/eslint.config.mjs b/tools/interface-mapping-validator/eslint.config.mjs new file mode 100644 index 0000000000..ae5100f276 --- /dev/null +++ b/tools/interface-mapping-validator/eslint.config.mjs @@ -0,0 +1,51 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + { + ignores: ['**/dist', 'src/fixtures/**'], + }, + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, + { + files: ['**/*.ts', '**/*.tsx'], + // Override or add rules here + rules: {}, + }, + { + files: ['**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'warn', + { + ignoredFiles: [ + '{projectRoot}/vite.config.{js,ts,mjs,mts}', + '{projectRoot}/eslint.config.{js,cjs,mjs}', + ], + ignoredDependencies: ['@forgerock/javascript-sdk'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, + { + ignores: [ + '**/*.md', + 'LICENSE', + 'dist', + 'coverage', + 'vite.config.*.timestamp*', + '*tsconfig.tsbuildinfo*', + ], + }, +]; diff --git a/tools/interface-mapping-validator/package.json b/tools/interface-mapping-validator/package.json new file mode 100644 index 0000000000..b3e49a5c07 --- /dev/null +++ b/tools/interface-mapping-validator/package.json @@ -0,0 +1,27 @@ +{ + "name": "@forgerock/interface-mapping-validator", + "version": "0.0.0", + "private": true, + "description": "Validates interface_mapping.md against actual SDK exports", + "type": "module", + "scripts": { + "build": "pnpm nx nxBuild", + "generate": "tsx src/main.ts --generate", + "lint": "pnpm nx nxLint", + "test": "pnpm nx nxTest", + "test:watch": "pnpm nx nxTest --watch", + "validate": "tsx src/main.ts", + "validate:fix": "tsx src/main.ts --fix" + }, + "dependencies": { + "ts-morph": "^25.0.0", + "vitest": "catalog:vitest" + }, + "devDependencies": { + "@forgerock/javascript-sdk": "4.9.0", + "vitest": "catalog:vitest" + }, + "nx": { + "tags": ["scope:tool"] + } +} diff --git a/tools/interface-mapping-validator/src/config.ts b/tools/interface-mapping-validator/src/config.ts new file mode 100644 index 0000000000..8144dcc093 --- /dev/null +++ b/tools/interface-mapping-validator/src/config.ts @@ -0,0 +1,22 @@ +export const NEW_SDK_PACKAGES = [ + 'packages/journey-client', + 'packages/oidc-client', + 'packages/device-client', + 'packages/protect', + 'packages/sdk-types', + 'packages/sdk-utilities', + 'packages/sdk-effects/logger', + 'packages/sdk-effects/storage', +] as const; + +export const LEGACY_SDK_INDEX_PATH = 'node_modules/@forgerock/javascript-sdk/dist/index.d.ts'; + +export const INTERFACE_MAPPING_PATH = 'interface_mapping.md'; + +export const PROTECTED_PREFIXES = ['Removed', 'Not exported', 'No direct equivalent'] as const; + +export const SECTIONS = { + QUICK_REFERENCE: 'Quick Reference', + PACKAGE_MAPPING: 'Package Mapping', + CALLBACKS: 'Callback Type Mapping', +} as const; diff --git a/tools/interface-mapping-validator/src/differ.test.ts b/tools/interface-mapping-validator/src/differ.test.ts new file mode 100644 index 0000000000..eeddf39338 --- /dev/null +++ b/tools/interface-mapping-validator/src/differ.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect } from 'vitest'; +import { diff } from './differ.js'; +import type { LegacyExport, NewSdkExport, MarkdownExtractionResult } from './types.js'; +import { SECTIONS } from './config.js'; + +// --------------------------------------------------------------------------- +// Test data helpers +// --------------------------------------------------------------------------- + +function makeLegacy(names: string[]): LegacyExport[] { + return names.map((name) => ({ name, kind: 'variable' as const })); +} + +function makeNewSdk( + entries: Array<{ + symbol: string; + importPath: string; + entryPoint?: string; + packageName?: string; + }>, +): NewSdkExport[] { + return entries.map(({ symbol, importPath, entryPoint, packageName }) => ({ + symbol, + importPath, + // entryPoint is the package.json exports key (e.g. "./types"), not the full import path + entryPoint: entryPoint ?? '.', + packageName: packageName ?? importPath.split('/').slice(0, 2).join('/'), + kind: 'variable' as const, + })); +} + +function makeDoc( + mappings: Array<{ + section: string; + legacySymbol: string; + newImport: string; + lineNumber: number; + }>, + entryPoints?: string[], +): MarkdownExtractionResult { + return { + mappings: mappings.map((m) => ({ + ...m, + otherColumns: [], + })), + entryPoints: entryPoints ?? [], + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('diff', () => { + it('returns no findings when everything is aligned', () => { + const legacy = makeLegacy(['FRAuth', 'Config']); + const newSdk = makeNewSdk([ + { symbol: 'FRAuth', importPath: '@anthropic/journey-client' }, + { symbol: 'Config', importPath: '@anthropic/oidc-client' }, + ]); + const documented = makeDoc( + [ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@anthropic/journey-client', + lineNumber: 10, + }, + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'Config', + newImport: '@anthropic/oidc-client', + lineNumber: 11, + }, + ], + ['@anthropic/journey-client', '@anthropic/oidc-client'], + ); + + const findings = diff(legacy, newSdk, documented); + expect(findings).toEqual([]); + }); + + it('reports undocumented legacy symbols', () => { + const legacy = makeLegacy(['FRAuth', 'UndocumentedThing']); + const newSdk = makeNewSdk([{ symbol: 'FRAuth', importPath: '@anthropic/journey-client' }]); + const documented = makeDoc([ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@anthropic/journey-client', + lineNumber: 10, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const undoc = findings.filter((f) => f.category === 'undocumented-legacy-symbol'); + + expect(undoc).toHaveLength(1); + expect(undoc[0].severity).toBe('error'); + expect(undoc[0].action).toBe('add'); + expect(undoc[0].message).toContain('UndocumentedThing'); + }); + + it('reports stale legacy symbols with lineNumber', () => { + const legacy = makeLegacy(['FRAuth']); + const newSdk = makeNewSdk([{ symbol: 'FRAuth', importPath: '@anthropic/journey-client' }]); + const documented = makeDoc([ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@anthropic/journey-client', + lineNumber: 10, + }, + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'ObsoleteSymbol', + newImport: '@anthropic/oidc-client', + lineNumber: 15, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const stale = findings.filter((f) => f.category === 'stale-legacy-symbol'); + + expect(stale).toHaveLength(1); + expect(stale[0].severity).toBe('error'); + expect(stale[0].action).toBe('remove'); + expect(stale[0].lineNumber).toBe(15); + expect(stale[0].message).toContain('ObsoleteSymbol'); + }); + + it('does NOT report "Removed" entries as stale', () => { + const legacy = makeLegacy(['FRAuth']); + const newSdk = makeNewSdk([{ symbol: 'FRAuth', importPath: '@anthropic/journey-client' }]); + const documented = makeDoc([ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@anthropic/journey-client', + lineNumber: 10, + }, + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'OldThing', + newImport: 'Removed - no longer needed', + lineNumber: 20, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const stale = findings.filter((f) => f.category === 'stale-legacy-symbol'); + + expect(stale).toHaveLength(0); + }); + + it('does NOT report "Not exported" entries as stale', () => { + const legacy = makeLegacy(['FRAuth']); + const newSdk = makeNewSdk([{ symbol: 'FRAuth', importPath: '@anthropic/journey-client' }]); + const documented = makeDoc([ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@anthropic/journey-client', + lineNumber: 10, + }, + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'InternalHelper', + newImport: 'Not exported - use X instead', + lineNumber: 25, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const stale = findings.filter((f) => f.category === 'stale-legacy-symbol'); + + expect(stale).toHaveLength(0); + }); + + it('reports invalid import paths', () => { + const legacy = makeLegacy(['FRAuth']); + const newSdk = makeNewSdk([{ symbol: 'FRAuth', importPath: '@anthropic/journey-client' }]); + const documented = makeDoc([ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@anthropic/nonexistent-package', + lineNumber: 10, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const invalid = findings.filter((f) => f.category === 'invalid-import-path'); + + expect(invalid).toHaveLength(1); + expect(invalid[0].severity).toBe('error'); + expect(invalid[0].action).toBe('update'); + expect(invalid[0].message).toContain('@anthropic/nonexistent-package'); + }); + + it('skips @forgerock/javascript-sdk paths for invalid import check', () => { + const legacy = makeLegacy(['FRAuth']); + const newSdk = makeNewSdk([{ symbol: 'FRAuth', importPath: '@anthropic/journey-client' }]); + const documented = makeDoc([ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@forgerock/javascript-sdk', + lineNumber: 10, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const invalid = findings.filter((f) => f.category === 'invalid-import-path'); + + expect(invalid).toHaveLength(0); + }); + + it('reports missing callbacks', () => { + const legacy = makeLegacy([]); + const newSdk = makeNewSdk([ + { + symbol: 'NameCallback', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + }, + { + symbol: 'PasswordCallback', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + }, + ]); + const documented = makeDoc([ + { + section: SECTIONS.CALLBACKS, + legacySymbol: 'NameCallback', + newImport: '@forgerock/journey-client/types', + lineNumber: 50, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const missing = findings.filter((f) => f.category === 'missing-callback'); + + expect(missing).toHaveLength(1); + expect(missing[0].severity).toBe('error'); + expect(missing[0].action).toBe('add'); + expect(missing[0].message).toContain('PasswordCallback'); + }); + + it('callback detection only matches journey-client/types, not other packages', () => { + const legacy = makeLegacy([]); + const newSdk = makeNewSdk([ + { + symbol: 'NameCallback', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + }, + { + symbol: 'OidcConfig', + importPath: '@forgerock/oidc-client/types', + entryPoint: './types', + }, + { + symbol: 'DeviceClient', + importPath: '@forgerock/device-client/types', + entryPoint: './types', + }, + ]); + const documented = makeDoc([]); + + const findings = diff(legacy, newSdk, documented); + const missing = findings.filter((f) => f.category === 'missing-callback'); + + // Only NameCallback from journey-client/types should be flagged, not OidcConfig or DeviceClient + expect(missing).toHaveLength(1); + expect(missing[0].message).toContain('NameCallback'); + }); + + it('callback detection only matches symbols ending with Callback', () => { + const legacy = makeLegacy([]); + const newSdk = makeNewSdk([ + { + symbol: 'NameCallback', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + }, + { + symbol: 'JourneyStep', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + }, + { + symbol: 'CallbackType', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + }, + ]); + const documented = makeDoc([]); + + const findings = diff(legacy, newSdk, documented); + const missing = findings.filter((f) => f.category === 'missing-callback'); + + // Only NameCallback ends with "Callback" + expect(missing).toHaveLength(1); + expect(missing[0].message).toContain('NameCallback'); + }); + + it('reports stale callbacks', () => { + const legacy = makeLegacy([]); + const newSdk = makeNewSdk([ + { + symbol: 'NameCallback', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + }, + ]); + const documented = makeDoc([ + { + section: SECTIONS.CALLBACKS, + legacySymbol: 'NameCallback', + newImport: '@forgerock/journey-client/types', + lineNumber: 50, + }, + { + section: SECTIONS.CALLBACKS, + legacySymbol: 'ObsoleteCallback', + newImport: '@forgerock/journey-client/types', + lineNumber: 55, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const stale = findings.filter((f) => f.category === 'stale-callback'); + + expect(stale).toHaveLength(1); + expect(stale[0].severity).toBe('error'); + expect(stale[0].action).toBe('remove'); + expect(stale[0].lineNumber).toBe(55); + expect(stale[0].message).toContain('ObsoleteCallback'); + }); + + it('reports undocumented new exports as warnings', () => { + const legacy = makeLegacy([]); + const newSdk = makeNewSdk([ + { symbol: 'FRAuth', importPath: '@anthropic/journey-client' }, + { symbol: 'NewThing', importPath: '@anthropic/journey-client' }, + ]); + const documented = makeDoc( + [ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'SomeLegacy', + newImport: "import { journey } from '@anthropic/journey-client'", + lineNumber: 10, + }, + ], + // entryPoints are import paths (not package.json export keys) + ['@anthropic/journey-client'], + ); + + const findings = diff(legacy, newSdk, documented); + const undoc = findings.filter((f) => f.category === 'undocumented-new-export'); + + // 'journey' is extracted from the newImport statement and is in allDocumentedSymbols. + // 'SomeLegacy' is also in allDocumentedSymbols. Neither 'FRAuth' nor 'NewThing' match. + // The OR means: referencedImportPaths or allDocumentedSymbols. + // referencedImportPaths has the full import statement, not '@anthropic/journey-client', + // so that branch won't match. Only allDocumentedSymbols can match. + expect(undoc).toHaveLength(2); + expect(undoc.every((f) => f.severity === 'warning')).toBe(true); + expect(undoc.every((f) => f.action === 'add')).toBe(true); + expect(undoc.some((f) => f.message.includes('NewThing'))).toBe(true); + expect(undoc.some((f) => f.message.includes('FRAuth'))).toBe(true); + }); + + it('undocumented-new-export does not flag symbols referenced in the doc', () => { + const legacy = makeLegacy(['FRAuth']); + const newSdk = makeNewSdk([ + { symbol: 'journey', importPath: '@anthropic/journey-client' }, + { symbol: 'Unreferenced', importPath: '@anthropic/journey-client' }, + ]); + const documented = makeDoc( + [ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: "import { journey } from '@anthropic/journey-client'", + lineNumber: 10, + }, + ], + ['@anthropic/journey-client'], + ); + + const findings = diff(legacy, newSdk, documented); + const undoc = findings.filter((f) => f.category === 'undocumented-new-export'); + + // 'journey' is extracted from newImport via regex and is in allDocumentedSymbols — not flagged + // 'Unreferenced' is not mentioned anywhere — flagged + expect(undoc).toHaveLength(1); + expect(undoc[0].message).toContain('Unreferenced'); + }); + + it('undocumented-new-export matches on importPath not entryPoint', () => { + const legacy = makeLegacy([]); + const newSdk = makeNewSdk([ + { symbol: 'Foo', importPath: '@anthropic/journey-client', entryPoint: '.' }, + ]); + // entryPoints contains import paths, not package.json export keys + const documented = makeDoc([], ['@anthropic/journey-client']); + + const findings = diff(legacy, newSdk, documented); + const undoc = findings.filter((f) => f.category === 'undocumented-new-export'); + + // Foo's importPath matches a documented entry point, so it IS checked (and flagged) + expect(undoc).toHaveLength(1); + expect(undoc[0].message).toContain('Foo'); + }); + + it('does NOT flag package-name entries starting with @ as stale', () => { + const legacy = makeLegacy(['FRAuth']); + const newSdk = makeNewSdk([{ symbol: 'FRAuth', importPath: '@anthropic/journey-client' }]); + const documented = makeDoc([ + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: 'FRAuth', + newImport: '@anthropic/journey-client', + lineNumber: 10, + }, + { + section: SECTIONS.QUICK_REFERENCE, + legacySymbol: '@forgerock/ping-protect', + newImport: '@anthropic/protect', + lineNumber: 15, + }, + ]); + + const findings = diff(legacy, newSdk, documented); + const stale = findings.filter((f) => f.category === 'stale-legacy-symbol'); + + // @forgerock/ping-protect starts with @ so it should be skipped + expect(stale).toHaveLength(0); + }); +}); diff --git a/tools/interface-mapping-validator/src/differ.ts b/tools/interface-mapping-validator/src/differ.ts new file mode 100644 index 0000000000..a5aa4a3b61 --- /dev/null +++ b/tools/interface-mapping-validator/src/differ.ts @@ -0,0 +1,193 @@ +import type { + Finding, + FindingAction, + FindingCategory, + FindingSeverity, + LegacyExport, + MarkdownExtractionResult, + NewSdkExport, +} from './types.js'; +import { PROTECTED_PREFIXES, SECTIONS } from './config.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Constructs a Finding object with the given metadata, optionally including a line number. + * + * @param category - The classification of the finding (e.g., undocumented-legacy-symbol). + * @param severity - Whether this is an error or warning. + * @param section - The markdown section where the finding applies. + * @param message - Human-readable description of the issue. + * @param action - The suggested remediation action (add, remove, or update). + * @param lineNumber - Optional 1-based line number in the markdown file. + * @returns A fully constructed Finding object. + */ +function makeFinding( + category: FindingCategory, + severity: FindingSeverity, + section: string, + message: string, + action: FindingAction, + lineNumber?: number, +): Finding { + const base: Finding = { category, severity, section, message, action }; + return lineNumber !== undefined ? { ...base, lineNumber } : base; +} + +/** + * Checks whether a documented mapping should be excluded from drift detection because it uses a protected prefix. + * + * @param mapping - A mapping with a newImport field to check against protected prefixes. + * @returns True if the mapping's import path starts with any protected prefix. + */ +function isProtected(mapping: { readonly newImport: string }): boolean { + return PROTECTED_PREFIXES.some((prefix) => mapping.newImport.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Compares legacy exports, new SDK exports, and the documented mappings to detect drift between them. + * + * @param legacy - Exports extracted from the legacy SDK. + * @param newSdk - Exports extracted from the new SDK packages. + * @param documented - Mappings and entry points extracted from the interface_mapping.md file. + * @returns A list of findings describing undocumented, stale, or invalid entries. + */ +export function diff( + legacy: LegacyExport[], + newSdk: NewSdkExport[], + documented: MarkdownExtractionResult, +): Finding[] { + const quickRefMappings = documented.mappings.filter( + (m) => m.section === SECTIONS.QUICK_REFERENCE, + ); + const callbackMappings = documented.mappings.filter((m) => m.section === SECTIONS.CALLBACKS); + + // Sets for efficient lookup + const legacyNames = new Set(legacy.map((e) => e.name)); + const documentedLegacySymbols = new Set(quickRefMappings.map((m) => m.legacySymbol)); + const newSdkImportPaths = new Set(newSdk.map((e) => e.importPath)); + const documentedCallbackSymbols = new Set(callbackMappings.map((m) => m.legacySymbol)); + + // 1. Undocumented legacy symbols: in legacy SDK but not in Quick Reference + const undocumentedLegacy = Array.from(legacyNames) + .filter((name) => !documentedLegacySymbols.has(name)) + .map((name) => + makeFinding( + 'undocumented-legacy-symbol', + 'error', + SECTIONS.QUICK_REFERENCE, + `Legacy symbol "${name}" is not documented in ${SECTIONS.QUICK_REFERENCE}`, + 'add', + ), + ); + + // 2. Stale legacy symbols: in Quick Reference but not in legacy SDK (skip protected) + const staleLegacy = quickRefMappings + .filter( + (m) => !isProtected(m) && !m.legacySymbol.startsWith('@') && !legacyNames.has(m.legacySymbol), + ) + .map((m) => + makeFinding( + 'stale-legacy-symbol', + 'error', + SECTIONS.QUICK_REFERENCE, + `Documented symbol "${m.legacySymbol}" is not exported by the legacy SDK`, + 'remove', + m.lineNumber, + ), + ); + + // 3. Invalid import paths: documented import path not in new SDK export paths + // Skip @forgerock/javascript-sdk paths and protected entries + const invalidPaths = documented.mappings + .filter( + (m) => + !isProtected(m) && + !m.newImport.startsWith('@forgerock/javascript-sdk') && + m.newImport.startsWith('@') && + !newSdkImportPaths.has(m.newImport), + ) + .map((m) => + makeFinding( + 'invalid-import-path', + 'error', + m.section, + `Import path "${m.newImport}" is not a valid new SDK export path`, + 'update', + m.lineNumber, + ), + ); + + // 4. Missing callbacks: callback from journey-client/types not in Callback Type Mapping + const callbackExports = newSdk.filter( + (e) => e.importPath === '@forgerock/journey-client/types' && e.symbol.endsWith('Callback'), + ); + + const missingCallbacks = callbackExports + .filter((cb) => !documentedCallbackSymbols.has(cb.symbol)) + .map((cb) => + makeFinding( + 'missing-callback', + 'error', + SECTIONS.CALLBACKS, + `Callback "${cb.symbol}" from ${cb.entryPoint} is not documented in ${SECTIONS.CALLBACKS}`, + 'add', + ), + ); + + // 5. Stale callbacks: in Callback Type Mapping but not exported + const callbackExportSymbols = new Set(callbackExports.map((e) => e.symbol)); + + const staleCallbacks = callbackMappings + .filter((m) => !isProtected(m) && !callbackExportSymbols.has(m.legacySymbol)) + .map((m) => + makeFinding( + 'stale-callback', + 'error', + SECTIONS.CALLBACKS, + `Documented callback "${m.legacySymbol}" is not exported by the new SDK`, + 'remove', + m.lineNumber, + ), + ); + + // 6. Undocumented new exports: in new SDK main entry points but not referenced in doc + const documentedImportPaths = new Set(documented.entryPoints); + const IMPORT_SYMBOL_RE = /import\s+(?:type\s+)?{\s*(\w+)\s*}\s+from/; + const allDocumentedSymbols = new Set([ + ...documented.mappings.map((m) => m.legacySymbol), + ...documented.mappings.flatMap((m) => { + const match = m.newImport.match(IMPORT_SYMBOL_RE); + return match ? [match[1] ?? ''] : []; + }), + ]); + + const undocumentedNew = newSdk + .filter( + (exp) => documentedImportPaths.has(exp.importPath) && !allDocumentedSymbols.has(exp.symbol), + ) + .map((exp) => + makeFinding( + 'undocumented-new-export', + 'warning', + SECTIONS.QUICK_REFERENCE, + `New SDK export "${exp.symbol}" from ${exp.entryPoint} is not referenced in the documentation`, + 'add', + ), + ); + + return [ + ...undocumentedLegacy, + ...staleLegacy, + ...invalidPaths, + ...missingCallbacks, + ...staleCallbacks, + ...undocumentedNew, + ]; +} diff --git a/tools/interface-mapping-validator/src/extractors/legacy.test.ts b/tools/interface-mapping-validator/src/extractors/legacy.test.ts new file mode 100644 index 0000000000..fb6f95db0a --- /dev/null +++ b/tools/interface-mapping-validator/src/extractors/legacy.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { resolve } from 'node:path'; +import { extractLegacyExports } from './legacy.js'; + +const FIXTURE_PATH = resolve(__dirname, '../fixtures/legacy-sample.d.ts'); + +describe('extractLegacyExports', () => { + it('extracts value exports from export {} declaration', () => { + const exports = extractLegacyExports(FIXTURE_PATH); + const names = exports.map((e) => e.name); + + expect(names).toContain('FRAuth'); + expect(names).toContain('CallbackType'); + expect(names).toContain('Config'); + expect(names).toContain('NameCallback'); + }); + + it('extracts type exports from export type {} declaration', () => { + const exports = extractLegacyExports(FIXTURE_PATH); + const names = exports.map((e) => e.name); + + expect(names).toContain('ConfigOptions'); + expect(names).toContain('FailureDetail'); + expect(names).toContain('Step'); + }); + + it('marks type exports as type kind', () => { + const exports = extractLegacyExports(FIXTURE_PATH); + const configOptions = exports.find((e) => e.name === 'ConfigOptions'); + + expect(configOptions).toBeDefined(); + expect(configOptions?.kind).toBe('type'); + }); + + it('marks value exports as variable kind', () => { + const exports = extractLegacyExports(FIXTURE_PATH); + const frAuth = exports.find((e) => e.name === 'FRAuth'); + + expect(frAuth).toBeDefined(); + expect(frAuth?.kind).toBe('variable'); + }); + + it('returns no duplicates', () => { + const exports = extractLegacyExports(FIXTURE_PATH); + const names = exports.map((e) => e.name); + const unique = [...new Set(names)]; + + expect(names).toEqual(unique); + }); +}); diff --git a/tools/interface-mapping-validator/src/extractors/legacy.ts b/tools/interface-mapping-validator/src/extractors/legacy.ts new file mode 100644 index 0000000000..586ab868ef --- /dev/null +++ b/tools/interface-mapping-validator/src/extractors/legacy.ts @@ -0,0 +1,21 @@ +import { Project } from 'ts-morph'; +import type { LegacyExport, ExportKind } from '../types.js'; + +/** + * Extracts all named exports from the legacy SDK's index file using static analysis. + * + * @param indexPath - Absolute path to the legacy SDK's index.ts barrel file. + * @returns An array of legacy exports with their names and kinds (type vs variable). + */ +export function extractLegacyExports(indexPath: string): LegacyExport[] { + const project = new Project({ skipAddingFilesFromTsConfig: true }); + const sourceFile = project.addSourceFileAtPath(indexPath); + + return sourceFile.getExportDeclarations().flatMap((exportDecl) => { + const isTypeOnly = exportDecl.isTypeOnly(); + return exportDecl.getNamedExports().map((namedExport) => ({ + name: namedExport.getAliasNode()?.getText() ?? namedExport.getName(), + kind: (isTypeOnly ? 'type' : 'variable') as ExportKind, + })); + }); +} diff --git a/tools/interface-mapping-validator/src/extractors/markdown.test.ts b/tools/interface-mapping-validator/src/extractors/markdown.test.ts new file mode 100644 index 0000000000..f0ae36d236 --- /dev/null +++ b/tools/interface-mapping-validator/src/extractors/markdown.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { resolve } from 'node:path'; +import { extractDocumentedMappings } from './markdown.js'; + +const FIXTURE_PATH = resolve(__dirname, '../fixtures/sample-mapping.md'); + +describe('extractDocumentedMappings', () => { + it('extracts Quick Reference mappings', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const qr = result.mappings.filter((m) => m.section === 'Quick Reference'); + + expect(qr).toHaveLength(4); + expect(qr.map((m) => m.legacySymbol)).toEqual(['FRAuth', 'Config', 'FRStep', 'HttpClient']); + }); + + it('strips backtick wrapping from legacy symbols', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const frAuth = result.mappings.find( + (m) => m.section === 'Quick Reference' && m.legacySymbol === 'FRAuth', + ); + + expect(frAuth).toBeDefined(); + expect(frAuth?.legacySymbol).toBe('FRAuth'); + }); + + it('preserves the full new import text', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const frAuth = result.mappings.find( + (m) => m.section === 'Quick Reference' && m.legacySymbol === 'FRAuth', + ); + + expect(frAuth).toBeDefined(); + expect(frAuth?.newImport).toContain('@forgerock/journey-client'); + }); + + it('extracts Callback Type Mapping entries', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const callbacks = result.mappings.filter((m) => m.section === 'Callback Type Mapping'); + + expect(callbacks).toHaveLength(2); + expect(callbacks.map((m) => m.legacySymbol)).toEqual(['NameCallback', 'PasswordCallback']); + }); + + it('extracts callback legacy symbol from import statement', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const name = result.mappings.find( + (m) => m.section === 'Callback Type Mapping' && m.legacySymbol === 'NameCallback', + ); + + expect(name).toBeDefined(); + }); + + it('collects import paths as entry points', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + + expect(result.entryPoints).toContain('@forgerock/journey-client'); + expect(result.entryPoints).toContain('@forgerock/journey-client/types'); + expect(result.entryPoints).toContain('@forgerock/journey-client/webauthn'); + }); + + it('records line numbers for each mapping', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const qr = result.mappings.filter((m) => m.section === 'Quick Reference'); + + for (const mapping of qr) { + expect(mapping.lineNumber).toBeGreaterThan(0); + } + + const lineNumbers = qr.map((m) => m.lineNumber); + expect(lineNumbers).toEqual([...lineNumbers].sort((a, b) => a - b)); + }); + + it('preserves other columns for Quick Reference', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const frAuth = result.mappings.find( + (m) => m.section === 'Quick Reference' && m.legacySymbol === 'FRAuth', + ); + + expect(frAuth).toBeDefined(); + expect(frAuth?.otherColumns).toEqual([]); + }); + + it('preserves other columns for Callback Type Mapping', () => { + const result = extractDocumentedMappings(FIXTURE_PATH); + const name = result.mappings.find( + (m) => m.section === 'Callback Type Mapping' && m.legacySymbol === 'NameCallback', + ); + + expect(name).toBeDefined(); + expect(name?.otherColumns).toEqual(['None']); + }); +}); diff --git a/tools/interface-mapping-validator/src/extractors/markdown.ts b/tools/interface-mapping-validator/src/extractors/markdown.ts new file mode 100644 index 0000000000..74a66a6290 --- /dev/null +++ b/tools/interface-mapping-validator/src/extractors/markdown.ts @@ -0,0 +1,175 @@ +import { readFileSync } from 'node:fs'; +import { SECTIONS } from '../config.js'; +import type { DocumentedMapping, MarkdownExtractionResult } from '../types.js'; + +const IMPORT_SYMBOL_RE = /import\s+(?:type\s+)?{\s*(\w+)\s*}\s+from\s+['"]([^'"]+)['"]/; +const BACKTICK_CONTENT_RE = /^`(.+)`$/; + +const TARGET_SECTIONS = new Set([ + SECTIONS.QUICK_REFERENCE, + SECTIONS.PACKAGE_MAPPING, + SECTIONS.CALLBACKS, +]); + +type SectionInfo = { + readonly name: string; + readonly headingLevel: number; +}; + +/** + * Splits a markdown table row into its cell values, stripping the outer pipes. + * + * @param line - A single markdown table row (e.g., `| foo | bar |`). + * @returns An array of trimmed cell contents. + */ +function parseTableRow(line: string): string[] { + return line + .split('|') + .slice(1, -1) + .map((cell) => cell.trim()); +} + +/** + * Extracts a package import path from a table cell containing an import statement. + * + * @param text - The raw table cell text that may contain an import statement. + * @returns The extracted entry point string, or null if no import statement is found. + */ +function extractEntryPoint(text: string): string | null { + const match = text.match(IMPORT_SYMBOL_RE); + return match ? (match[2] ?? '') : null; +} + +/** + * Extracts the symbol name from a table cell, using section-specific parsing rules. + * + * @param cell - The raw table cell text containing a symbol reference. + * @param section - The section name, which determines how the cell is parsed (backtick vs import statement). + * @returns The extracted symbol name, or null if the cell does not match the expected format. + */ +function extractSymbolName(cell: string, section: string): string | null { + const trimmed = cell.trim(); + + if (section === SECTIONS.QUICK_REFERENCE) { + const backtickMatch = trimmed.match(BACKTICK_CONTENT_RE); + return backtickMatch ? (backtickMatch[1] ?? '') : trimmed; + } + + if (section === SECTIONS.PACKAGE_MAPPING || section === SECTIONS.CALLBACKS) { + const importMatch = trimmed.match(IMPORT_SYMBOL_RE); + return importMatch ? (importMatch[1] ?? '') : null; + } + + return null; +} + +/** + * Parses the interface mapping markdown file and extracts all documented symbol mappings and entry points. + * + * @param filePath - Absolute path to the interface_mapping.md file. + * @returns The extracted mappings and the set of entry point import paths found in the document. + */ +export function extractDocumentedMappings(filePath: string): MarkdownExtractionResult { + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + type ParseState = { + readonly mappings: readonly DocumentedMapping[]; + readonly entryPoints: readonly string[]; + readonly currentSection: SectionInfo | null; + readonly inCodeBlock: boolean; + readonly headerRowSeen: boolean; + }; + + const initialState: ParseState = { + mappings: [], + entryPoints: [], + currentSection: null, + inCodeBlock: false, + headerRowSeen: false, + }; + + const finalState = lines.reduce((state, line, i) => { + const lineNumber = i + 1; + + // Track fenced code blocks to avoid parsing their contents + if (line.trimStart().startsWith('```')) { + return { ...state, inCodeBlock: !state.inCodeBlock }; + } + + if (state.inCodeBlock) return state; + + // Detect headings + const headingMatch = line.match(/^(#{1,4})\s+(?:\d+\.\s+)?(.+)/); + if (headingMatch) { + const level = (headingMatch[1] ?? '').length; + const title = (headingMatch[2] ?? '').trim(); + + if (TARGET_SECTIONS.has(title)) { + return { + ...state, + currentSection: { name: title, headingLevel: level }, + headerRowSeen: false, + }; + } else if (state.currentSection && level <= state.currentSection.headingLevel) { + return { ...state, currentSection: null, headerRowSeen: false }; + } + return state; + } + + if (!state.currentSection) return state; + + if (!line.startsWith('|')) { + // A non-table, non-empty line after a table ends table parsing + if (line.trim() !== '' && line.trim() !== '---') { + return { ...state, headerRowSeen: false }; + } + return state; + } + + // Separator row (e.g. |---|---|) + if (line.match(/^\|[\s-|]+$/)) { + return { ...state, headerRowSeen: true }; + } + + // Header row (first | row before separator) — skip + if (!state.headerRowSeen) return state; + + // Data row + const columns = parseTableRow(line); + if (columns.length < 2) return state; + + const [firstCol, secondCol, ...rest] = columns; + const legacySymbol = extractSymbolName(firstCol ?? '', state.currentSection.name); + const newImport = (secondCol ?? '').trim(); + + if (!legacySymbol) return state; + + // Collect entry points from import statements in both columns + const newEntryPoints = [extractEntryPoint(newImport), extractEntryPoint(firstCol ?? '')].filter( + (ep): ep is string => ep !== null, + ); + + const uniqueEntryPoints = newEntryPoints.filter((ep) => !state.entryPoints.includes(ep)); + + return { + ...state, + mappings: [ + ...state.mappings, + { + section: state.currentSection.name, + legacySymbol, + newImport, + otherColumns: rest.map((c) => c.trim()), + lineNumber, + }, + ], + entryPoints: [...state.entryPoints, ...uniqueEntryPoints], + }; + }, initialState); + + return { + mappings: finalState.mappings as DocumentedMapping[], + entryPoints: finalState.entryPoints as string[], + }; +} diff --git a/tools/interface-mapping-validator/src/extractors/new-sdk.test.ts b/tools/interface-mapping-validator/src/extractors/new-sdk.test.ts new file mode 100644 index 0000000000..b7d900fafc --- /dev/null +++ b/tools/interface-mapping-validator/src/extractors/new-sdk.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { resolve } from 'node:path'; +import { extractNewSdkExports } from './new-sdk.js'; + +const FIXTURE_DIR = resolve(__dirname, '../fixtures/mock-package'); + +describe('extractNewSdkExports', () => { + it('extracts exports from the main entry point', () => { + const exports = extractNewSdkExports([FIXTURE_DIR]); + const mainExports = exports.filter((e) => e.entryPoint === '.'); + + expect(mainExports.map((e) => e.symbol)).toContain('journey'); + }); + + it('extracts exports from the ./types entry point', () => { + const exports = extractNewSdkExports([FIXTURE_DIR]); + const typeExports = exports.filter((e) => e.entryPoint === './types'); + + expect(typeExports.map((e) => e.symbol)).toContain('JourneyStep'); + expect(typeExports.map((e) => e.symbol)).toContain('JourneyLoginSuccess'); + expect(typeExports.map((e) => e.symbol)).toContain('NameCallback'); + expect(typeExports.map((e) => e.symbol)).toContain('PasswordCallback'); + }); + + it('builds correct import paths', () => { + const exports = extractNewSdkExports([FIXTURE_DIR]); + const journeyStep = exports.find((e) => e.symbol === 'JourneyStep'); + + expect(journeyStep).toBeDefined(); + expect(journeyStep?.packageName).toBe('@mock/test-client'); + expect(journeyStep?.importPath).toBe('@mock/test-client/types'); + }); + + it('builds import path without suffix for main entry point', () => { + const exports = extractNewSdkExports([FIXTURE_DIR]); + const journey = exports.find((e) => e.symbol === 'journey'); + + expect(journey).toBeDefined(); + expect(journey?.importPath).toBe('@mock/test-client'); + }); + + it('skips ./package.json entry point', () => { + const exports = extractNewSdkExports([FIXTURE_DIR]); + const pkgExports = exports.filter((e) => e.entryPoint === './package.json'); + + expect(pkgExports).toHaveLength(0); + }); + + it('skips entry points whose source file cannot be resolved', () => { + // ./webauthn points to a file that doesn't exist in the fixture + const exports = extractNewSdkExports([FIXTURE_DIR]); + const webauthnExports = exports.filter((e) => e.entryPoint === './webauthn'); + + expect(webauthnExports).toHaveLength(0); + }); + + it('classifies types vs classes correctly', () => { + const exports = extractNewSdkExports([FIXTURE_DIR]); + const journeyStep = exports.find((e) => e.symbol === 'JourneyStep'); + const nameCallback = exports.find((e) => e.symbol === 'NameCallback'); + + expect(journeyStep).toBeDefined(); + expect(journeyStep?.kind).toBe('type'); + expect(nameCallback).toBeDefined(); + expect(nameCallback?.kind).toBe('class'); + }); +}); diff --git a/tools/interface-mapping-validator/src/extractors/new-sdk.ts b/tools/interface-mapping-validator/src/extractors/new-sdk.ts new file mode 100644 index 0000000000..3d8986f780 --- /dev/null +++ b/tools/interface-mapping-validator/src/extractors/new-sdk.ts @@ -0,0 +1,112 @@ +import { Project, Node } from 'ts-morph'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import type { NewSdkExport, ExportKind } from '../types.js'; + +type PackageExports = Record>; + +/** + * Resolves a conditional export map to a single file path, preferring types over import over default. + * + * @param target - The conditional export object from package.json (e.g., `{ types, import, default }`). + * @returns The resolved file path, or undefined if no recognized condition exists. + */ +function resolveConditionalExport(target: Record): string | undefined { + return target['types'] ?? target['import'] ?? target['default']; +} + +/** + * Converts a dist output path back to its corresponding TypeScript source path. + * + * @param packageDir - Absolute path to the package directory. + * @param distPath - Relative dist path from package.json exports (e.g., `./dist/index.d.ts`). + * @returns The resolved absolute source file path, or undefined if conversion fails. + */ +function resolveSourcePath(packageDir: string, distPath: string): string | undefined { + const srcPath = distPath + .replace(/^\.\//, '') + .replace(/^dist\//, '') + .replace(/\.d\.ts$/, '.ts') + .replace(/\.js$/, '.ts'); + + return resolve(packageDir, srcPath); +} + +/** + * Classifies an AST node into an export kind based on its declaration type. + * + * @param node - The ts-morph AST node to classify. + * @returns The export kind (class, interface, type, enum, function, or variable as fallback). + */ +function getDeclarationKind(node: Node): ExportKind { + if (Node.isClassDeclaration(node)) return 'class'; + if (Node.isInterfaceDeclaration(node)) return 'interface'; + if (Node.isTypeAliasDeclaration(node)) return 'type'; + if (Node.isEnumDeclaration(node)) return 'enum'; + if (Node.isFunctionDeclaration(node)) return 'function'; + return 'variable'; +} + +/** + * Parses a single TypeScript source file and extracts all named exports with their declaration kinds. + * + * @param project - Shared ts-morph Project instance (reused across files for performance). + * @param filePath - Absolute path to the TypeScript source file. + * @returns An array of export names paired with their declaration kind (class, interface, type, etc.). + */ +function extractExportsFromFile( + project: Project, + filePath: string, +): Array<{ name: string; kind: ExportKind }> { + const sourceFile = project.addSourceFileAtPath(filePath); + + return Array.from(sourceFile.getExportedDeclarations()) + .filter(([name]) => name !== 'default') + .flatMap(([name, declarations]) => { + const decl = declarations[0]; + return decl ? [{ name, kind: getDeclarationKind(decl) }] : []; + }); +} + +/** + * Extracts all public exports from the new SDK packages by reading their package.json exports fields. + * + * @param packageDirs - Absolute paths to new SDK package directories to scan. + * @returns An array of exports with symbol names, package names, entry points, and import paths. + */ +export function extractNewSdkExports(packageDirs: string[]): NewSdkExport[] { + const project = new Project({ skipAddingFilesFromTsConfig: true }); + + return packageDirs.flatMap((dir) => { + const pkgJsonPath = join(dir, 'package.json'); + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { + name: string; + exports?: PackageExports; + }; + + const packageName = pkgJson.name; + const exportsField = pkgJson.exports; + if (!exportsField) return []; + + return Object.entries(exportsField).flatMap(([entryPoint, target]) => { + if (entryPoint === './package.json') return []; + + const distPath = typeof target === 'string' ? target : resolveConditionalExport(target); + if (!distPath) return []; + + const sourcePath = resolveSourcePath(dir, distPath); + if (!sourcePath || !existsSync(sourcePath)) return []; + + const importPath = + entryPoint === '.' ? packageName : `${packageName}/${entryPoint.replace(/^\.\//, '')}`; + + return extractExportsFromFile(project, sourcePath).map((exp) => ({ + symbol: exp.name, + packageName, + entryPoint, + importPath, + kind: exp.kind, + })); + }); + }); +} diff --git a/tools/interface-mapping-validator/src/fixer.test.ts b/tools/interface-mapping-validator/src/fixer.test.ts new file mode 100644 index 0000000000..1ebe0a1089 --- /dev/null +++ b/tools/interface-mapping-validator/src/fixer.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from 'vitest'; +import { applyFixes } from './fixer.js'; +import type { Finding } from './types.js'; +import { SECTIONS } from './config.js'; + +const SAMPLE_DOC = `# Interface Mapping + +## 0. Quick Reference + +| Legacy Symbol | New Import | +|---|---| +| \`FRAuth\` | \`import { journey } from '@forgerock/journey-client'\` | +| \`OldThing\` | \`import { old } from '@forgerock/old-package'\` | +| \`Config\` | Removed — pass config to factory params | + +--- + +## 5. Callbacks + +### Callback Type Mapping + +| Legacy Import | New Import | Method Changes | +|---|---|---| +| \`import { NameCallback } from '@forgerock/javascript-sdk'\` | \`import { NameCallback } from '@forgerock/journey-client/types'\` | None | +| \`import { OldCallback } from '@forgerock/javascript-sdk'\` | \`import { OldCallback } from '@forgerock/journey-client/types'\` | None | + +Some trailing prose that must not be touched. +`; + +describe('applyFixes', () => { + it('removes a stale row from Quick Reference by lineNumber', () => { + const findings: Finding[] = [ + { + category: 'stale-legacy-symbol', + severity: 'warning', + section: SECTIONS.QUICK_REFERENCE, + message: 'OldThing is no longer in legacy SDK', + action: 'remove', + lineNumber: 8, + }, + ]; + + const result = applyFixes(SAMPLE_DOC, findings); + + expect(result).not.toContain('OldThing'); + expect(result).toContain('FRAuth'); + expect(result).toContain('Config'); + }); + + it('adds a new row to Quick Reference', () => { + const findings: Finding[] = [ + { + category: 'undocumented-legacy-symbol', + severity: 'error', + section: SECTIONS.QUICK_REFERENCE, + message: 'FRStep is exported from legacy SDK but not documented', + action: 'add', + suggestedRow: ['`FRStep`', '*TODO: add mapping*'], + }, + ]; + + const result = applyFixes(SAMPLE_DOC, findings); + + expect(result).toContain('| `FRStep` | *TODO: add mapping* |'); + // Existing rows preserved + expect(result).toContain('FRAuth'); + expect(result).toContain('OldThing'); + }); + + it('removes a stale callback row', () => { + const findings: Finding[] = [ + { + category: 'stale-callback', + severity: 'warning', + section: SECTIONS.CALLBACKS, + message: 'OldCallback is no longer in legacy SDK', + action: 'remove', + lineNumber: 20, + }, + ]; + + const result = applyFixes(SAMPLE_DOC, findings); + + expect(result).not.toContain('OldCallback'); + expect(result).toContain('NameCallback'); + }); + + it('adds a new callback row', () => { + const findings: Finding[] = [ + { + category: 'missing-callback', + severity: 'error', + section: SECTIONS.CALLBACKS, + message: 'ChoiceCallback is not documented', + action: 'add', + suggestedRow: [ + "`import { ChoiceCallback } from '@forgerock/javascript-sdk'`", + '*TODO: add mapping*', + 'None', + ], + }, + ]; + + const result = applyFixes(SAMPLE_DOC, findings); + + expect(result).toContain( + "| `import { ChoiceCallback } from '@forgerock/javascript-sdk'` | *TODO: add mapping* | None |", + ); + expect(result).toContain('NameCallback'); + expect(result).toContain('OldCallback'); + }); + + it('preserves non-table content byte-for-byte', () => { + const findings: Finding[] = [ + { + category: 'stale-legacy-symbol', + severity: 'warning', + section: SECTIONS.QUICK_REFERENCE, + message: 'OldThing is stale', + action: 'remove', + lineNumber: 8, + }, + ]; + + const result = applyFixes(SAMPLE_DOC, findings); + + // The heading, separator, and trailing prose must be preserved exactly + expect(result).toContain('# Interface Mapping'); + expect(result).toContain('## 0. Quick Reference'); + expect(result).toContain('---'); + expect(result).toContain('## 5. Callbacks'); + expect(result).toContain('### Callback Type Mapping'); + expect(result).toContain('Some trailing prose that must not be touched.'); + }); + + it('never removes protected entries even if a remove finding targets one', () => { + const findings: Finding[] = [ + { + category: 'stale-legacy-symbol', + severity: 'warning', + section: SECTIONS.QUICK_REFERENCE, + message: 'Config is stale', + action: 'remove', + lineNumber: 9, // The "Config | Removed ..." line + }, + ]; + + const result = applyFixes(SAMPLE_DOC, findings); + + // Config row starts with "Removed" in second column — must be preserved + expect(result).toContain('Config'); + expect(result).toContain('Removed — pass config to factory params'); + }); + + it('handles multiple fixes at once (remove + add across sections)', () => { + const findings: Finding[] = [ + { + category: 'stale-legacy-symbol', + severity: 'warning', + section: SECTIONS.QUICK_REFERENCE, + message: 'OldThing is stale', + action: 'remove', + lineNumber: 8, + }, + { + category: 'undocumented-legacy-symbol', + severity: 'error', + section: SECTIONS.QUICK_REFERENCE, + message: 'FRStep not documented', + action: 'add', + suggestedRow: ['`FRStep`', '*TODO: add mapping*'], + }, + { + category: 'stale-callback', + severity: 'warning', + section: SECTIONS.CALLBACKS, + message: 'OldCallback stale', + action: 'remove', + lineNumber: 20, + }, + { + category: 'missing-callback', + severity: 'error', + section: SECTIONS.CALLBACKS, + message: 'ChoiceCallback missing', + action: 'add', + suggestedRow: [ + "`import { ChoiceCallback } from '@forgerock/javascript-sdk'`", + '*TODO: add mapping*', + 'None', + ], + }, + ]; + + const result = applyFixes(SAMPLE_DOC, findings); + + // Removals applied + expect(result).not.toContain('OldThing'); + expect(result).not.toContain('OldCallback'); + + // Additions applied + expect(result).toContain('| `FRStep` | *TODO: add mapping* |'); + expect(result).toContain( + "| `import { ChoiceCallback } from '@forgerock/javascript-sdk'` | *TODO: add mapping* | None |", + ); + + // Existing preserved + expect(result).toContain('FRAuth'); + expect(result).toContain('Config'); + expect(result).toContain('NameCallback'); + }); + + it('returns original content unchanged when no findings', () => { + const result = applyFixes(SAMPLE_DOC, []); + + expect(result).toBe(SAMPLE_DOC); + }); + + it('targets the actual heading, not a Table of Contents entry', () => { + const docWithToC = `# Interface Mapping + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Callbacks](#callbacks) + +## 0. Quick Reference + +| Legacy Symbol | New Import | +|---|---| +| \`FRAuth\` | \`import { journey } from '@forgerock/journey-client'\` | +| \`OldThing\` | \`import { old } from '@forgerock/old-package'\` | + +## 5. Callbacks + +### Callback Type Mapping + +| Legacy Import | New Import | Method Changes | +|---|---|---| +| \`import { NameCallback } from '@forgerock/javascript-sdk'\` | \`import { NameCallback } from '@forgerock/journey-client/types'\` | None | +`; + + const findings: Finding[] = [ + { + category: 'undocumented-legacy-symbol', + severity: 'error', + section: SECTIONS.QUICK_REFERENCE, + message: 'FRStep not documented', + action: 'add', + suggestedRow: ['`FRStep`', '*TODO: add mapping*'], + }, + ]; + + const result = applyFixes(docWithToC, findings); + + // The new row should appear after the Quick Reference table, not after the ToC line + const lines = result.split('\n'); + const tocLine = lines.findIndex((l) => l.includes('[Quick Reference]')); + const addedLine = lines.findIndex((l) => l.includes('FRStep')); + const headingLine = lines.findIndex((l) => /^##\s+.*Quick Reference/.test(l)); + + expect(tocLine).toBeGreaterThan(-1); + expect(headingLine).toBeGreaterThan(tocLine); + expect(addedLine).toBeGreaterThan(headingLine); + }); +}); diff --git a/tools/interface-mapping-validator/src/fixer.ts b/tools/interface-mapping-validator/src/fixer.ts new file mode 100644 index 0000000000..397c10ba1e --- /dev/null +++ b/tools/interface-mapping-validator/src/fixer.ts @@ -0,0 +1,125 @@ +import type { Finding } from './types.js'; +import { PROTECTED_PREFIXES } from './config.js'; + +/** + * Find the section heading marker in the lines array for a given section name. + * + * @param lines - The full markdown content split into lines. + * @param sectionName - The section title to search for within heading lines. + * @returns The 0-based index of the heading line, or -1 if not found. + */ +function findSectionStart(lines: readonly string[], sectionName: string): number { + return lines.findIndex((line) => /^#{1,4}\s+/.test(line) && line.includes(sectionName)); +} + +/** + * Find the last data row of a table within a section. + * A data row starts with '|' and is not a separator row (containing only |, -, and spaces). + * + * @param lines - The full markdown content split into lines. + * @param sectionStart - The 0-based index of the section heading to search from. + * @returns The 0-based index of the last table data row, or -1 if no table is found. + */ +function findLastTableRow(lines: readonly string[], sectionStart: number): number { + const isSeparator = (line: string) => /^\|[-|\s]+\|$/.test(line); + const isTableLine = (line: string) => line.trim().startsWith('|'); + + const { lastDataRow } = lines.slice(sectionStart + 1).reduce( + (acc, line, i) => { + if (acc.done) return acc; + const trimmed = line.trim(); + if (isTableLine(trimmed)) { + const idx = sectionStart + 1 + i; + return { + lastDataRow: isSeparator(trimmed) ? acc.lastDataRow : idx, + inTable: true, + done: false, + }; + } + // Non-table line after table started — stop + return acc.inTable ? { ...acc, done: true } : acc; + }, + { lastDataRow: -1, inTable: false, done: false }, + ); + + return lastDataRow; +} + +/** + * Check whether a line's second column starts with a protected prefix. + * Protected rows must never be removed. + * + * @param line - A markdown table row to inspect. + * @returns True if the row's second column begins with a protected prefix. + */ +function isProtectedRow(line: string): boolean { + // Parse table columns: split by | and trim + const columns = line + .split('|') + .map((col) => col.trim()) + .filter((col) => col.length > 0); + + if (columns.length < 2) { + return false; + } + + const secondCol = columns[1] ?? ''; + return PROTECTED_PREFIXES.some((prefix) => secondCol.startsWith(prefix)); +} + +/** + * Format an array of cell values into a markdown table row. + * + * @param cells - The cell contents to join into a pipe-delimited row. + * @returns A formatted markdown table row string. + */ +function formatRow(cells: readonly string[]): string { + return `| ${cells.join(' | ')} |`; +} + +/** + * Apply fixes to interface mapping markdown content based on findings. + * Round-trip safe: non-table content is preserved byte-for-byte. + * + * @param content - The raw markdown content to modify. + * @param findings - The list of findings whose actions (add/remove) drive the modifications. + * @returns The updated markdown content with fixes applied. + */ +export function applyFixes(content: string, findings: readonly Finding[]): string { + if (findings.length === 0) { + return content; + } + + const lines = content.split('\n'); + + // Separate removals and additions + const removals = findings.filter((f) => f.action === 'remove' && f.lineNumber !== undefined); + const additions = findings.filter((f) => f.action === 'add' && f.suggestedRow !== undefined); + + // Apply removals: collect line indices to remove, then filter + const linesToRemove = new Set( + removals + .filter((f) => { + const lineIndex = (f.lineNumber ?? 0) - 1; + return lineIndex >= 0 && lineIndex < lines.length && !isProtectedRow(lines[lineIndex]); + }) + .map((f) => (f.lineNumber ?? 1) - 1), + ); + + const filteredLines = lines.filter((_, i) => !linesToRemove.has(i)); + + // Apply additions: fold over additions, inserting each at end of its relevant table + const result = additions.reduce((acc, finding) => { + const sectionName = finding.section; + const sectionStart = findSectionStart(acc, sectionName); + if (sectionStart === -1) return acc; + + const lastRow = findLastTableRow(acc, sectionStart); + if (lastRow === -1) return acc; + + const newRow = formatRow(finding.suggestedRow ?? []); + return [...acc.slice(0, lastRow + 1), newRow, ...acc.slice(lastRow + 1)]; + }, filteredLines); + + return (result as string[]).join('\n'); +} diff --git a/tools/interface-mapping-validator/src/fixtures/legacy-sample.d.ts b/tools/interface-mapping-validator/src/fixtures/legacy-sample.d.ts new file mode 100644 index 0000000000..2529ddce32 --- /dev/null +++ b/tools/interface-mapping-validator/src/fixtures/legacy-sample.d.ts @@ -0,0 +1,8 @@ +import { default as FRAuth } from './fake/fr-auth'; +import { CallbackType } from './fake/enums'; +import { default as NameCallback } from './fake/name-callback'; +import { Step, FailureDetail } from './fake/interfaces'; +import { default as Config, ConfigOptions } from './fake/config'; + +export type { ConfigOptions, FailureDetail, Step }; +export { CallbackType, Config, FRAuth, NameCallback }; diff --git a/tools/interface-mapping-validator/src/fixtures/mock-package/package.json b/tools/interface-mapping-validator/src/fixtures/mock-package/package.json new file mode 100644 index 0000000000..e4807c4ad1 --- /dev/null +++ b/tools/interface-mapping-validator/src/fixtures/mock-package/package.json @@ -0,0 +1,9 @@ +{ + "name": "@mock/test-client", + "exports": { + ".": "./dist/src/index.js", + "./types": "./dist/src/types.d.ts", + "./webauthn": "./dist/src/lib/webauthn.js", + "./package.json": "./package.json" + } +} diff --git a/tools/interface-mapping-validator/src/fixtures/mock-package/src/index.ts b/tools/interface-mapping-validator/src/fixtures/mock-package/src/index.ts new file mode 100644 index 0000000000..0061a0d1a0 --- /dev/null +++ b/tools/interface-mapping-validator/src/fixtures/mock-package/src/index.ts @@ -0,0 +1,2 @@ +export function journey() {} +export const callbackType = {} as const; diff --git a/tools/interface-mapping-validator/src/fixtures/mock-package/src/types.ts b/tools/interface-mapping-validator/src/fixtures/mock-package/src/types.ts new file mode 100644 index 0000000000..a8ab46dccd --- /dev/null +++ b/tools/interface-mapping-validator/src/fixtures/mock-package/src/types.ts @@ -0,0 +1,4 @@ +export type JourneyStep = { type: 'Step' }; +export type JourneyLoginSuccess = { type: 'LoginSuccess' }; +export class NameCallback {} +export class PasswordCallback {} diff --git a/tools/interface-mapping-validator/src/fixtures/sample-mapping.md b/tools/interface-mapping-validator/src/fixtures/sample-mapping.md new file mode 100644 index 0000000000..c715eace06 --- /dev/null +++ b/tools/interface-mapping-validator/src/fixtures/sample-mapping.md @@ -0,0 +1,40 @@ +# Interface Mapping: Legacy SDK → Ping SDK + +## 0. Quick Reference + +| Legacy Symbol | New Import | +| ------------- | --------------------------------------------------------------------------------------- | +| `FRAuth` | `import { journey } from '@forgerock/journey-client'` → factory returns `JourneyClient` | +| `Config` | Removed — pass config to `journey()` / `oidc()` factory params | +| `FRStep` | `import type { JourneyStep } from '@forgerock/journey-client/types'` | +| `HttpClient` | Removed — use `fetch` + manual `Authorization` header | + +--- + +## 1. Package Mapping + +| Legacy Import | New Package | Purpose | +| -------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------- | +| `import { FRAuth } from '@forgerock/javascript-sdk'` | `import { journey } from '@forgerock/journey-client'` | Authentication tree/journey flow | +| `import { FRWebAuthn } from '@forgerock/javascript-sdk'` | `import { WebAuthn } from '@forgerock/journey-client/webauthn'` | WebAuthn integration | + +--- + +## 3. Authentication Flow + +Some prose here that should not be parsed. + +```typescript +const journeyClient = await journey({ config }); +``` + +--- + +## 5. Callbacks + +### Callback Type Mapping + +| Legacy Import | New Import | Method Changes | +| -------------------------------------------------------------- | -------------------------------------------------------------------- | -------------- | +| `import { NameCallback } from '@forgerock/javascript-sdk'` | `import { NameCallback } from '@forgerock/journey-client/types'` | None | +| `import { PasswordCallback } from '@forgerock/javascript-sdk'` | `import { PasswordCallback } from '@forgerock/journey-client/types'` | None | diff --git a/tools/interface-mapping-validator/src/generator.test.ts b/tools/interface-mapping-validator/src/generator.test.ts new file mode 100644 index 0000000000..92190c189c --- /dev/null +++ b/tools/interface-mapping-validator/src/generator.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect } from 'vitest'; +import { generateSections } from './generator.js'; +import type { LegacyExport, NewSdkExport, SymbolMapping } from './types.js'; + +// --------------------------------------------------------------------------- +// Test data helpers +// --------------------------------------------------------------------------- + +function makeLegacy(names: string[]): LegacyExport[] { + return names.map((name) => ({ name, kind: 'variable' as const })); +} + +function makeNewSdk( + entries: Array<{ + symbol: string; + importPath: string; + entryPoint?: string; + packageName?: string; + }>, +): NewSdkExport[] { + return entries.map(({ symbol, importPath, entryPoint, packageName }) => ({ + symbol, + importPath, + entryPoint: entryPoint ?? '.', + packageName: packageName ?? importPath.split('/').slice(0, 2).join('/'), + kind: 'variable' as const, + })); +} + +// --------------------------------------------------------------------------- +// Quick Reference — config-based rows +// --------------------------------------------------------------------------- + +describe('generateSections', () => { + describe('Quick Reference — renamed symbol', () => { + it('produces a row with import statement for renamed mapping', () => { + const legacy = makeLegacy(['Config']); + const newSdk = makeNewSdk([]); + const config: Record = { + Config: { new: 'DaVinciConfig', package: '@anthropic/sdk-client' }, + }; + + const result = generateSections(legacy, newSdk, config); + + expect(result.quickReference).toContain( + "| `Config` | `import { DaVinciConfig } from '@anthropic/sdk-client'` |", + ); + expect(result.unmapped).toEqual([]); + }); + + it('includes a note when provided', () => { + const legacy = makeLegacy(['Config']); + const config: Record = { + Config: { new: 'DaVinciConfig', package: '@anthropic/sdk-client', note: 'see migration' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.quickReference).toContain( + "| `Config` | `import { DaVinciConfig } from '@anthropic/sdk-client'` — see migration |", + ); + }); + + it('omits import path when package is empty', () => { + const legacy = makeLegacy(['Config']); + const config: Record = { + Config: { new: 'DaVinciConfig', package: '', note: 'use directly' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.quickReference).toContain('| `Config` | `DaVinciConfig` — use directly |'); + }); + }); + + describe('Quick Reference — type mapping', () => { + it('uses import type syntax when type: true', () => { + const legacy = makeLegacy(['StepOptions']); + const config: Record = { + StepOptions: { + new: 'StepConfig', + package: '@forgerock/journey-client', + type: true, + }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.quickReference).toContain( + "| `StepOptions` | `import type { StepConfig } from '@forgerock/journey-client'` |", + ); + }); + }); + + describe('Quick Reference — removed symbol', () => { + it('produces a Removed row with note', () => { + const legacy = makeLegacy(['TokenStorage']); + const config: Record = { + TokenStorage: { status: 'removed', note: 'Use browser APIs' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.quickReference).toContain('| `TokenStorage` | Removed — Use browser APIs |'); + expect(result.unmapped).toEqual([]); + }); + }); + + describe('Quick Reference — internal symbol', () => { + it('produces a Not exported row with note', () => { + const legacy = makeLegacy(['Dispatcher']); + const config: Record = { + Dispatcher: { status: 'internal', note: 'Internal implementation detail' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.quickReference).toContain( + '| `Dispatcher` | Not exported — Internal implementation detail |', + ); + expect(result.unmapped).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Quick Reference — auto-match + // --------------------------------------------------------------------------- + + describe('Quick Reference — auto-matched callback', () => { + it('auto-matches a Callback type from journey-client/types', () => { + const legacy = makeLegacy(['PasswordCallback']); + const newSdk = makeNewSdk([ + { + symbol: 'PasswordCallback', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + packageName: '@forgerock/journey-client', + }, + ]); + + const result = generateSections(legacy, newSdk, {}); + + expect(result.quickReference).toContain( + "| `PasswordCallback` | `import { PasswordCallback } from '@forgerock/journey-client/types'` |", + ); + expect(result.unmapped).toEqual([]); + }); + }); + + describe('Quick Reference — auto-matched 1:1 name', () => { + it('auto-matches a symbol with exact name in new SDK', () => { + const legacy = makeLegacy(['PolicyKey']); + const newSdk = makeNewSdk([{ symbol: 'PolicyKey', importPath: '@forgerock/sdk-types' }]); + + const result = generateSections(legacy, newSdk, {}); + + expect(result.quickReference).toContain( + "| `PolicyKey` | `import { PolicyKey } from '@forgerock/sdk-types'` |", + ); + expect(result.unmapped).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Quick Reference — unmapped + // --------------------------------------------------------------------------- + + describe('Quick Reference — unmapped symbol', () => { + it('adds unresolved symbols to the unmapped list', () => { + const legacy = makeLegacy(['ObscureThing']); + const result = generateSections(legacy, makeNewSdk([]), {}); + + expect(result.unmapped).toEqual(['ObscureThing']); + }); + + it('does not include config entries in unmapped', () => { + const legacy = makeLegacy(['Config', 'Removed']); + const config: Record = { + Config: { new: 'NewConfig', package: '@pkg/a' }, + Removed: { status: 'removed', note: 'gone' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.unmapped).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Quick Reference — packageMap entries + // --------------------------------------------------------------------------- + + describe('Quick Reference — packageMap entries', () => { + it('appends package map entries at the end of quick reference', () => { + const legacy = makeLegacy([]); + const packageMap = { + '@forgerock/javascript-sdk': { + new: '@forgerock/sdk-types', + note: 'Core types moved', + }, + }; + + const result = generateSections(legacy, makeNewSdk([]), {}, packageMap); + + expect(result.quickReference).toContain( + '| `@forgerock/javascript-sdk` | `@forgerock/sdk-types` — Core types moved |', + ); + }); + }); + + // --------------------------------------------------------------------------- + // Package Mapping table + // --------------------------------------------------------------------------- + + describe('Package Mapping table', () => { + it('includes renamed symbols with non-empty package', () => { + const legacy = makeLegacy(['Config']); + const config: Record = { + Config: { new: 'DaVinciConfig', package: '@anthropic/sdk-client' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.packageMapping).toContain( + "| `import { Config } from '@forgerock/javascript-sdk'` | `import { DaVinciConfig } from '@anthropic/sdk-client'` | Config |", + ); + }); + + it('uses import type for type mappings', () => { + const legacy = makeLegacy(['Opts']); + const config: Record = { + Opts: { new: 'Options', package: '@pkg/a', type: true }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.packageMapping).toContain( + "| `import type { Opts } from '@forgerock/javascript-sdk'` | `import type { Options } from '@pkg/a'` | Opts |", + ); + }); + + it('excludes removed symbols', () => { + const legacy = makeLegacy(['Gone']); + const config: Record = { + Gone: { status: 'removed', note: 'No longer needed' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + // Should not appear in package mapping at all + expect(result.packageMapping).not.toContain('Gone'); + }); + + it('excludes internal symbols', () => { + const legacy = makeLegacy(['Secret']); + const config: Record = { + Secret: { status: 'internal', note: 'Private' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + expect(result.packageMapping).not.toContain('Secret'); + }); + + it('excludes renamed symbols with empty package', () => { + const legacy = makeLegacy(['Config']); + const config: Record = { + Config: { new: 'NewConfig', package: '' }, + }; + + const result = generateSections(legacy, makeNewSdk([]), config); + + // Header is always present, but no data row for Config + const lines = result.packageMapping.split('\n').filter((l) => l.includes('Config')); + expect(lines).toHaveLength(0); + }); + }); + + // --------------------------------------------------------------------------- + // Callback Type Mapping table + // --------------------------------------------------------------------------- + + describe('Callback Type Mapping table', () => { + it('includes Callback types from journey-client/types', () => { + const legacy = makeLegacy(['PasswordCallback']); + const newSdk = makeNewSdk([ + { + symbol: 'PasswordCallback', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + packageName: '@forgerock/journey-client', + }, + ]); + + const result = generateSections(legacy, newSdk, {}); + + expect(result.callbackMapping).toContain( + "| `import { PasswordCallback } from '@forgerock/javascript-sdk'` | `import { PasswordCallback } from '@forgerock/journey-client/types'` | None |", + ); + }); + + it('excludes non-Callback types from callback table', () => { + const newSdk = makeNewSdk([ + { + symbol: 'StepConfig', + importPath: '@forgerock/journey-client/types', + entryPoint: './types', + packageName: '@forgerock/journey-client', + }, + ]); + + const result = generateSections(makeLegacy([]), newSdk, {}); + + expect(result.callbackMapping).not.toContain('StepConfig'); + }); + + it('excludes Callbacks from other packages', () => { + const newSdk = makeNewSdk([ + { + symbol: 'PasswordCallback', + importPath: '@forgerock/other-package/types', + entryPoint: './types', + packageName: '@forgerock/other-package', + }, + ]); + + const result = generateSections(makeLegacy([]), newSdk, {}); + + expect(result.callbackMapping).not.toContain('PasswordCallback'); + }); + }); + + // --------------------------------------------------------------------------- + // Table structure + // --------------------------------------------------------------------------- + + describe('table structure', () => { + it('quick reference has correct header and separator', () => { + const result = generateSections(makeLegacy([]), makeNewSdk([]), {}); + + const lines = result.quickReference.split('\n'); + expect(lines[0]).toBe('| Legacy Symbol | New SDK Equivalent |'); + expect(lines[1]).toBe('| --- | --- |'); + }); + + it('package mapping has correct header and separator', () => { + const result = generateSections(makeLegacy([]), makeNewSdk([]), {}); + + const lines = result.packageMapping.split('\n'); + expect(lines[0]).toBe('| Legacy Import | New Import | Notes |'); + expect(lines[1]).toBe('| --- | --- | --- |'); + }); + + it('callback mapping has correct header and separator', () => { + const result = generateSections(makeLegacy([]), makeNewSdk([]), {}); + + const lines = result.callbackMapping.split('\n'); + expect(lines[0]).toBe('| Legacy Import | New Import | Notes |'); + expect(lines[1]).toBe('| --- | --- | --- |'); + }); + }); + + describe('Migration Dependencies', () => { + it('generates unique package rows from renamed config entries', () => { + const legacy = makeLegacy(['FRAuth', 'FRStep']); + const newSdk = makeNewSdk([ + { symbol: 'journey', importPath: '@forgerock/journey-client' }, + { symbol: 'JourneyStep', importPath: '@forgerock/journey-client/types' }, + ]); + const config: Record = { + FRAuth: { new: 'journey', package: '@forgerock/journey-client' }, + FRStep: { new: 'JourneyStep', package: '@forgerock/journey-client/types', type: true }, + }; + + const result = generateSections(legacy, newSdk, config); + + // Both map to @forgerock/journey-client — should appear only once + const rows = result.migrationDependencies.split('\n').filter((l) => l.startsWith('|')); + const dataRows = rows.slice(2); // skip header + separator + expect(dataRows).toHaveLength(1); + expect(dataRows[0]).toContain('@forgerock/journey-client'); + }); + + it('includes package map entries', () => { + const result = generateSections( + makeLegacy([]), + makeNewSdk([]), + {}, + { + '@forgerock/ping-protect': { new: '@forgerock/protect', note: 'Protect integration' }, + }, + ); + + expect(result.migrationDependencies).toContain('@forgerock/ping-protect'); + expect(result.migrationDependencies).toContain('@forgerock/protect'); + }); + + it('excludes removed and internal symbols', () => { + const legacy = makeLegacy(['Config', 'WebAuthnOutcome']); + const result = generateSections(legacy, makeNewSdk([]), { + Config: { status: 'removed', note: 'gone' }, + WebAuthnOutcome: { status: 'internal', note: 'internal' }, + }); + + const dataRows = result.migrationDependencies + .split('\n') + .filter((l) => l.startsWith('|')) + .slice(2); + expect(dataRows).toHaveLength(0); + }); + }); +}); diff --git a/tools/interface-mapping-validator/src/generator.ts b/tools/interface-mapping-validator/src/generator.ts new file mode 100644 index 0000000000..900fa497fe --- /dev/null +++ b/tools/interface-mapping-validator/src/generator.ts @@ -0,0 +1,310 @@ +import type { + LegacyExport, + NewSdkExport, + SymbolMapping, + RenamedMapping, + GeneratedSections, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- + +/** + * Checks whether a symbol mapping represents a rename (symbol exists in the new SDK under a different name or package). + * + * @param m - The symbol mapping to check. + * @returns True if the mapping contains `new` and `package` fields indicating a rename. + */ +function isRenamed(m: SymbolMapping): m is RenamedMapping { + return 'new' in m && 'package' in m; +} + +/** + * Checks whether a symbol mapping indicates the symbol was removed from the new SDK. + * + * @param m - The symbol mapping to check. + * @returns True if the mapping has status 'removed'. + */ +function isRemoved(m: SymbolMapping): m is { readonly status: 'removed'; readonly note: string } { + return 'status' in m && m.status === 'removed'; +} + +/** + * Checks whether a symbol mapping indicates the symbol is internal and not publicly exported. + * + * @param m - The symbol mapping to check. + * @returns True if the mapping has status 'internal'. + */ +function isInternal(m: SymbolMapping): m is { readonly status: 'internal'; readonly note: string } { + return 'status' in m && m.status === 'internal'; +} + +// --------------------------------------------------------------------------- +// Row formatters +// --------------------------------------------------------------------------- + +/** + * Formats a symbol and package into a backtick-wrapped import statement for use in markdown tables. + * + * @param symbol - The symbol name being imported. + * @param pkg - The package path to import from. + * @param isType - Whether to use `import type` instead of `import`. + * @returns A markdown-formatted inline code import statement. + */ +function importStatement(symbol: string, pkg: string, isType: boolean): string { + const keyword = isType ? 'import type' : 'import'; + return `\`${keyword} { ${symbol} } from '${pkg}'\``; +} + +/** + * Formats a Quick Reference table row with a backtick-wrapped legacy symbol and its replacement. + * + * @param legacy - The legacy symbol name. + * @param rhs - The right-hand side content describing the new SDK equivalent. + * @returns A formatted markdown table row. + */ +function quickRefRow(legacy: string, rhs: string): string { + return `| \`${legacy}\` | ${rhs} |`; +} + +/** + * Formats a Quick Reference row for a renamed symbol, including its new import path and optional note. + * + * @param legacyName - The original legacy symbol name. + * @param m - The rename mapping containing the new symbol name, package, and optional note. + * @returns A formatted markdown table row. + */ +function formatRenamedQuickRef(legacyName: string, m: RenamedMapping): string { + const note = m.note ? ` — ${m.note}` : ''; + if (m.package === '') { + return quickRefRow(legacyName, `\`${m.new}\`${note}`); + } + return quickRefRow(legacyName, `${importStatement(m.new, m.package, m.type === true)}${note}`); +} + +/** + * Formats a Quick Reference row for a symbol that has been removed from the new SDK. + * + * @param legacyName - The original legacy symbol name. + * @param note - An explanation of why it was removed or what to use instead. + * @returns A formatted markdown table row. + */ +function formatRemovedQuickRef(legacyName: string, note: string): string { + return quickRefRow(legacyName, `Removed — ${note}`); +} + +/** + * Formats a Quick Reference row for a symbol that is internal and not publicly exported. + * + * @param legacyName - The original legacy symbol name. + * @param note - An explanation of the symbol's internal status. + * @returns A formatted markdown table row. + */ +function formatInternalQuickRef(legacyName: string, note: string): string { + return quickRefRow(legacyName, `Not exported — ${note}`); +} + +/** + * Formats a Quick Reference row for a callback that auto-matches to `@forgerock/journey-client/types`. + * + * @param name - The callback symbol name (same in both legacy and new SDK). + * @returns A formatted markdown table row. + */ +function formatAutoCallbackQuickRef(name: string): string { + return quickRefRow(name, `${importStatement(name, '@forgerock/journey-client/types', false)}`); +} + +/** + * Formats a Quick Reference row for a symbol that auto-matches 1:1 by name in the new SDK. + * + * @param name - The symbol name (same in both legacy and new SDK). + * @param importPath - The new SDK import path where the symbol is found. + * @returns A formatted markdown table row. + */ +function formatAutoMatchQuickRef(name: string, importPath: string): string { + return quickRefRow(name, `${importStatement(name, importPath, false)}`); +} + +// --------------------------------------------------------------------------- +// Index builders +// --------------------------------------------------------------------------- + +/** + * Builds a lookup index from symbol name to its new SDK export for fast auto-matching. + * + * @param newSdk - The full list of new SDK exports. + * @returns A map from symbol name to its NewSdkExport entry (last-write-wins for duplicates). + */ +function buildNewSdkIndex(newSdk: readonly NewSdkExport[]): Map { + return new Map(newSdk.map((exp) => [exp.symbol, exp] as const)); +} + +/** + * Checks whether a new SDK export is a callback type from the journey-client/types entry point. + * + * @param exp - The new SDK export to check. + * @returns True if the export is from `@forgerock/journey-client/types` and ends with "Callback". + */ +function isCallbackFromJourneyClient(exp: NewSdkExport): boolean { + return exp.importPath === '@forgerock/journey-client/types' && exp.symbol.endsWith('Callback'); +} + +// --------------------------------------------------------------------------- +// Main generator +// --------------------------------------------------------------------------- + +const LEGACY_PKG = '@forgerock/javascript-sdk'; + +const QR_HEADER = '| Legacy Symbol | New SDK Equivalent |'; +const QR_SEP = '| --- | --- |'; + +const PKG_HEADER = '| Legacy Import | New Import | Notes |'; +const PKG_SEP = '| --- | --- | --- |'; + +const CB_HEADER = '| Legacy Import | New Import | Notes |'; +const CB_SEP = '| --- | --- | --- |'; + +const MIG_HEADER = '| Legacy | New | Purpose |'; +const MIG_SEP = '| --- | --- | --- |'; + +/** + * Purpose descriptions for new SDK packages, used in the MIGRATION.md dependencies table. + */ +const PACKAGE_PURPOSES: Record = { + '@forgerock/journey-client': 'Authentication tree/journey flows', + '@forgerock/oidc-client': 'OAuth2/OIDC token management, user info, logout', + '@forgerock/device-client': 'Device profile & management (OATH, Push, WebAuthn, Bound, Profile)', + '@forgerock/protect': 'PingOne Protect/Signals integration', + '@forgerock/sdk-types': 'Shared types and enums', + '@forgerock/sdk-utilities': 'URL, string, and OIDC utilities', + '@forgerock/sdk-logger': 'Logging abstraction', + '@forgerock/storage': 'Storage abstraction', +}; + +/** + * Generates the Quick Reference, Package Mapping, and Callback Mapping markdown table sections + * by combining legacy exports, new SDK exports, and the manual symbol/package configuration. + * + * @param legacy - Exports extracted from the legacy SDK. + * @param newSdk - Exports extracted from the new SDK packages. + * @param config - Manual symbol mapping configuration (renames, removals, internals). + * @param packageMap - Optional package-level mappings (e.g., legacy package to new package). + * @returns Generated markdown table strings for each section, plus a list of unmapped symbols. + */ +export function generateSections( + legacy: LegacyExport[], + newSdk: NewSdkExport[], + config: Record, + packageMap?: Record, +): GeneratedSections { + const newSdkIndex = buildNewSdkIndex(newSdk); + + // Process each legacy export into { qr, pkg, unmapped } per symbol + const processed = legacy.map(({ name }) => { + const mapping = config[name]; + + if (mapping !== undefined) { + if (isRenamed(mapping)) { + const qr = formatRenamedQuickRef(name, mapping); + const pkg = + mapping.package !== '' + ? `| \`${mapping.type === true ? 'import type' : 'import'} { ${name} } from '${LEGACY_PKG}'\` | \`${mapping.type === true ? 'import type' : 'import'} { ${mapping.new} } from '${mapping.package}'\` | ${name} |` + : null; + return { qr, pkg, unmapped: null as string | null }; + } else if (isRemoved(mapping)) { + return { + qr: formatRemovedQuickRef(name, mapping.note), + pkg: null, + unmapped: null as string | null, + }; + } else if (isInternal(mapping)) { + return { + qr: formatInternalQuickRef(name, mapping.note), + pkg: null, + unmapped: null as string | null, + }; + } + } + + // Auto-match + const sdkExport = newSdkIndex.get(name); + if ( + name.endsWith('Callback') && + sdkExport !== undefined && + sdkExport.importPath === '@forgerock/journey-client/types' + ) { + return { qr: formatAutoCallbackQuickRef(name), pkg: null, unmapped: null as string | null }; + } else if (sdkExport !== undefined) { + return { + qr: formatAutoMatchQuickRef(name, sdkExport.importPath), + pkg: null, + unmapped: null as string | null, + }; + } + + return { qr: null as string | null, pkg: null, unmapped: name as string | null }; + }); + + const packageMapRows = + packageMap !== undefined + ? Object.entries(packageMap).map(([legacyPkg, info]) => { + const note = info.note ? ` — ${info.note}` : ''; + return `| \`${legacyPkg}\` | \`${info.new}\`${note} |`; + }) + : []; + + const qrRows = [...processed.flatMap((p) => (p.qr !== null ? [p.qr] : [])), ...packageMapRows]; + const pkgRows = processed.flatMap((p) => (p.pkg !== null ? [p.pkg] : [])); + const unmapped = processed.flatMap((p) => (p.unmapped !== null ? [p.unmapped] : [])); + + // Build callback mapping from newSdk + const cbRows = newSdk + .filter(isCallbackFromJourneyClient) + .map( + (exp) => + `| \`import { ${exp.symbol} } from '${LEGACY_PKG}'\` | \`import { ${exp.symbol} } from '${exp.importPath}'\` | None |`, + ); + + // Build migration dependencies table (unique target packages) + const configEntries = Object.values(config) + .filter((m): m is RenamedMapping => isRenamed(m) && m.package !== '') + .map((m) => ({ + legacy: LEGACY_PKG, + basePkg: m.package.split('/').slice(0, 2).join('/'), + })); + + const packageMapEntries = + packageMap !== undefined + ? Object.entries(packageMap).map(([legacyPkg, info]) => ({ + legacy: legacyPkg, + basePkg: info.new, + note: info.note, + })) + : []; + + const migRows = [...configEntries, ...packageMapEntries].reduce<{ + readonly seen: ReadonlySet; + readonly rows: readonly string[]; + }>( + (acc, entry) => { + if (acc.seen.has(entry.basePkg)) return acc; + const purpose = + PACKAGE_PURPOSES[entry.basePkg] ?? + ('note' in entry ? (entry.note ?? entry.basePkg) : entry.basePkg); + return { + seen: new Set([...acc.seen, entry.basePkg]), + rows: [...acc.rows, `| \`${entry.legacy}\` | \`${entry.basePkg}\` | ${purpose} |`], + }; + }, + { seen: new Set(), rows: [] as readonly string[] }, + ).rows; + + // Assemble tables + const quickReference = [QR_HEADER, QR_SEP, ...qrRows].join('\n'); + const packageMapping = [PKG_HEADER, PKG_SEP, ...pkgRows].join('\n'); + const callbackMapping = [CB_HEADER, CB_SEP, ...cbRows].join('\n'); + const migrationDependencies = [MIG_HEADER, MIG_SEP, ...migRows].join('\n'); + + return { quickReference, packageMapping, callbackMapping, migrationDependencies, unmapped }; +} diff --git a/tools/interface-mapping-validator/src/integration.test.ts b/tools/interface-mapping-validator/src/integration.test.ts new file mode 100644 index 0000000000..7619856251 --- /dev/null +++ b/tools/interface-mapping-validator/src/integration.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { extractLegacyExports } from './extractors/legacy.js'; +import { extractNewSdkExports } from './extractors/new-sdk.js'; +import { extractDocumentedMappings } from './extractors/markdown.js'; +import { diff } from './differ.js'; +import { formatReport } from './reporter.js'; +import { generateSections } from './generator.js'; +import { replaceSections } from './writer.js'; +import { SYMBOL_MAP, PACKAGE_MAP } from './mapping-config.js'; +import type { + LegacyExport, + NewSdkExport, + MarkdownExtractionResult, + GeneratedSections, +} from './types.js'; +import { LEGACY_SDK_INDEX_PATH, INTERFACE_MAPPING_PATH, NEW_SDK_PACKAGES } from './config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// src/ -> interface-mapping-validator/ -> tools/ -> workspace root +const WORKSPACE_ROOT = resolve(__dirname, '..', '..', '..'); +const TOOL_ROOT = resolve(__dirname, '..'); + +/** + * The legacy SDK is a devDependency of this tool, so its node_modules + * live under the tool directory (pnpm strict mode), not the workspace root. + * Resolve from TOOL_ROOT to find the actual .d.ts file. + */ +const legacyPath = resolve(TOOL_ROOT, LEGACY_SDK_INDEX_PATH); +const mappingPath = resolve(WORKSPACE_ROOT, INTERFACE_MAPPING_PATH); +const packageDirs = NEW_SDK_PACKAGES.map((p) => resolve(WORKSPACE_ROOT, p)); + +/** + * ts-morph extraction is expensive (creates a Project per file), so we + * extract once in beforeAll and share across tests. + */ +describe('integration: full pipeline against real workspace data', () => { + let legacy: LegacyExport[]; + let newSdk: NewSdkExport[]; + let documented: MarkdownExtractionResult; + + beforeAll(() => { + legacy = extractLegacyExports(legacyPath); + newSdk = extractNewSdkExports(packageDirs); + documented = extractDocumentedMappings(mappingPath); + }, 60_000); + + it('extracts legacy exports from the real SDK', () => { + expect(legacy.length).toBeGreaterThan(40); + + const names = legacy.map((e) => e.name); + expect(names).toContain('FRAuth'); + expect(names).toContain('NameCallback'); + }); + + it('extracts new SDK exports from real packages', () => { + expect(newSdk.length).toBeGreaterThan(10); + + const journeyExports = newSdk.filter((e) => e.packageName === '@forgerock/journey-client'); + expect(journeyExports.length).toBeGreaterThan(0); + }); + + it('parses the real interface_mapping.md', () => { + expect(documented.mappings.length).toBeGreaterThan(30); + expect(documented.entryPoints.length).toBeGreaterThan(3); + }); + + it('runs the full diff pipeline without throwing', () => { + const findings = diff(legacy, newSdk, documented); + const report = formatReport(findings); + + expect(report).toContain('Summary:'); + }); + + it('produces a report with structured finding categories', () => { + const findings = diff(legacy, newSdk, documented); + + // Every finding should have the required shape + for (const finding of findings) { + expect(finding).toHaveProperty('category'); + expect(finding).toHaveProperty('severity'); + expect(finding).toHaveProperty('section'); + expect(finding).toHaveProperty('message'); + expect(finding).toHaveProperty('action'); + expect(['error', 'warning']).toContain(finding.severity); + expect(['add', 'remove', 'update']).toContain(finding.action); + } + }); +}); + +describe('integration: generation', () => { + let legacy: LegacyExport[]; + let newSdk: NewSdkExport[]; + let generated: GeneratedSections; + + beforeAll(() => { + const legacyP = resolve(TOOL_ROOT, LEGACY_SDK_INDEX_PATH); + const packageDirs = NEW_SDK_PACKAGES.map((p) => resolve(WORKSPACE_ROOT, p)); + + legacy = extractLegacyExports(legacyP); + newSdk = extractNewSdkExports(packageDirs); + generated = generateSections(legacy, newSdk, SYMBOL_MAP, PACKAGE_MAP); + }, 60_000); + + it('generates sections with zero unmapped symbols', () => { + expect(generated.unmapped).toEqual([]); + }); + + it('every SYMBOL_MAP key exists in legacy SDK exports', () => { + const legacyNames = new Set(legacy.map((e) => e.name)); + + for (const key of Object.keys(SYMBOL_MAP)) { + expect(legacyNames.has(key), `SYMBOL_MAP key "${key}" not in legacy SDK`).toBe(true); + } + }); + + it('generated output can be written and produces valid markdown', () => { + const mappingP = resolve(WORKSPACE_ROOT, INTERFACE_MAPPING_PATH); + + const content = readFileSync(mappingP, 'utf-8'); + const updated = replaceSections(content, { + quickReference: generated.quickReference, + packageMapping: generated.packageMapping, + callbackMapping: generated.callbackMapping, + }); + + // Updated content should be valid and preserve structure + expect(updated.length).toBeGreaterThan(1000); + expect(updated).toContain('## 0. Quick Reference'); + expect(updated).toContain('## 1. Package Mapping'); + expect(updated).toContain('### Callback Type Mapping'); + // Hand-written sections preserved + expect(updated).toContain('## 2. Configuration'); + expect(updated).toContain('## 3. Authentication Flow'); + }); +}); diff --git a/tools/interface-mapping-validator/src/main.ts b/tools/interface-mapping-validator/src/main.ts new file mode 100644 index 0000000000..b0b528137f --- /dev/null +++ b/tools/interface-mapping-validator/src/main.ts @@ -0,0 +1,144 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { extractLegacyExports } from './extractors/legacy.js'; +import { extractNewSdkExports } from './extractors/new-sdk.js'; +import { extractDocumentedMappings } from './extractors/markdown.js'; +import { diff } from './differ.js'; +import { applyFixes } from './fixer.js'; +import { formatReport } from './reporter.js'; +import { generateSections } from './generator.js'; +import { replaceSections, replaceMigrationDependencies } from './writer.js'; +import { SYMBOL_MAP, PACKAGE_MAP } from './mapping-config.js'; +import { LEGACY_SDK_INDEX_PATH, INTERFACE_MAPPING_PATH, NEW_SDK_PACKAGES } from './config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * CLI entry point that orchestrates extraction, diffing, reporting, and optional fixing or generation + * of the interface mapping documentation. Supports `--fix` and `--generate` flags. + */ +function main(): void { + const args = process.argv.slice(2); + const shouldFix = args.includes('--fix'); + const shouldGenerate = args.includes('--generate'); + + // Resolve paths relative to workspace root (two levels up from tool dir) + const workspaceRoot = resolve(__dirname, '..', '..', '..'); + const mappingPathArg = args.find((a) => a.startsWith('--mapping=')); + const mappingPath = mappingPathArg + ? resolve(mappingPathArg.split('=')[1] ?? '') + : resolve(workspaceRoot, INTERFACE_MAPPING_PATH); + // Legacy SDK is a devDependency of this tool, resolve from tool's own node_modules + const toolRoot = resolve(__dirname, '..'); + const legacyPath = resolve(toolRoot, LEGACY_SDK_INDEX_PATH); + const packageDirs = NEW_SDK_PACKAGES.map((p) => resolve(workspaceRoot, p)); + + console.log('Extracting legacy SDK exports...'); + const legacy = extractLegacyExports(legacyPath); + console.log(` Found ${legacy.length} legacy exports`); + + console.log('Extracting new SDK exports...'); + const newSdk = extractNewSdkExports(packageDirs); + console.log(` Found ${newSdk.length} new SDK exports`); + + console.log('Parsing interface_mapping.md...'); + const documented = extractDocumentedMappings(mappingPath); + console.log(` Found ${documented.mappings.length} documented mappings`); + console.log(''); + + if (shouldGenerate) { + console.log('Generating sections 0, 1, 5...'); + const generated = generateSections(legacy, newSdk, SYMBOL_MAP, PACKAGE_MAP); + + if (generated.unmapped.length > 0) { + console.log(`\n${generated.unmapped.length} unmapped legacy symbols:`); + for (const name of generated.unmapped) { + console.log(` ✗ ${name} — add to SYMBOL_MAP in mapping-config.ts`); + } + console.log('\nGeneration aborted. Map all symbols first.'); + process.exit(1); + } + + // Update interface_mapping.md + const content = readFileSync(mappingPath, 'utf-8'); + const updated = replaceSections(content, { + quickReference: generated.quickReference, + packageMapping: generated.packageMapping, + callbackMapping: generated.callbackMapping, + }); + writeFileSync(mappingPath, updated, 'utf-8'); + + // Update MIGRATION.md Package Dependencies table + const migrationPath = resolve(workspaceRoot, 'MIGRATION.md'); + const migrationContent = readFileSync(migrationPath, 'utf-8'); + const migrationUpdated = replaceMigrationDependencies( + migrationContent, + generated.migrationDependencies, + ); + writeFileSync(migrationPath, migrationUpdated, 'utf-8'); + + // Format both files with prettier if available + try { + execFileSync('pnpm', ['prettier', '--write', mappingPath, migrationPath], { + stdio: 'pipe', + }); + console.log('Formatted with prettier.'); + } catch { + // Prettier not available — skip formatting + } + + console.log(`Updated ${mappingPath}`); + console.log(' Section 0: Quick Reference'); + console.log(' Section 1: Package Mapping'); + console.log(' Section 5: Callback Type Mapping'); + console.log(`Updated ${migrationPath}`); + console.log(' Package Dependencies table'); + + // Re-validate after generation + console.log('\nValidating generated output...\n'); + const reDocumented = extractDocumentedMappings(mappingPath); + const reFindings = diff(legacy, newSdk, reDocumented); + const reReport = formatReport(reFindings); + console.log(reReport); + + const errorCount = reFindings.filter((f) => f.severity === 'error').length; + process.exit(errorCount > 0 ? 1 : 0); + } + + const findings = diff(legacy, newSdk, documented); + const report = formatReport(findings); + console.log(report); + + if (shouldFix && findings.length > 0) { + const fixableFindings = findings.filter((f) => f.action === 'add' || f.action === 'remove'); + + if (fixableFindings.length > 0) { + console.log(''); + console.log(`Applying ${fixableFindings.length} fixes...`); + + const content = readFileSync(mappingPath, 'utf-8'); + const fixed = applyFixes(content, fixableFindings); + writeFileSync(mappingPath, fixed, 'utf-8'); + + console.log('Fixes applied. Re-validating...'); + console.log(''); + + // Re-validate + const reDocumented = extractDocumentedMappings(mappingPath); + const reFindings = diff(legacy, newSdk, reDocumented); + const reReport = formatReport(reFindings); + console.log(reReport); + + const remainingErrors = reFindings.filter((f) => f.severity === 'error').length; + process.exit(remainingErrors > 0 ? 1 : 0); + } + } + + const errorCount = findings.filter((f) => f.severity === 'error').length; + process.exit(errorCount > 0 ? 1 : 0); +} + +main(); diff --git a/tools/interface-mapping-validator/src/mapping-config.ts b/tools/interface-mapping-validator/src/mapping-config.ts new file mode 100644 index 0000000000..22e9f7eb5b --- /dev/null +++ b/tools/interface-mapping-validator/src/mapping-config.ts @@ -0,0 +1,295 @@ +import type { SymbolMapping } from './types.js'; + +/** + * Maps every legacy `@forgerock/javascript-sdk` symbol to its new SDK equivalent. + * + * Callbacks that exist with an identical name in `@forgerock/journey-client/types` + * are auto-discovered and intentionally omitted here. + */ +export const SYMBOL_MAP: Record = { + // --------------------------------------------------------------------------- + // Renamed / Moved — value exports + // --------------------------------------------------------------------------- + FRAuth: { + new: 'journey', + package: '@forgerock/journey-client', + note: 'factory returns `JourneyClient`', + }, + FRStep: { + new: 'JourneyStep', + package: '@forgerock/journey-client/types', + type: true, + }, + FRLoginSuccess: { + new: 'JourneyLoginSuccess', + package: '@forgerock/journey-client/types', + type: true, + }, + FRLoginFailure: { + new: 'JourneyLoginFailure', + package: '@forgerock/journey-client/types', + type: true, + }, + FRCallback: { + new: 'BaseCallback', + package: '@forgerock/journey-client/types', + }, + CallbackType: { + new: 'callbackType', + package: '@forgerock/journey-client', + }, + StepType: { + new: 'StepType', + package: '@forgerock/journey-client/types', + type: true, + }, + TokenManager: { + new: 'oidc', + package: '@forgerock/oidc-client', + note: '`oidcClient.token.*`', + }, + TokenStorage: { + new: 'oidc', + package: '@forgerock/oidc-client', + note: '`oidcClient.token.*`', + }, + OAuth2Client: { + new: 'oidc', + package: '@forgerock/oidc-client', + note: '`oidcClient.authorize.*` / `oidcClient.token.*`', + }, + UserManager: { + new: 'oidc', + package: '@forgerock/oidc-client', + note: '`oidcClient.user.info()`', + }, + FRUser: { + new: 'oidc', + package: '@forgerock/oidc-client', + note: '`oidcClient.user.logout()`', + }, + ResponseType: { + new: 'ResponseType', + package: '@forgerock/sdk-types', + type: true, + }, + SessionManager: { + new: 'journeyClient.terminate()', + package: '', + note: 'method on JourneyClient, not a standalone import', + }, + FRWebAuthn: { + new: 'WebAuthn', + package: '@forgerock/journey-client/webauthn', + }, + WebAuthnStepType: { + new: 'WebAuthnStepType', + package: '@forgerock/journey-client/webauthn', + }, + FRQRCode: { + new: 'QRCode', + package: '@forgerock/journey-client/qr-code', + }, + FRRecoveryCodes: { + new: 'RecoveryCodes', + package: '@forgerock/journey-client/recovery-codes', + }, + FRPolicy: { + new: 'Policy', + package: '@forgerock/journey-client/policy', + }, + PolicyKey: { + new: 'PolicyKey', + package: '@forgerock/sdk-types', + }, + FRDevice: { + new: 'deviceClient', + package: '@forgerock/device-client', + }, + deviceClient: { + new: 'deviceClient', + package: '@forgerock/device-client', + }, + + // --------------------------------------------------------------------------- + // Renamed / Moved — type exports + // --------------------------------------------------------------------------- + AuthResponse: { + new: 'AuthResponse', + package: '@forgerock/journey-client/types', + type: true, + }, + Callback: { + new: 'Callback', + package: '@forgerock/sdk-types', + type: true, + note: 'AM callback interface', + }, + FailureDetail: { + new: 'FailureDetail', + package: '@forgerock/journey-client/types', + type: true, + }, + GetAuthorizationUrlOptions: { + new: 'GetAuthorizationUrlOptions', + package: '@forgerock/sdk-types', + type: true, + }, + IdPValue: { + new: 'IdPValue', + package: '@forgerock/journey-client/types', + type: true, + }, + MessageCreator: { + new: 'MessageCreator', + package: '@forgerock/journey-client/policy', + type: true, + }, + NameValue: { + new: 'NameValue', + package: '@forgerock/sdk-types', + type: true, + }, + OAuth2Tokens: { + new: 'Tokens', + package: '@forgerock/sdk-types', + type: true, + note: 'renamed to `Tokens`', + }, + PolicyRequirement: { + new: 'PolicyRequirement', + package: '@forgerock/sdk-types', + type: true, + }, + ProcessedPropertyError: { + new: 'ProcessedPropertyError', + package: '@forgerock/journey-client/policy', + type: true, + }, + RelyingParty: { + new: 'RelyingParty', + package: '@forgerock/journey-client/webauthn', + type: true, + }, + Step: { + new: 'Step', + package: '@forgerock/sdk-types', + type: true, + note: 'AM step response interface', + }, + StepDetail: { + new: 'StepDetail', + package: '@forgerock/sdk-types', + type: true, + }, + Tokens: { + new: 'Tokens', + package: '@forgerock/sdk-types', + type: true, + }, + WebAuthnAuthenticationMetadata: { + new: 'WebAuthnAuthenticationMetadata', + package: '@forgerock/journey-client/webauthn', + type: true, + }, + WebAuthnCallbacks: { + new: 'WebAuthnCallbacks', + package: '@forgerock/journey-client/webauthn', + type: true, + }, + WebAuthnRegistrationMetadata: { + new: 'WebAuthnRegistrationMetadata', + package: '@forgerock/journey-client/webauthn', + type: true, + }, + + // --------------------------------------------------------------------------- + // Removed + // --------------------------------------------------------------------------- + Config: { + status: 'removed', + note: 'pass config to `journey()` / `oidc()` factory params', + }, + HttpClient: { + status: 'removed', + note: 'use `fetch` + manual `Authorization` header', + }, + Auth: { + status: 'removed', + note: 'no replacement needed', + }, + Deferred: { + status: 'removed', + note: 'use native `Promise` constructor', + }, + PKCE: { + status: 'removed', + note: 'handled internally by `@forgerock/oidc-client`', + }, + LocalStorage: { + status: 'removed', + note: 'use `@forgerock/storage` or native APIs', + }, + ErrorCode: { + status: 'removed', + note: 'use `GenericError.type` instead', + }, + ConfigOptions: { + status: 'removed', + note: 'use factory params on `journey()` / `oidc()` instead', + }, + FRCallbackFactory: { + status: 'removed', + note: 'custom callback factories not supported', + }, + FRStepHandler: { + status: 'removed', + note: 'step handling is internal to JourneyClient', + }, + GetOAuth2TokensOptions: { + status: 'removed', + note: 'use `oidcClient.token.get()` params instead', + }, + GetTokensOptions: { + status: 'removed', + note: 'use `oidcClient.token.get()` params instead', + }, + LoggerFunctions: { + status: 'removed', + note: 'use `CustomLogger` from `@forgerock/sdk-logger` instead', + }, + StepOptions: { + status: 'removed', + note: 'per-call config overrides removed; use factory params', + }, + ValidConfigOptions: { + status: 'removed', + note: 'config is encapsulated in client instances', + }, + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + WebAuthnOutcome: { + status: 'internal', + note: 'internal to webauthn module', + }, + WebAuthnOutcomeType: { + status: 'internal', + note: 'internal to webauthn module', + }, + defaultMessageCreator: { + status: 'internal', + note: 'internal to `@forgerock/journey-client/policy`', + }, +}; + +/** + * Package-level renames from legacy to new SDK packages. + */ +export const PACKAGE_MAP: Record = { + '@forgerock/ping-protect': { + new: '@forgerock/protect', + note: 'PingOne Protect/Signals integration', + }, +}; diff --git a/tools/interface-mapping-validator/src/reporter.test.ts b/tools/interface-mapping-validator/src/reporter.test.ts new file mode 100644 index 0000000000..5db2f91fd6 --- /dev/null +++ b/tools/interface-mapping-validator/src/reporter.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import type { Finding } from './types.js'; +import { formatReport } from './reporter.js'; + +const makeFinding = (overrides: Partial = {}): Finding => ({ + category: 'undocumented-legacy-symbol', + severity: 'error', + section: 'Classes', + message: 'Test finding message', + action: 'add', + ...overrides, +}); + +describe('formatReport', () => { + it('returns clean report with zero errors and zero warnings when no findings', () => { + const result = formatReport([]); + + expect(result).toContain('Interface Mapping Drift Report'); + expect(result).toContain('Summary: 0 errors, 0 warnings'); + }); + + it('groups findings by category with counts', () => { + const findings: Finding[] = [ + makeFinding({ category: 'undocumented-legacy-symbol', message: 'Missing: Config' }), + makeFinding({ category: 'undocumented-legacy-symbol', message: 'Missing: Session' }), + makeFinding({ + category: 'stale-legacy-symbol', + message: 'Stale: OldThing', + severity: 'warning', + }), + ]; + + const result = formatReport(findings); + + expect(result).toContain('Undocumented Legacy Symbols (2)'); + expect(result).toContain('Stale Entries (1)'); + }); + + it('separates errors from warnings in summary and singularizes when count is 1', () => { + const findings: Finding[] = [ + makeFinding({ severity: 'error', message: 'An error finding' }), + makeFinding({ + severity: 'warning', + message: 'A warning finding', + category: 'stale-legacy-symbol', + }), + ]; + + const result = formatReport(findings); + + expect(result).toContain('Summary: 1 error, 1 warning'); + }); + + it('pluralizes errors and warnings when counts are greater than 1', () => { + const findings: Finding[] = [ + makeFinding({ severity: 'error', message: 'Error 1' }), + makeFinding({ severity: 'error', message: 'Error 2', category: 'invalid-import-path' }), + makeFinding({ severity: 'warning', message: 'Warning 1', category: 'stale-legacy-symbol' }), + makeFinding({ severity: 'warning', message: 'Warning 2', category: 'stale-callback' }), + ]; + + const result = formatReport(findings); + + expect(result).toContain('Summary: 2 errors, 2 warnings'); + }); + + it('includes individual finding messages in output', () => { + const findings: Finding[] = [ + makeFinding({ severity: 'error', message: 'Missing: CallbackType in section Classes' }), + makeFinding({ + severity: 'warning', + message: 'Stale: OldClass no longer exported', + category: 'stale-legacy-symbol', + }), + ]; + + const result = formatReport(findings); + + expect(result).toContain('Missing: CallbackType in section Classes'); + expect(result).toContain('Stale: OldClass no longer exported'); + }); + + it('uses error marker for errors and warning marker for warnings', () => { + const findings: Finding[] = [ + makeFinding({ severity: 'error', message: 'error-msg' }), + makeFinding({ severity: 'warning', message: 'warning-msg', category: 'stale-legacy-symbol' }), + ]; + + const result = formatReport(findings); + + expect(result).toMatch(/✗\s+error-msg/); + expect(result).toMatch(/⚠\s+warning-msg/); + }); + + it('renders categories in the specified display order', () => { + const findings: Finding[] = [ + makeFinding({ category: 'incorrect-import', message: 'last category' }), + makeFinding({ category: 'undocumented-legacy-symbol', message: 'first category' }), + makeFinding({ + category: 'stale-legacy-symbol', + message: 'second category', + severity: 'warning', + }), + ]; + + const result = formatReport(findings); + + const undocIdx = result.indexOf('Undocumented Legacy Symbols'); + const staleIdx = result.indexOf('Stale Entries'); + const incorrectIdx = result.indexOf('Incorrect Imports'); + + expect(undocIdx).toBeLessThan(staleIdx); + expect(staleIdx).toBeLessThan(incorrectIdx); + }); + + it('omits categories with no findings', () => { + const findings: Finding[] = [ + makeFinding({ category: 'invalid-import-path', message: 'bad path' }), + ]; + + const result = formatReport(findings); + + expect(result).toContain('Invalid Import Paths (1)'); + expect(result).not.toContain('Undocumented Legacy Symbols'); + expect(result).not.toContain('Stale Entries'); + expect(result).not.toContain('Missing Callbacks'); + }); +}); diff --git a/tools/interface-mapping-validator/src/reporter.ts b/tools/interface-mapping-validator/src/reporter.ts new file mode 100644 index 0000000000..8101a54454 --- /dev/null +++ b/tools/interface-mapping-validator/src/reporter.ts @@ -0,0 +1,81 @@ +import type { Finding, FindingCategory } from './types.js'; + +const CATEGORY_LABELS: Record = { + 'undocumented-legacy-symbol': 'Undocumented Legacy Symbols', + 'stale-legacy-symbol': 'Stale Entries', + 'undocumented-new-export': 'Undocumented New Exports', + 'invalid-import-path': 'Invalid Import Paths', + 'missing-callback': 'Missing Callbacks', + 'stale-callback': 'Stale Callbacks', + 'incorrect-import': 'Incorrect Imports', +}; + +const DISPLAY_ORDER: readonly FindingCategory[] = [ + 'undocumented-legacy-symbol', + 'stale-legacy-symbol', + 'invalid-import-path', + 'missing-callback', + 'stale-callback', + 'undocumented-new-export', + 'incorrect-import', +]; + +/** + * Returns a count-prefixed noun, appending "s" for counts other than 1. + * + * @param count - The numeric count. + * @param singular - The singular form of the noun. + * @returns A string like "1 error" or "3 errors". + */ +function pluralize(count: number, singular: string): string { + return count === 1 ? `${count} ${singular}` : `${count} ${singular}s`; +} + +/** + * Groups a flat list of findings into a map keyed by their category. + * + * @param findings - The findings to group. + * @returns A map from finding category to the findings in that category. + */ +function groupByCategory(findings: readonly Finding[]): Map { + return findings.reduce>((groups, finding) => { + const existing = groups.get(finding.category) ?? []; + return new Map(groups).set(finding.category, [...existing, finding]); + }, new Map()); +} + +/** + * Formats a list of findings into a human-readable drift report with categories and a summary line. + * + * @param findings - The findings to render. + * @returns A multi-line report string suitable for console output. + */ +export function formatReport(findings: readonly Finding[]): string { + const grouped = groupByCategory(findings); + + const categoryLines = DISPLAY_ORDER.flatMap((category) => { + const categoryFindings = grouped.get(category); + if (!categoryFindings || categoryFindings.length === 0) return []; + + const label = CATEGORY_LABELS[category]; + return [ + `${label} (${categoryFindings.length})`, + ...categoryFindings.map((finding) => { + const marker = finding.severity === 'error' ? '\u2717' : '\u26A0'; + return ` ${marker} ${finding.message}`; + }), + '', + ]; + }); + + const errorCount = findings.filter((f) => f.severity === 'error').length; + const warningCount = findings.filter((f) => f.severity === 'warning').length; + + return [ + 'Interface Mapping Drift Report', + '\u2550'.repeat(30), + '', + ...categoryLines, + `Summary: ${pluralize(errorCount, 'error')}, ${pluralize(warningCount, 'warning')}`, + ].join('\n'); +} diff --git a/tools/interface-mapping-validator/src/types.ts b/tools/interface-mapping-validator/src/types.ts new file mode 100644 index 0000000000..63746142df --- /dev/null +++ b/tools/interface-mapping-validator/src/types.ts @@ -0,0 +1,77 @@ +export type ExportKind = 'class' | 'interface' | 'type' | 'enum' | 'function' | 'variable'; + +export type LegacyExport = { + readonly name: string; + readonly kind: ExportKind; +}; + +export type NewSdkExport = { + readonly symbol: string; + readonly packageName: string; + readonly entryPoint: string; + readonly importPath: string; + readonly kind: ExportKind; +}; + +export type DocumentedMapping = { + readonly section: string; + readonly legacySymbol: string; + readonly newImport: string; + readonly otherColumns: readonly string[]; + readonly lineNumber: number; +}; + +export type FindingCategory = + | 'undocumented-legacy-symbol' + | 'stale-legacy-symbol' + | 'undocumented-new-export' + | 'invalid-import-path' + | 'missing-callback' + | 'stale-callback' + | 'incorrect-import'; + +export type FindingSeverity = 'error' | 'warning'; + +export type FindingAction = 'add' | 'remove' | 'update'; + +export type Finding = { + readonly category: FindingCategory; + readonly severity: FindingSeverity; + readonly section: string; + readonly message: string; + readonly action: FindingAction; + readonly lineNumber?: number; + readonly suggestedRow?: readonly string[]; +}; + +export type MarkdownExtractionResult = { + readonly mappings: readonly DocumentedMapping[]; + readonly entryPoints: readonly string[]; +}; + +export type RenamedMapping = { + readonly new: string; + readonly package: string; + readonly type?: boolean; + readonly note?: string; +}; + +export type RemovedMapping = { + readonly status: 'removed'; + readonly note: string; +}; + +export type InternalMapping = { + readonly status: 'internal'; + readonly note: string; +}; + +export type SymbolMapping = RenamedMapping | RemovedMapping | InternalMapping; + +export type GeneratedSections = { + readonly quickReference: string; + readonly packageMapping: string; + readonly callbackMapping: string; + readonly migrationDependencies: string; + readonly unmapped: readonly string[]; +}; diff --git a/tools/interface-mapping-validator/src/writer.test.ts b/tools/interface-mapping-validator/src/writer.test.ts new file mode 100644 index 0000000000..7106637e89 --- /dev/null +++ b/tools/interface-mapping-validator/src/writer.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from 'vitest'; +import { replaceSections, replaceMigrationDependencies } from './writer.js'; + +// --------------------------------------------------------------------------- +// Test fixture +// --------------------------------------------------------------------------- + +const FIXTURE = `# Interface Mapping: Legacy SDK → Ping SDK + +## Table of Contents + +0. [Quick Reference](#0-quick-reference) +1. [Package Mapping](#1-package-mapping) +5. [Callbacks](#5-callbacks) + +--- + +## 0. Quick Reference + +Flat lookup table for AI context injection. + +| Legacy Symbol | New Import | +|---|---| +| \`FRAuth\` | old mapping | + +--- + +## 1. Package Mapping + +The legacy SDK is a single package. + +| Legacy Import | New Package | Purpose | +|---|---|---| +| old row | old row | old row | + +--- + +## 2. Configuration + +This section should NOT be touched. + +### Architecture Change + +| Aspect | Legacy | New | +|---|---|---| +| Pattern | Global | Per-client | + +--- + +## 5. Callbacks + +### Base Class Change + +| Legacy | New | Notes | +|---|---|---| +| FRCallback | BaseCallback | Renamed | + +### Callback Type Mapping + +| Legacy Import | New Import | Method Changes | +|---|---|---| +| old callback row | old callback row | None | + +Some trailing prose.`; + +// --------------------------------------------------------------------------- +// Replacement tables +// --------------------------------------------------------------------------- + +const newQuickRefTable = `| Legacy Symbol | New Import | +|---|---| +| \`FRAuth\` | \`authn()\` | +| \`FRUser\` | \`user()\` |`; + +const newPackageTable = `| Legacy Import | New Package | Purpose | +|---|---|---| +| @forgerock/javascript-sdk | @anthropic/ping-auth | Auth | +| @forgerock/javascript-sdk | @anthropic/ping-protect | Protect |`; + +const newCallbackTable = `| Legacy Import | New Import | Method Changes | +|---|---|---| +| NameCallback | NameCallback | Same | +| PasswordCallback | PasswordCallback | Same |`; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('replaceSections', () => { + it('replaces Section 0 (Quick Reference) table with new content', () => { + const result = replaceSections(FIXTURE, { quickReference: newQuickRefTable }); + + expect(result).toContain('| `FRAuth` | `authn()` |'); + expect(result).toContain('| `FRUser` | `user()` |'); + expect(result).not.toContain('| `FRAuth` | old mapping |'); + }); + + it('preserves Section 0 heading and preamble text', () => { + const result = replaceSections(FIXTURE, { quickReference: newQuickRefTable }); + + expect(result).toContain('## 0. Quick Reference'); + expect(result).toContain('Flat lookup table for AI context injection.'); + }); + + it('replaces Section 1 (Package Mapping) table', () => { + const result = replaceSections(FIXTURE, { packageMapping: newPackageTable }); + + expect(result).toContain('| @forgerock/javascript-sdk | @anthropic/ping-auth | Auth |'); + expect(result).toContain('| @forgerock/javascript-sdk | @anthropic/ping-protect | Protect |'); + expect(result).not.toContain('| old row | old row | old row |'); + }); + + it('preserves Section 1 preamble text', () => { + const result = replaceSections(FIXTURE, { packageMapping: newPackageTable }); + + expect(result).toContain('The legacy SDK is a single package.'); + }); + + it('replaces Callback Type Mapping table only — Base Class Change table preserved, trailing prose preserved', () => { + const result = replaceSections(FIXTURE, { callbackMapping: newCallbackTable }); + + // New callback table present + expect(result).toContain('| NameCallback | NameCallback | Same |'); + expect(result).toContain('| PasswordCallback | PasswordCallback | Same |'); + // Old callback row gone + expect(result).not.toContain('| old callback row | old callback row | None |'); + // Base Class Change table preserved + expect(result).toContain('| FRCallback | BaseCallback | Renamed |'); + // Trailing prose preserved + expect(result).toContain('Some trailing prose.'); + }); + + it('preserves Section 2 completely (not a target section)', () => { + const result = replaceSections(FIXTURE, { + quickReference: newQuickRefTable, + packageMapping: newPackageTable, + callbackMapping: newCallbackTable, + }); + + expect(result).toContain('## 2. Configuration'); + expect(result).toContain('This section should NOT be touched.'); + expect(result).toContain('| Pattern | Global | Per-client |'); + }); + + it('preserves Table of Contents', () => { + const result = replaceSections(FIXTURE, { + quickReference: newQuickRefTable, + packageMapping: newPackageTable, + callbackMapping: newCallbackTable, + }); + + expect(result).toContain('## Table of Contents'); + expect(result).toContain('0. [Quick Reference](#0-quick-reference)'); + expect(result).toContain('1. [Package Mapping](#1-package-mapping)'); + expect(result).toContain('5. [Callbacks](#5-callbacks)'); + }); + + it('returns original content when empty replacements object passed', () => { + const result = replaceSections(FIXTURE, {}); + + expect(result).toBe(FIXTURE); + }); + + it('returns original content unchanged when heading is not found', () => { + const docWithoutSection0 = FIXTURE.replace('## 0. Quick Reference', '## 0. Something Else'); + const result = replaceSections(docWithoutSection0, { + quickReference: newQuickRefTable, + }); + + expect(result).toBe(docWithoutSection0); + }); +}); + +describe('replaceMigrationDependencies', () => { + const MIGRATION_FIXTURE = `# Migration Guide + +## Package Dependencies + +| Legacy | New | Purpose | +| --- | --- | --- | +| \`@forgerock/javascript-sdk\` | \`@forgerock/journey-client\` | Old purpose | + +--- + +## SDK Initialization & Configuration + +This content should not be touched. +`; + + it('replaces the Package Dependencies table', () => { + const newTable = + '| Legacy | New | Purpose |\n| --- | --- | --- |\n| `@forgerock/javascript-sdk` | `@forgerock/journey-client` | New purpose |'; + const result = replaceMigrationDependencies(MIGRATION_FIXTURE, newTable); + + expect(result).toContain('New purpose'); + expect(result).not.toContain('Old purpose'); + }); + + it('preserves surrounding content', () => { + const newTable = '| Legacy | New | Purpose |\n| --- | --- | --- |'; + const result = replaceMigrationDependencies(MIGRATION_FIXTURE, newTable); + + expect(result).toContain('# Migration Guide'); + expect(result).toContain('## SDK Initialization & Configuration'); + expect(result).toContain('This content should not be touched.'); + }); +}); diff --git a/tools/interface-mapping-validator/src/writer.ts b/tools/interface-mapping-validator/src/writer.ts new file mode 100644 index 0000000000..e5b4ed2d47 --- /dev/null +++ b/tools/interface-mapping-validator/src/writer.ts @@ -0,0 +1,74 @@ +export type SectionReplacements = { + quickReference?: string; + packageMapping?: string; + callbackMapping?: string; +}; + +type SectionPattern = { + key: keyof SectionReplacements; + heading: RegExp; +}; + +const SECTION_PATTERNS: readonly SectionPattern[] = [ + { key: 'quickReference', heading: /^## 0\.\s+Quick Reference/m }, + { key: 'packageMapping', heading: /^## 1\.\s+Package Mapping/m }, + { key: 'callbackMapping', heading: /^### Callback Type Mapping/m }, +] as const; + +/** + * Replaces the first markdown table found under a specific heading with new table content. + * Pure function — returns the original content unchanged if the heading is not found. + * + * @param content - The full markdown document content. + * @param headingPattern - A regex matching the section heading that contains the table. + * @param newTable - The replacement table content (header + separator + data rows). + * @returns The updated markdown content, or the original content if the heading is not found. + */ +function replaceTableInSection(content: string, headingPattern: RegExp, newTable: string): string { + const headingMatch = headingPattern.exec(content); + if (!headingMatch) return content; + + const lines = content.split('\n'); + const headingOffset = content.slice(0, headingMatch.index).split('\n').length - 1; + + // Scan forward from heading to find first table line + const tableStart = lines.findIndex((line, i) => i > headingOffset && line.startsWith('|')); + if (tableStart === -1) return content; + + // Find last consecutive table line + const afterTable = lines.findIndex((line, i) => i > tableStart && !line.startsWith('|')); + const tableEnd = (afterTable === -1 ? lines.length : afterTable) - 1; + + // Build new content: everything before table + new table + everything after table + return [ + ...lines.slice(0, tableStart), + ...newTable.split('\n'), + ...lines.slice(tableEnd + 1), + ].join('\n'); +} + +/** + * Replace the Package Dependencies table in MIGRATION.md. + * + * @param content - The full MIGRATION.md content. + * @param newTable - The replacement table content (header + separator + data rows). + * @returns The updated markdown content with the Package Dependencies table replaced. + */ +export function replaceMigrationDependencies(content: string, newTable: string): string { + return replaceTableInSection(content, /^## Package Dependencies/m, newTable); +} + +/** + * Replace markdown table sections in-place while preserving all surrounding + * content (headings, preamble text, trailing prose, and unrelated sections). + * + * @param content - The full markdown document content. + * @param replacements - An object mapping section keys to their new table content. + * @returns The updated markdown content with the specified table sections replaced. + */ +export function replaceSections(content: string, replacements: SectionReplacements): string { + return SECTION_PATTERNS.reduce((result, { key, heading }) => { + const newTable = replacements[key]; + return newTable !== undefined ? replaceTableInSection(result, heading, newTable) : result; + }, content); +} diff --git a/tools/interface-mapping-validator/tsconfig.json b/tools/interface-mapping-validator/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/tools/interface-mapping-validator/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tools/interface-mapping-validator/tsconfig.lib.json b/tools/interface-mapping-validator/tsconfig.lib.json new file mode 100644 index 0000000000..bd266ca12d --- /dev/null +++ b/tools/interface-mapping-validator/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts"], + "exclude": ["vitest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "compilerOptions": { + "moduleResolution": "nodenext", + "module": "NodeNext", + "target": "ES2022", + "outDir": "./dist", + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "noUncheckedIndexedAccess": false, + "noImplicitOverride": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true, + "sourceMap": true, + "lib": ["es2022"] + }, + "references": [] +} diff --git a/tools/interface-mapping-validator/tsconfig.spec.json b/tools/interface-mapping-validator/tsconfig.spec.json new file mode 100644 index 0000000000..5f35a633f1 --- /dev/null +++ b/tools/interface-mapping-validator/tsconfig.spec.json @@ -0,0 +1,42 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "module": "nodenext", + "moduleResolution": "nodenext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "declaration": true + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/tools/interface-mapping-validator/vitest.config.ts b/tools/interface-mapping-validator/vitest.config.ts new file mode 100644 index 0000000000..295dc88708 --- /dev/null +++ b/tools/interface-mapping-validator/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/tools/interface-mapping-validator', + plugins: [], + test: { + watch: false, + passWithNoTests: true, + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/tsconfig.json b/tsconfig.json index 92f092123e..d21d369c14 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -78,6 +78,9 @@ }, { "path": "./e2e/am-mock-api" + }, + { + "path": "./tools/interface-mapping-validator" } ] }