From bef2ca9cb8acdb3a3d7bbf6eb05dd396edce3d1c Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Mon, 24 Aug 2026 11:40:14 +0200 Subject: [PATCH 1/5] poc --- .../error/button/add-ns-enum.patch.d.ts | 11 + .../api-gen/error/button/add-ns-ns.patch.d.ts | 10 + .../error/button/add-ns-value.patch.d.ts | 8 + .../button/no-upstream-interface.patch.d.ts | 8 + .../button/no-upstream-namespace.patch.d.ts | 8 + .../button/remove-missing-member.patch.d.ts | 10 + .../button/remove-missing-property.patch.d.ts | 10 + .../error/demo/no-augmentation.patch.d.ts | 6 + .../error/demo/no-upstream-dts.patch.d.ts | 4 + .../api-gen/error/demo/no-upstream.patch.d.ts | 4 + .../icon/place-taken-by-upstream.patch.d.ts | 10 + .../@fixtures/upstream/box/interfaces.d.ts | 31 +++ .../@fixtures/upstream/button/interfaces.d.ts | 27 +++ .../upstream/checkbox/interfaces.d.ts | 25 ++ .../@fixtures/upstream/icon/interfaces.d.ts | 15 ++ .../@fixtures/upstream/internal/README.md | 1 + .../upstream/types/base-component.d.ts | 5 + .../@fixtures/upstream/types/events.d.ts | 3 + .../valid/box/carry-imports.patch.d.ts | 10 + fixtures/api-gen/valid/box/empty.patch.d.ts | 4 + .../valid/box/override-nested-ns.patch.d.ts | 10 + .../valid/box/remove-nested-ns.patch.d.ts | 13 + .../valid/button-alpha/interfaces.patch.d.ts | 11 + .../api-gen/valid/button/add-props.patch.d.ts | 15 ++ .../valid/button/carry-imports.patch.d.ts | 18 ++ .../api-gen/valid/button/empty.patch.d.ts | 4 + .../valid/button/marker-imports.patch.d.ts | 12 + .../valid/button/remove-member.patch.d.ts | 13 + .../valid/button/remove-property.patch.d.ts | 10 + .../valid/checkbox/override-props.patch.d.ts | 25 ++ package-lock.json | 75 +++++- package.json | 9 +- src/api-gen/apply-patch.ts | 105 ++++++++ src/api-gen/generate-proxy-interfaces.ts | 172 ++++++++++++++ src/api-gen/index.ts | 5 + src/api-gen/markers.ts | 22 ++ src/api-gen/parse-patch.ts | 119 ++++++++++ src/api-gen/resolve-package.ts | 38 +++ src/api-gen/to-proxy-source.ts | 62 +++++ .../generate-proxy-interfaces.test.ts.snap | 200 ++++++++++++++++ .../api-gen/generate-proxy-interfaces.test.ts | 224 ++++++++++++++++++ test/api-gen/test-helpers.ts | 39 +++ test/api-gen/to-proxy-source.test.ts | 126 ++++++++++ test/tsconfig.json | 7 +- tsconfig.test.json | 3 +- 45 files changed, 1533 insertions(+), 14 deletions(-) create mode 100644 fixtures/api-gen/error/button/add-ns-enum.patch.d.ts create mode 100644 fixtures/api-gen/error/button/add-ns-ns.patch.d.ts create mode 100644 fixtures/api-gen/error/button/add-ns-value.patch.d.ts create mode 100644 fixtures/api-gen/error/button/no-upstream-interface.patch.d.ts create mode 100644 fixtures/api-gen/error/button/no-upstream-namespace.patch.d.ts create mode 100644 fixtures/api-gen/error/button/remove-missing-member.patch.d.ts create mode 100644 fixtures/api-gen/error/button/remove-missing-property.patch.d.ts create mode 100644 fixtures/api-gen/error/demo/no-augmentation.patch.d.ts create mode 100644 fixtures/api-gen/error/demo/no-upstream-dts.patch.d.ts create mode 100644 fixtures/api-gen/error/demo/no-upstream.patch.d.ts create mode 100644 fixtures/api-gen/error/icon/place-taken-by-upstream.patch.d.ts create mode 100644 fixtures/api-gen/node_modules/@fixtures/upstream/box/interfaces.d.ts create mode 100644 fixtures/api-gen/node_modules/@fixtures/upstream/button/interfaces.d.ts create mode 100644 fixtures/api-gen/node_modules/@fixtures/upstream/checkbox/interfaces.d.ts create mode 100644 fixtures/api-gen/node_modules/@fixtures/upstream/icon/interfaces.d.ts create mode 100644 fixtures/api-gen/node_modules/@fixtures/upstream/internal/README.md create mode 100644 fixtures/api-gen/node_modules/@fixtures/upstream/types/base-component.d.ts create mode 100644 fixtures/api-gen/node_modules/@fixtures/upstream/types/events.d.ts create mode 100644 fixtures/api-gen/valid/box/carry-imports.patch.d.ts create mode 100644 fixtures/api-gen/valid/box/empty.patch.d.ts create mode 100644 fixtures/api-gen/valid/box/override-nested-ns.patch.d.ts create mode 100644 fixtures/api-gen/valid/box/remove-nested-ns.patch.d.ts create mode 100644 fixtures/api-gen/valid/button-alpha/interfaces.patch.d.ts create mode 100644 fixtures/api-gen/valid/button/add-props.patch.d.ts create mode 100644 fixtures/api-gen/valid/button/carry-imports.patch.d.ts create mode 100644 fixtures/api-gen/valid/button/empty.patch.d.ts create mode 100644 fixtures/api-gen/valid/button/marker-imports.patch.d.ts create mode 100644 fixtures/api-gen/valid/button/remove-member.patch.d.ts create mode 100644 fixtures/api-gen/valid/button/remove-property.patch.d.ts create mode 100644 fixtures/api-gen/valid/checkbox/override-props.patch.d.ts create mode 100644 src/api-gen/apply-patch.ts create mode 100644 src/api-gen/generate-proxy-interfaces.ts create mode 100644 src/api-gen/index.ts create mode 100644 src/api-gen/markers.ts create mode 100644 src/api-gen/parse-patch.ts create mode 100644 src/api-gen/resolve-package.ts create mode 100644 src/api-gen/to-proxy-source.ts create mode 100644 test/api-gen/__snapshots__/generate-proxy-interfaces.test.ts.snap create mode 100644 test/api-gen/generate-proxy-interfaces.test.ts create mode 100644 test/api-gen/test-helpers.ts create mode 100644 test/api-gen/to-proxy-source.test.ts diff --git a/fixtures/api-gen/error/button/add-ns-enum.patch.d.ts b/fixtures/api-gen/error/button/add-ns-enum.patch.d.ts new file mode 100644 index 0000000..40a685d --- /dev/null +++ b/fixtures/api-gen/error/button/add-ns-enum.patch.d.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/button' { + export namespace ButtonProps { + enum Severity { + Low, + High, + } + } +} diff --git a/fixtures/api-gen/error/button/add-ns-ns.patch.d.ts b/fixtures/api-gen/error/button/add-ns-ns.patch.d.ts new file mode 100644 index 0000000..644ef7c --- /dev/null +++ b/fixtures/api-gen/error/button/add-ns-ns.patch.d.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/button' { + export namespace ButtonProps { + namespace IconProps { + type Name = string; + } + } +} diff --git a/fixtures/api-gen/error/button/add-ns-value.patch.d.ts b/fixtures/api-gen/error/button/add-ns-value.patch.d.ts new file mode 100644 index 0000000..08d3d77 --- /dev/null +++ b/fixtures/api-gen/error/button/add-ns-value.patch.d.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/button' { + export namespace ButtonProps { + export const fallback: string; + } +} diff --git a/fixtures/api-gen/error/button/no-upstream-interface.patch.d.ts b/fixtures/api-gen/error/button/no-upstream-interface.patch.d.ts new file mode 100644 index 0000000..b4edf3e --- /dev/null +++ b/fixtures/api-gen/error/button/no-upstream-interface.patch.d.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/button' { + export interface ButtonPropz { + children: React.ReactNode; + } +} diff --git a/fixtures/api-gen/error/button/no-upstream-namespace.patch.d.ts b/fixtures/api-gen/error/button/no-upstream-namespace.patch.d.ts new file mode 100644 index 0000000..7ee10c2 --- /dev/null +++ b/fixtures/api-gen/error/button/no-upstream-namespace.patch.d.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/button' { + export namespace ButtonPropz { + type IconName = string; + } +} diff --git a/fixtures/api-gen/error/button/remove-missing-member.patch.d.ts b/fixtures/api-gen/error/button/remove-missing-member.patch.d.ts new file mode 100644 index 0000000..e2faf64 --- /dev/null +++ b/fixtures/api-gen/error/button/remove-missing-member.patch.d.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Remove } from '@cloudscape-design/documenter/api-gen/markers'; + +declare module '@fixtures/upstream/button' { + export namespace ButtonProps { + type AltText = Remove; + } +} diff --git a/fixtures/api-gen/error/button/remove-missing-property.patch.d.ts b/fixtures/api-gen/error/button/remove-missing-property.patch.d.ts new file mode 100644 index 0000000..ee0a583 --- /dev/null +++ b/fixtures/api-gen/error/button/remove-missing-property.patch.d.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Remove } from '@cloudscape-design/documenter/api-gen/markers'; + +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + altText: Remove; + } +} diff --git a/fixtures/api-gen/error/demo/no-augmentation.patch.d.ts b/fixtures/api-gen/error/demo/no-augmentation.patch.d.ts new file mode 100644 index 0000000..bcede96 --- /dev/null +++ b/fixtures/api-gen/error/demo/no-augmentation.patch.d.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface DemoProps { + content?: string; +} diff --git a/fixtures/api-gen/error/demo/no-upstream-dts.patch.d.ts b/fixtures/api-gen/error/demo/no-upstream-dts.patch.d.ts new file mode 100644 index 0000000..4ca23a4 --- /dev/null +++ b/fixtures/api-gen/error/demo/no-upstream-dts.patch.d.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/internal' {} diff --git a/fixtures/api-gen/error/demo/no-upstream.patch.d.ts b/fixtures/api-gen/error/demo/no-upstream.patch.d.ts new file mode 100644 index 0000000..5376704 --- /dev/null +++ b/fixtures/api-gen/error/demo/no-upstream.patch.d.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/unresolved' {} diff --git a/fixtures/api-gen/error/icon/place-taken-by-upstream.patch.d.ts b/fixtures/api-gen/error/icon/place-taken-by-upstream.patch.d.ts new file mode 100644 index 0000000..8bfbd90 --- /dev/null +++ b/fixtures/api-gen/error/icon/place-taken-by-upstream.patch.d.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This patch sits in a directory called `icon` but proxies `button`, which itself imports +// `../icon/interfaces`. The consumer's name and the upstream component want the same place. +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + iconName: never; + } +} diff --git a/fixtures/api-gen/node_modules/@fixtures/upstream/box/interfaces.d.ts b/fixtures/api-gen/node_modules/@fixtures/upstream/box/interfaces.d.ts new file mode 100644 index 0000000..a8c0475 --- /dev/null +++ b/fixtures/api-gen/node_modules/@fixtures/upstream/box/interfaces.d.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface BoxProps { + /** + * Box content + */ + children?: string; + /** + * Box variant + */ + variant?: BoxProps.Variant; + /** + * Box visual accent + * @awsuiSystem core + */ + visualAccent?: BoxProps.VisualAccent; +} + +export declare namespace BoxProps { + type Variant = 'div' | 'span'; + interface VisualAccent { + color: BoxProps.VisualAccent.Color; + aspectRatio?: BoxProps.VisualAccent.AspectRatio; + } + namespace VisualAccent { + type Color = 'red' | 'blue'; + type AspectRatio = 'auto' | 'equal'; + } + export {}; +} diff --git a/fixtures/api-gen/node_modules/@fixtures/upstream/button/interfaces.d.ts b/fixtures/api-gen/node_modules/@fixtures/upstream/button/interfaces.d.ts new file mode 100644 index 0000000..18abd00 --- /dev/null +++ b/fixtures/api-gen/node_modules/@fixtures/upstream/button/interfaces.d.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events.js'; +import { IconProps } from '../icon/interfaces.js' + +export interface ButtonProps extends BaseComponentProps { + /** + * Button text + */ + children?: React.ReactNode; + /** + * Click handler + */ + onClick?: NonCancelableEventHandler; + /** + * Icon name + */ + iconName?: ButtonProps.IconName; +} + +export declare namespace ButtonProps { + export type IconName = IconProps.Name; + export {}; +} \ No newline at end of file diff --git a/fixtures/api-gen/node_modules/@fixtures/upstream/checkbox/interfaces.d.ts b/fixtures/api-gen/node_modules/@fixtures/upstream/checkbox/interfaces.d.ts new file mode 100644 index 0000000..eb2763e --- /dev/null +++ b/fixtures/api-gen/node_modules/@fixtures/upstream/checkbox/interfaces.d.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BaseComponentProps } from '../types/base-component'; + +export interface BaseCheckboxProps { + checked: boolean; + ariaLabel?: string; +} + +export interface CheckboxProps extends BaseComponentProps { + /** + * Checkbox label + */ + children?: React.ReactNode; + indeterminate?: boolean; + onChange: (detail: CheckboxProps.EventDetail) => void; +} + +export declare namespace CheckboxProps { + interface EventDetail { + checked: boolean; + } + export {}; +} \ No newline at end of file diff --git a/fixtures/api-gen/node_modules/@fixtures/upstream/icon/interfaces.d.ts b/fixtures/api-gen/node_modules/@fixtures/upstream/icon/interfaces.d.ts new file mode 100644 index 0000000..ac894c5 --- /dev/null +++ b/fixtures/api-gen/node_modules/@fixtures/upstream/icon/interfaces.d.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BaseComponentProps } from '../types/base-component'; + +export interface IconProps extends BaseComponentProps { + /** + * Icon name + */ + name: IconProps.Name; +} + +export declare namespace IconProps { + type Name = 'user' | 'settings'; +} \ No newline at end of file diff --git a/fixtures/api-gen/node_modules/@fixtures/upstream/internal/README.md b/fixtures/api-gen/node_modules/@fixtures/upstream/internal/README.md new file mode 100644 index 0000000..f5ce72c --- /dev/null +++ b/fixtures/api-gen/node_modules/@fixtures/upstream/internal/README.md @@ -0,0 +1 @@ +Intentionally contains no declaration file. diff --git a/fixtures/api-gen/node_modules/@fixtures/upstream/types/base-component.d.ts b/fixtures/api-gen/node_modules/@fixtures/upstream/types/base-component.d.ts new file mode 100644 index 0000000..a0766ed --- /dev/null +++ b/fixtures/api-gen/node_modules/@fixtures/upstream/types/base-component.d.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export interface BaseComponentProps { + className?: string; +} diff --git a/fixtures/api-gen/node_modules/@fixtures/upstream/types/events.d.ts b/fixtures/api-gen/node_modules/@fixtures/upstream/types/events.d.ts new file mode 100644 index 0000000..355f7d6 --- /dev/null +++ b/fixtures/api-gen/node_modules/@fixtures/upstream/types/events.d.ts @@ -0,0 +1,3 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export type NonCancelableEventHandler = (event: { detail: unknown }) => void; diff --git a/fixtures/api-gen/valid/box/carry-imports.patch.d.ts b/fixtures/api-gen/valid/box/carry-imports.patch.d.ts new file mode 100644 index 0000000..5611d45 --- /dev/null +++ b/fixtures/api-gen/valid/box/carry-imports.patch.d.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { IconProps } from '@fixtures/upstream/icon'; + +declare module '@fixtures/upstream/box' { + export interface BoxProps { + icon?: IconProps; + } +} diff --git a/fixtures/api-gen/valid/box/empty.patch.d.ts b/fixtures/api-gen/valid/box/empty.patch.d.ts new file mode 100644 index 0000000..bee38e6 --- /dev/null +++ b/fixtures/api-gen/valid/box/empty.patch.d.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/box' {} diff --git a/fixtures/api-gen/valid/box/override-nested-ns.patch.d.ts b/fixtures/api-gen/valid/box/override-nested-ns.patch.d.ts new file mode 100644 index 0000000..2bac8b0 --- /dev/null +++ b/fixtures/api-gen/valid/box/override-nested-ns.patch.d.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/box' { + export namespace BoxProps { + export interface VisualAccent { + color: BoxProps.VisualAccent.Color; + } + } +} diff --git a/fixtures/api-gen/valid/box/remove-nested-ns.patch.d.ts b/fixtures/api-gen/valid/box/remove-nested-ns.patch.d.ts new file mode 100644 index 0000000..ded4504 --- /dev/null +++ b/fixtures/api-gen/valid/box/remove-nested-ns.patch.d.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Remove } from '@cloudscape-design/documenter/api-gen/markers'; + +declare module '@fixtures/upstream/box' { + export interface BoxProps { + visualAccent: Remove; + } + export namespace BoxProps { + export type VisualAccent = Remove; + } +} diff --git a/fixtures/api-gen/valid/button-alpha/interfaces.patch.d.ts b/fixtures/api-gen/valid/button-alpha/interfaces.patch.d.ts new file mode 100644 index 0000000..abc87fe --- /dev/null +++ b/fixtures/api-gen/valid/button-alpha/interfaces.patch.d.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Remove } from '@cloudscape-design/documenter/api-gen/markers'; + +// Proxies the same upstream component as `valid/button`, at a second place in the proxy tree. +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + children: Remove; + } +} diff --git a/fixtures/api-gen/valid/button/add-props.patch.d.ts b/fixtures/api-gen/valid/button/add-props.patch.d.ts new file mode 100644 index 0000000..08d60c8 --- /dev/null +++ b/fixtures/api-gen/valid/button/add-props.patch.d.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + /** + * Annotation API-docs + */ + annotation?: ButtonProps.Annotation; + } + + export namespace ButtonProps { + export type Annotation = string; + } +} diff --git a/fixtures/api-gen/valid/button/carry-imports.patch.d.ts b/fixtures/api-gen/valid/button/carry-imports.patch.d.ts new file mode 100644 index 0000000..734b48c --- /dev/null +++ b/fixtures/api-gen/valid/button/carry-imports.patch.d.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Remove } from '@cloudscape-design/documenter/api-gen/markers'; +import type { CheckboxProps } from '@fixtures/upstream/checkbox'; +import * as BoxTypes from '@fixtures/upstream/box'; +import '@fixtures/upstream/button'; + +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + onClick: Remove; + /** + * Wrapper applied around the button content. + */ + wrapper?: BoxTypes.BoxProps; + toggle?: CheckboxProps; + } +} diff --git a/fixtures/api-gen/valid/button/empty.patch.d.ts b/fixtures/api-gen/valid/button/empty.patch.d.ts new file mode 100644 index 0000000..8b91656 --- /dev/null +++ b/fixtures/api-gen/valid/button/empty.patch.d.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/button' {} diff --git a/fixtures/api-gen/valid/button/marker-imports.patch.d.ts b/fixtures/api-gen/valid/button/marker-imports.patch.d.ts new file mode 100644 index 0000000..39eeaa3 --- /dev/null +++ b/fixtures/api-gen/valid/button/marker-imports.patch.d.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FutureMarker, Remove as Drop } from '@cloudscape-design/documenter/api-gen/markers'; +import * as ApiGen from '@cloudscape-design/documenter/api-gen/markers'; + +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + onClick: Drop; + iconName: ApiGen.Remove; + } +} diff --git a/fixtures/api-gen/valid/button/remove-member.patch.d.ts b/fixtures/api-gen/valid/button/remove-member.patch.d.ts new file mode 100644 index 0000000..350e935 --- /dev/null +++ b/fixtures/api-gen/valid/button/remove-member.patch.d.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Remove } from '@cloudscape-design/documenter/api-gen/markers'; + +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + iconName: Remove; + } + export namespace ButtonProps { + export type IconName = Remove; + } +} diff --git a/fixtures/api-gen/valid/button/remove-property.patch.d.ts b/fixtures/api-gen/valid/button/remove-property.patch.d.ts new file mode 100644 index 0000000..8a0eac6 --- /dev/null +++ b/fixtures/api-gen/valid/button/remove-property.patch.d.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Remove } from '@cloudscape-design/documenter/api-gen/markers'; + +declare module '@fixtures/upstream/button' { + export interface ButtonProps { + iconName: Remove; + } +} diff --git a/fixtures/api-gen/valid/checkbox/override-props.patch.d.ts b/fixtures/api-gen/valid/checkbox/override-props.patch.d.ts new file mode 100644 index 0000000..491a381 --- /dev/null +++ b/fixtures/api-gen/valid/checkbox/override-props.patch.d.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/checkbox' { + export interface BaseCheckboxProps { + /** + * ARIA label of the checkbox control + */ + ariaLabel: string; + } + + export interface CheckboxProps { + /** + * Visible checkbox label + */ + children?: React.ReactNode; + } + + export namespace CheckboxProps { + export interface EventDetail { + checked: boolean; + indeterminate: boolean; + } + } +} diff --git a/package-lock.json b/package-lock.json index ee15406..3f838bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "change-case": "^4.1.1", "micromatch": "^4.0.8", - "pathe": "^1.1.2" + "pathe": "^1.1.2", + "ts-morph": "^28.0.0" }, "devDependencies": { "@eslint/compat": "^1.2.9", @@ -782,6 +783,53 @@ "eslint": ">=7.7.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1862,6 +1910,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4979,6 +5033,12 @@ "tslib": "^2.0.3" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", @@ -6056,7 +6116,6 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6073,7 +6132,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6091,7 +6149,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6135,6 +6192,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 0c64b78..96bcff3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "homepage": "https://cloudscape.design", "main": "./index.js", "exports": { - ".": "./index.js" + ".": "./index.js", + "./api-gen": "./api-gen/index.js", + "./api-gen/markers": "./api-gen/markers.js" }, "scripts": { "lint": "eslint .", @@ -19,6 +21,7 @@ "prepare": "husky" }, "devDependencies": { + "@eslint/compat": "^1.2.9", "@tony.ganchev/eslint-plugin-header": "^3.3.1", "@types/micromatch": "^4.0.1", "@types/node": "^20.0.0", @@ -26,7 +29,6 @@ "@types/react-dom": "^16.9.8", "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.20.1", - "@eslint/compat": "^1.2.9", "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-react": "^7.31.11", @@ -42,7 +44,8 @@ "dependencies": { "change-case": "^4.1.1", "micromatch": "^4.0.8", - "pathe": "^1.1.2" + "pathe": "^1.1.2", + "ts-morph": "^28.0.0" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/src/api-gen/apply-patch.ts b/src/api-gen/apply-patch.ts new file mode 100644 index 0000000..af5077b --- /dev/null +++ b/src/api-gen/apply-patch.ts @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as ts from 'ts-morph'; + +import { Patch } from './parse-patch'; + +export function applyPatch(out: ts.SourceFile, patch: Patch): void { + patchInterfaces(out, patch); + patchNamespaces(out, patch); + carryOverImports(out, patch); +} + +function patchInterfaces(out: ts.SourceFile, patch: Patch) { + for (const interfacePatch of patch.interfaces) { + const outInterface = findInterface(interfacePatch.interfaceName); + for (const override of removalsFirst(interfacePatch.propertyOverrides)) { + if (override.declaration === undefined) { + removeProperty(outInterface, override.propertyName); + } else { + insertProperty(outInterface, override.propertyName, override.declaration); + } + } + } + + function findInterface(interfaceName: string) { + const target = out.getInterface(interfaceName); + if (!target) { + throw patch.error(`Interface "${interfaceName}" does not exist in "${patch.moduleSpecifier}".`); + } + return target; + } + + function removeProperty(outInterface: ts.InterfaceDeclaration, propertyName: string) { + const existing = outInterface.getProperty(propertyName); + if (existing) { + existing.remove(); + } else { + throw patch.error(`Property "${outInterface.getName()}.${propertyName}" cannot be removed (it does not exist).`); + } + } + + function insertProperty(outInterface: ts.InterfaceDeclaration, propertyName: string, propertyDeclaration: string) { + const existing = outInterface.getProperty(propertyName); + existing?.remove(); + outInterface.addMember(dedentJsDoc(propertyDeclaration)); + } +} + +function patchNamespaces(out: ts.SourceFile, patch: Patch) { + for (const namespacePatch of patch.namespaces) { + const outNamespace = findNamespace(namespacePatch.namespaceName); + for (const override of removalsFirst(namespacePatch.memberOverrides)) { + if (override.declaration === undefined) { + removeMember(outNamespace, override.memberName); + } else { + insertMember(outNamespace, override.memberName, override.declaration); + } + } + } + + function findNamespace(namespaceName: string) { + const namespaces = out.getModules().filter(m => m.getDeclarationKind() === ts.ModuleDeclarationKind.Namespace); + const target = namespaces.find(ns => ns.getName() === namespaceName); + if (!target) { + throw patch.error(`Namespace "${namespaceName}" does not exist in "${patch.moduleSpecifier}".`); + } + return target; + } + + function removeMember(outNamespace: ts.ModuleDeclaration, memberName: string) { + const existing = findExistingMembers(outNamespace, memberName); + if (existing.length > 0) { + existing.forEach(member => member.remove()); + } else { + throw patch.error(`Member "${outNamespace.getName()}.${memberName}" cannot be removed (it does not exist).`); + } + } + + function insertMember(outNamespace: ts.ModuleDeclaration, memberName: string, memberDeclaration: string) { + findExistingMembers(outNamespace, memberName).forEach(member => member.remove()); + outNamespace.addStatements(dedentJsDoc(memberDeclaration)); + } + + function findExistingMembers(outNamespace: ts.ModuleDeclaration, namespaceName: string) { + return [ + outNamespace.getTypeAlias(namespaceName), + outNamespace.getInterface(namespaceName), + outNamespace.getModule(namespaceName), + ].filter(declaration => declaration !== undefined); + } +} + +function carryOverImports(out: ts.SourceFile, patch: Patch) { + out.addImportDeclarations(patch.imports); +} + +function removalsFirst(overrides: T[]): T[] { + return [...overrides].sort((a, b) => Number(a.declaration !== undefined) - Number(b.declaration !== undefined)); +} + +/** Normalizes indentation of the js-docs when they are carried over from the patch file. */ +function dedentJsDoc(jsDoc: string): string { + return jsDoc.replace(/\n[ \t]*\*/g, '\n *'); +} diff --git a/src/api-gen/generate-proxy-interfaces.ts b/src/api-gen/generate-proxy-interfaces.ts new file mode 100644 index 0000000..07694e3 --- /dev/null +++ b/src/api-gen/generate-proxy-interfaces.ts @@ -0,0 +1,172 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import * as ts from 'ts-morph'; + +import { applyPatch } from './apply-patch'; +import { Patch, parsePatch } from './parse-patch'; +import { resolveRelativeImport, resolveUpstreamModule } from './resolve-package'; +import { toProxySource } from './to-proxy-source'; + +export interface UpstreamImport { + /** Path of the emitted file holding the import, e.g. `select/interfaces.ts`. */ + sourcePath: string; + /** Absolute path of the upstream declaration file the import resolves to. */ + resolvedPath: string; + /** Specifier as the upstream declaration file wrote it. */ + importSpecifier: string; +} + +export interface GenerateProxyInterfacesOptions { + /** Paths to patch files of the components to proxy. */ + entryPoints: string[]; + /** Override the specifier an emitted import is written with. Return `{}` to keep it unchanged. */ + resolveImport?: ResolveImport; +} + +type ResolveImport = (imported: UpstreamImport) => { importSpecifier?: string }; + +export interface GenerateProxyInterfacesResult { + /** The proxied interfaces and everything they depend on, ordered by path. */ + files: ProxyFile[]; +} + +export interface ProxyFile { + /** Place in the proxy tree: the directory the consumer gave a proxied component, upstream's own otherwise. */ + path: string; + /** Generated source with license header and awsui-system tags removed. */ + source: string; +} + +interface PendingFile { + /** Upstream declaration file to read. */ + upstreamPath: string; + /** Where the file belongs in the proxy tree. */ + emittedPath: string; + /** Package the file belongs to, from which the paths of the files it reaches are derived. */ + packageDir: string; + /** Set when an entry point named this file: the patch to apply to it. */ + patch?: Patch; +} + +/** + * Generate the interfaces for a set of proxied components. Each entry point names one upstream + * component to proxy and may patch its declarations, or leave them as they are. The result holds + * those interfaces and everything they reach, down to the shared public types, all transformed alike + * so the emitted tree resolves within itself. + */ +export function generateProxyInterfaces({ + entryPoints, + resolveImport, +}: GenerateProxyInterfacesOptions): GenerateProxyInterfacesResult { + const project = new ts.Project({ + useInMemoryFileSystem: true, + manipulationSettings: { indentationText: ts.IndentationText.TwoSpaces, quoteKind: ts.QuoteKind.Single }, + }); + + const pending = entryPoints.map(entryPath => readPatch(project, entryPath)); + const emitted = new Map(); + const queued = new Set(pending.map(file => file.upstreamPath)); + + while (pending.length > 0) { + const file = pending.shift()!; + if (emitted.has(file.emittedPath)) { + throw new Error(`Two declarations claim the place "${file.emittedPath}" in the proxy tree.`); + } + + // Transform upstream interfaces that correspond to the given file path. + const out = toProxySource(project, file.emittedPath, readFileSync(file.upstreamPath, 'utf-8')); + + // Follow file's relative imports to pull its dependencies, such as other components or shared types. + for (const reached of followImports(out, file, resolveImport)) { + if (!queued.has(reached.upstreamPath)) { + queued.add(reached.upstreamPath); + pending.push(reached); + } + } + + // A file reached through imports has no patch of its own. + if (file.patch) { + applyPatch(out, file.patch); + } + + out.formatText({ indentSize: 2, convertTabsToSpaces: true }); + emitted.set(file.emittedPath, { path: file.emittedPath, source: out.getFullText() }); + } + + return { files: [...emitted.values()].sort((a, b) => a.path.localeCompare(b.path)) }; +} + +function readPatch(project: ts.Project, patchPath: string): PendingFile { + try { + const patchSource = readFileSync(patchPath, 'utf-8'); + const patch = parsePatch( + project.createSourceFile(`patch/${patchPath}`, patchSource, { overwrite: true }), + patchPath, + ); + const upstream = resolveUpstreamModule(patch.moduleSpecifier, path.dirname(path.resolve(patchPath))); + return { + upstreamPath: upstream.filePath, + emittedPath: createEmittedPathFromPatchPath(patchPath), + packageDir: upstream.packageDir, + patch, + }; + } catch (error) { + if (error instanceof Error) { + error.message = `${patchPath}: ${error.message}`; + } + throw error; + } +} + +function followImports(source: ts.SourceFile, file: PendingFile, resolveImport?: ResolveImport): PendingFile[] { + const reached: PendingFile[] = []; + const resolvedReferences = getResolvedReferences(source, path.dirname(file.upstreamPath)); + for (const { reference, resolvedPath, importSpecifier } of resolvedReferences) { + if (resolveImport) { + const override = resolveImport({ sourcePath: file.emittedPath, resolvedPath, importSpecifier }); + if (override?.importSpecifier !== undefined) { + reference.setModuleSpecifier(override.importSpecifier); + } + } + reached.push({ + upstreamPath: resolvedPath, + emittedPath: createEmittedPathFromUpstreamPath(resolvedPath, file.packageDir), + packageDir: file.packageDir, + }); + } + return reached; +} + +function getResolvedReferences(source: ts.SourceFile, fromDir: string) { + const references = new Array<{ reference: ts.ImportDeclaration; importSpecifier: string; resolvedPath: string }>(); + for (const reference of source.getImportDeclarations()) { + const importSpecifier = reference.getModuleSpecifierValue(); + if (!reference.isModuleSpecifierRelative()) { + continue; + } + const resolvedPath = resolveRelativeImport(importSpecifier, fromDir); + if (resolvedPath) { + references.push({ reference, importSpecifier, resolvedPath }); + } + } + return references; +} + +function createEmittedPathFromPatchPath(patchPath: string) { + let emittedPath = patchPath; // 1. Start from patch path, e.g. "./src/button/interfaces.patch.d.ts" + emittedPath = path.dirname(emittedPath); // 2. ... -> "./src/button" + emittedPath = path.basename(emittedPath); // 3. ... -> "button" + emittedPath = `${emittedPath}/interfaces.ts`; // 4. ... -> "button/interfaces.ts" + return emittedPath; +} + +function createEmittedPathFromUpstreamPath(resolvedPath: string, packageDir: string) { + let emittedPath = resolvedPath; // 1. Start from resolved path, e.g. "/repo/node_modules/@cloudscape-design/components/icon/interfaces.d.ts" + emittedPath = path.relative(packageDir, emittedPath); // 2. ... -> "icon/interfaces.d.ts" + emittedPath = emittedPath.split(path.sep).join('/'); // 3. ... unchanged on POSIX, backslashes to slashes on Windows + emittedPath = emittedPath.replace(/\.d\.ts$/, '.ts'); // 4. ... -> "icon/interfaces.ts" + return emittedPath; +} diff --git a/src/api-gen/index.ts b/src/api-gen/index.ts new file mode 100644 index 0000000..bb753b8 --- /dev/null +++ b/src/api-gen/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { generateProxyInterfaces } from './generate-proxy-interfaces'; +export type { GenerateProxyInterfacesOptions, GenerateProxyInterfacesResult } from './generate-proxy-interfaces'; diff --git a/src/api-gen/markers.ts b/src/api-gen/markers.ts new file mode 100644 index 0000000..f4490a0 --- /dev/null +++ b/src/api-gen/markers.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare const removed: unique symbol; + +/** + * Marks an interface property or a namespace member for removal. + * + * ```ts + * import type * as ApiGen from '@cloudscape-design/documenter/api-gen/markers'; + * + * declare module '@cloudscape-design/components/button' { + * export interface ButtonProps { + * style: ApiGen.Remove; + * } + * export namespace ButtonProps { + * export type Style = ApiGen.Remove; + * } + * } + * ``` + */ +export type Remove = typeof removed; diff --git a/src/api-gen/parse-patch.ts b/src/api-gen/parse-patch.ts new file mode 100644 index 0000000..b02ff96 --- /dev/null +++ b/src/api-gen/parse-patch.ts @@ -0,0 +1,119 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as ts from 'ts-morph'; + +const MARKERS_MODULE = '@cloudscape-design/documenter/api-gen/markers'; +const MARKERS = ['Remove'] as const; + +export interface Patch { + patchPath: string; + moduleSpecifier: string; + imports: ts.ImportDeclarationStructure[]; + interfaces: InterfacePatch[]; + namespaces: NamespacePatch[]; + error: (message: string) => Error; +} + +interface InterfacePatch { + interfaceName: string; + propertyOverrides: InterfacePropertyOverride[]; +} + +interface InterfacePropertyOverride { + propertyName: string; + declaration?: string; +} + +interface NamespacePatch { + namespaceName: string; + memberOverrides: NamespaceMemberOverride[]; +} + +interface NamespaceMemberOverride { + memberName: string; + declaration?: string; +} + +type PatchableMember = ts.PropertySignature | ts.TypeAliasDeclaration | ts.InterfaceDeclaration; + +export function parsePatch(patch: ts.SourceFile, patchPath: string): Patch { + const augmentation = patch.getModules().find(m => m.getDeclarationKind() === ts.ModuleDeclarationKind.Module); + if (!augmentation) { + throw new Error('The patch must contain a `declare module "..."` augmentation.'); + } + + const matchMarker = createMarkerMatcher(patch); + const override = (member: PatchableMember) => + matchMarker(member) !== 'Remove' ? { declaration: member.getText({ includeJsDocComments: true }) } : {}; + + return { + patchPath, + moduleSpecifier: augmentation.compilerNode.name.text, + imports: collectImports(patch), + interfaces: augmentation.getInterfaces().map(i => ({ + interfaceName: i.getName(), + propertyOverrides: i + .getProperties() + .map(property => ({ propertyName: property.getName(), ...override(property) })), + })), + namespaces: augmentation + .getModules() + .filter(m => m.getDeclarationKind() === ts.ModuleDeclarationKind.Namespace) + .map(ns => ({ + namespaceName: ns.getName(), + memberOverrides: getNsMembers(ns).map(member => ({ memberName: member.getName(), ...override(member) })), + })), + error: message => new Error(`${patchPath}: ${message}`), + }; +} + +function getNsMembers(namespace: ts.ModuleDeclaration) { + return namespace.getStatements().map(statement => { + if (ts.Node.isTypeAliasDeclaration(statement) || ts.Node.isInterfaceDeclaration(statement)) { + return statement; + } else { + const name = `${namespace.getName()}.${statement.getSymbol()?.getName() ?? statement.getKindName()}`; + throw new Error(`A namespace patch may declare only type aliases and interfaces; "${name}" is neither.`); + } + }); +} + +function collectImports(patch: ts.SourceFile): ts.ImportDeclarationStructure[] { + return patch + .getImportDeclarations() + .filter(declaration => declaration.getModuleSpecifierValue() !== MARKERS_MODULE) + .map(declaration => declaration.getStructure()); +} + +type Marker = (typeof MARKERS)[number]; +type MatchMarker = (member: PatchableMember) => null | Marker; + +function createMarkerMatcher(patch: ts.SourceFile): MatchMarker { + const typeToMarker = new Map(); + for (const declaration of patch.getImportDeclarations()) { + // Ignore any import other than from '…/api-gen/markers'. + if (declaration.getModuleSpecifierValue() !== MARKERS_MODULE) { + continue; + } + // Handle `import { Remove } from '…/api-gen/markers'` or `import { Remove as Drop } from '…/api-gen/markers'`. + for (const namedImport of declaration.getNamedImports()) { + const marker = MARKERS.find(marker => marker === namedImport.getName()); + if (marker) { + const aliasName = namedImport.getAliasNode()?.getText(); + const typeName = aliasName ?? namedImport.getName(); + typeToMarker.set(typeName, marker); + } + } + // Handle `import * as ApiGen from '…/api-gen/markers'`. + const namespaceImport = declaration.getNamespaceImport(); + if (namespaceImport) { + MARKERS.forEach(marker => typeToMarker.set(`${namespaceImport.getText()}.${marker}`, marker)); + } + } + return member => { + // Only a property or a type alias can carry a marker. + const typeText = ts.Node.isInterfaceDeclaration(member) ? undefined : member.getTypeNode()?.getText(); + return typeText === undefined ? null : (typeToMarker.get(typeText) ?? null); + }; +} diff --git a/src/api-gen/resolve-package.ts b/src/api-gen/resolve-package.ts new file mode 100644 index 0000000..8a5e998 --- /dev/null +++ b/src/api-gen/resolve-package.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'node:fs'; +import path from 'node:path'; + +export interface UpstreamFile { + packageDir: string; + filePath: string; +} + +export function resolveUpstreamModule(moduleSpecifier: string, fromDir: string): UpstreamFile { + const [scope, name, ...subpath] = moduleSpecifier.split('/'); + const packageDir = resolvePackageDir(`${scope}/${name}`, fromDir); + const filePath = path.join(packageDir, ...subpath, 'interfaces.d.ts'); + return { packageDir, filePath }; +} + +export function resolveRelativeImport(specifier: string, fromDir: string): null | string { + const target = path.resolve(fromDir, specifier).replace(/\.js$/, ''); + const candidate = `${target}.d.ts`; + return fs.existsSync(candidate) ? candidate : null; +} + +function resolvePackageDir(packageName: string, fromDir: string): string { + let dir = path.resolve(fromDir); + while (true) { + const candidate = path.join(dir, 'node_modules', packageName); + if (fs.existsSync(candidate)) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`Cannot resolve package "${packageName}" from ${fromDir}.`); + } + dir = parent; + } +} diff --git a/src/api-gen/to-proxy-source.ts b/src/api-gen/to-proxy-source.ts new file mode 100644 index 0000000..2f44c88 --- /dev/null +++ b/src/api-gen/to-proxy-source.ts @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as ts from 'ts-morph'; + +const LICENSE_HEADER = /^\/\/ Copyright Amazon\.com[^\n]*\r?\n\/\/ SPDX-License-Identifier:[^\n]*\r?\n+/; + +export function toProxySource(project: ts.Project, emittedPath: string, sourceStr: string): ts.SourceFile { + const source = project.createSourceFile(`out/${emittedPath}`, stripLicenseHeader(sourceStr)); + exportAmbientNamespaces(source); + stripAwsuiSystemTags(source); + return source; +} + +function stripLicenseHeader(declarations: string): string { + return declarations.replace(LICENSE_HEADER, ''); +} + +/** In references *.d.ts files the namespace members are implicitly exported; real ones need explicit exports. */ +function exportAmbientNamespaces(source: ts.SourceFile): void { + for (const namespace of source.getModules()) { + if (namespace.getDeclarationKind() === ts.ModuleDeclarationKind.Namespace) { + addExports(namespace); + } + } + + function addExports(namespace: ts.ModuleDeclaration): void { + namespace.setHasDeclareKeyword(false); + + // Remove `export {}` from the reference declarations. + for (const exportDeclaration of namespace.getExportDeclarations()) { + if (exportDeclaration.getNamedExports().length === 0) { + exportDeclaration.remove(); + } + } + + // Handle interfaces and type aliases. + for (const member of [...namespace.getTypeAliases(), ...namespace.getInterfaces()]) { + member.setIsExported(true); + } + + // Handle nested namespaces. + for (const nested of namespace.getModules()) { + nested.setIsExported(true); + addExports(nested); + } + } +} + +/** Removes `@awsuiSystem` annotations, whatever system they name, keeping any api-docs they sit beside. */ +function stripAwsuiSystemTags(source: ts.SourceFile): void { + for (const jsDoc of source.getDescendantsOfKind(ts.SyntaxKind.JSDoc)) { + for (const tag of jsDoc.getTags()) { + if (tag.getTagName() === 'awsuiSystem') { + tag.remove(); + } + } + if (jsDoc.getInnerText().trim() === '') { + jsDoc.remove(); + } + } +} diff --git a/test/api-gen/__snapshots__/generate-proxy-interfaces.test.ts.snap b/test/api-gen/__snapshots__/generate-proxy-interfaces.test.ts.snap new file mode 100644 index 0000000..7c198d8 --- /dev/null +++ b/test/api-gen/__snapshots__/generate-proxy-interfaces.test.ts.snap @@ -0,0 +1,200 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`additions and overrides > adds new properties and namespace members 1`] = ` +"import React from 'react'; +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events.js'; +import { IconProps } from '../icon/interfaces.js' + +export interface ButtonProps extends BaseComponentProps { + /** + * Button text + */ + children?: React.ReactNode; + /** + * Click handler + */ + onClick?: NonCancelableEventHandler; + /** + * Icon name + */ + iconName?: ButtonProps.IconName; + /** + * Annotation API-docs + */ + annotation?: ButtonProps.Annotation; +} + +export namespace ButtonProps { + export type IconName = IconProps.Name; + export type Annotation = string; +} +" +`; + +exports[`additions and overrides > overrides properties and namespace members of multi-interface upstream component 1`] = ` +"import { BaseComponentProps } from '../types/base-component'; + +export interface BaseCheckboxProps { + checked: boolean; + /** + * ARIA label of the checkbox control + */ + ariaLabel: string; +} + +export interface CheckboxProps extends BaseComponentProps { + indeterminate?: boolean; + onChange: (detail: CheckboxProps.EventDetail) => void; + /** + * Visible checkbox label + */ + children?: React.ReactNode; +} + +export namespace CheckboxProps { + export interface EventDetail { + checked: boolean; + indeterminate: boolean; + } +} +" +`; + +exports[`additions and overrides > overrides the interface and drops the namespace that uses the same name 1`] = ` +"export interface BoxProps { + /** + * Box content + */ + children?: string; + /** + * Box variant + */ + variant?: BoxProps.Variant; + /** + * Box visual accent + */ + visualAccent?: BoxProps.VisualAccent; +} + +export namespace BoxProps { + export type Variant = 'div' | 'span'; + export interface VisualAccent { + color: BoxProps.VisualAccent.Color; + } +} +" +`; + +exports[`carried imports > carries the imports the patch declares, leaving out the marker module 1`] = ` +"import React from 'react'; +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events.js'; +import { IconProps } from '../icon/interfaces.js' +import type { CheckboxProps } from '@fixtures/upstream/checkbox'; +import * as BoxTypes from '@fixtures/upstream/box'; +import '@fixtures/upstream/button'; + +export interface ButtonProps extends BaseComponentProps { + /** + * Button text + */ + children?: React.ReactNode; + /** + * Icon name + */ + iconName?: ButtonProps.IconName; + /** + * Wrapper applied around the button content. + */ + wrapper?: BoxTypes.BoxProps; + toggle?: CheckboxProps; +} + +export namespace ButtonProps { + export type IconName = IconProps.Name; +} +" +`; + +exports[`removals > recognizes the marker imported under an alias and through a namespace import 1`] = ` +"import React from 'react'; +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events.js'; +import { IconProps } from '../icon/interfaces.js' + +export interface ButtonProps extends BaseComponentProps { + /** + * Button text + */ + children?: React.ReactNode; +} + +export namespace ButtonProps { + export type IconName = IconProps.Name; +} +" +`; + +exports[`removals > removes an interface property 1`] = ` +"import React from 'react'; +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events.js'; +import { IconProps } from '../icon/interfaces.js' + +export interface ButtonProps extends BaseComponentProps { + /** + * Button text + */ + children?: React.ReactNode; + /** + * Click handler + */ + onClick?: NonCancelableEventHandler; +} + +export namespace ButtonProps { + export type IconName = IconProps.Name; +} +" +`; + +exports[`removals > removes an interface property and a namespace member 1`] = ` +"import React from 'react'; +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events.js'; +import { IconProps } from '../icon/interfaces.js' + +export interface ButtonProps extends BaseComponentProps { + /** + * Button text + */ + children?: React.ReactNode; + /** + * Click handler + */ + onClick?: NonCancelableEventHandler; +} + +export namespace ButtonProps { +} +" +`; + +exports[`removals > removes both the interface and the namespace that use the same name 1`] = ` +"export interface BoxProps { + /** + * Box content + */ + children?: string; + /** + * Box variant + */ + variant?: BoxProps.Variant; +} + +export namespace BoxProps { + export type Variant = 'div' | 'span'; +} +" +`; diff --git a/test/api-gen/generate-proxy-interfaces.test.ts b/test/api-gen/generate-proxy-interfaces.test.ts new file mode 100644 index 0000000..e581231 --- /dev/null +++ b/test/api-gen/generate-proxy-interfaces.test.ts @@ -0,0 +1,224 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test, vi } from 'vitest'; + +import { generateFixture, generateFixtures, sourceOf } from './test-helpers'; + +describe('upstream resolution', () => { + test('resolves upstream component with no external imports', () => { + const files = generateFixture('valid/box', 'empty'); + expect(files.map(file => file.path)).toEqual(['box/interfaces.ts']); + }); + + test('resolves upstream component that imports from another component and from shared types', () => { + const files = generateFixture('valid/button', 'empty'); + expect(files.map(file => file.path)).toEqual([ + 'button/interfaces.ts', + 'icon/interfaces.ts', + 'types/base-component.ts', + 'types/events.ts', + ]); + }); + + test('throws when patch contains no module augmentation statement', () => { + expect(() => generateFixture('error/demo', 'no-augmentation')).toThrow( + 'error/demo/no-augmentation.patch.d.ts: The patch must contain ' + 'a `declare module "..."` augmentation.', + ); + }); + + test('throws when declared module is not found', () => { + expect(() => generateFixture('error/demo', 'no-upstream')).toThrow('Cannot resolve package "@fixtures/unresolved"'); + }); + + test('throws when declared module has no interfaces.d.ts', () => { + expect(() => generateFixture('error/demo', 'no-upstream-dts')).toThrow( + 'fixtures/api-gen/node_modules/@fixtures/upstream/internal/interfaces.d.ts', + ); + }); +}); + +describe('patch validation', () => { + test('throws when the patch names an interface the upstream module does not export', () => { + expect(() => generateFixture('error/button', 'no-upstream-interface')).toThrow( + 'Interface "ButtonPropz" does not exist in "@fixtures/upstream/button".', + ); + }); + + test('throws when the patch names a namespace the upstream module does not declare', () => { + expect(() => generateFixture('error/button', 'no-upstream-namespace')).toThrow( + 'Namespace "ButtonPropz" does not exist in "@fixtures/upstream/button".', + ); + }); + + test('throws when a removal names an interface property the upstream module does not declare', () => { + expect(() => generateFixture('error/button', 'remove-missing-property')).toThrow( + 'Property "ButtonProps.altText" cannot be removed (it does not exist).', + ); + }); + + test('throws when a removal names a namespace member the upstream module does not declare', () => { + expect(() => generateFixture('error/button', 'remove-missing-member')).toThrow( + 'Member "ButtonProps.AltText" cannot be removed (it does not exist).', + ); + }); + + test('throws when a patch declares an enum in a namespace', () => { + expect(() => generateFixture('error/button', 'add-ns-enum')).toThrow( + 'A namespace patch may declare only type aliases and interfaces; "ButtonProps.Severity" is neither.', + ); + }); + + test('throws when a patch declares a value in a namespace', () => { + expect(() => generateFixture('error/button', 'add-ns-value')).toThrow( + 'A namespace patch may declare only type aliases and interfaces; "ButtonProps.VariableStatement" is neither.', + ); + }); + + test('throws when a patch declares a nested namespace', () => { + expect(() => generateFixture('error/button', 'add-ns-ns')).toThrow( + 'A namespace patch may declare only type aliases and interfaces; "ButtonProps.IconProps" is neither.', + ); + }); +}); + +describe('removals', () => { + test('removes an interface property', () => { + const source = sourceOf(generateFixture('valid/button', 'remove-property'), 'button/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); + + test('removes an interface property and a namespace member', () => { + const source = sourceOf(generateFixture('valid/button', 'remove-member'), 'button/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); + + test('removes both the interface and the namespace that use the same name', () => { + const source = sourceOf(generateFixture('valid/box', 'remove-nested-ns'), 'box/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); + + test('recognizes the marker imported under an alias and through a namespace import', () => { + const source = sourceOf(generateFixture('valid/button', 'marker-imports'), 'button/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); +}); + +describe('additions and overrides', () => { + test('adds new properties and namespace members', () => { + const source = sourceOf(generateFixture('valid/button', 'add-props'), 'button/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); + + test('overrides properties and namespace members of multi-interface upstream component', () => { + const source = sourceOf(generateFixture('valid/checkbox', 'override-props'), 'checkbox/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); + + test('overrides the interface and drops the namespace that uses the same name', () => { + const source = sourceOf(generateFixture('valid/box', 'override-nested-ns'), 'box/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); +}); + +describe('carried imports', () => { + test('carries the imports the patch declares, leaving out the marker module', () => { + const source = sourceOf(generateFixture('valid/button', 'carry-imports'), 'button/interfaces.ts'); + expect(source).toMatchSnapshot(); + }); + + test('does not offer carried imports to resolveImport, nor follow them as dependencies', () => { + const resolveImport = vi.fn(); + const files = generateFixture('valid/box', 'carry-imports', { resolveImport }); + expect(files).toHaveLength(1); + expect(resolveImport).not.toHaveBeenCalled(); + expect(sourceOf(files, 'box/interfaces.ts')).toContain("from '@fixtures/upstream/icon'"); + }); +}); + +describe('resolved imports', () => { + test('offers every relative import that resolves, with the file holding it and the file it reaches', () => { + const offered: Array<[string, string, string]> = []; + generateFixture('valid/button', 'empty', { + resolveImport: ({ sourcePath, importSpecifier, resolvedPath }) => { + offered.push([sourcePath, importSpecifier, resolvedPath.replace(/.*node_modules\//, '')]); + return {}; + }, + }); + expect(offered).toEqual([ + ['button/interfaces.ts', '../types/base-component', '@fixtures/upstream/types/base-component.d.ts'], + ['button/interfaces.ts', '../types/events.js', '@fixtures/upstream/types/events.d.ts'], + ['button/interfaces.ts', '../icon/interfaces.js', '@fixtures/upstream/icon/interfaces.d.ts'], + ['icon/interfaces.ts', '../types/base-component', '@fixtures/upstream/types/base-component.d.ts'], + ]); + }); + + test('writes the specifier resolveImport returns into the emitted source', () => { + const source = sourceOf( + generateFixture('valid/button', 'empty', { + resolveImport: ({ importSpecifier }) => + importSpecifier === '../icon/interfaces.js' ? { importSpecifier: '@fixtures/upstream/icon' } : {}, + }), + 'button/interfaces.ts', + ); + expect(source).toContain("from '@fixtures/upstream/icon'"); + expect(source).not.toContain("from '../icon/interfaces.js'"); + }); + + test('keeps a specifier unchanged when resolveImport returns no override', () => { + const source = sourceOf( + generateFixture('valid/button', 'empty', { resolveImport: () => ({}) }), + 'button/interfaces.ts', + ); + expect(source).toContain("from '../icon/interfaces.js'"); + }); + + test('emits the same tree whether or not specifiers are rewritten', () => { + const rewritten = generateFixture('valid/button', 'empty', { + resolveImport: ({ importSpecifier }) => ({ importSpecifier: importSpecifier.replace('types', 'shared') }), + }); + expect(rewritten.map(file => file.path)).toEqual(generateFixture('valid/button', 'empty').map(file => file.path)); + }); +}); + +describe('emitted tree', () => { + test('emits a dependency once when several patches reach it', () => { + const files = generateFixtures([ + ['valid/button', 'empty'], + ['valid/checkbox', 'override-props'], + ]); + + expect(files.map(file => file.path)).toEqual([ + 'button/interfaces.ts', + 'checkbox/interfaces.ts', + 'icon/interfaces.ts', + 'types/base-component.ts', + 'types/events.ts', + ]); + }); + + test('proxies one upstream component at every place the consumer gives it', () => { + const files = generateFixtures([['valid/button', 'remove-property'], ['valid/button-alpha']]); + + expect(files.map(file => file.path)).toContain('button/interfaces.ts'); + expect(sourceOf(files, 'button/interfaces.ts')).toContain('children?: React.ReactNode;'); + + expect(files.map(file => file.path)).toContain('button-alpha/interfaces.ts'); + expect(sourceOf(files, 'button-alpha/interfaces.ts')).not.toContain('children'); + }); + + test('throws when two patches claim one place in the proxy tree', () => { + expect(() => + generateFixtures([ + ['valid/button', 'empty'], + ['valid/button', 'remove-property'], + ]), + ).toThrow('Two declarations claim the place "button/interfaces.ts" in the proxy tree.'); + }); + + test('throws when a patch is named after a component the proxied one reaches', () => { + expect(() => generateFixture('error/icon', 'place-taken-by-upstream')).toThrow( + 'Two declarations claim the place "icon/interfaces.ts" in the proxy tree.', + ); + }); +}); diff --git a/test/api-gen/test-helpers.ts b/test/api-gen/test-helpers.ts new file mode 100644 index 0000000..e997960 --- /dev/null +++ b/test/api-gen/test-helpers.ts @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import pathe from 'pathe'; +import { + generateProxyInterfaces, + GenerateProxyInterfacesOptions, + ProxyFile, +} from '../../src/api-gen/generate-proxy-interfaces'; + +export const FIXTURES_DIR = pathe.resolve('fixtures/api-gen'); + +export function getPatchPath(casePath: string, variant = 'interfaces') { + const caseDir = pathe.join(FIXTURES_DIR, casePath); + return pathe.join(caseDir, `${variant}.patch.d.ts`); +} + +export function generateFixture( + path: string, + variant = 'interfaces', + options?: Omit, +) { + return generateProxyInterfaces({ entryPoints: [getPatchPath(path, variant)], ...options }).files; +} + +export function generateFixtures( + entryPoints: Array<[path: string, variant?: string]>, + options?: Omit, +) { + return generateProxyInterfaces({ + entryPoints: entryPoints.map(([path, variant]) => getPatchPath(path, variant)), + ...options, + }).files; +} + +export function sourceOf(files: ProxyFile[], path: string) { + const file = files.find(candidate => candidate.path === path)!; + return file.source; +} diff --git a/test/api-gen/to-proxy-source.test.ts b/test/api-gen/to-proxy-source.test.ts new file mode 100644 index 0000000..4fd5106 --- /dev/null +++ b/test/api-gen/to-proxy-source.test.ts @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest'; + +import * as ts from 'ts-morph'; + +import { toProxySource } from '../../src/api-gen/to-proxy-source'; +import { describe } from 'node:test'; + +function transform(declarations: string) { + const project = new ts.Project({ + useInMemoryFileSystem: true, + manipulationSettings: { indentationText: ts.IndentationText.TwoSpaces }, + }); + return toProxySource(project, 'demo/interfaces.ts', declarations).getFullText(); +} + +describe('strips license header', () => { + const HEADER = + '// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n// SPDX-License-Identifier: Apache-2.0\n'; + const SOURCE = 'export type A = string;'; + + test('strips the license header and trailing \n', () => { + expect(transform(`${HEADER}${SOURCE}`)).toBe(SOURCE); + expect(transform(`${HEADER}\n${SOURCE}`)).toBe(`${SOURCE}`); + expect(transform(`${HEADER}\n\n${SOURCE}`)).toBe(`${SOURCE}`); + expect(transform(`${HEADER} ${SOURCE}`)).toBe(` ${SOURCE}`); + }); + + test('leaves a header that is not at the start of the source untouched', () => { + expect(transform(`\n${HEADER}${SOURCE}`)).toBe(`\n${HEADER}${SOURCE}`); + }); + + test('strips a license header with CRLF line endings', () => { + expect(transform(`${HEADER.replace(/\n/g, '\r\n')}${SOURCE}`)).toBe(SOURCE); + }); +}); + +describe('strips system tags', () => { + const inline = (input: string) => input.replace(/[ \t\n]+/g, ' '); + + test('leaves a comment without annotations untouched', () => { + [ + 'export type X = Y;', + `/** + * API docs + */ + export type X = Y;`, + `/** + * API docs + * @deprecated + */ + export type X = Y; `, + ].forEach(input => { + expect(transform(input)).toBe(input); + }); + }); + + test('drops a comment that holds nothing but the annotation', () => { + [ + `/** @awsuiSystem core */ + export type X = Y; `, + `/** + * @awsuiSystem experimental + */ + export type X = Y;`, + `/** + * @awsuiSystem core + * @awsuiSystem experimental + */ + export type X = Y;`, + ].forEach(input => { + expect(transform(input).trim()).toBe('export type X = Y;'); + }); + }); + + test('drops the annotation but keeps the api-docs beside it', () => { + [ + [ + `/** API docs @awsuiSystem core */ + export type X = Y;`, + `/** API docs */ export type X = Y;`, + ], + [ + `/** + * API docs + * @awsuiSystem core + */ + export type X = Y;`, + `/** * API docs */ export type X = Y;`, + ], + [ + `/** + * API docs + * @awsuiSystem core + * @deprecated + */ + export type X = Y;`, + `/** * API docs * @deprecated */ export type X = Y;`, + ], + ].forEach(([input, expectation]) => expect(inline(transform(input))).toBe(expectation)); + }); + + test('drops annotation from an interface member', () => { + [ + [ + `interface I { + /** @awsuiSystem core */ + x: Y; + }`, + `interface I { x: Y; }`, + ], + [ + `interface I { + /** + * API docs + * @awsuiSystem core + */ + x: Y; + }`, + `interface I { /** * API docs */ x: Y; }`, + ], + ].forEach(([input, expectation]) => expect(inline(transform(input))).toBe(expectation)); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 03aa48f..38ca0b1 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,8 +1,3 @@ { - "extends": "../tsconfig.json", - "compilerOptions": { - "types": ["node", "jest"], - "noEmit": true - }, - "include": ["./**/*.ts"] + "extends": "../tsconfig.test.json" } diff --git a/tsconfig.test.json b/tsconfig.test.json index a9087e8..a73ac17 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -3,7 +3,8 @@ "compilerOptions": { // issues with old Typescript version https://github.com/vitest-dev/vitest/issues/4567 "skipLibCheck": true, - "noEmit": true + "noEmit": true, + "rootDir": "." }, "include": ["test"] } From ba7ecb5f53333ddf51222fa5ed5f90cd9abfb1ea Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Tue, 1 Sep 2026 13:57:22 +0200 Subject: [PATCH 2/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/api-gen/to-proxy-source.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/api-gen/to-proxy-source.test.ts b/test/api-gen/to-proxy-source.test.ts index 4fd5106..d57db28 100644 --- a/test/api-gen/to-proxy-source.test.ts +++ b/test/api-gen/to-proxy-source.test.ts @@ -1,12 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { expect, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; import * as ts from 'ts-morph'; import { toProxySource } from '../../src/api-gen/to-proxy-source'; -import { describe } from 'node:test'; function transform(declarations: string) { const project = new ts.Project({ From 92477f44fefe731dcbb6cf334c5a8392c02076a1 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Wed, 2 Sep 2026 14:24:27 +0200 Subject: [PATCH 3/5] queues imports by upstream path + emitted path --- .../api-gen/valid/custom-icon/interfaces.patch.d.ts | 8 ++++++++ src/api-gen/generate-proxy-interfaces.ts | 7 ++++--- test/api-gen/generate-proxy-interfaces.test.ts | 13 +++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 fixtures/api-gen/valid/custom-icon/interfaces.patch.d.ts diff --git a/fixtures/api-gen/valid/custom-icon/interfaces.patch.d.ts b/fixtures/api-gen/valid/custom-icon/interfaces.patch.d.ts new file mode 100644 index 0000000..a2630c1 --- /dev/null +++ b/fixtures/api-gen/valid/custom-icon/interfaces.patch.d.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '@fixtures/upstream/icon' { + export interface IconProps { + tone?: string; + } +} diff --git a/src/api-gen/generate-proxy-interfaces.ts b/src/api-gen/generate-proxy-interfaces.ts index 07694e3..59e867d 100644 --- a/src/api-gen/generate-proxy-interfaces.ts +++ b/src/api-gen/generate-proxy-interfaces.ts @@ -68,7 +68,8 @@ export function generateProxyInterfaces({ const pending = entryPoints.map(entryPath => readPatch(project, entryPath)); const emitted = new Map(); - const queued = new Set(pending.map(file => file.upstreamPath)); + const queueKey = (file: PendingFile) => `${file.upstreamPath}\n${file.emittedPath}`; + const queued = new Set(pending.map(queueKey)); while (pending.length > 0) { const file = pending.shift()!; @@ -81,8 +82,8 @@ export function generateProxyInterfaces({ // Follow file's relative imports to pull its dependencies, such as other components or shared types. for (const reached of followImports(out, file, resolveImport)) { - if (!queued.has(reached.upstreamPath)) { - queued.add(reached.upstreamPath); + if (!queued.has(queueKey(reached))) { + queued.add(queueKey(reached)); pending.push(reached); } } diff --git a/test/api-gen/generate-proxy-interfaces.test.ts b/test/api-gen/generate-proxy-interfaces.test.ts index e581231..dd8ceae 100644 --- a/test/api-gen/generate-proxy-interfaces.test.ts +++ b/test/api-gen/generate-proxy-interfaces.test.ts @@ -207,6 +207,19 @@ describe('emitted tree', () => { expect(sourceOf(files, 'button-alpha/interfaces.ts')).not.toContain('children'); }); + test('emits a dependency at its own place when another patch proxies it elsewhere', () => { + const files = generateFixtures([['valid/custom-icon'], ['valid/button', 'empty']]); + + expect(files.map(file => file.path)).toEqual([ + 'button/interfaces.ts', + 'custom-icon/interfaces.ts', // custom override + 'icon/interfaces.ts', // referenced from button + 'types/base-component.ts', + 'types/events.ts', + ]); + expect(sourceOf(files, 'button/interfaces.ts')).toContain("from '../icon/interfaces.js'"); + }); + test('throws when two patches claim one place in the proxy tree', () => { expect(() => generateFixtures([ From ec355d632fdf4ee317806d6cd615d8130dfaf149 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Thu, 3 Sep 2026 05:29:18 +0200 Subject: [PATCH 4/5] remove removals-first logic --- src/api-gen/apply-patch.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/api-gen/apply-patch.ts b/src/api-gen/apply-patch.ts index af5077b..cf63cf0 100644 --- a/src/api-gen/apply-patch.ts +++ b/src/api-gen/apply-patch.ts @@ -14,7 +14,7 @@ export function applyPatch(out: ts.SourceFile, patch: Patch): void { function patchInterfaces(out: ts.SourceFile, patch: Patch) { for (const interfacePatch of patch.interfaces) { const outInterface = findInterface(interfacePatch.interfaceName); - for (const override of removalsFirst(interfacePatch.propertyOverrides)) { + for (const override of interfacePatch.propertyOverrides) { if (override.declaration === undefined) { removeProperty(outInterface, override.propertyName); } else { @@ -50,7 +50,7 @@ function patchInterfaces(out: ts.SourceFile, patch: Patch) { function patchNamespaces(out: ts.SourceFile, patch: Patch) { for (const namespacePatch of patch.namespaces) { const outNamespace = findNamespace(namespacePatch.namespaceName); - for (const override of removalsFirst(namespacePatch.memberOverrides)) { + for (const override of namespacePatch.memberOverrides) { if (override.declaration === undefined) { removeMember(outNamespace, override.memberName); } else { @@ -95,10 +95,6 @@ function carryOverImports(out: ts.SourceFile, patch: Patch) { out.addImportDeclarations(patch.imports); } -function removalsFirst(overrides: T[]): T[] { - return [...overrides].sort((a, b) => Number(a.declaration !== undefined) - Number(b.declaration !== undefined)); -} - /** Normalizes indentation of the js-docs when they are carried over from the patch file. */ function dedentJsDoc(jsDoc: string): string { return jsDoc.replace(/\n[ \t]*\*/g, '\n *'); From aaf6b3c19f676350af827ca6489d73cd60147198 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Thu, 3 Sep 2026 05:39:26 +0200 Subject: [PATCH 5/5] explain paths with code comments --- src/api-gen/generate-proxy-interfaces.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/api-gen/generate-proxy-interfaces.ts b/src/api-gen/generate-proxy-interfaces.ts index 59e867d..f1bf25b 100644 --- a/src/api-gen/generate-proxy-interfaces.ts +++ b/src/api-gen/generate-proxy-interfaces.ts @@ -13,14 +13,14 @@ import { toProxySource } from './to-proxy-source'; export interface UpstreamImport { /** Path of the emitted file holding the import, e.g. `select/interfaces.ts`. */ sourcePath: string; - /** Absolute path of the upstream declaration file the import resolves to. */ + /** Absolute path of the upstream declaration file the import resolves to, e.g. `/repo/node_modules/@cloudscape-design/components/types/base-component.d.ts`. */ resolvedPath: string; - /** Specifier as the upstream declaration file wrote it. */ + /** Specifier as the upstream declaration file wrote it, e.g. `../types/base-component`. */ importSpecifier: string; } export interface GenerateProxyInterfacesOptions { - /** Paths to patch files of the components to proxy. */ + /** Paths to patch files of the components to proxy, e.g. `['src/proxy/select/interfaces.patch.d.ts']`. */ entryPoints: string[]; /** Override the specifier an emitted import is written with. Return `{}` to keep it unchanged. */ resolveImport?: ResolveImport; @@ -34,18 +34,18 @@ export interface GenerateProxyInterfacesResult { } export interface ProxyFile { - /** Place in the proxy tree: the directory the consumer gave a proxied component, upstream's own otherwise. */ + /** Place in the proxy tree: the directory the consumer gave a proxied component, upstream's own otherwise, e.g. `select/interfaces.ts`. */ path: string; /** Generated source with license header and awsui-system tags removed. */ source: string; } interface PendingFile { - /** Upstream declaration file to read. */ + /** Upstream declaration file to read, e.g. `/repo/node_modules/@cloudscape-design/components/select/interfaces.d.ts`. */ upstreamPath: string; - /** Where the file belongs in the proxy tree. */ + /** Where the file belongs in the proxy tree, e.g. `select/interfaces.ts`. */ emittedPath: string; - /** Package the file belongs to, from which the paths of the files it reaches are derived. */ + /** Package the file belongs to, from which the paths of the files it reaches are derived, e.g. `/repo/node_modules/@cloudscape-design/components`. */ packageDir: string; /** Set when an entry point named this file: the patch to apply to it. */ patch?: Patch;