diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0f6aa44..e1e352c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "6.1.1" + ".": "6.2.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d054241..dc53215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [6.2.0](https://github.com/npm/package-json/compare/v6.1.1...v6.2.0) (2025-05-21) +### Features +* [`228539f`](https://github.com/npm/package-json/commit/228539fe73402e28d1f5b875f34cd5aeb0474d2e) [#145](https://github.com/npm/package-json/pull/145) adds fixName step for publishing (#145) (@owlstronaut) + ## [6.1.1](https://github.com/npm/package-json/compare/v6.1.0...v6.1.1) (2025-01-21) ### Bug Fixes * [`526473b`](https://github.com/npm/package-json/commit/526473bf1f2fcb8b1b3c3af68f890df203ebe33d) [#139](https://github.com/npm/package-json/pull/139) remove max-len linting bypasses (@wraithgar) diff --git a/lib/index.js b/lib/index.js index 828b899..7eff602 100644 --- a/lib/index.js +++ b/lib/index.js @@ -41,6 +41,7 @@ class PackageJson { 'binRefs', 'bundleDependencies', 'bundleDependenciesFalse', + 'fixName', 'fixNameField', 'fixVersionField', 'fixRepositoryField', diff --git a/lib/normalize.js b/lib/normalize.js index 7115390..845f675 100644 --- a/lib/normalize.js +++ b/lib/normalize.js @@ -3,6 +3,7 @@ const clean = require('semver/functions/clean') const fs = require('node:fs/promises') const path = require('node:path') const { log } = require('proc-log') +const moduleBuiltin = require('node:module') /** * @type {import('hosted-git-info')} @@ -144,7 +145,7 @@ const normalize = async (pkg, { strict, steps, root, changes, allowLegacyCase }) const pkgId = `${data.name ?? ''}@${data.version ?? ''}` // name and version are load bearing so we have to clean them up first - if (steps.includes('fixNameField') || steps.includes('normalizeData')) { + if (steps.includes('fixName') || steps.includes('fixNameField') || steps.includes('normalizeData')) { if (!data.name && !strict) { changes?.push('Missing "name" field was set to an empty string') data.name = '' @@ -170,6 +171,13 @@ const normalize = async (pkg, { strict, steps, root, changes, allowLegacyCase }) } } + if (steps.includes('fixName')) { + // Check for conflicts with builtin modules + if (moduleBuiltin.builtinModules.includes(data.name)) { + log.warn('package-json', pkgId, `Package name "${data.name}" conflicts with a Node.js built-in module name`) + } + } + if (steps.includes('fixVersionField') || steps.includes('normalizeData')) { // allow "loose" semver 1.0 versions in non-strict mode // enforce strict semver 2.0 compliance in strict mode diff --git a/package.json b/package.json index 5421878..263d67f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@npmcli/package-json", - "version": "6.1.1", + "version": "6.2.0", "description": "Programmatic API to update package.json", "keywords": [ "npm", diff --git a/tap-snapshots/test/fix.js.test.cjs b/tap-snapshots/test/fix.js.test.cjs index fc9faa8..d2b6cbe 100644 --- a/tap-snapshots/test/fix.js.test.cjs +++ b/tap-snapshots/test/fix.js.test.cjs @@ -69,13 +69,21 @@ Array [ ] ` -exports[`test/fix.js TAP with changes fixNameField scoped whitespace > must match snapshot 1`] = ` +exports[`test/fix.js TAP with changes fixName step allows uppercase in package name > must match snapshot 1`] = ` +Array [] +` + +exports[`test/fix.js TAP with changes fixName step warning for builtin module name > must match snapshot 1`] = ` +Array [] +` + +exports[`test/fix.js TAP with changes fixNameField scoped package name with whitespace > must match snapshot 1`] = ` Array [ "Whitespace was trimmed from \\"name\\"", ] ` -exports[`test/fix.js TAP with changes fixNameField unscoped whitespace > must match snapshot 1`] = ` +exports[`test/fix.js TAP with changes fixNameField unscoped package name with whitespace > must match snapshot 1`] = ` Array [ "Whitespace was trimmed from \\"name\\"", ] diff --git a/test/fix-name.js b/test/fix-name.js new file mode 100644 index 0000000..87d9521 --- /dev/null +++ b/test/fix-name.js @@ -0,0 +1,159 @@ +const t = require('tap') +const PackageJson = require('../') +const normalize = require('../lib/normalize.js') + +const pkg = (data = {}) => { + return JSON.stringify({ + name: '@npmcli/test-package', + version: '1.0.0', + ...data, + }) +} + +// Helper to test the fixName step +const testFixNameStep = async (t, testdir, expectation) => { + const p = t.testdir(testdir) + + // Test using normalize directly with fixName step + const instance = await PackageJson.load(p) + await normalize(instance, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + + // Apply expectation function if provided + if (expectation) { + expectation(t, instance.content) + } + + return instance +} + +t.test('fixName step', async t => { + t.test('valid package name passes validation', async t => { + const testdir = { + 'package.json': pkg({ name: '@npmcli/test-package' }), + } + + await testFixNameStep(t, testdir, (t, content) => { + t.strictSame(content.name, '@npmcli/test-package', 'name should remain unchanged') + }) + }) + + t.test('missing name field throws error', async t => { + const testdir = { + 'package.json': pkg({ name: undefined }), + } + + await t.rejects( + async () => { + const instance = await PackageJson.load(t.testdir(testdir)) + await normalize(instance, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + }, + { message: /name field must be a string/ }, + 'should reject with appropriate error message' + ) + }) + + t.test('non-string name field throws error', async t => { + const testdir = { + 'package.json': pkg({ name: ['@npmcli/invalid-test-package'] }), + } + + await t.rejects( + async () => { + const instance = await PackageJson.load(t.testdir(testdir)) + await normalize(instance, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + }, + { message: /name field must be a string/ }, + 'should reject with appropriate error message' + ) + }) + + t.test('invalid package name formats throw error', async t => { + t.test('leading dot', async t => { + const testdir = { + 'package.json': pkg({ name: '.npmcli-test-package' }), + } + + await t.rejects( + async () => { + const instance = await PackageJson.load(t.testdir(testdir)) + await normalize(instance, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + }, + { message: /Invalid name/ }, + 'should reject names starting with a dot' + ) + }) + + t.test('invalid scoped package name format', async t => { + const testdir = { + 'package.json': pkg({ name: '@npmcli/test/package/extra' }), + } + + await t.rejects( + async () => { + const instance = await PackageJson.load(t.testdir(testdir)) + await normalize(instance, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + }, + { message: /Invalid name/ }, + 'should reject invalid scoped package names' + ) + }) + + t.test('node_modules reserved name', async t => { + const testdir = { + 'package.json': pkg({ name: 'node_modules' }), + } + + await t.rejects( + async () => { + const instance = await PackageJson.load(t.testdir(testdir)) + await normalize(instance, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + }, + { message: /Invalid name/ }, + 'should reject reserved name node_modules' + ) + }) + + t.test('favicon.ico reserved name', async t => { + const testdir = { + 'package.json': pkg({ name: 'favicon.ico' }), + } + + await t.rejects( + async () => { + const instance = await PackageJson.load(t.testdir(testdir)) + await normalize(instance, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + }, + { message: /Invalid name/ }, + 'should reject reserved name favicon.ico' + ) + }) + }) + + t.test('builtin module name conflict', async t => { + const builtinModules = require('node:module').builtinModules + const builtinName = builtinModules[0] // Use the first builtin module name + + const testdir = { + 'package.json': pkg({ name: builtinName }), + } + + // For builtin modules, we don't throw but we log a warning + const instance = await testFixNameStep(t, testdir, (t, content) => { + t.strictSame(content.name, builtinName, 'should allow builtin module names') + }) + + // We can't directly test the warning since it's logged, but we can confirm the name remains unchanged and the operation completes without error + t.ok(instance, 'operation should complete without error') + }) + + t.test('package with uppercase name', async t => { + const testdir = { + 'package.json': pkg({ name: '@NPMCLI/Test-Package' }), + } + + // Case is allowed + await testFixNameStep(t, testdir, (t, content) => { + t.strictSame(content.name, '@NPMCLI/Test-Package', 'should allow uppercase in package name for publishing') + }) + }) +}) diff --git a/test/fix.js b/test/fix.js index ed80b97..2a133ec 100644 --- a/test/fix.js +++ b/test/fix.js @@ -105,19 +105,58 @@ for (const [name, testFix] of Object.entries(testMethods)) { { message: /Invalid name/ } ) }) - t.test('scoped whitespace', async t => { + t.test('scoped package name with whitespace', async t => { const testdir = { 'package.json': pkg({ name: '@npmcli/test-package ' }), } - const { content } = await testFix(t, testdir) - t.strictSame(content.name, '@npmcli/test-package') + + // When using fixNameField, whitespace should be trimmed + const fixed = await testFix(t, testdir, { steps: ['fixNameField'] }) + t.strictSame(fixed.content.name, '@npmcli/test-package', 'whitespace should be trimmed') }) - t.test('unscoped whitespace', async t => { + t.test('unscoped package name with whitespace', async t => { const testdir = { - 'package.json': pkg({ name: '@npmcli/test-package ' }), + 'package.json': pkg({ name: 'npmcli-test-package ' }), } - const { content } = await testFix(t, testdir) - t.strictSame(content.name, '@npmcli/test-package') + + // When using fixNameField, whitespace should be trimmed + const fixed = await testFix(t, testdir, { steps: ['fixNameField'] }) + t.strictSame(fixed.content.name, 'npmcli-test-package', 'whitespace should be trimmed') + }) + }) + t.test('fixName step', async t => { + t.test('warning for builtin module name', async t => { + const builtinModules = require('node:module').builtinModules + const builtinName = builtinModules[0] // Use the first builtin module name + + const testdir = { + 'package.json': pkg({ name: builtinName }), + } + + // Should not throw error since this is just a warning + const { content } = await testFix(t, testdir, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + t.strictSame(content.name, builtinName, 'should allow but warn about builtin module name') + }) + + t.test('should reject invalid package name', async t => { + const testdir = { + 'package.json': pkg({ name: '.invalid-name' }), + } + + await t.rejects( + testFix(t, testdir, { steps: ['fixName'], strict: true, allowLegacyCase: true }), + { message: /Invalid name/ } + ) + }) + + t.test('allows uppercase in package name', async t => { + const testdir = { + 'package.json': pkg({ name: '@NPMCLI/Test-Package' }), + } + + // With fixName, uppercase is allowed + const { content } = await testFix(t, testdir, { steps: ['fixName'], strict: true, allowLegacyCase: true }) + t.strictSame(content.name, '@NPMCLI/Test-Package', 'should allow uppercase in package name') }) }) t.test('fixVersionField', async t => {