mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 13:48:14 +00:00
358 lines
13 KiB
JavaScript
358 lines
13 KiB
JavaScript
/**
|
|
* 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
|
|
*/
|
|
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 };
|
|
}
|
|
|
|
// ── 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 },
|
|
];
|
|
|
|
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);
|