From c49186474cea7baf2b6f755b124ebd4dd622c957 Mon Sep 17 00:00:00 2001 From: DevBot Date: Thu, 3 Sep 2026 09:18:36 +0800 Subject: [PATCH] docs(surface): classify every public export with Beta stability classes (#1223) Extend package-surface:check so the machine-readable package-export-classes block in PACKAGE_SURFACE.md must classify every named export of every published subpath (stable-candidate / experimental / internal-importable / compatibility-only / deprecated), verified against source through the same TypeScript module resolution as the public-interface snapshot; unclassified, stale or prose-undocumented exports fail the gate. Document the semantic-owner vs physical-source splits (App-owned action/loader protocol and adapter-owned build contract types physically on ADR-0122 frozen element files) with compatibility reason and removal condition; no physical migration. Fix the composeFetchMiddleware location wording and complete the build-utils helper enumeration. --- docs/current/PACKAGE_SURFACE.md | 428 ++++++++++++++++++++++++++- tools/check-package-surface.test.ts | 84 ++++++ tools/check-package-surface.ts | 444 ++++++++++++++++++---------- 3 files changed, 795 insertions(+), 161 deletions(-) create mode 100644 tools/check-package-surface.test.ts diff --git a/docs/current/PACKAGE_SURFACE.md b/docs/current/PACKAGE_SURFACE.md index 990c7f113..4817548a5 100644 --- a/docs/current/PACKAGE_SURFACE.md +++ b/docs/current/PACKAGE_SURFACE.md @@ -50,7 +50,10 @@ migration guide (ADR-0124 Consequences, #915). ## Subpath inventory The machine-readable map below is compared against each package's `exports` -field by `deno task package-surface:check`; any drift fails the gate. +field by `deno task package-surface:check`; any drift fails the gate. A second +machine-readable block (`package-export-classes`) in the Beta.1 stability +section below classifies every named export of every subpath; the same gate +compares it against source. "Internal but importable" subpaths stay reachable for build adapters, generated code and optional integrations, but they carry no compatibility promise and are not application-authoring surface. @@ -82,8 +85,10 @@ promise and are not application-authoring surface. - `@openelement/element/build-utils` (alpha.17): build-time helpers (`transformIslandSource`, `formatJson`, - `pathToTagName`, `normalizeSeparators`, `SsrRenderError`, - `createRuntimeAdapter` and the runtime handler types) for build adapters. + `pathToTagName`, `normalizeSeparators`, `insertBeforeBodyClose`, + `normalizeRoutePatternForURLPattern`, `SsrRenderError`, + `createRuntimeAdapter`, `composeFetchMiddleware` and the runtime handler + types) for build adapters. They were removed from the element root export; application code must not import them. - The `open-element-render` and `open-element-hydration` modules are @@ -135,6 +140,406 @@ promise and are not application-authoring surface. - `@openelement/ui/open-props-tokens.js` is a resolver-compatibility alias of `open-props-tokens`. +## Beta.1 export stability classes (ADR-0151, #1223) + +Every named export of every published subpath carries exactly one stability +class. The machine-readable `package-export-classes` block below is compared +against the real export surface by `deno task package-surface:check`, which +enumerates exports through the same TypeScript module resolution as the +public-interface snapshot; an unclassified export, a stale classification or a +classified name missing from the prose fails the gate. + +- **stable-candidate** — supported application-authoring, third-party + component or deployment-integration contract intended to reach 1.0 + unchanged. Entries that are also frozen (ADR-0119, ADR-0122) still require + a major-version ADR to change. +- **experimental** — admitted but explicitly unsettled; may change or move + without a major-version bump while its tracking issue is open. +- **internal-importable** — reachable at a supported subpath because generated + code, build adapters or sibling OpenElement packages import it; carries no + application-authoring compatibility promise. +- **compatibility-only** — retained solely for backward compatibility; new + code must not adopt it. +- **deprecated** — scheduled for removal. No export currently carries this + class. + + + +### `@openelement/element` + +| Subpath | Class | Exports | +| ----------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| root | compatibility-only | `OpenElementRouteKind`, `OpenElementRouteNode` | +| | experimental | `DANGEROUS_KEYS`, `element`, `injectPropsSafe`, `isDangerousKey`, `property` | +| | internal-importable | `AppShellConfig`, `assertValidTagName`, `collectPublicProps`, `CompatibilityClassification`, `CompatibilityTier`, `ComponentLayer`, `createLogger`, `DATA_SSR_PROPS`, `deepGetElementById`, `ensureDeepFragmentNavigation`, `ensurePreHydrationClickCapture`, `formatError`, `FrameworkOptions`, `isValidTagName`, `renderDsd`, `RenderDsdOptions`, `RenderOutput`, `RouteEntry`, `SpecialFileType`, `SsrAdmissionDecision`, `wrapInDocument` | +| | stable-candidate | `Action`, `ACTION_FETCH_HEADER`, `ActionContext`, `ActionResult`, `computed`, `consumeContext`, `Context`, `createContext`, `effect`, `ERROR_PREFIX`, `ErrorBoundary`, `ErrorTelemetryHook`, `escapeAttr`, `escapeHtml`, `HYDRATION_STRATEGIES`, `HydrationStrategy`, `IslandOptions`, `isSafeAttributeName`, `Loader`, `LoaderContext`, `LocalePath`, `Middleware`, `OpenElement`, `OpenElementAttribute`, `OpenElementCssPart`, `OpenElementDeclaration`, `OpenElementError`, `OpenElementEvent`, `OpenElementPackageManifest`, `OpenElementSlot`, `PROBLEM_JSON_MEDIA_TYPE`, `ProblemDetails`, `provideContext`, `RenderError`, `reportError`, `ServerRouteContext`, `ServerRouteMetadata`, `setErrorTelemetryHook`, `signal`, `Signal`, `SpaAction`, `SpaActionContext`, `SpaLoader`, `SpaLoaderContext`, `StyleSheet`, `StyleSheetLike`, `trustedHtml`, `TrustedHtml` | +| `jsx-runtime` | stable-candidate | `Fragment`, `jsx`, `JSX`, `jsxs` | +| `jsx-dev-runtime` | stable-candidate | `Fragment`, `JSX`, `jsxDEV` | +| `sanitize` | stable-candidate | `isSafeUrl`, `sanitizeHtml`, `SanitizeOptions` | +| `build-utils` | internal-importable | `composeFetchMiddleware`, `createRuntimeAdapter`, `formatJson`, `insertBeforeBodyClose`, `normalizeRoutePatternForURLPattern`, `normalizeSeparators`, `OpenElementRequestHandler`, `pathToTagName`, `RuntimeContext`, `SsrRenderError`, `transformIslandSource` | + +### `@openelement/app` + +| Subpath | Class | Exports | +| -------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| root | internal-importable | `ActionOutcome`, `classifyActionResult`, `projectPageProps` | +| | stable-candidate | `Action`, `ACTION_FETCH_HEADER`, `ActionContext`, `ActionResult`, `createRequestContext`, `CreateRequestContextOptions`, `defineApp`, `defineIslandConfig`, `definePage`, `fail`, `isActionFailure`, `IslandConfig`, `IslandDeliveryStrategy`, `isOpenElementNotFound`, `isOpenElementRedirect`, `Loader`, `LoaderContext`, `notFound`, `OpenElementActionFailure`, `OpenElementNotFound`, `OpenElementPageDescriptor`, `OpenElementRedirect`, `OpenElementRequestContext`, `PageComponentConstructor`, `PageErrorProjector`, `PagePropsContext`, `PagePropsProjector`, `PROBLEM_JSON_MEDIA_TYPE`, `ProblemDetails`, `redirect`, `ServerRouteContext`, `ServerRouteMetadata`, `SpaAction`, `SpaActionContext`, `SpaAppInstance`, `SpaLoader`, `SpaLoaderContext` | +| `model` | stable-candidate | `createRequestContext`, `CreateRequestContextOptions`, `OpenElementRequestContext` | +| `spa` | stable-candidate | `defineApp`, `SpaAppInstance` | +| `i18n` | internal-importable | `loadI18nData`, `LocalePath`, `normalizeLocalePath`, `OpenElementI18nOptions` | +| `preact` | stable-candidate | `definePreactIsland`, `PreactIslandConstructor`, `PreactIslandOptions` | + +### `@openelement/adapter-vite` + +| Subpath | Class | Exports | +| ------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| root | internal-importable | `ArtifactInfo`, `buildIslandChunkMap`, `BuildManifest`, `buildSpeculationRulesJson`, `extractCustomElementTags`, `FrameworkOptions`, `generateIslandManifests`, `injectClientScript`, `injectCspMeta`, `injectSpeculationRules`, `injectViewTransitionMeta`, `OpenElementBuildContext`, `printBuildManifest`, `scanClientBuild`, `scanSSGOutput`, `SpeculationRulesOptions`, `SsgBehaviorOptions`, `writeIslandManifests` | +| | stable-candidate | `buildApp`, `buildHeadExtras`, `CollectionEntry`, `CollectionFieldDefinition`, `CollectionFieldType`, `CollectionOptions`, `CollectionSchema`, `CollectionSchemaContext`, `CollectionSchemaResult`, `createCollectionPlugin`, `default`, `generateSitemap`, `HeadExtrasResult`, `loadCollectionData`, `mdxPlugin`, `openElement`, `OpenElementBlogOptions`, `OpenElementBuildContextLike`, `OpenElementHeaderNavLink`, `OpenElementI18nContextOptions`, `OpenElementNavSection`, `OpenElementOptions`, `OpenMdxPluginOptions`, `openPipeline`, `OpenPipelineConfig`, `writeCollectionDataModule` | +| `nitro-mount` | stable-candidate | `createOpenElementNitroHandler`, `NitroRequestEvent`, `OpenElementNitroMountOptions` | +| `sitemap` | stable-candidate | `generateSitemap`, `SitemapOptions` | +| `cli/build` | — | CLI entry module; no importable exports | +| `cli/start` | internal-importable | `extractServeMode` | + +### `@openelement/create` + +| Subpath | Class | Exports | +| ------- | ----- | --------------------------------------- | +| root | — | CLI entry module; no importable exports | + +### `@openelement/ui` + +| Subpath | Class | Exports | +| ---------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| root | stable-candidate | `manifest`, `OpenBadge`, `OpenButton`, `OpenCallout`, `OpenCard`, `OpenCodeBlock`, `OpenDialog`, `OpenDropdown`, `OpenInput`, `openPropsRootSheet`, `openPropsTokenSheet`, `OpenTabs`, `OpenThemeToggle`, `registerOpenUi` | +| `open-badge` | stable-candidate | `OpenBadge` | +| `open-button` | stable-candidate | `OpenButton` | +| `open-callout` | stable-candidate | `OpenCallout` | +| `open-card` | stable-candidate | `OpenCard` | +| `open-code-block` | stable-candidate | `OpenCodeBlock` | +| `open-dialog` | stable-candidate | `OpenDialog` | +| `open-dropdown` | stable-candidate | `OpenDropdown` | +| `open-input` | stable-candidate | `OpenInput` | +| `open-tabs` | stable-candidate | `OpenTabs` | +| `open-theme-toggle` | stable-candidate | `OpenThemeToggle` | +| `open-props-tokens` | stable-candidate | `openPropsRootSheet`, `openPropsTokenSheet` | +| `open-props-tokens.js` | compatibility-only | `openPropsRootSheet`, `openPropsTokenSheet` | +| The adapter root `default` export is the `openPipeline` alias. `cli/build`, | | | +| `cli/start` and the `create` root are CLI entry modules: `cli/start` exports | | | +| `extractServeMode` for CLI tests only, and the other two export nothing | | | +| importable. `@openelement/app/i18n` stays the optional locale-expansion | | | +| integration point with no compatibility promise. | | | + +### Semantic owner vs physical source + +Where physical source lives in a different package than the canonical semantic +owner (registry of record: +[`SEMANTIC_OWNERSHIP.md`](./SEMANTIC_OWNERSHIP.md)), the tables above classify +the export at its physical location. The split entries, each with its +compatibility reason and removal condition (migration is deliberately deferred +because the physical declarations sit on ADR-0122 frozen paths): + +| Export group | Semantic owner | Physical source | Compatibility reason | Removal condition | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Action/loader protocol: `Action`, `ActionContext`, `ActionResult`, `Loader`, `LoaderContext`, `ProblemDetails`, `ServerRouteContext`, `ServerRouteMetadata`, `SpaAction`, `SpaActionContext`, `SpaLoader`, `SpaLoaderContext`, `ACTION_FETCH_HEADER`, `PROBLEM_JSON_MEDIA_TYPE` | App (loader/action/outcome authoring semantics, #1206) | `@openelement/element` root; declarations in element `internal/protocol/data.ts` | The retired protocol package collapse placed the declarations in element; the 0.42 freeze (ADR-0122) froze both the element-root re-export and the file. The app root re-exports the same names so application authors import from the semantic owner | Physical move to App requires a major-version ADR amending ADR-0122 | +| Build/framework contract types: `FrameworkOptions`, `RouteEntry`, `AppShellConfig`, `CompatibilityClassification`, `CompatibilityTier`, `ComponentLayer`, `SpecialFileType` | Adapter Vite (build and deploy contracts) | `@openelement/element` root; declarations in element `internal/protocol/framework.ts` | Same protocol collapse; consumed only by the adapter, generated code and sibling packages, hence classified internal-importable | Physical move to the adapter requires a major-version ADR amending ADR-0122 | +| Route-tree metadata: `OpenElementRouteKind`, `OpenElementRouteNode` | App route model | `@openelement/element` root; declarations in element `internal/protocol/app-model.ts` | Frozen protocol metadata retained on the element root with zero in-repo consumers, hence compatibility-only | Removable at the next major version under the ADR-0122 amendment gate | + ## 0.41.0 interface freeze boundary (ADR-0119) Frozen at 0.41.0: the element authoring helper, `definePage`, `buildApp`, the five-package @@ -184,8 +589,8 @@ unaffected: their `tagName` export remains the registration tag. `application/problem+json` (`PROBLEM_JSON_MEDIA_TYPE`) since 0.42.0-alpha.13 (#863, ADR-0123 addendum item 13). -- alpha.13 additions (ADR-0123 train): `Middleware` / - `composeFetchMiddleware` (element root + `./build-utils`) — the +- alpha.13 additions (ADR-0123 train): `Middleware` (element root) and + `composeFetchMiddleware` (`./build-utils`) — the WinterCG-shaped `(request, next) => Response` middleware contract wired through `middleware.use` (#858); `cli/preview` merged into `cli/start --mode=preview` (#859); `PageRenderingMode` narrowed to @@ -193,10 +598,10 @@ unaffected: their `tagName` export remains the registration tag. ### v0.44 experimental additions (unfrozen) -| Export (element root) | Class | Purpose | Removal/move condition | -| ----------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `isDangerousKey`, `injectPropsSafe`, `DANGEROUS_KEYS` | experimental (#1214) | The one canonical prototype-pollution guard shared by page projection (SPA bootstrap, `projectPageProps`) and adapter codegen, which serializes `DANGEROUS_KEYS` into generated server runtimes at build time | May move to a dedicated security subpath at the B1.2 surface freeze | -| `element`, `property` | experimental (#1209) | Compile-time-only decorator intrinsics: the compiler admits them by binding provenance (a runtime named import from `@openelement/element`) and erases them from generated code; evaluated without the compiler (unit tests, config evaluation) they are inert no-ops, carrying no runtime semantics and acting as no second recognizer | May move to a dedicated authoring subpath at the B1.2 surface freeze | +| Export (element root) | Class | Purpose | Removal/move condition | +| ----------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `isDangerousKey`, `injectPropsSafe`, `DANGEROUS_KEYS` | experimental (#1214) | The one canonical prototype-pollution guard shared by page projection (SPA bootstrap, `projectPageProps`) and adapter codegen, which serializes `DANGEROUS_KEYS` into generated server runtimes at build time | Kept at the element root at the B1.2 freeze (#1223); may move to a dedicated security subpath before 1.0 | +| `element`, `property` | experimental (#1209) | Compile-time-only decorator intrinsics: the compiler admits them by binding provenance (a runtime named import from `@openelement/element`) and erases them from generated code; evaluated without the compiler (unit tests, config evaluation) they are inert no-ops, carrying no runtime semantics and acting as no second recognizer | Kept at the element root at the B1.2 freeze (#1223); may move to a dedicated authoring subpath before 1.0 | v0.44 (ADR-0143) also re-shapes existing entries without adding names: `definePage` takes the compiled page element class as its first argument @@ -237,7 +642,8 @@ Earlier removed product experiments and adapters remain historical only: Node/Deno/Bun and may use only Web/ES globals plus `node:` builtins. - Alpha internal packages and subpaths have no compatibility promise. -The package export map, generated resolver table and the subpath inventory -above are checked together by `deno task package-surface:check`. Historical +The package export map, generated resolver table, subpath inventory and +export stability classification above are checked together by +`deno task package-surface:check`. Historical ADR and release evidence retain their original package names; they are not current usage documentation. diff --git a/tools/check-package-surface.test.ts b/tools/check-package-surface.test.ts new file mode 100644 index 000000000..9ab41a74b --- /dev/null +++ b/tools/check-package-surface.test.ts @@ -0,0 +1,84 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import { + EXPORT_STABILITY_CLASSES, + exportClassDrift, + extractExportClassMap, +} from './check-package-surface.ts'; + +const DOC = `# Package Surface Inventory + + + +prose mentioning \`OpenElement\`. +`; + +Deno.test('export class vocabulary is the five Beta stability classes', () => { + assertEquals(EXPORT_STABILITY_CLASSES, [ + 'stable-candidate', + 'experimental', + 'internal-importable', + 'compatibility-only', + 'deprecated', + ]); +}); + +Deno.test('extractExportClassMap parses the machine-readable block', () => { + const map = extractExportClassMap(DOC); + assertEquals(map, { + '@openelement/element': { + '.': { OpenElement: 'stable-candidate', element: 'experimental' }, + 'build-utils': { formatJson: 'internal-importable' }, + }, + }); +}); + +Deno.test('extractExportClassMap rejects a missing or invalid block', () => { + assertEquals(extractExportClassMap('no block here'), null); + assertEquals(extractExportClassMap(''), null); + assertEquals( + extractExportClassMap(''), + null, + ); +}); + +Deno.test('exportClassDrift passes when classification matches source exactly', () => { + const map = extractExportClassMap(DOC)!; + assertEquals( + exportClassDrift(map, '@openelement/element', '.', ['OpenElement', 'element']), + [], + ); +}); + +Deno.test('exportClassDrift reports unclassified exports and stale classifications', () => { + const map = extractExportClassMap(DOC)!; + const failures = exportClassDrift(map, '@openelement/element', '.', [ + 'OpenElement', + 'element', + 'NewExport', + ]); + assertEquals(failures.length, 1); + assertStringIncludes(failures[0], 'NewExport'); + const stale = exportClassDrift(map, '@openelement/element', '.', ['OpenElement']); + assertEquals(stale.length, 1); + assertStringIncludes(stale[0], 'element'); +}); + +Deno.test('exportClassDrift rejects unknown stability classes and missing entries', () => { + const map = extractExportClassMap(DOC)!; + const unknown = exportClassDrift( + { '@openelement/element': { '.': { OpenElement: 'stable-ish' } } }, + '@openelement/element', + '.', + ['OpenElement'], + ); + assertStringIncludes(unknown.join('\n'), 'stable-ish'); + const missing = exportClassDrift(map, '@openelement/element', 'sanitize', []); + assertStringIncludes(missing.join('\n'), 'sanitize'); +}); diff --git a/tools/check-package-surface.ts b/tools/check-package-surface.ts index ccb676328..d5eca42e3 100644 --- a/tools/check-package-surface.ts +++ b/tools/check-package-surface.ts @@ -5,6 +5,7 @@ import { } from './project-constants.ts'; import { readPackages, releasePublishOrder } from './lib/package-graph.ts'; import { OPENELEMENT_EXPORT_FILES } from '../packages/adapter-vite/src/generated-export-files.ts'; +import { resolve } from '@std/path'; const retainedPackages = [...RETAINED_PACKAGE_NAMES].sort(); const removedPackages = [...REMOVED_PACKAGE_NAMES].sort(); @@ -36,10 +37,6 @@ async function rejectRetiredImports(dir: string): Promise { } } -for (const dir of ['packages', 'examples', 'www/app', 'tools/third-party-wc-smoke']) { - await rejectRetiredImports(dir); -} - function normalizeExports(exports: unknown): Record { if (typeof exports === 'string') return { '.': exports.replace(/^\.\//, '') }; if (!exports || typeof exports !== 'object') return {}; @@ -53,81 +50,6 @@ function normalizeExports(exports: unknown): Record { ); } -const packages = releasePublishOrder(await readPackages()); -const releasePackages = packages.map((pkg) => pkg.name).sort(); -if (PACKAGE_COUNT !== retainedPackages.length) { - failures.push(`PACKAGE_COUNT is ${PACKAGE_COUNT}, expected ${retainedPackages.length}.`); -} -if (JSON.stringify(releasePackages) !== JSON.stringify(retainedPackages)) { - failures.push( - `Release package order mismatch. expected=${retainedPackages.join(', ')} actual=${ - releasePackages.join(', ') - }`, - ); -} - -for (const pkg of packages) { - try { - const info = await Deno.stat(pkg.dir); - if (!info.isDirectory) failures.push(`${pkg.dir} is not a directory.`); - } catch { - failures.push(`${pkg.dir} is missing.`); - } -} - -for (const pkg of packages) { - const shortName = pkg.name.replace('@openelement/', ''); - const resolverExports = OPENELEMENT_EXPORT_FILES[shortName]; - if (!resolverExports) continue; - const actualExports = normalizeExports(pkg.exports); - const expected = Object.fromEntries( - Object.entries(resolverExports) - .map(([key, value]) => [key, value.replace(/^\.\//, '')]) - .sort(([left], [right]) => left.localeCompare(right)), - ); - if (JSON.stringify(actualExports) !== JSON.stringify(expected)) { - failures.push( - `${pkg.name} exports drift from adapter-vite resolver map. expected=${ - JSON.stringify(expected) - } actual=${JSON.stringify(actualExports)}`, - ); - } -} - -for (const pkg of removedPackages) { - const dir = `packages/${pkg.replace('@openelement/', '')}`; - try { - await Deno.stat(dir); - failures.push(`${dir} must be removed from the v0.40 package graph.`); - } catch { - // Expected. - } -} - -const docs = await Deno.readTextFile('docs/current/PACKAGE_SURFACE.md'); -for (const pkg of retainedPackages) { - if (!docs.includes(`\`${pkg}\``)) { - failures.push(`${pkg} missing from docs/current/PACKAGE_SURFACE.md.`); - } -} -for (const pkg of removedPackages) { - const currentSection = docs.split('## Removed from current graph')[0] ?? docs; - if (currentSection.includes(`\`${pkg}\``)) { - failures.push(`${pkg} must not appear as a current package in PACKAGE_SURFACE.md.`); - } -} - -for (const required of ['5-package', '0.41.x stable', 'ADR-0113']) { - if (!docs.includes(required)) { - failures.push(`PACKAGE_SURFACE.md missing required anchor: ${required}`); - } -} - -// ─── Subpath inventory alignment (alpha.17 package B) ────── -// The machine-readable package-surface-map block in PACKAGE_SURFACE.md must -// exactly match each package's deno.json exports, and every supported -// subpath must be visible in the prose outside the comment block. - interface SurfaceMapEntry { supported: string[]; internal: string[]; @@ -160,100 +82,322 @@ function extractSurfaceMap(doc: string): Record | null return map; } -const surfaceMap = extractSurfaceMap(docs); -if (!surfaceMap) { - failures.push( - 'PACKAGE_SURFACE.md missing or invalid JSON block.', - ); -} else { - const mappedPackages = Object.keys(surfaceMap).sort(); - if (JSON.stringify(mappedPackages) !== JSON.stringify(retainedPackages)) { +// ─── Export stability classification (Beta.1, #1223) ────── +// Every named export of every published subpath must carry one of the five +// Beta stability classes in the machine-readable package-export-classes block +// of PACKAGE_SURFACE.md, and each classified name must also be visible in the +// human-readable prose. Drift in either direction fails the gate. + +export const EXPORT_STABILITY_CLASSES = [ + 'stable-candidate', + 'experimental', + 'internal-importable', + 'compatibility-only', + 'deprecated', +] as const; + +export type ExportClassMap = Record>>; + +export function extractExportClassMap(doc: string): ExportClassMap | null { + const BEGIN = '', begin); + if (end === -1) return null; + let parsed: unknown; + try { + parsed = JSON.parse(doc.slice(begin + BEGIN.length, end).trim()); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const map: ExportClassMap = {}; + for (const [pkg, subpaths] of Object.entries(parsed as Record)) { + if (!subpaths || typeof subpaths !== 'object' || Array.isArray(subpaths)) return null; + const subpathMap: Record> = {}; + for (const [subpath, names] of Object.entries(subpaths as Record)) { + if (!names || typeof names !== 'object' || Array.isArray(names)) return null; + const nameMap: Record = {}; + for (const [name, classification] of Object.entries(names as Record)) { + if (typeof classification !== 'string') return null; + nameMap[name] = classification; + } + subpathMap[subpath] = nameMap; + } + map[pkg] = subpathMap; + } + return map; +} + +export function exportClassDrift( + map: ExportClassMap, + pkgName: string, + subpath: string, + actualNames: string[], +): string[] { + const drift: string[] = []; + const entry = map[pkgName]?.[subpath]; + if (!entry) { + drift.push(`${pkgName} subpath "${subpath}" has no export-classification entry.`); + return drift; + } + for (const [name, classification] of Object.entries(entry)) { + if (!EXPORT_STABILITY_CLASSES.includes(classification as never)) { + drift.push( + `${pkgName} ${subpath} export "${name}" carries unknown stability class "${classification}".`, + ); + } + if (!actualNames.includes(name)) { + drift.push( + `${pkgName} ${subpath} classifies "${name}" but the source no longer exports it.`, + ); + } + } + for (const name of actualNames) { + if (!(name in entry)) { + drift.push(`${pkgName} ${subpath} export "${name}" has no stability classification.`); + } + } + return drift; +} + +const APILIST_REQUIRED_PACKAGES = [ + '@openelement/element', + '@openelement/app', + '@openelement/adapter-vite', +]; + +async function main(): Promise { + for (const dir of ['packages', 'examples', 'www/app', 'tools/third-party-wc-smoke']) { + await rejectRetiredImports(dir); + } + + const packages = releasePublishOrder(await readPackages()); + const releasePackages = packages.map((pkg) => pkg.name).sort(); + if (PACKAGE_COUNT !== retainedPackages.length) { + failures.push(`PACKAGE_COUNT is ${PACKAGE_COUNT}, expected ${retainedPackages.length}.`); + } + if (JSON.stringify(releasePackages) !== JSON.stringify(retainedPackages)) { failures.push( - `package-surface-map packages mismatch. expected=${retainedPackages.join(', ')} actual=${ - mappedPackages.join(', ') + `Release package order mismatch. expected=${retainedPackages.join(', ')} actual=${ + releasePackages.join(', ') }`, ); } - // Prose with the machine-readable block removed: supported subpaths must be - // documented for humans, not only for the checker. - const prose = docs.replace(//, ''); + for (const pkg of packages) { + try { + const info = await Deno.stat(pkg.dir); + if (!info.isDirectory) failures.push(`${pkg.dir} is not a directory.`); + } catch { + failures.push(`${pkg.dir} is missing.`); + } + } for (const pkg of packages) { - const entry = surfaceMap[pkg.name]; - if (!entry) continue; - const actual = Object.keys(normalizeExports(pkg.exports)).sort(); - const expected = [...entry.supported, ...entry.internal].sort(); - if (JSON.stringify(actual) !== JSON.stringify(expected)) { + const shortName = pkg.name.replace('@openelement/', ''); + const resolverExports = OPENELEMENT_EXPORT_FILES[shortName]; + if (!resolverExports) continue; + const actualExports = normalizeExports(pkg.exports); + const expected = Object.fromEntries( + Object.entries(resolverExports) + .map(([key, value]) => [key, value.replace(/^\.\//, '')]) + .sort(([left], [right]) => left.localeCompare(right)), + ); + if (JSON.stringify(actualExports) !== JSON.stringify(expected)) { failures.push( - `${pkg.name} exports drift from PACKAGE_SURFACE.md subpath inventory. expected=${ + `${pkg.name} exports drift from adapter-vite resolver map. expected=${ JSON.stringify(expected) - } actual=${JSON.stringify(actual)}`, + } actual=${JSON.stringify(actualExports)}`, ); } - for (const subpath of entry.supported) { - if (subpath === '.') continue; // root is described as "root" in the table - if (!prose.includes(`\`${subpath}\``)) { + } + + for (const pkg of removedPackages) { + const dir = `packages/${pkg.replace('@openelement/', '')}`; + try { + await Deno.stat(dir); + failures.push(`${dir} must be removed from the v0.40 package graph.`); + } catch { + // Expected. + } + } + + const docs = await Deno.readTextFile('docs/current/PACKAGE_SURFACE.md'); + for (const pkg of retainedPackages) { + if (!docs.includes(`\`${pkg}\``)) { + failures.push(`${pkg} missing from docs/current/PACKAGE_SURFACE.md.`); + } + } + for (const pkg of removedPackages) { + const currentSection = docs.split('## Removed from current graph')[0] ?? docs; + if (currentSection.includes(`\`${pkg}\``)) { + failures.push(`${pkg} must not appear as a current package in PACKAGE_SURFACE.md.`); + } + } + + for (const required of ['5-package', '0.41.x stable', 'ADR-0113']) { + if (!docs.includes(required)) { + failures.push(`PACKAGE_SURFACE.md missing required anchor: ${required}`); + } + } + + // ─── Subpath inventory alignment (alpha.17 package B) ────── + // The machine-readable package-surface-map block in PACKAGE_SURFACE.md must + // exactly match each package's deno.json exports, and every supported + // subpath must be visible in the prose outside the comment block. + + const surfaceMap = extractSurfaceMap(docs); + if (!surfaceMap) { + failures.push( + 'PACKAGE_SURFACE.md missing or invalid JSON block.', + ); + } else { + const mappedPackages = Object.keys(surfaceMap).sort(); + if (JSON.stringify(mappedPackages) !== JSON.stringify(retainedPackages)) { + failures.push( + `package-surface-map packages mismatch. expected=${retainedPackages.join(', ')} actual=${ + mappedPackages.join(', ') + }`, + ); + } + + // Prose with the machine-readable block removed: supported subpaths must be + // documented for humans, not only for the checker. + const prose = docs.replace(//, ''); + + for (const pkg of packages) { + const entry = surfaceMap[pkg.name]; + if (!entry) continue; + const actual = Object.keys(normalizeExports(pkg.exports)).sort(); + const expected = [...entry.supported, ...entry.internal].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { failures.push( - `${pkg.name} supported subpath "${subpath}" is not documented in PACKAGE_SURFACE.md prose.`, + `${pkg.name} exports drift from PACKAGE_SURFACE.md subpath inventory. expected=${ + JSON.stringify(expected) + } actual=${JSON.stringify(actual)}`, ); } + for (const subpath of entry.supported) { + if (subpath === '.') continue; // root is described as "root" in the table + if (!prose.includes(`\`${subpath}\``)) { + failures.push( + `${pkg.name} supported subpath "${subpath}" is not documented in PACKAGE_SURFACE.md prose.`, + ); + } + } } } -} -// ─── www apilist surface literals ───────────────────────── -// The supported-subpath chips on www/app/routes/apilist.tsx must match each -// package's exports map ('root' stands for the '.' export). Entries with -// placeholder chips ('CLI only', 'retained primitive subpaths') are skipped; -// element, app and adapter-vite must always be checked so the gate cannot -// silently no-op. + // ─── Export stability classification wiring ───────────── + // The package-export-classes block must classify every named export of every + // published subpath; the actual names come from the same TypeScript module + // resolution the public-interface snapshot uses. Imported lazily so the pure + // classification helpers above stay loadable without the TypeScript runtime. -const APILIST_REQUIRED_PACKAGES = [ - '@openelement/element', - '@openelement/app', - '@openelement/adapter-vite', -]; - -const apilist = await Deno.readTextFile('www/app/routes/apilist.tsx'); -const apilistChecked: string[] = []; -for ( - const match of apilist.matchAll( - /importPath: '([^']+)'[\s\S]*?exports: \[([^\]]*)\]/g, - ) -) { - const [, importPath, exportsLiteral] = match; - const pkg = packages.find((candidate) => candidate.name === importPath); - if (!pkg) continue; // e.g. npm:@openelement/create is not a workspace package - const chips = [...exportsLiteral.matchAll(/'([^']+)'/g)].map((chip) => chip[1]); - if (chips.some((chip) => !/^[a-z0-9./-]+$/.test(chip))) continue; // placeholder chips - const documented = chips - .map((chip) => (chip === 'root' ? '.' : chip)) - .sort((left, right) => left.localeCompare(right)); - const actual = Object.keys(normalizeExports(pkg.exports)).sort((left, right) => - left.localeCompare(right) - ); - if (JSON.stringify(documented) !== JSON.stringify(actual)) { + const { publicInterfaceShape } = await import('./check-public-interface-snapshot.ts'); + const exportClassMap = extractExportClassMap(docs); + if (!exportClassMap) { failures.push( - `www/app/routes/apilist.tsx ${pkg.name} exports drift. expected=${ - JSON.stringify(actual) - } actual=${JSON.stringify(documented)}`, + 'PACKAGE_SURFACE.md missing or invalid JSON block.', ); + } else { + const classedPackages = Object.keys(exportClassMap).sort(); + if (JSON.stringify(classedPackages) !== JSON.stringify(retainedPackages)) { + failures.push( + `package-export-classes packages mismatch. expected=${retainedPackages.join(', ')} actual=${ + classedPackages.join(', ') + }`, + ); + } + // Prose with both machine-readable blocks removed: every classified export + // name must be documented for humans, not only for the checker. + const proseWithoutMaps = docs + .replace(//, '') + .replace(//, ''); + const classedNames = new Set(); + for (const pkg of packages) { + const entry = exportClassMap[pkg.name]; + if (!entry) continue; + const exportsMap = typeof pkg.exports === 'string' ? { '.': pkg.exports } : pkg.exports ?? {}; + const actualSubpaths = Object.keys(exportsMap).map((key) => key.replace(/^\.\//, '')) + .sort(); + const classedSubpaths = Object.keys(entry).sort(); + if (JSON.stringify(actualSubpaths) !== JSON.stringify(classedSubpaths)) { + failures.push( + `${pkg.name} export-classification subpaths mismatch. expected=${ + actualSubpaths.join(', ') + } actual=${classedSubpaths.join(', ')}`, + ); + continue; + } + for (const [subpath, source] of Object.entries(exportsMap)) { + const normalized = subpath.replace(/^\.\//, ''); + const entryFile = resolve(pkg.dir, String(source).replace(/^\.\//, '')); + const shape = await publicInterfaceShape(entryFile, pkg.dir); + const actualNames = shape.publicSymbols.map((symbol) => symbol.split('=')[0]); + failures.push(...exportClassDrift(exportClassMap, pkg.name, normalized, actualNames)); + for (const name of Object.keys(entry[normalized] ?? {})) classedNames.add(name); + } + } + for (const name of classedNames) { + if (!proseWithoutMaps.includes(`\`${name}\``)) { + failures.push( + `Classified export "${name}" is not documented in PACKAGE_SURFACE.md prose.`, + ); + } + } } - apilistChecked.push(pkg.name); -} -for (const required of APILIST_REQUIRED_PACKAGES) { - if (!apilistChecked.includes(required)) { - failures.push( - `www/app/routes/apilist.tsx does not document ${required} exports as concrete subpaths.`, + + // ─── www apilist surface literals ───────────────────────── + // The supported-subpath chips on www/app/routes/apilist.tsx must match each + // package's exports map ('root' stands for the '.' export). Entries with + // placeholder chips ('CLI only', 'retained primitive subpaths') are skipped; + // element, app and adapter-vite must always be checked so the gate cannot + // silently no-op. + + const apilist = await Deno.readTextFile('www/app/routes/apilist.tsx'); + const apilistChecked: string[] = []; + for ( + const match of apilist.matchAll( + /importPath: '([^']+)'[\s\S]*?exports: \[([^\]]*)\]/g, + ) + ) { + const [, importPath, exportsLiteral] = match; + const pkg = packages.find((candidate) => candidate.name === importPath); + if (!pkg) continue; // e.g. npm:@openelement/create is not a workspace package + const chips = [...exportsLiteral.matchAll(/'([^']+)'/g)].map((chip) => chip[1]); + if (chips.some((chip) => !/^[a-z0-9./-]+$/.test(chip))) continue; // placeholder chips + const documented = chips + .map((chip) => (chip === 'root' ? '.' : chip)) + .sort((left, right) => left.localeCompare(right)); + const actual = Object.keys(normalizeExports(pkg.exports)).sort((left, right) => + left.localeCompare(right) ); + if (JSON.stringify(documented) !== JSON.stringify(actual)) { + failures.push( + `www/app/routes/apilist.tsx ${pkg.name} exports drift. expected=${ + JSON.stringify(actual) + } actual=${JSON.stringify(documented)}`, + ); + } + apilistChecked.push(pkg.name); + } + for (const required of APILIST_REQUIRED_PACKAGES) { + if (!apilistChecked.includes(required)) { + failures.push( + `www/app/routes/apilist.tsx does not document ${required} exports as concrete subpaths.`, + ); + } + } + + if (failures.length > 0) { + console.error('Package surface check failed:'); + for (const failure of failures) console.error(`- ${failure}`); + Deno.exit(1); } -} -if (failures.length > 0) { - console.error('Package surface check failed:'); - for (const failure of failures) console.error(`- ${failure}`); - Deno.exit(1); + console.log(`Package surface check passed (${retainedPackages.length} packages retained).`); } -console.log(`Package surface check passed (${retainedPackages.length} packages retained).`); +if (import.meta.main) await main();