diff --git a/tools/package-quality/src/check/metadata.ts b/tools/package-quality/src/check/metadata.ts index 1119cb984..9f8e1f7a8 100644 --- a/tools/package-quality/src/check/metadata.ts +++ b/tools/package-quality/src/check/metadata.ts @@ -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 && diff --git a/tools/package-quality/src/smoke.ts b/tools/package-quality/src/smoke.ts index 47c5105df..3614c680e 100644 --- a/tools/package-quality/src/smoke.ts +++ b/tools/package-quality/src/smoke.ts @@ -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 { + 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() 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')",