/** * Workspace consistency checker. * * Validates that manually-maintained lists across config files stay in sync * with the actual package structure. Run via `pnpm check:workspace`. * * Checks: * 1. CI test coverage — every testable package is tested in CI * 2. Commitlint scopes — every package dir is a valid commit scope * 3. Root tsconfig references — every composite project is referenced * 4. Package metadata — non-private packages have required fields * 5. Release-please config — every versioned package is registered * 6. Define imports — no bare side-effect imports from relative paths * 7. i18n locales — tag lists match locale files and generated stubs */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const PACKAGES_DIR = join(ROOT, 'packages'); // ── Helpers ───────────────────────────────────────────────────────────────── function readText(path) { return readFileSync(path, 'utf8'); } function readJson(path) { const text = readText(path); // Strip single-line // comments (for tsconfig files). const stripped = text.replace(/^\s*\/\/.*$/gm, ''); return JSON.parse(stripped); } /** Lists package directory names that contain a package.json. */ function getPackageDirs() { return readdirSync(PACKAGES_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory() && existsSync(join(PACKAGES_DIR, d.name, 'package.json'))) .map((d) => d.name); } function readPackageJson(dir) { return readJson(join(PACKAGES_DIR, dir, 'package.json')); } // ── Check 1: CI test coverage ─────────────────────────────────────────────── function checkCiTestCoverage() { const warnings = []; const ciText = readText(join(ROOT, '.github/workflows/ci.yml')); // Collect package names from the test matrix. const matrixMatch = ciText.match(/matrix:\s*\n\s*package:\s*\n((?:\s*-\s*'[^']+'\s*\n)+)/); const testedInCi = new Set(); if (matrixMatch) { for (const m of matrixMatch[1].matchAll(/'([^']+)'/g)) { testedInCi.add(m[1]); } } // Collect package names from standalone test jobs (e.g. test-spf). // Match: `--filter="@videojs/xxx"` in turbo run test commands. for (const m of ciText.matchAll(/turbo run test --filter="([^"]+)"/g)) { testedInCi.add(m[1]); } // Find packages that have a "test" script but aren't tested in CI. for (const dir of getPackageDirs()) { const pkg = readPackageJson(dir); if (!pkg.scripts?.test) continue; if (!testedInCi.has(pkg.name)) { warnings.push(`${pkg.name} has a "test" script but is not tested in CI`); } } return { ok: warnings.length === 0, warnings }; } // ── Check 2: Commitlint scope-enum ────────────────────────────────────────── /** * Known aliases where the commit scope differs from the directory name. * Key: directory name, Value: expected scope. */ const SCOPE_ALIASES = new Map([['skins', 'skin']]); function checkCommitlintScopes() { const warnings = []; const text = readText(join(ROOT, 'commitlint.config.js')); // Extract the array from scope-enum rule. const match = text.match(/scope-enum[^[]*\[([^\]]+)\]/s); if (!match) { return { ok: false, warnings: ['Could not parse scope-enum from commitlint.config.js'] }; } const scopes = new Set([...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1])); for (const dir of getPackageDirs()) { const scope = SCOPE_ALIASES.get(dir) ?? dir; if (!scopes.has(scope)) { warnings.push(`Package dir "${dir}" missing from commitlint scope-enum (expected scope: "${scope}")`); } } return { ok: warnings.length === 0, warnings }; } // ── Check 3: Root tsconfig references ─────────────────────────────────────── /** * Intentionally excluded from root references. * packages/icons: private, custom build script, uses outDir/rootDir instead of declarationDir. */ const TSCONFIG_EXCLUDE = new Set(['packages/icons']); function checkTsconfigReferences() { const warnings = []; const rootTsconfig = readJson(join(ROOT, 'tsconfig.json')); const baseTsconfig = readJson(join(ROOT, 'tsconfig.base.json')); const referenced = new Set(rootTsconfig.references.map((r) => r.path)); // Base config sets composite: true, so all extending configs inherit it. const baseComposite = baseTsconfig.compilerOptions?.composite === true; // Find all tsconfig.json files under packages/ recursively. function findTsconfigs(dir, results = []) { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'types') { continue; } const full = join(dir, entry.name); if (entry.isDirectory()) { findTsconfigs(full, results); } else if (entry.name === 'tsconfig.json') { results.push(full); } } return results; } for (const tsconfigPath of findTsconfigs(PACKAGES_DIR)) { const relative = tsconfigPath.slice(ROOT.length + 1).replace(/\/tsconfig\.json$/, ''); if (TSCONFIG_EXCLUDE.has(relative)) continue; const tsconfig = readJson(tsconfigPath); const explicitComposite = tsconfig.compilerOptions?.composite; // Composite if explicitly true, or inherited from base and not overridden. const isComposite = explicitComposite === true || (explicitComposite === undefined && baseComposite); if (!isComposite) continue; if (!referenced.has(relative)) { warnings.push(`Missing reference: ${relative}`); } } return { ok: warnings.length === 0, warnings }; } // ── Check 4: Package metadata ─────────────────────────────────────────────── /** Required on all non-private packages. */ const REQUIRED_FIELDS = ['sideEffects', 'files', 'exports']; /** Required only when the package has a root "." export. */ const ROOT_EXPORT_FIELDS = ['main', 'module', 'types']; /** * Packages excluded from metadata checks. * CLI is bin-only — sideEffects/exports don't apply. */ const METADATA_EXCLUDE = new Set(['cli']); function checkPackageMetadata() { const warnings = []; for (const dir of getPackageDirs()) { const pkg = readPackageJson(dir); // Skip private packages — they're internal. if (pkg.private) continue; // Skip packages that don't need library metadata. if (METADATA_EXCLUDE.has(dir)) continue; // publishConfig.access is required for scoped public packages. if (pkg.publishConfig?.access !== 'public') { warnings.push(`${pkg.name}: missing publishConfig.access = "public"`); } for (const field of REQUIRED_FIELDS) { if (pkg[field] === undefined) { warnings.push(`${pkg.name}: missing "${field}"`); } } // main/module/types only required when there's a root "." export. const hasRootExport = pkg.exports?.['.'] !== undefined; if (hasRootExport) { for (const field of ROOT_EXPORT_FIELDS) { if (pkg[field] === undefined) { warnings.push(`${pkg.name}: missing "${field}" (has "." export)`); } } } } return { ok: warnings.length === 0, warnings }; } // ── Check 5: Release-please config ────────────────────────────────────────── function checkReleasePleaseConfig() { const warnings = []; const config = readJson(join(ROOT, '.github/release-please/release-please-config.json')); const configPackages = new Set(Object.keys(config.packages)); const linkedVersions = config.plugins.find((p) => p.type === 'linked-versions'); const components = new Set(linkedVersions?.components ?? []); // Every package with a version field should be registered. for (const dir of getPackageDirs()) { const pkg = readPackageJson(dir); if (!pkg.version) continue; const pkgPath = `packages/${dir}`; if (!configPackages.has(pkgPath)) { warnings.push(`${pkg.name}: missing from release-please packages`); } if (!components.has(pkg.name)) { warnings.push(`${pkg.name}: missing from release-please linked-versions components`); } } return { ok: warnings.length === 0, warnings }; } // ── Check 6: Bundled docs publishing ───────────────────────────────────────── /** * `@videojs/html` and `@videojs/react` ship the per-framework markdown docs * subtree inside their tarballs (see `site/scripts/copy-package-docs.js`). * Both wires (the `files[]` entry and the `prepack` script) must stay in sync * — without one, publishing silently drops the docs. */ function checkBundledDocs() { const warnings = []; for (const dir of ['html', 'react']) { const pkg = readPackageJson(dir); if (!pkg.files?.includes('docs')) { warnings.push(`${pkg.name}: missing "docs" entry in "files" — bundled docs would not ship`); } const prepack = pkg.scripts?.prepack; const expected = `node --import tsx ../../site/scripts/copy-package-docs.ts ${dir}`; if (prepack !== expected) { warnings.push(`${pkg.name}: prepack script should be \`${expected}\` (got: ${prepack ?? 'missing'})`); } } return { ok: warnings.length === 0, warnings }; } // ── Check 7: Define imports ────────────────────────────────────────────────── /** * Bare side-effect imports from relative paths in the define directory cause * non-deterministic registration order when loaded as native ESM in the * browser. All registration must go through explicit safeDefine() calls. */ function checkDefineImports() { const warnings = []; const defineDir = join(PACKAGES_DIR, 'html/src/define'); if (!existsSync(defineDir)) { return { ok: true, warnings: [] }; } // Matches: import './foo'; import "../bar"; import './foo/bar'; // Ignores value imports: import { X } from './foo'; import X from './foo'; // Ignores CSS imports: import './foo.css'; import './foo.css?inline'; const sideEffectImportRe = /^import\s+['"](\.[^'"]+)['"]\s*;/gm; const cssSpecifierRe = /\.css(?:\?|$)/; function findTsFiles(dir, results = []) { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'tests') continue; const full = join(dir, entry.name); if (entry.isDirectory()) { findTsFiles(full, results); } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { results.push(full); } } return results; } for (const filePath of findTsFiles(defineDir)) { const content = readText(filePath); const relative = filePath.slice(ROOT.length + 1); const sideEffects = []; for (const match of content.matchAll(sideEffectImportRe)) { const specifier = match[1]; if (cssSpecifierRe.test(specifier)) continue; sideEffects.push(specifier); } // A single side-effect import (e.g. skin importing its ui module) is safe // because ESM evaluates it synchronously before the importing module's body. // Multiple side-effect imports are the problem — they race in the browser. if (sideEffects.length > 1) { for (const specifier of sideEffects) { warnings.push(`${relative}: bare side-effect import "${specifier}" — use safeDefine() instead`); } } } return { ok: warnings.length === 0, warnings }; } // ── Check 7: i18n locale consistency ───────────────────────────────────────── const GENERATED_I18N_HEADER = '/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */'; function parseLocaleTagArray(source, exportName) { const match = source.match(new RegExp(`export const ${exportName} = \\[([\\s\\S]*?)\\] as const`)); if (!match) { return undefined; } return [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]); } function localeAliases(tags) { const counts = new Map(); for (const tag of tags) { if (!tag.includes('-')) continue; const lang = tag.split('-')[0]; counts.set(lang, (counts.get(lang) ?? 0) + 1); } return [...counts].filter(([, count]) => count > 1).map(([lang]) => lang); } function checkI18nLocales() { const warnings = []; const builtInPath = join(PACKAGES_DIR, 'core/src/core/i18n/locales.ts'); const builtInSource = readText(builtInPath); const locales = parseLocaleTagArray(builtInSource, 'LOCALES'); if (locales === undefined) { warnings.push('Could not parse LOCALES from packages/core/src/core/i18n/locales.ts'); return { ok: false, warnings }; } const localeFiles = [...locales, ...localeAliases(locales)]; const coreLocalesDir = join(PACKAGES_DIR, 'core/src/core/i18n/locales'); if (!existsSync(coreLocalesDir)) { warnings.push('Missing generated locale directory packages/core/src/core/i18n/locales'); return { ok: false, warnings }; } const coreFiles = readdirSync(coreLocalesDir) .filter((file) => file.endsWith('.ts')) .map((file) => file.slice(0, -3)); const expectedCore = new Set(['all', 'en', ...localeFiles]); for (const tag of localeFiles) { if (!coreFiles.includes(tag)) { warnings.push(`LOCALES tag "${tag}" has no packages/core/src/core/i18n/locales/${tag}.ts`); } } for (const file of coreFiles) { if (!expectedCore.has(file)) { warnings.push(`Unexpected locale file packages/core/src/core/i18n/locales/${file}.ts (not in locales.ts)`); } } const allPath = join(coreLocalesDir, 'all.ts'); if (!existsSync(allPath)) { warnings.push('Missing generated locale bundle packages/core/src/core/i18n/locales/all.ts'); } else if (!readText(allPath).startsWith(GENERATED_I18N_HEADER)) { warnings.push( 'packages/core/src/core/i18n/locales/all.ts is not generated — run pnpm -F @videojs/core generate:locales' ); } const loadLocalePath = join(PACKAGES_DIR, 'core/src/core/i18n/load-locale.ts'); if (!existsSync(loadLocalePath)) { warnings.push('Missing generated locale loader packages/core/src/core/i18n/load-locale.ts'); } else { const loadLocaleSource = readText(loadLocalePath); if (!loadLocaleSource.startsWith(GENERATED_I18N_HEADER)) { warnings.push( 'packages/core/src/core/i18n/load-locale.ts is not generated — run pnpm -F @videojs/core generate:locales' ); } else { const loaderTags = new Set( [...loadLocaleSource.matchAll(/import\('\.\/locales\/([^']+)'\)/g)].map((match) => match[1]) ); for (const tag of localeFiles) { if (!loaderTags.delete(tag)) { warnings.push(`LOCALES tag "${tag}" has no lazy importer in packages/core/src/core/i18n/load-locale.ts`); } } for (const tag of loaderTags) { warnings.push(`Unexpected lazy importer packages/core/src/core/i18n/load-locale.ts for "${tag}"`); } } } for (const pkg of ['html', 'react']) { const localesDir = join(PACKAGES_DIR, `${pkg}/src/i18n/locales`); const expectedPlatform = new Set(['all', 'en', ...localeFiles]); if (!existsSync(localesDir)) { warnings.push(`Missing generated re-export directory packages/${pkg}/src/i18n/locales`); continue; } for (const tag of expectedPlatform) { const filePath = join(localesDir, `${tag}.ts`); if (!existsSync(filePath)) { warnings.push(`Missing generated re-export packages/${pkg}/src/i18n/locales/${tag}.ts`); continue; } if (!readText(filePath).startsWith(GENERATED_I18N_HEADER)) { warnings.push( `packages/${pkg}/src/i18n/locales/${tag}.ts is not generated — run pnpm -F @videojs/core generate:locales` ); } } for (const file of readdirSync(localesDir)) { if (!file.endsWith('.ts')) continue; const tag = file.slice(0, -3); if (!expectedPlatform.has(tag)) { warnings.push(`Unexpected locale re-export packages/${pkg}/src/i18n/locales/${file}`); } } } return { ok: warnings.length === 0, warnings }; } // ── Main ──────────────────────────────────────────────────────────────────── const checks = [ { name: 'CI test coverage', fn: checkCiTestCoverage }, { name: 'Commitlint scopes', fn: checkCommitlintScopes }, { name: 'Root tsconfig references', fn: checkTsconfigReferences }, { name: 'Package metadata', fn: checkPackageMetadata }, { name: 'Release-please config', fn: checkReleasePleaseConfig }, { name: 'Bundled docs publishing', fn: checkBundledDocs }, { name: 'Define imports', fn: checkDefineImports }, { name: 'i18n locales', fn: checkI18nLocales }, ]; let failed = 0; for (const check of checks) { const result = check.fn(); if (result.ok) { console.log(`\x1b[32m✓\x1b[0m ${check.name}`); } else { failed++; console.log(`\x1b[31m✗\x1b[0m ${check.name}`); for (const w of result.warnings) { console.log(` ${w}`); } } } console.log(); console.log(`${checks.length - failed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);