#!/usr/bin/env bun // Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * Package verification script for @google/design.md * * Runs 17 checks across 4 phases to ensure the package is correctly * structured for npm publication. Exit code 0 = all pass, 1 = failures. * * Usage: bun run scripts/check-package.ts */ import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs'; import { join, resolve } from 'path'; import { execSync } from 'child_process'; import { Glob, $ } from 'bun'; import { tmpdir } from 'os'; // ── Helpers ──────────────────────────────────────────────────────── const ROOT = resolve(import.meta.dir, '..'); const PKG_PATH = join(ROOT, 'package.json'); let passed = 0; let failed = 0; function pass(label: string) { console.log(` ✅ ${label}`); passed++; } function fail(label: string, detail?: string) { console.error(` ❌ ${label}`); if (detail) console.error(` → ${detail}`); failed++; } function check(label: string, ok: boolean, detail?: string) { if (ok) pass(label); else fail(label, detail); } function heading(title: string) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 56 - title.length))}`); } function readPkg(): Record { return JSON.parse(readFileSync(PKG_PATH, 'utf-8')); } function exec(cmd: string, opts?: { cwd?: string }): { ok: boolean; stdout: string; stderr: string } { try { const stdout = execSync(cmd, { cwd: opts?.cwd ?? ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${process.env.PATH ?? ''}` }, }); return { ok: true, stdout, stderr: '' }; } catch (e: unknown) { const err = e as { stdout?: string; stderr?: string }; return { ok: false, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }; } } // ── Phase 1: Pre-pack validation ─────────────────────────────────── function phase1_config() { heading('Phase 1a: Package.json validation'); const pkg = readPkg(); // 1. files field exists const files = pkg.files as string[] | undefined; check('#1 `files` field exists', Array.isArray(files) && files.length > 0, 'Add a "files" array to package.json to control what ships'); // 2. exports structure const exports = pkg.exports as Record> | undefined; check('#2 `exports["."]` defined', !!exports?.['.'], 'Missing exports["."] in package.json'); if (exports?.['.']) { check('#3 `exports["."].types` field exists', !!exports['.'].types, 'Missing types condition in exports["."]'); } else { fail('#3 `exports["."].types` field exists', 'Skipped — no exports'); } // 4. main field const main = pkg.main as string | undefined; check('#4 `main` field exists', !!main, 'Missing "main" field in package.json'); // 5. types field const types = pkg.types as string | undefined; check('#5 `types` field exists', !!types, 'Missing "types" field in package.json'); // 5c. bin map exposes a Windows-friendly alias (no dot in the name). // The `design.md` bin file is unrunnable on Windows because the `.md` // suffix collides with the Markdown file association, so PowerShell // opens the shim in the user's Markdown editor instead of executing it. // A dot-free alias such as `designmd` lets the npm CMD/PowerShell shims // resolve cleanly via PATHEXT. See https://github.com/google-labs-code/design.md/issues/54. const bin = pkg.bin as Record | string | undefined; const binEntries = typeof bin === 'object' && bin !== null ? Object.keys(bin) : []; const hasDotFreeAlias = binEntries.some((name) => !name.includes('.')); check('#5c bin map exposes a Windows-friendly alias (no dot in the name)', hasDotFreeAlias, `bin entries: ${binEntries.join(', ') || '(none)'} — add an alias without a dot for Windows compatibility`); } function phase1_paths() { heading('Phase 1b: Path resolution (post-build)'); const pkg = readPkg(); const exports = pkg.exports as Record> | undefined; if (exports?.['.']) { const importPath = join(ROOT, exports['.'].import); const typesPath = join(ROOT, exports['.'].types); check('#2b `exports["."].import` resolves', existsSync(importPath), `Missing: ${exports['.'].import}`); check('#3b `exports["."].types` resolves', existsSync(typesPath), `Missing: ${exports['.'].types}`); } const main = pkg.main as string | undefined; check('#4b `main` resolves', !!main && existsSync(join(ROOT, main)), `Missing: ${main}`); const types = pkg.types as string | undefined; check('#5b `types` resolves', !!types && existsSync(join(ROOT, types)), `Missing: ${types}`); } // ── Phase 2: Clean build ─────────────────────────────────────────── function phase2() { heading('Phase 2: Clean build'); // 6. Clean dist const distPath = join(ROOT, 'dist'); if (existsSync(distPath)) { rmSync(distPath, { recursive: true, force: true }); } check('#6 Clean dist', !existsSync(distPath)); // 7. Build const build = exec('bun run build'); check('#7 Build succeeds', build.ok, 'tsc exited with non-zero'); if (!existsSync(distPath)) { fail('#8 No test files in dist', 'dist/ does not exist after build'); fail('#9 No fixture files in dist', 'dist/ does not exist after build'); return; } // 8. No test files in dist const testGlob = new Glob('**/*.test.*'); const testFiles = Array.from(testGlob.scanSync({ cwd: distPath, absolute: false })); check('#8 No test files in dist', testFiles.length === 0, `Found: ${testFiles.join(', ')}`); // 9. No fixture files in dist const fixtureGlob = new Glob('**/fixtures/**'); const fixtureFiles = Array.from(fixtureGlob.scanSync({ cwd: distPath, absolute: false })); check('#9 No fixture files in dist', fixtureFiles.length === 0, `Found: ${fixtureFiles.join(', ')}`); } // ── Phase 3: Pack audit ──────────────────────────────────────────── function phase3() { heading('Phase 3: Pack audit'); // 10. npm pack --dry-run const pack = exec('npm pack --dry-run --json 2>/dev/null'); let fileList: string[] = []; if (pack.ok) { try { const parsed = JSON.parse(pack.stdout); const files = (parsed[0]?.files ?? parsed.files ?? []) as Array<{ path: string }>; fileList = files.map((f) => f.path); } catch { // Fallback: parse the non-JSON dry-run output const lines = pack.stdout.split('\n'); fileList = lines .map((l) => l.replace(/^npm notice\s+\d+[\w.]+\s+/, '').trim()) .filter((l) => l.includes('/') || l.endsWith('.js') || l.endsWith('.json')); } } check('#10 `npm pack --dry-run` succeeds', pack.ok && fileList.length > 0, 'npm pack failed or returned empty file list'); if (fileList.length === 0) { fail('#11 No source .ts files in tarball', 'Skipped — no file list'); fail('#12 No test files in tarball', 'Skipped — no file list'); fail('#13 No config files in tarball', 'Skipped — no file list'); fail('#14 No fixtures in tarball', 'Skipped — no file list'); fail('#15 Entry point in tarball', 'Skipped — no file list'); return; } // 11. No source .ts files (allow .d.ts and .d.ts.map) const rawTs = fileList.filter( (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.d.ts.map') ); check('#11 No source .ts files in tarball', rawTs.length === 0, `Found: ${rawTs.join(', ')}`); // 12. No test files const testInPack = fileList.filter((f) => f.includes('.test.')); check('#12 No test files in tarball', testInPack.length === 0, `Found: ${testInPack.join(', ')}`); // 13. No config files const configInPack = fileList.filter((f) => f.includes('tsconfig')); check('#13 No config files in tarball', configInPack.length === 0, `Found: ${configInPack.join(', ')}`); // 14. No fixtures const fixturesInPack = fileList.filter((f) => f.includes('fixtures')); check('#14 No fixtures in tarball', fixturesInPack.length === 0, `Found: ${fixturesInPack.join(', ')}`); // 15. Entry point present const hasIndex = fileList.some((f) => f.includes('dist/index.js')); const hasTypes = fileList.some((f) => f.includes('dist/index.d.ts')); check('#15 Entry point present in tarball', hasIndex && hasTypes, `index.js: ${hasIndex}, index.d.ts: ${hasTypes}`); } // ── Phase 4: Consumer smoke test ─────────────────────────────────── function phase4() { heading('Phase 4: Consumer smoke test'); const distIndex = join(ROOT, 'dist', 'linter', 'index.js'); const distTypes = join(ROOT, 'dist', 'linter', 'index.d.ts'); // 16. Import resolution (ESM) const tmpDir = mkdtempSync(join(tmpdir(), 'check-pkg-')); const smokeFile = join(tmpDir, 'smoke.mjs'); writeFileSync( smokeFile, `import { lint } from '${distIndex}';\n` + `if (typeof lint !== 'function') { process.exit(1); }\n` + `console.log('ok');\n` ); const importCheck = exec(`node ${smokeFile}`); if (!importCheck.ok || !importCheck.stdout.trim().endsWith('ok')) { console.error('Smoke test failed!'); console.error('STDOUT:', importCheck.stdout); console.error('STDERR:', importCheck.stderr); } check('#16 Import resolution (ESM)', importCheck.ok && importCheck.stdout.trim().endsWith('ok'), 'Could not import lint() from dist/index.js'); // 17. Type declarations exist and export lint const dtsContent = existsSync(distTypes) ? readFileSync(distTypes, 'utf-8') : ''; const hasLintExport = dtsContent.includes('export') && dtsContent.includes('lint'); const hasLintReportType = dtsContent.includes('LintReport'); check('#17 Type declarations valid', hasLintExport && hasLintReportType, `index.d.ts missing lint export or LintReport type`); // 18. Runtime sanity const sanityFile = join(tmpDir, 'sanity.mjs'); writeFileSync( sanityFile, `import { lint } from '${distIndex}';\n` + `const result = lint('---\\nname: Test\\ncolors:\\n primary: "#ff0000"\\n---');\n` + `const keys = Object.keys(result);\n` + `const expected = ['designSystem','findings','summary','tailwindConfig'];\n` + `const hasAll = expected.every(k => keys.includes(k));\n` + `if (!hasAll) {\n` + ` console.error('Missing keys. Actual:', keys);\n` + ` process.exit(1);\n` + `}\n` + `if (typeof result.designSystem !== 'object') { process.exit(1); }\n` + `if (!Array.isArray(result.findings)) { process.exit(1); }\n` + `if (typeof result.summary.errors !== 'number') { process.exit(1); }\n` + `console.log('ok');\n` ); const sanityCheck = exec(`node ${sanityFile}`); if (!sanityCheck.ok || !sanityCheck.stdout.trim().endsWith('ok')) { console.error('Sanity check failed!'); console.error('STDOUT:', sanityCheck.stdout); console.error('STDERR:', sanityCheck.stderr); } check('#18 Runtime sanity', sanityCheck.ok && sanityCheck.stdout.trim().endsWith('ok'), 'lint() did not return expected shape'); // 19. CLI entry point works const cliIndex = join(ROOT, 'dist', 'index.js'); const cliCheck = exec(`node ${cliIndex} --help`); check('#19 CLI entry point valid', cliCheck.ok && !cliCheck.stderr.includes('ENOENT'), 'CLI failed to run or reported missing files'); // 20. CLI spec command works const specCheck = exec(`node ${cliIndex} spec`); check('#20 CLI spec command valid', specCheck.ok && !specCheck.stderr.includes('Failed to load spec.md'), 'CLI spec command failed to load spec.md'); // Cleanup try { rmSync(tmpDir, { recursive: true, force: true }); } catch { // best effort } } // ── Main ─────────────────────────────────────────────────────────── console.log('🔍 Package verification: @google/design.md\n'); phase1_config(); phase2(); phase1_paths(); phase3(); phase4(); console.log(`\n${'═'.repeat(60)}`); console.log(` ✅ ${passed} passed ❌ ${failed} failed`); console.log(`${'═'.repeat(60)}\n`); if (failed > 0) { process.exit(1); }