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:
parent
6a888ea692
commit
15d3d8bf10
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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')",
|
||||
|
|
|
|||
Loading…
Reference in a new issue