build(packages): strengthen publication smoke checks

- Validate every public runtime and declaration export in packed artifacts

- Add bounded Node import checks and a typed consumer build

Co-authored-by: Joseph Cumines <joeycumines@gmail.com>
This commit is contained in:
Danila Poyarkov 2026-08-13 18:40:01 +03:00
parent 6a888ea692
commit 15d3d8bf10
2 changed files with 224 additions and 34 deletions

View file

@ -3,7 +3,7 @@ import { join } from 'node:path'
import { publicPackageDirs } from '../packages'
interface PackageJSON {
interface PackageJson {
name: string
version: string
main?: string
@ -16,11 +16,15 @@ interface PackageJSON {
const errors: string[] = []
function readPackageJSON(packageDir: string): PackageJSON {
function isDeclarationPath(value: string): boolean {
return /\.d\.[cm]?ts$/.test(value)
}
function readPackageJson(packageDir: string): PackageJson {
return JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
}
const rootPackage = readPackageJSON('.')
const rootPackage = readPackageJson('.')
const expectedVersion = rootPackage.version
function checkRuntimePath(packageName: string, field: string, value: string): void {
@ -48,21 +52,51 @@ function checkIncludedRuntimePath(
}
}
function walkExports(packageName: string, value: unknown, path: string[] = []): void {
function checkIncludedTypePath(
packageName: string,
field: string,
value: string,
files: string[]
): void {
if (!isDeclarationPath(value)) {
errors.push(`${packageName}: ${field} must point to a declaration file (${value})`)
}
if (value.startsWith('./src/')) {
errors.push(`${packageName}: ${field} must not point to source files (${value})`)
}
const normalized = value.replace(/^\.\//, '')
const topLevelDir = normalized.split('/')[0]
if (topLevelDir && !files.includes(topLevelDir)) {
errors.push(
`${packageName}: ${field} points to ${value}, but files does not include ${topLevelDir}`
)
}
}
function walkExports(
packageName: string,
value: unknown,
files: string[],
path: string[] = []
): void {
if (typeof value === 'string') {
const key = path.at(-1)
if (key !== 'types' && key !== 'bun')
if (key === 'bun') return
if (key === 'types') {
checkIncludedTypePath(packageName, `exports.${path.join('.')}`, value, files)
} else {
checkRuntimePath(packageName, `exports.${path.join('.')}`, value)
}
return
}
if (!value || typeof value !== 'object') return
for (const [key, child] of Object.entries(value)) {
walkExports(packageName, child, [...path, key])
walkExports(packageName, child, files, [...path, key])
}
}
for (const packageDir of publicPackageDirs) {
const pkg = readPackageJSON(packageDir)
const pkg = readPackageJson(packageDir)
if (pkg.version !== expectedVersion) {
errors.push(`${pkg.name}: version ${pkg.version} must match root version ${expectedVersion}`)
@ -73,6 +107,7 @@ for (const packageDir of publicPackageDirs) {
}
if (pkg.main) checkRuntimePath(pkg.name, 'main', pkg.main)
if (pkg.types) checkIncludedTypePath(pkg.name, 'types', pkg.types, pkg.files ?? [])
if (typeof pkg.bin === 'string') {
checkIncludedRuntimePath(pkg.name, 'bin', pkg.bin, pkg.files ?? [])
@ -82,7 +117,7 @@ for (const packageDir of publicPackageDirs) {
}
}
walkExports(pkg.name, pkg.exports)
walkExports(pkg.name, pkg.exports, pkg.files ?? [])
if (
pkg.publishConfig &&

View file

@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -6,6 +6,12 @@ import { fileURLToPath } from 'node:url'
import { publicPackageDirs } from './packages'
const rootDir = fileURLToPath(new URL('../../..', import.meta.url))
const tsgoBin = join(
rootDir,
'node_modules',
'.bin',
process.platform === 'win32' ? 'tsgo.cmd' : 'tsgo'
)
function run(command: string[], cwd = rootDir): string {
const proc = Bun.spawnSync(command, { cwd, stdout: 'pipe', stderr: 'pipe' })
@ -20,8 +26,150 @@ function run(command: string[], cwd = rootDir): string {
return stdout.trim()
}
const EVAL_TIMEOUT_S = 30
function nodeEval(code: string, cwd: string): void {
run(['node', '--input-type=module', '--eval', code], cwd)
const args =
process.platform === 'win32'
? ['node', '--input-type=module', '--eval', code]
: ['timeout', String(EVAL_TIMEOUT_S), 'node', '--input-type=module', '--eval', code]
run(args, cwd)
}
function writeTypeConsumer(cwd: string): void {
writeFileSync(
join(cwd, 'tsconfig.package-smoke.json'),
JSON.stringify(
{
compilerOptions: {
strict: true,
target: 'ES2022',
module: 'NodeNext',
moduleResolution: 'NodeNext',
lib: ['ES2022', 'DOM'],
typeRoots: [join(rootDir, 'node_modules', '@types')],
skipLibCheck: true,
noEmit: true
},
include: ['package-type-consumer.ts']
},
null,
2
),
'utf8'
)
writeFileSync(
join(cwd, 'package-type-consumer.ts'),
`import { createEditor, type Editor } from '@open-pencil/core'
import { htmlToDesignDocument, type DesignDocument } from '@open-pencil/dom-css'
import { FIG_PACKAGE_STATUS, type FigContainerDocument } from '@open-pencil/fig'
import { FIG_KIWI_DEFAULT_VERSION, buildFigKiwi } from '@open-pencil/kiwi/fig/container'
import { type GUID as KiwiGUID } from '@open-pencil/kiwi/fig'
import { parsePenFile, type PenDocument } from '@open-pencil/pen'
import { SceneGraph, type Color, type SceneNode, type Vector } from '@open-pencil/scene-graph'
import { testIdSelector } from '@open-pencil/vue'
const graph = new SceneGraph()
const editorFactory: typeof createEditor = createEditor
declare const editor: Editor
declare const designDocument: DesignDocument
const color: Color = { r: 1, g: 0.5, b: 0, a: 1 }
const vector: Vector = { x: 1, y: 2 }
const maybeNode: SceneNode | undefined = graph.getPages()[0]
const penDocument: PenDocument = { version: '1', children: [] }
const figDocument: FigContainerDocument = {
schemaDeflated: new Uint8Array([1]),
dataRaw: new Uint8Array([2])
}
const kiwiGuid: KiwiGUID = { sessionID: 1, localID: 2 }
void editorFactory
void editor
void designDocument
void color
void vector
void maybeNode
void penDocument
void figDocument
void kiwiGuid
void FIG_PACKAGE_STATUS
void FIG_KIWI_DEFAULT_VERSION
void buildFigKiwi
void parsePenFile
void htmlToDesignDocument
void testIdSelector
`,
'utf8'
)
}
function checkTypeConsumer(cwd: string): void {
writeTypeConsumer(cwd)
run([tsgoBin, '--noEmit', '-p', 'tsconfig.package-smoke.json'], cwd)
}
interface PackageJson {
name: string
types?: string
exports?: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function readPackageJson(packageDir: string): PackageJson {
return JSON.parse(readFileSync(join(rootDir, packageDir, 'package.json'), 'utf8'))
}
function collectExportTypePaths(value: unknown, paths: string[] = []): string[] {
if (typeof value === 'string') return paths
if (!value || typeof value !== 'object') return paths
for (const [key, child] of Object.entries(value)) {
if (key === 'types' && typeof child === 'string') paths.push(child)
else collectExportTypePaths(child, paths)
}
return paths
}
function packageArchivePath(path: string): string {
return `package/${path.replace(/^\.\//, '')}`
}
function exportKeyToSpecifier(packageName: string, exportKey: string): string | null {
if (exportKey === './package.json') return null
if (exportKey === '.') return packageName
if (!exportKey.startsWith('./')) {
throw new Error(`${packageName}: unsupported export key ${exportKey}`)
}
if (exportKey.includes('*')) {
throw new Error(
`${packageName}: package smoke cannot exhaustively import pattern export ${exportKey}`
)
}
return `${packageName}/${exportKey.slice(2)}`
}
function collectPublicImportSpecifiers(packageJSON: PackageJson): string[] {
const { exports } = packageJSON
if (!exports) return []
if (typeof exports === 'string') return [packageJSON.name]
if (!isRecord(exports)) return []
const keys = Object.keys(exports)
if (keys.length === 0) return []
const hasExportMapKeys = keys.some((key) => key.startsWith('.'))
if (!hasExportMapKeys) return [packageJSON.name]
const specifiers: string[] = []
for (const key of keys) {
const specifier = exportKeyToSpecifier(packageJSON.name, key)
if (specifier) specifiers.push(specifier)
}
return specifiers
}
const tempDir = mkdtempSync(join(tmpdir(), 'open-pencil-package-smoke-'))
@ -30,7 +178,12 @@ try {
run(['bun', 'run', 'build:packages'])
const tarballs: string[] = []
const publicImportSpecifiers = new Set<string>()
for (const packageDir of publicPackageDirs) {
const packageJSON = readPackageJson(packageDir)
for (const specifier of collectPublicImportSpecifiers(packageJSON)) {
publicImportSpecifiers.add(specifier)
}
const output = run(
['bun', 'pm', 'pack', '--destination', tempDir, '--quiet'],
join(rootDir, packageDir)
@ -51,35 +204,37 @@ try {
console.error(`${basename(tarball)} includes runtime TypeScript:\n${runtimeTs.join('\n')}`)
process.exit(1)
}
const entries = new Set(contents.split('\n'))
const typePaths = [
...(packageJSON.types ? [packageJSON.types] : []),
...collectExportTypePaths(packageJSON.exports)
]
const missingTypePaths = typePaths
.map(packageArchivePath)
.filter((entry) => !entries.has(entry))
if (missingTypePaths.length > 0) {
console.error(
`${basename(tarball)} is missing declared type files:\n${missingTypePaths.join('\n')}`
)
process.exit(1)
}
}
run(['npm', 'init', '-y'], tempDir)
run(['npm', 'install', '--ignore-scripts', '--no-audit', '--no-fund', ...tarballs], tempDir)
nodeEval("await import('@open-pencil/kiwi')", tempDir)
nodeEval("await import('@open-pencil/kiwi/schema-runtime')", tempDir)
nodeEval("await import('@open-pencil/kiwi/fig')", tempDir)
nodeEval("await import('@open-pencil/kiwi/fig/codec')", tempDir)
nodeEval("await import('@open-pencil/kiwi/fig/container')", tempDir)
nodeEval("await import('@open-pencil/kiwi/fig/guid')", tempDir)
nodeEval("await import('@open-pencil/kiwi/fig/parse')", tempDir)
nodeEval("await import('@open-pencil/fig')", tempDir)
nodeEval("await import('@open-pencil/scene-graph')", tempDir)
nodeEval("await import('@open-pencil/scene-graph/copy')", tempDir)
nodeEval("await import('@open-pencil/scene-graph/coordinate')", tempDir)
nodeEval("await import('@open-pencil/scene-graph/geometry')", tempDir)
nodeEval("await import('@open-pencil/scene-graph/images')", tempDir)
nodeEval("await import('@open-pencil/scene-graph/matrix')", tempDir)
nodeEval("await import('@open-pencil/scene-graph/parse-path')", tempDir)
nodeEval("await import('@open-pencil/scene-graph/primitives')", tempDir)
nodeEval("await import('@open-pencil/pen')", tempDir)
nodeEval("await import('@open-pencil/core')", tempDir)
nodeEval("await import('@open-pencil/dom-css')", tempDir)
nodeEval("await import('@open-pencil/dom-css/browser')", tempDir)
nodeEval("await import('@open-pencil/dom-css/jsx-runtime')", tempDir)
nodeEval("await import('@open-pencil/dom-css/jsx-dev-runtime')", tempDir)
nodeEval("await import('@open-pencil/vue')", tempDir)
nodeEval("await import('@open-pencil/mcp')", tempDir)
// @open-pencil/mcp/stdio is a CLI entry point that creates a WebSocket
// connection on import. It is verified via the openpencil-mcp --help
// command below, not via import eval.
const evalSkipSpecifiers = new Set(['@open-pencil/mcp/stdio'])
for (const specifier of [...publicImportSpecifiers].sort()) {
if (evalSkipSpecifiers.has(specifier)) continue
nodeEval(`await import(${JSON.stringify(specifier)})`, tempDir)
}
checkTypeConsumer(tempDir)
nodeEval(
"const { guidToString } = await import('@open-pencil/kiwi/fig/guid'); if (guidToString({ sessionID: 1, localID: 2 }) !== '1:2') throw new Error('Kiwi GUID subpath failed')",