Skip to content

Commit 7d20e33

Browse files
author
DevBot
committed
test(tools): gate www public-import boundary against published export maps (#1177, B2.3)
The website is an npm-first consumer of the five retained packages. Prove mechanically that every @openelement/* specifier in the shipped site surface (www/app plus vite.config/content-collections/build-pagefind) resolves to a published export subpath — never a private source path — so in-repo workspace resolution is identical to the packed npm artifact boundary. Negative control: a temporary private-source import fails the gate.
1 parent fe4d531 commit 7d20e33

3 files changed

Lines changed: 183 additions & 1 deletion

File tree

tools/autoflow/policy.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@ const GATES: readonly GateDefinition[] = [
3737
name: 'package-surface:check',
3838
command: ['deno', 'task', 'package-surface:check'],
3939
tiers: ['push', 'ci', 'release'],
40-
triggers: [/^packages\//, /^deno\.json$/, /^tools\/lib\/package-graph\.ts$/],
40+
triggers: [
41+
/^packages\//,
42+
/^deno\.json$/,
43+
/^tools\/lib\/package-graph\.ts$/,
44+
// #1177 (B2.3): the gate also asserts the www public-import boundary.
45+
/^www\//,
46+
/^tools\/check-package-surface\.ts$/,
47+
/^tools\/lib\/typescript-ast\.ts$/,
48+
],
4149
},
4250
{
4351
name: 'interface:snapshot',

tools/check-package-surface.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
EXPORT_STABILITY_CLASSES,
44
exportClassDrift,
55
extractExportClassMap,
6+
extractWwwPackageSpecifiers,
7+
wwwImportBoundaryDrift,
68
} from './check-package-surface.ts';
79

810
const DOC = `# Package Surface Inventory
@@ -82,3 +84,75 @@ Deno.test('exportClassDrift rejects unknown stability classes and missing entrie
8284
const missing = exportClassDrift(map, '@openelement/element', 'sanitize', []);
8385
assertStringIncludes(missing.join('\n'), 'sanitize');
8486
});
87+
88+
// ─── www public-import boundary (#1177, B2.3) ─────────────
89+
// The website must consume @openelement/* exactly as an external npm consumer
90+
// would: every specifier resolves to a published export subpath of one of the
91+
// five retained packages, never to a private source path. www-local import-map
92+
// aliases declared in www/deno.json are the only permitted non-package
93+
// @openelement specifiers.
94+
95+
const WWW_EXPORTS = new Map([
96+
['@openelement/element', new Set(['.', 'jsx-runtime', 'jsx-dev-runtime', 'sanitize'])],
97+
['@openelement/ui', new Set(['.', 'open-theme-toggle'])],
98+
]);
99+
const WWW_LOCAL_ALIASES = ['@openelement/site-ui/', '@openelement/generated/'];
100+
101+
Deno.test('wwwImportBoundaryDrift accepts published root and subpath specifiers', () => {
102+
assertEquals(
103+
wwwImportBoundaryDrift(
104+
[
105+
'@openelement/element',
106+
'@openelement/ui/open-theme-toggle',
107+
'@openelement/element/sanitize',
108+
],
109+
WWW_EXPORTS,
110+
WWW_LOCAL_ALIASES,
111+
),
112+
[],
113+
);
114+
});
115+
116+
Deno.test('wwwImportBoundaryDrift accepts www-local aliases declared in www/deno.json', () => {
117+
assertEquals(
118+
wwwImportBoundaryDrift(
119+
['@openelement/site-ui/locale.ts', '@openelement/generated/nav'],
120+
WWW_EXPORTS,
121+
WWW_LOCAL_ALIASES,
122+
),
123+
[],
124+
);
125+
});
126+
127+
Deno.test('wwwImportBoundaryDrift rejects unpublished subpaths and unknown packages', () => {
128+
const drift = wwwImportBoundaryDrift(
129+
['@openelement/element/src/protocol/data.ts', '@openelement/content'],
130+
WWW_EXPORTS,
131+
WWW_LOCAL_ALIASES,
132+
);
133+
assertEquals(drift.length, 2);
134+
assertStringIncludes(drift[0], '@openelement/content');
135+
assertStringIncludes(drift[1], 'src/protocol/data.ts');
136+
});
137+
138+
Deno.test('wwwImportBoundaryDrift ignores non-openelement specifiers', () => {
139+
assertEquals(
140+
wwwImportBoundaryDrift(['vite', './local.ts', 'npm:marked@15'], WWW_EXPORTS, []),
141+
[],
142+
);
143+
});
144+
145+
Deno.test('extractWwwPackageSpecifiers collects imports, exports and jsxImportSource', () => {
146+
const source = `/** @jsxImportSource @openelement/element */
147+
import { OpenElement } from '@openelement/element';
148+
import '@openelement/ui/open-theme-toggle';
149+
export { signal } from '@openelement/element';
150+
const lazy = import('@openelement/element/sanitize');
151+
import './relative.ts';
152+
`;
153+
assertEquals(extractWwwPackageSpecifiers(source).sort(), [
154+
'@openelement/element',
155+
'@openelement/element/sanitize',
156+
'@openelement/ui/open-theme-toggle',
157+
]);
158+
});

tools/check-package-surface.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import {
44
RETAINED_PACKAGE_NAMES,
55
} from './project-constants.ts';
66
import { readPackages, releasePublishOrder } from './lib/package-graph.ts';
7+
import { extractStaticModuleSpecifiers } from './lib/typescript-ast.ts';
78
import { OPENELEMENT_EXPORT_FILES } from '../packages/adapter-vite/src/generated-export-files.ts';
89
import { resolve } from '@std/path';
10+
import { exists } from '@std/fs';
11+
import { walk } from '@std/fs/walk';
912

1013
const retainedPackages = [...RETAINED_PACKAGE_NAMES].sort();
1114
const removedPackages = [...REMOVED_PACKAGE_NAMES].sort();
@@ -167,6 +170,53 @@ const APILIST_REQUIRED_PACKAGES = [
167170
'@openelement/adapter-vite',
168171
];
169172

173+
// ─── www public-import boundary (#1177, B2.3) ─────────────
174+
// The website must consume @openelement/* exactly as an external npm consumer
175+
// would: every specifier in the shipped site surface (www/app plus the build
176+
// entry points) resolves to a published export subpath of one of the five
177+
// retained packages — never a private source path. The only permitted
178+
// non-package @openelement specifiers are the www-local import-map aliases
179+
// declared in www/deno.json. www/e2e is deliberately out of scope: its probe
180+
// harness (browser-bundle.ts) bundles package sources in memory and ships
181+
// nothing.
182+
183+
export function extractWwwPackageSpecifiers(source: string, path = 'source.ts'): string[] {
184+
const specifiers = new Set<string>();
185+
for (const { value } of extractStaticModuleSpecifiers(source, path)) {
186+
if (value.startsWith('@openelement/')) specifiers.add(value);
187+
}
188+
for (
189+
const match of source.matchAll(/\/\*\*?\s*@jsxImportSource\s+(@openelement\/[^\s*]+)/g)
190+
) {
191+
specifiers.add(match[1]);
192+
}
193+
return [...specifiers];
194+
}
195+
196+
export function wwwImportBoundaryDrift(
197+
specifiers: readonly string[],
198+
publishedSubpaths: ReadonlyMap<string, ReadonlySet<string>>,
199+
localAliasPrefixes: readonly string[],
200+
): string[] {
201+
const drift: string[] = [];
202+
for (const specifier of specifiers) {
203+
if (!specifier.startsWith('@openelement/')) continue;
204+
if (localAliasPrefixes.some((prefix) => specifier.startsWith(prefix))) continue;
205+
const segments = specifier.split('/');
206+
const pkgName = segments.slice(0, 2).join('/');
207+
const subpath = segments.slice(2).join('/') || '.';
208+
const published = publishedSubpaths.get(pkgName);
209+
if (!published) {
210+
drift.push(`${specifier} does not resolve to a retained published package.`);
211+
continue;
212+
}
213+
if (!published.has(subpath)) {
214+
drift.push(`${specifier} is not a published export subpath of ${pkgName}.`);
215+
}
216+
}
217+
return drift.sort();
218+
}
219+
170220
async function main(): Promise<void> {
171221
for (const dir of ['packages', 'examples', 'www/app', 'tools/third-party-wc-smoke']) {
172222
await rejectRetiredImports(dir);
@@ -391,6 +441,56 @@ async function main(): Promise<void> {
391441
}
392442
}
393443

444+
// ─── www public-import boundary (#1177, B2.3) ─────────────
445+
// Prove the shipped site surface (www/app + its build entry points) imports
446+
// only published export subpaths, so workspace resolution during in-repo
447+
// development is byte-identical to the packed npm artifacts.
448+
449+
const publishedSubpaths = new Map(
450+
packages.map((pkg) => [
451+
pkg.name,
452+
new Set(Object.keys(normalizeExports(pkg.exports))),
453+
]),
454+
);
455+
const wwwConfig = JSON.parse(await Deno.readTextFile('www/deno.json'));
456+
const localAliasPrefixes = Object.keys(wwwConfig.imports ?? {})
457+
.filter((key) => key.startsWith('@openelement/'));
458+
459+
const wwwSurfaceFiles = [
460+
'www/vite.config.ts',
461+
'www/content-collections.ts',
462+
'www/build-pagefind.ts',
463+
];
464+
for await (
465+
const { path: file } of walk('www/app', {
466+
includeDirs: false,
467+
skip: [/(^|\/)dist(\/|$)/],
468+
})
469+
) {
470+
if (/\.(?:ts|tsx)$/.test(file)) wwwSurfaceFiles.push(file);
471+
}
472+
const specifierOrigins = new Map<string, string[]>();
473+
for (const file of wwwSurfaceFiles.sort()) {
474+
if (!await exists(file)) continue;
475+
const text = await Deno.readTextFile(file);
476+
for (const specifier of extractWwwPackageSpecifiers(text, file)) {
477+
const origins = specifierOrigins.get(specifier) ?? [];
478+
origins.push(file);
479+
specifierOrigins.set(specifier, origins);
480+
}
481+
}
482+
for (
483+
const item of wwwImportBoundaryDrift(
484+
[...specifierOrigins.keys()],
485+
publishedSubpaths,
486+
localAliasPrefixes,
487+
)
488+
) {
489+
const specifier = item.split(' ')[0];
490+
const origins = specifierOrigins.get(specifier) ?? [];
491+
failures.push(`www import boundary: ${item} (imported by ${origins.join(', ')})`);
492+
}
493+
394494
if (failures.length > 0) {
395495
console.error('Package surface check failed:');
396496
for (const failure of failures) console.error(`- ${failure}`);

0 commit comments

Comments
 (0)