From 2aa832ea3fa94ab8e7fd6033d1cba4a9e8d507d8 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:17:31 +0100 Subject: [PATCH] feat: group format messages into one Publint emits messages when you have ESM files in a CJS package but the extension doesn't match. For example, 'foo.js' in a CJS package which contains ESM should really be `foo.mjs`. This is often a very noisy message in a repo that matches it since its usually the case that _many_ files have this issue or none do. This change basically groups them all up into one summary message so you can investigate yourself. --- src/analyze/publint.ts | 51 ++++++++++++++++++++++++ src/test/analyze/publint.test.ts | 53 +++++++++++++++++++++++++ test/fixtures/lazy-esm-cjs/a.js | 1 + test/fixtures/lazy-esm-cjs/b.js | 1 + test/fixtures/lazy-esm-cjs/c.js | 1 + test/fixtures/lazy-esm-cjs/package.json | 6 +++ 6 files changed, 113 insertions(+) create mode 100644 src/test/analyze/publint.test.ts create mode 100644 test/fixtures/lazy-esm-cjs/a.js create mode 100644 test/fixtures/lazy-esm-cjs/b.js create mode 100644 test/fixtures/lazy-esm-cjs/c.js create mode 100644 test/fixtures/lazy-esm-cjs/package.json diff --git a/src/analyze/publint.ts b/src/analyze/publint.ts index c1b722f..d3b8967 100644 --- a/src/analyze/publint.ts +++ b/src/analyze/publint.ts @@ -1,7 +1,26 @@ import {publint} from 'publint'; import {formatMessage} from 'publint/utils'; +import type {Message as PublintMessage} from 'publint'; import type {ReportPluginResult, AnalysisContext} from '../types.js'; +type FormatMessage = Extract< + PublintMessage, + {code: 'FILE_INVALID_FORMAT' | 'FILE_INVALID_EXPLICIT_FORMAT'} +>; + +function groupFormatMessages(messages: FormatMessage[]): string { + const [first] = messages; + const count = messages.length; + const {actualFormat, expectFormat, actualExtension, expectExtension} = + first.args; + + if (first.code === 'FILE_INVALID_EXPLICIT_FORMAT') { + return `${count} files end with the ${actualExtension} extension, but the code is written in ${actualFormat}. Consider using the ${expectExtension} extension.`; + } + + return `${count} files are written in ${actualFormat}, but are interpreted as ${expectFormat}. Consider using the ${expectExtension} extension.`; +} + export async function runPublint( context: AnalysisContext ): Promise { @@ -11,7 +30,24 @@ export async function runPublint( try { const publintResult = await publint({pack: 'auto', pkgDir: context.root}); + + const groups = new Map(); for (const problem of publintResult.messages) { + if ( + problem.code === 'FILE_INVALID_FORMAT' || + problem.code === 'FILE_INVALID_EXPLICIT_FORMAT' + ) { + const {actualFormat, expectFormat, expectExtension} = problem.args; + const key = `${problem.code}:${actualFormat}:${expectFormat}:${expectExtension}`; + const group = groups.get(key); + if (group) { + group.push(problem); + } else { + groups.set(key, [problem]); + } + continue; + } + result.messages.push({ severity: problem.type, score: 0, @@ -19,6 +55,21 @@ export async function runPublint( message: formatMessage(problem, publintResult.pkg) ?? '' }); } + + for (const group of groups.values()) { + const [first] = group; + const message = + group.length === 1 + ? (formatMessage(first, publintResult.pkg) ?? '') + : groupFormatMessages(group); + + result.messages.push({ + severity: first.type, + score: 0, + file: 'package.json', + message + }); + } } catch (error) { console.error(`Failed to run publint: ${error}`); } diff --git a/src/test/analyze/publint.test.ts b/src/test/analyze/publint.test.ts new file mode 100644 index 0000000..43599ff --- /dev/null +++ b/src/test/analyze/publint.test.ts @@ -0,0 +1,53 @@ +import {describe, it, expect} from 'vitest'; +import path from 'node:path'; +import {runPublint} from '../../analyze/publint.js'; +import {LocalFileSystem} from '../../local-file-system.js'; +import type {AnalysisContext} from '../../types.js'; + +function makeContext(root: string): AnalysisContext { + return { + fs: new LocalFileSystem(root), + root, + messages: [], + stats: { + name: 'test-package', + version: '1.0.0', + dependencyCount: {production: 0, development: 0}, + extraStats: [] + }, + lockfile: { + type: 'npm', + packages: [], + root: { + name: 'test-package', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + }, + packageFile: { + name: 'test-package', + version: '1.0.0' + } + }; +} + +describe('runPublint', () => { + it('groups per-file format warnings into a single message', async () => { + const fixture = path.join(__dirname, '../../../test/fixtures/lazy-esm-cjs'); + + const result = await runPublint(makeContext(fixture)); + + const formatMessages = result.messages.filter((m) => + m.message.includes('are interpreted as') + ); + + expect(formatMessages).toHaveLength(1); + expect(formatMessages[0]?.severity).toBe('warning'); + expect(formatMessages[0]?.message).toBe( + '4 files are written in ESM, but are interpreted as CJS. Consider using the .mjs extension.' + ); + }); +}); diff --git a/test/fixtures/lazy-esm-cjs/a.js b/test/fixtures/lazy-esm-cjs/a.js new file mode 100644 index 0000000..cc798ff --- /dev/null +++ b/test/fixtures/lazy-esm-cjs/a.js @@ -0,0 +1 @@ +export const a = 1; diff --git a/test/fixtures/lazy-esm-cjs/b.js b/test/fixtures/lazy-esm-cjs/b.js new file mode 100644 index 0000000..2021030 --- /dev/null +++ b/test/fixtures/lazy-esm-cjs/b.js @@ -0,0 +1 @@ +export const b = 2; diff --git a/test/fixtures/lazy-esm-cjs/c.js b/test/fixtures/lazy-esm-cjs/c.js new file mode 100644 index 0000000..5f0cabe --- /dev/null +++ b/test/fixtures/lazy-esm-cjs/c.js @@ -0,0 +1 @@ +export const c = 3; diff --git a/test/fixtures/lazy-esm-cjs/package.json b/test/fixtures/lazy-esm-cjs/package.json new file mode 100644 index 0000000..36a1452 --- /dev/null +++ b/test/fixtures/lazy-esm-cjs/package.json @@ -0,0 +1,6 @@ +{ + "name": "lazy-esm-cjs", + "version": "1.0.0", + "type": "commonjs", + "main": "./a.js" +}