From 4bc8ec876af9c972ba36432bf3a9ef0f85566b43 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Tue, 14 Apr 2026 08:40:36 +0100 Subject: [PATCH] [AI] Add publishConfig.imports sync validator with pre-commit integration (#7469) * [AI] Add publishConfig.imports sync validator with pre-commit integration Add a TypeScript script that validates publishConfig.imports stays in sync with imports in all packages/*/package.json files. Runs automatically in the pre-commit hook via lint-staged with --fix mode. Co-Authored-By: Claude Opus 4.6 (1M context) * [AI] Add release notes for #7469 Co-Authored-By: Claude Opus 4.6 (1M context) * [AI] Guard main() with require.main and respect lint-staged file args Co-Authored-By: Claude Opus 4.6 (1M context) * [AI] Handle non-string imports targets in derivePublishImports - Add type guard to check for non-string values in imports - Throw descriptive error when conditional imports are encountered - Update type signature to accept Record - Add test case for non-string imports error handling This prevents TypeError when packages have conditional imports (e.g., #browser-preload in desktop-client) Co-authored-by: Matiss Janis Aboltins * [AI] Limit resolvePackageJsonPaths scope to packages directory - Add packagesRoot constant to restrict path resolution - Update while loop condition to only traverse within packages directory - Add additional check to ensure candidate paths are under packages/ - Prevents resolution to repo root package.json for missing/deleted files This ensures the validator only processes package.json files under packages/* and avoids accidentally targeting the monorepo root manifest. Co-authored-by: Matiss Janis Aboltins --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Cursor Agent Co-authored-by: Matiss Janis Aboltins --- .../validate-publish-imports.test.ts | 218 ++++++++++++++++++ bin/validate-publish-imports.ts | 216 +++++++++++++++++ bin/vitest.config.mts | 9 + package.json | 3 + tsconfig.json | 3 +- upcoming-release-notes/7469.md | 6 + vitest.config.ts | 1 + 7 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 bin/__tests__/validate-publish-imports.test.ts create mode 100644 bin/validate-publish-imports.ts create mode 100644 bin/vitest.config.mts create mode 100644 upcoming-release-notes/7469.md diff --git a/bin/__tests__/validate-publish-imports.test.ts b/bin/__tests__/validate-publish-imports.test.ts new file mode 100644 index 0000000000..24c94292fb --- /dev/null +++ b/bin/__tests__/validate-publish-imports.test.ts @@ -0,0 +1,218 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + derivePublishImports, + validatePackage, +} from '../validate-publish-imports.js'; + +describe('derivePublishImports', () => { + it('prepends ./build/ to .js paths', () => { + const imports = { + '#account-db': './src/account-db.js', + }; + expect(derivePublishImports(imports)).toEqual({ + '#account-db': './build/src/account-db.js', + }); + }); + + it('converts .ts extension to .js and prepends ./build/', () => { + const imports = { + '#migrations': './src/migrations.ts', + }; + expect(derivePublishImports(imports)).toEqual({ + '#migrations': './build/src/migrations.js', + }); + }); + + it('converts .tsx extension to .js and prepends ./build/', () => { + const imports = { + '#component': './src/component.tsx', + }; + expect(derivePublishImports(imports)).toEqual({ + '#component': './build/src/component.js', + }); + }); + + it('preserves wildcard patterns', () => { + const imports = { + '#accounts/*': './src/accounts/*.js', + '#services/*': './src/app-gocardless/services/*.ts', + }; + expect(derivePublishImports(imports)).toEqual({ + '#accounts/*': './build/src/accounts/*.js', + '#services/*': './build/src/app-gocardless/services/*.js', + }); + }); + + it('handles multiple entries with mixed extensions', () => { + const imports = { + '#account-db': './src/account-db.js', + '#migrations': './src/migrations.ts', + '#app-gocardless/errors': './src/app-gocardless/errors.ts', + '#util/*': './src/util/*.ts', + '#scripts/*': './src/scripts/*.js', + }; + expect(derivePublishImports(imports)).toEqual({ + '#account-db': './build/src/account-db.js', + '#migrations': './build/src/migrations.js', + '#app-gocardless/errors': './build/src/app-gocardless/errors.js', + '#util/*': './build/src/util/*.js', + '#scripts/*': './build/src/scripts/*.js', + }); + }); + + it('returns empty object for empty imports', () => { + expect(derivePublishImports({})).toEqual({}); + }); + + it('throws error for non-string imports values', () => { + const imports = { + '#foo': './src/foo.js', + '#conditional': { + browser: './src/browser.js', + node: './src/node.js', + }, + }; + expect(() => derivePublishImports(imports)).toThrow( + 'Unsupported imports target for "#conditional". Expected a string path.', + ); + }); + + it('handles paths with /index.js suffix', () => { + const imports = { + '#util/title': './src/util/title/index.js', + }; + expect(derivePublishImports(imports)).toEqual({ + '#util/title': './build/src/util/title/index.js', + }); + }); +}); + +describe('validatePackage', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-imports-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writePackageJson(content: Record) { + const filePath = path.join(tmpDir, 'package.json'); + fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n'); + return filePath; + } + + it('skips packages with no publishConfig', () => { + const filePath = writePackageJson({ + name: 'test-pkg', + imports: { '#foo': './src/foo.js' }, + }); + const { result, warnings } = validatePackage(filePath); + expect(result).toBeNull(); + expect(warnings).toEqual([]); + }); + + it('skips packages with publishConfig but no publishConfig.imports', () => { + const filePath = writePackageJson({ + name: 'test-pkg', + imports: { '#foo': './src/foo.js' }, + publishConfig: { access: 'public' }, + }); + const { result, warnings } = validatePackage(filePath); + expect(result).toBeNull(); + expect(warnings).toEqual([]); + }); + + it('warns when publishConfig.imports exists but imports does not', () => { + const filePath = writePackageJson({ + name: 'test-pkg', + publishConfig: { + imports: { '#foo': './build/src/foo.js' }, + }, + }); + const { result, warnings } = validatePackage(filePath); + expect(result).toBeNull(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('orphaned'); + }); + + it('returns no errors when publishConfig.imports matches', () => { + const filePath = writePackageJson({ + name: 'test-pkg', + imports: { + '#foo': './src/foo.js', + '#bar': './src/bar.ts', + }, + publishConfig: { + imports: { + '#foo': './build/src/foo.js', + '#bar': './build/src/bar.js', + }, + }, + }); + const { result } = validatePackage(filePath); + expect(result).not.toBeNull(); + expect(result!.missingKeys).toEqual([]); + expect(result!.extraKeys).toEqual([]); + expect(result!.wrongValues).toEqual([]); + }); + + it('detects missing keys in publishConfig.imports', () => { + const filePath = writePackageJson({ + name: 'test-pkg', + imports: { + '#foo': './src/foo.js', + '#bar': './src/bar.ts', + }, + publishConfig: { + imports: { + '#foo': './build/src/foo.js', + }, + }, + }); + const { result } = validatePackage(filePath); + expect(result!.missingKeys).toEqual(['#bar']); + }); + + it('detects extra keys in publishConfig.imports', () => { + const filePath = writePackageJson({ + name: 'test-pkg', + imports: { + '#foo': './src/foo.js', + }, + publishConfig: { + imports: { + '#foo': './build/src/foo.js', + '#orphan': './build/src/orphan.js', + }, + }, + }); + const { result } = validatePackage(filePath); + expect(result!.extraKeys).toEqual(['#orphan']); + }); + + it('detects wrong values in publishConfig.imports', () => { + const filePath = writePackageJson({ + name: 'test-pkg', + imports: { + '#foo': './src/foo.ts', + }, + publishConfig: { + imports: { + '#foo': './src/foo.ts', + }, + }, + }); + const { result } = validatePackage(filePath); + expect(result!.wrongValues).toEqual([ + { key: '#foo', expected: './build/src/foo.js', actual: './src/foo.ts' }, + ]); + }); +}); diff --git a/bin/validate-publish-imports.ts b/bin/validate-publish-imports.ts new file mode 100644 index 0000000000..11980b9bfe --- /dev/null +++ b/bin/validate-publish-imports.ts @@ -0,0 +1,216 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Derives publishConfig.imports from imports by: + * 1. Prepending ./build/ to each value path + * 2. Replacing .ts/.tsx extensions with .js + */ +export function derivePublishImports( + imports: Record, +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(imports)) { + if (typeof value !== 'string') { + throw new Error( + `Unsupported imports target for "${key}". Expected a string path.`, + ); + } + const withBuildPrefix = value.replace(/^\.\//, './build/'); + const withJsExtension = withBuildPrefix.replace(/\.tsx?$/, '.js'); + result[key] = withJsExtension; + } + return result; +} + +export type ValidationResult = { + packagePath: string; + packageName: string; + missingKeys: string[]; + extraKeys: string[]; + wrongValues: Array<{ key: string; expected: string; actual: string }>; +}; + +/** + * Validates publishConfig.imports against imports for a single package.json. + * Returns null if the package should be skipped (no publishConfig.imports). + * Returns a ValidationResult if the package has both fields. + */ +export function validatePackage(packageJsonPath: string): { + result: ValidationResult | null; + warnings: string[]; +} { + const warnings: string[] = []; + const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const packageName: string = content.name ?? packageJsonPath; + + const imports: Record | undefined = content.imports; + const publishImports: Record | undefined = + content.publishConfig?.imports; + + // No publishConfig.imports → skip + if (!publishImports) { + return { result: null, warnings }; + } + + // Has publishConfig.imports but no imports → warn + if (!imports) { + warnings.push( + `${packageName}: orphaned publishConfig.imports (no imports field)`, + ); + return { result: null, warnings }; + } + + const expected = derivePublishImports(imports); + const expectedKeys = new Set(Object.keys(expected)); + const actualKeys = new Set(Object.keys(publishImports)); + + const missingKeys = [...expectedKeys].filter(k => !actualKeys.has(k)); + const extraKeys = [...actualKeys].filter(k => !expectedKeys.has(k)); + const wrongValues: ValidationResult['wrongValues'] = []; + + for (const key of expectedKeys) { + if (actualKeys.has(key) && publishImports[key] !== expected[key]) { + wrongValues.push({ + key, + expected: expected[key], + actual: publishImports[key], + }); + } + } + + return { + result: { + packagePath: packageJsonPath, + packageName, + missingKeys, + extraKeys, + wrongValues, + }, + warnings, + }; +} + +export function fixPackage(packageJsonPath: string): boolean { + const raw = fs.readFileSync(packageJsonPath, 'utf-8'); + const content = JSON.parse(raw); + + if (!content.imports || !content.publishConfig?.imports) { + return false; + } + + const expected = derivePublishImports(content.imports); + + // Check if already correct + if ( + JSON.stringify(content.publishConfig.imports) === JSON.stringify(expected) + ) { + return false; + } + + content.publishConfig.imports = expected; + fs.writeFileSync(packageJsonPath, JSON.stringify(content, null, 2) + '\n'); + return true; +} + +function findPackageJsonFiles(): string[] { + const packagesDir = path.resolve(__dirname, '..', 'packages'); + const entries = fs.readdirSync(packagesDir, { withFileTypes: true }); + const results: string[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const pkgPath = path.join(packagesDir, entry.name, 'package.json'); + if (fs.existsSync(pkgPath)) { + results.push(pkgPath); + } + } + } + return results; +} + +function resolvePackageJsonPaths(filePaths: string[]): string[] { + const packagesRoot = path.resolve(__dirname, '..', 'packages'); + const seen = new Set(); + for (const filePath of filePaths) { + const resolvedPath = path.resolve(filePath); + let dir = path.dirname(resolvedPath); + while (dir.startsWith(packagesRoot + path.sep)) { + const candidate = path.join(dir, 'package.json'); + if ( + fs.existsSync(candidate) && + candidate.startsWith(packagesRoot + path.sep) + ) { + seen.add(candidate); + break; + } + dir = path.dirname(dir); + } + } + return [...seen]; +} + +function main() { + const args = process.argv.slice(2); + const fixMode = args.includes('--fix'); + const filePaths = args.filter(arg => !arg.startsWith('--')); + + const packageJsonFiles = + filePaths.length > 0 + ? resolvePackageJsonPaths(filePaths) + : findPackageJsonFiles(); + + let hasErrors = false; + const allWarnings: string[] = []; + + for (const pkgPath of packageJsonFiles) { + if (fixMode) { + const fixed = fixPackage(pkgPath); + if (fixed) { + const name = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).name; + console.log(`Fixed publishConfig.imports in ${name}`); + } + } else { + const { result, warnings } = validatePackage(pkgPath); + allWarnings.push(...warnings); + + if (result) { + const hasIssues = + result.missingKeys.length > 0 || + result.extraKeys.length > 0 || + result.wrongValues.length > 0; + + if (hasIssues) { + hasErrors = true; + console.error(`\n${result.packageName}:`); + + for (const key of result.missingKeys) { + console.error(` Missing key: ${key}`); + } + for (const key of result.extraKeys) { + console.error(` Extra key: ${key}`); + } + for (const { key, expected, actual } of result.wrongValues) { + console.error(` Wrong value for ${key}:`); + console.error(` expected: ${expected}`); + console.error(` actual: ${actual}`); + } + } + } + } + } + + for (const warning of allWarnings) { + console.warn(`Warning: ${warning}`); + } + + if (hasErrors) { + console.error( + '\npublishConfig.imports is out of sync. Run with --fix to auto-fix.', + ); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} diff --git a/bin/vitest.config.mts b/bin/vitest.config.mts new file mode 100644 index 0000000000..a396249aae --- /dev/null +++ b/bin/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['__tests__/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/package.json b/package.json index d96bb80746..9d3cdfe8ac 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,9 @@ "socks": ">=2.8.3" }, "lint-staged": { + "packages/*/package.json": [ + "ts-node ./bin/validate-publish-imports.ts --fix" + ], "*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [ "oxfmt --no-error-on-unmatched-pattern" ], diff --git a/tsconfig.json b/tsconfig.json index 150e8c41af..6cf84586ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,7 +53,8 @@ ], "ts-node": { "compilerOptions": { - "module": "CommonJS" + "module": "CommonJS", + "types": ["node"] } } } diff --git a/upcoming-release-notes/7469.md b/upcoming-release-notes/7469.md new file mode 100644 index 0000000000..f7e14078db --- /dev/null +++ b/upcoming-release-notes/7469.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Add publishConfig.imports sync validator with pre-commit integration diff --git a/vitest.config.ts b/vitest.config.ts index c1665c22f5..396ff120a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: [ + 'bin/vitest.config.mts', 'packages/*/vitest.config.{ts,mts}', 'packages/component-library/vitest.web.config.ts', {