[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) <[email protected]>

* [AI] Add release notes for #7469

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* [AI] Guard main() with require.main and respect lint-staged file args

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* [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<string, string | object>
- 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 <[email protected]>

* [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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Matiss Janis Aboltins <[email protected]>
This commit is contained in:
Matiss Janis Aboltins
2026-04-14 07:40:36 +00:00
committed by GitHub
co-authored by Matiss Janis Aboltins Claude Opus 4.6 Cursor Agent
parent c4f3fb0b93
commit 4bc8ec876a
7 changed files with 455 additions and 1 deletions
@@ -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<string, unknown>) {
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' },
]);
});
});
+216
View File
@@ -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<string, string | object>,
): Record<string, string> {
const result: Record<string, string> = {};
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<string, string | object> | undefined = content.imports;
const publishImports: Record<string, string> | 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<string>();
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();
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['__tests__/**/*.test.ts'],
environment: 'node',
},
});
+3
View File
@@ -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"
],
+2 -1
View File
@@ -53,7 +53,8 @@
],
"ts-node": {
"compilerOptions": {
"module": "CommonJS"
"module": "CommonJS",
"types": ["node"]
}
}
}
+6
View File
@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Add publishConfig.imports sync validator with pre-commit integration
+1
View File
@@ -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',
{