Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/javascript-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ jobs:
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @cratis/components run ci

# Source's ci script also checks public API TSDoc and the packed CSS budgets.
# Source's ci script also checks public API TSDoc, the packed CSS budgets, and that the
# built declarations match the committed api-surface.json snapshot.

# Informational: which exports this change removes, changes, or adds compared with the
# latest published release. Release intent stays with the semver label and the reviewer.
- name: Report public API changes against the latest release
run: yarn workspace @cratis/components run report-api-compatibility

# Matrix over the renderer-adapter workspaces that today run one after another inside
# `run-task-on-workspaces.js ci`: Conformance, MUI, PrimeReact 11, PrimeReact 10. In that
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ This is a framework-library repository. [Component source](https://github.com/Cr
keeps public types, stories, and specifications near each component; export and
package verification lives under `Source/scripts/`.

The public API of every typed subpath is kept in a reviewable snapshot, `Source/api-surface.json`.
`yarn ci` fails when the built declarations differ from it; after an intentional API change, run
`cd Source && yarn generate-api-surface` and commit the snapshot, so the pull request shows exactly
which exports it adds, removes, or changes. `yarn report-api-compatibility [version]` compares the
built API with a published release (default: the latest); CI adds that report to the job summary.
It lists removals and changes as breaking-change candidates, but release intent is still decided
by a person, and DOM, parts, and behavior need their own review. Only exported declarations are
compared: a change to a type that is reachable only through an unexported name does not show up.
A re-export of another package is recorded as `re-export of <package>#<name>`, so a change inside
that package does not show up either.

Before pushing Source changes, run `cd Source && yarn ci` after `yarn install` at the
repository root. It includes public API TSDoc coverage and packs the built package to check
aggregate and per-area CSS budgets (`yarn verify-packed-css` runs that check alone after a
Expand Down
652 changes: 652 additions & 0 deletions Source/api-surface.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion Source/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@
"compile": "yarn generate-parts && node ../scripts/run-typescript.mjs -p tsconfig.json -noEmit && yarn g:lint",
"copy-css": "./scripts/copy-css.sh",
"verify-exports": "node ./scripts/verify-exports.mjs",
"verify-api-surface": "node ./scripts/verify-api-surface.mjs",
"generate-api-surface": "node ./scripts/verify-api-surface.mjs --update",
"report-api-compatibility": "node ./scripts/report-api-compatibility.mjs",
"verify-source-maps": "node ./scripts/verify-source-maps.mjs",
"verify-package-archive": "node ./scripts/verify-package-archive.mjs",
"verify-packed-css": "node ./scripts/verify-packed-css.mjs",
Expand All @@ -278,7 +281,7 @@
"lint": "yarn g:lint",
"lint:ci": "yarn g:lint:ci",
"test": "yarn g:test",
"ci": "yarn generate-parts && yarn verify-parts-manifest && yarn verify-api-docs && yarn g:ci && yarn typecheck-stories && yarn g:build && yarn verify-renderer-contracts && yarn verify-package-graph-report && yarn verify-packed-css && yarn verify-source-maps",
"ci": "yarn generate-parts && yarn verify-parts-manifest && yarn verify-api-docs && yarn g:ci && yarn typecheck-stories && yarn g:build && yarn verify-renderer-contracts && yarn verify-package-graph-report && yarn verify-packed-css && yarn verify-source-maps && yarn verify-api-surface",
"up": "yarn g:up",
"dev": "yarn workspace @cratis/components.storybook dev",
"build-storybook": "yarn workspace @cratis/components.storybook build"
Expand Down
148 changes: 148 additions & 0 deletions Source/scripts/for_api_surface/when_computing_the_api_surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { compareApiSurfaces, computeApiSurface } from '../lib/api-surface.mjs';

let packageDir: string;

const writePackage = (declarations: string, directory = packageDir, exports: Record<string, unknown> = {}) => {
mkdirSync(path.join(directory, 'dist'), { recursive: true });
writeFileSync(path.join(directory, 'package.json'), JSON.stringify({
name: 'example', exports: { './Example': { types: './dist/Example.d.ts', import: './dist/Example.js' }, ...exports },
}));
writeFileSync(path.join(directory, 'dist', 'Example.d.ts'), declarations);
};

const installExternalPackage = (directory: string) => {
const external = path.join(directory, 'node_modules', '@example', 'external');
mkdirSync(external, { recursive: true });
writeFileSync(path.join(external, 'package.json'), JSON.stringify({ name: '@example/external', types: './index.d.ts' }));
writeFileSync(path.join(external, 'index.d.ts'), 'export interface Shared { id: string; }\n');
};

describe('when computing the API surface', () => {
beforeEach(() => {
packageDir = mkdtempSync(path.join(os.tmpdir(), 'api-surface-spec-'));
});
afterEach(() => rmSync(packageDir, { recursive: true, force: true }));

it('should describe a destructured component parameter by its type alone', () => {
writePackage(
'/** A documented example. */\n' +
'export interface ExampleProps { label: string; }\n' +
'export declare const Example: ({ label, }: ExampleProps) => void;\n',
);
const surface = computeApiSurface(packageDir);
expect(surface['./Example'].Example).toBe('Example: (props: ExampleProps) => void');
expect(surface['./Example'].ExampleProps).toBe('export interface ExampleProps { label: string; }');
});

it('should report a new prop only as a change to the props type', () => {
writePackage('export interface ExampleProps { label: string; }\nexport declare const Example: ({ label, }: ExampleProps) => void;\n');
const before = computeApiSurface(packageDir);
writePackage('export interface ExampleProps { label: string; icon?: string; }\nexport declare const Example: ({ label, icon, }: ExampleProps) => void;\n');
const after = computeApiSurface(packageDir);
expect(compareApiSurfaces(before, after)).toEqual({ removed: [], changed: ['./Example#ExampleProps'], added: [] });
});

it('should record a re-export of another package by package and name, whether or not it resolves', () => {
const declarations = "export { Shared } from '@example/external';\n";
writePackage(declarations);
installExternalPackage(packageDir);
const resolved = computeApiSurface(packageDir);
const unresolvedDir = mkdtempSync(path.join(os.tmpdir(), 'api-surface-spec-unresolved-'));
let unresolved: ReturnType<typeof computeApiSurface>;
try {
writePackage(declarations, unresolvedDir);
unresolved = computeApiSurface(unresolvedDir);
} finally {
rmSync(unresolvedDir, { recursive: true, force: true });
}
expect(resolved['./Example'].Shared).toBe('re-export of @example/external#Shared');
expect(unresolved['./Example'].Shared).toBe(resolved['./Example'].Shared);
});

it('should record a star re-export of another package by package and name', () => {
writePackage("export * from '@example/external';\n");
installExternalPackage(packageDir);
expect(computeApiSurface(packageDir)['./Example'].Shared).toBe('re-export of @example/external#Shared');
});

it('should report exporting a class only as a type as a change', () => {
mkdirSync(path.join(packageDir, 'dist'), { recursive: true });
writeFileSync(path.join(packageDir, 'dist', 'Widget.d.ts'), 'export declare class Widget { }\n');
writePackage("export { Widget } from './Widget';\n");
const before = computeApiSurface(packageDir);
writePackage("export type { Widget } from './Widget';\n");
const after = computeApiSurface(packageDir);
expect(compareApiSurfaces(before, after)).toEqual({ removed: [], changed: ['./Example#Widget'], added: [] });
});

it('should include a typed JSON subpath and skip plain stylesheets', () => {
mkdirSync(path.join(packageDir, 'dist'), { recursive: true });
writeFileSync(path.join(packageDir, 'dist', 'schema.d.ts'), 'declare const schema: unknown;\nexport default schema;\n');
writePackage('export declare const value: string;\n', packageDir, {
'./schema.json': { types: './dist/schema.d.ts', import: './dist/schema.json' },
'./styles': './dist/styles.css',
});
expect(Object.keys(computeApiSurface(packageDir))).toEqual(['./Example', './schema.json']);
});

it('should fail on a plain export target that is not a stylesheet or JSON', () => {
writePackage('export declare const value: string;\n', packageDir, { './Plain': './dist/Plain.js' });
expect(() => computeApiSurface(packageDir)).toThrow(/does not understand/u);
});

it('should report switching a star export to a type-only star export as a change', () => {
mkdirSync(path.join(packageDir, 'dist'), { recursive: true });
writeFileSync(path.join(packageDir, 'dist', 'Widget.d.ts'), 'export declare class Widget { }\n');
writeFileSync(path.join(packageDir, 'dist', 'inner.d.ts'), "export * from './Widget';\n");
writePackage("export * from './inner';\n");
const before = computeApiSurface(packageDir);
writeFileSync(path.join(packageDir, 'dist', 'inner.d.ts'), "export type * from './Widget';\n");
const after = computeApiSurface(packageDir);
expect(compareApiSurfaces(before, after)).toEqual({ removed: [], changed: ['./Example#Widget'], added: [] });
});

it('should record a package re-export the same way whatever the package does internally', () => {
writePackage("export { Shared } from '@example/external';\n");
installExternalPackage(packageDir);
const external = path.join(packageDir, 'node_modules', '@example', 'external');
writeFileSync(path.join(external, 'shared.d.ts'), 'export declare class Shared { }\n');
writeFileSync(path.join(external, 'index.d.ts'), "export type { Shared } from './shared';\n");
expect(computeApiSurface(packageDir)['./Example'].Shared).toBe('re-export of @example/external#Shared');
});

it('should describe a namespace re-export of an internal module member by member', () => {
mkdirSync(path.join(packageDir, 'dist'), { recursive: true });
writeFileSync(path.join(packageDir, 'dist', 'inner.d.ts'), 'declare const hidden: number;\nexport declare const second: typeof hidden;\nexport declare class First { }\nexport {};\n');
writePackage("export * as Inner from './inner';\n");
const before = computeApiSurface(packageDir);
expect(before['./Example'].Inner).toBe('namespace { First: export declare class First { }; second: second: typeof hidden; }');
writeFileSync(path.join(packageDir, 'dist', 'inner.d.ts'), 'declare const hidden: string;\nexport declare const second: number;\nexport declare class First { }\nexport {};\n');
expect(compareApiSurfaces(before, computeApiSurface(packageDir)).changed).toEqual(['./Example#Inner']);
});

it("should let a barrel's own value export shadow a type-only star export", () => {
mkdirSync(path.join(packageDir, 'dist'), { recursive: true });
writeFileSync(path.join(packageDir, 'dist', 'w.d.ts'), 'export declare class W { }\n');
writeFileSync(path.join(packageDir, 'dist', 'middle.d.ts'), "export { W } from './w';\nexport type * from './w';\n");
writePackage("export * from './middle';\n");
const before = computeApiSurface(packageDir);
expect(before['./Example'].W).toBe('export declare class W { }');
writeFileSync(path.join(packageDir, 'dist', 'middle.d.ts'), "export type { W } from './w';\nexport type * from './w';\n");
expect(compareApiSurfaces(before, computeApiSurface(packageDir)).changed).toEqual(['./Example#W']);
});

it('should mark a name reached only through a type-only star export inside a cycle', () => {
mkdirSync(path.join(packageDir, 'dist'), { recursive: true });
writeFileSync(path.join(packageDir, 'dist', 'w.d.ts'), 'export declare class W { }\n');
writeFileSync(path.join(packageDir, 'dist', 'cycle.d.ts'), "export * from './Example';\nexport type * from './w';\n");
writePackage("export * from './cycle';\n");
expect(computeApiSurface(packageDir)['./Example'].W).toBe('(type-only) export declare class W { }');
});
});
Loading
Loading