From 7b8493c30908bfaf641d36433506694e4dbd85ea Mon Sep 17 00:00:00 2001 From: Pankraz76 Date: Sun, 8 Nov 2026 18:39:22 +0000 Subject: [PATCH] Add dependencyOverrides to npm-based formatters (#2534) Formatters and plugins installed via npm often depend on other packages through version ranges, so a new release of a transitive dependency can silently change formatting output (e.g. prettier-plugin-sql -> sql-formatter@^15). Allow pinning such dependencies via npm's `overrides` field in the generated package.json, for prettier, ESLint and tsfmt: - lib: new create(...) overloads taking `dependencyOverrides`; existing signatures (incl. the public TsFmtFormatterStep.State constructor) are kept. Entries are sorted and JSON-escaped. When no overrides are set, the generated package.json is byte-identical to before, so existing node_modules directories and cached step state stay valid. - lib: replace placeholders literally, so values starting with `$` (npm override references such as "$prettier") don't break rendering. - plugin-gradle: `dependencyOverrides(Map)` on prettier/eslint/tsfmt. - plugin-maven: `` on prettier/eslint/tsfmt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTr23xWrpXPm9jbQGavZ8E --- CHANGES.md | 2 + .../spotless/npm/EslintFormatterStep.java | 18 ++- .../npm/NpmFormatterStepStateBase.java | 35 ++++- .../spotless/npm/PrettierFormatterStep.java | 18 ++- .../spotless/npm/TsFmtFormatterStep.java | 17 +- .../spotless/npm/eslint-package.json.template | 2 +- .../npm/prettier-package.json.template | 2 +- .../spotless/npm/tsfmt-package.json.template | 2 +- .../spotless/npm/NodeServerLayoutTest.java | 2 +- .../npm/NpmPackageJsonRenderingTest.java | 147 ++++++++++++++++++ plugin-gradle/CHANGES.md | 2 + plugin-gradle/README.md | 27 +++- .../gradle/spotless/FormatExtension.java | 15 +- .../gradle/spotless/JavascriptExtension.java | 4 +- .../gradle/spotless/TypescriptExtension.java | 4 +- .../spotless/PrettierIntegrationTest.java | 26 +++- plugin-maven/CHANGES.md | 2 + plugin-maven/README.md | 34 +++- .../spotless/maven/generic/Prettier.java | 4 +- .../maven/javascript/AbstractEslint.java | 4 +- .../npm/AbstractNpmFormatterStepFactory.java | 10 +- .../spotless/maven/typescript/Tsfmt.java | 4 +- .../prettier/PrettierFormatStepTest.java | 25 ++- .../spotless/npm/InstalledNpmPackages.java | 69 ++++++++ .../spotless/npm/EslintFormatterStepTest.java | 36 ++++- .../npm/PrettierFormatterStepTest.java | 41 ++++- .../spotless/npm/TsFmtFormatterStepTest.java | 38 ++++- 27 files changed, 548 insertions(+), 42 deletions(-) create mode 100644 lib/src/test/java/com/diffplug/spotless/npm/NpmPackageJsonRenderingTest.java create mode 100644 testlib/src/main/java/com/diffplug/spotless/npm/InstalledNpmPackages.java diff --git a/CHANGES.md b/CHANGES.md index eb93716703..2da18a21ae 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ This document is intended for Spotless developers. We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +- Add `dependencyOverrides` to the npm-based formatter steps (`PrettierFormatterStep`, `EslintFormatterStep`, `TsFmtFormatterStep`), written to the npm `overrides` field of the generated `package.json` to pin transitive dependencies. ([#2534](https://github.com/diffplug/spotless/issues/2534)) ### Fixed - GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019)) diff --git a/lib/src/main/java/com/diffplug/spotless/npm/EslintFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/npm/EslintFormatterStep.java index e17446552a..49a579cd31 100644 --- a/lib/src/main/java/com/diffplug/spotless/npm/EslintFormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/npm/EslintFormatterStep.java @@ -68,12 +68,21 @@ public static Map defaultDevDependenciesWithEslint(String versio } public static FormatterStep create(Map devDependencies, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) { + return create(devDependencies, Map.of(), provisioner, projectDir, buildDir, cacheDir, npmPathResolver, eslintConfig); + } + + /** + * @param dependencyOverrides written to the npm {@code overrides} field of the generated {@code package.json}, + * e.g. to pin a transitive dependency to an exact version + */ + public static FormatterStep create(Map devDependencies, Map dependencyOverrides, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) { requireNonNull(devDependencies); + requireNonNull(dependencyOverrides); requireNonNull(provisioner); requireNonNull(projectDir); requireNonNull(buildDir); return FormatterStep.createLazy(NAME, - () -> new State(NAME, devDependencies, projectDir, buildDir, cacheDir, npmPathResolver, eslintConfig), + () -> new State(NAME, devDependencies, dependencyOverrides, projectDir, buildDir, cacheDir, npmPathResolver, eslintConfig), State::createFormatterFunc); } @@ -83,12 +92,13 @@ private static class State extends NpmFormatterStepStateBase implements Serializ private final EslintConfig origEslintConfig; private EslintConfig eslintConfigInUse; - State(String stepName, Map devDependencies, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) throws IOException { + State(String stepName, Map devDependencies, Map dependencyOverrides, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) throws IOException { super(stepName, new NpmConfig( - replaceDevDependencies( + replaceDependencies( NpmResourceHelper.readUtf8StringFromClasspath(EslintFormatterStep.class, "/com/diffplug/spotless/npm/eslint-package.json.template"), - new TreeMap<>(devDependencies)), + new TreeMap<>(devDependencies), + dependencyOverrides), NpmResourceHelper.readUtf8StringFromClasspath(EslintFormatterStep.class, "/com/diffplug/spotless/npm/common-serve.js", "/com/diffplug/spotless/npm/eslint-serve.js"), diff --git a/lib/src/main/java/com/diffplug/spotless/npm/NpmFormatterStepStateBase.java b/lib/src/main/java/com/diffplug/spotless/npm/NpmFormatterStepStateBase.java index 7fc8f2033b..66237e6404 100644 --- a/lib/src/main/java/com/diffplug/spotless/npm/NpmFormatterStepStateBase.java +++ b/lib/src/main/java/com/diffplug/spotless/npm/NpmFormatterStepStateBase.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,8 @@ import java.util.Iterator; import java.util.Map; import java.util.Map.Entry; +import java.util.StringJoiner; +import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -150,6 +152,32 @@ protected ServerProcessInfo npmRunServer() throws ServerStartException, IOExcept } protected static String replaceDevDependencies(String template, Map devDependencies) { + return replaceDependencies(template, devDependencies, Map.of()); + } + + /** + * Renders the {@code package.json} template. + * + * @param dependencyOverrides written to the npm {@code overrides} + * field, sorted by name. When empty, no {@code overrides} field is written, so the result is identical + * to the one of earlier versions (and existing {@code node_modules} directories stay valid). + */ + protected static String replaceDependencies(String template, Map devDependencies, Map dependencyOverrides) { + return replacePlaceholders(template, Map.of( + "devDependencies", devDependenciesBlock(devDependencies), + "dependencyOverrides", dependencyOverridesBlock(dependencyOverrides))); + } + + private static String dependencyOverridesBlock(Map dependencyOverrides) { + if (dependencyOverrides.isEmpty()) { + return ""; + } + StringJoiner entries = new StringJoiner(",\n", "\n\t\"overrides\": {\n", "\n\t},"); + new TreeMap<>(dependencyOverrides).forEach((name, spec) -> entries.add("\t\t" + JsonEscaper.jsonEscape(name) + ": " + JsonEscaper.jsonEscape(spec))); + return entries.toString(); + } + + private static String devDependenciesBlock(Map devDependencies) { StringBuilder builder = new StringBuilder(); Iterator> entryIter = devDependencies.entrySet().iterator(); while (entryIter.hasNext()) { @@ -163,13 +191,14 @@ protected static String replaceDevDependencies(String template, Map replacements) { String result = template; for (Entry entry : replacements.entrySet()) { - result = result.replaceAll("\\Q${" + entry.getKey() + "}\\E", entry.getValue()); + // literal replacement: values may contain '$' (e.g. npm override references like "$prettier") + result = result.replace("${" + entry.getKey() + "}", entry.getValue()); } return result; } diff --git a/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java index e12107d971..e1d958917b 100644 --- a/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java @@ -51,11 +51,20 @@ public static Map defaultDevDependenciesWithPrettier(String vers } public static FormatterStep create(Map devDependencies, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) { + return create(devDependencies, Map.of(), provisioner, projectDir, buildDir, cacheDir, npmPathResolver, prettierConfig); + } + + /** + * @param dependencyOverrides written to the npm {@code overrides} field of the generated {@code package.json}, + * e.g. to pin a transitive dependency of a prettier plugin to an exact version + */ + public static FormatterStep create(Map devDependencies, Map dependencyOverrides, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) { requireNonNull(devDependencies); + requireNonNull(dependencyOverrides); requireNonNull(provisioner); requireNonNull(buildDir); return FormatterStep.createLazy(NAME, - () -> new State(NAME, devDependencies, projectDir, buildDir, cacheDir, npmPathResolver, prettierConfig), + () -> new State(NAME, devDependencies, dependencyOverrides, projectDir, buildDir, cacheDir, npmPathResolver, prettierConfig), State::createFormatterFunc); } @@ -64,12 +73,13 @@ private static class State extends NpmFormatterStepStateBase implements Serializ private static final long serialVersionUID = -539537027004745812L; private final PrettierConfig prettierConfig; - State(String stepName, Map devDependencies, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) throws IOException { + State(String stepName, Map devDependencies, Map dependencyOverrides, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) throws IOException { super(stepName, new NpmConfig( - replaceDevDependencies( + replaceDependencies( NpmResourceHelper.readUtf8StringFromClasspath(PrettierFormatterStep.class, "/com/diffplug/spotless/npm/prettier-package.json.template"), - new TreeMap<>(devDependencies)), + new TreeMap<>(devDependencies), + dependencyOverrides), NpmResourceHelper.readUtf8StringFromClasspath(PrettierFormatterStep.class, "/com/diffplug/spotless/npm/common-serve.js", "/com/diffplug/spotless/npm/prettier-serve.js"), diff --git a/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java index 34bb39795e..cd9b88ae71 100644 --- a/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/npm/TsFmtFormatterStep.java @@ -43,10 +43,19 @@ public final class TsFmtFormatterStep { public static final String NAME = "tsfmt-format"; public static FormatterStep create(Map versions, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map inlineTsFmtSettings) { + return create(versions, Map.of(), provisioner, projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings); + } + + /** + * @param dependencyOverrides written to the npm {@code overrides} field of the generated {@code package.json}, + * e.g. to pin a transitive dependency to an exact version + */ + public static FormatterStep create(Map versions, Map dependencyOverrides, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map inlineTsFmtSettings) { + requireNonNull(dependencyOverrides); requireNonNull(provisioner); requireNonNull(buildDir); return FormatterStep.createLazy(NAME, - () -> new State(NAME, versions, projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings), + () -> new State(NAME, versions, dependencyOverrides, projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings), State::createFormatterFunc); } @@ -73,9 +82,13 @@ public static class State extends NpmFormatterStepStateBase implements Serializa @Nullable private final TypedTsFmtConfigFile configFile; public State(String stepName, Map versions, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map inlineTsFmtSettings) throws IOException { + this(stepName, versions, Map.of(), projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings); + } + + public State(String stepName, Map versions, Map dependencyOverrides, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map inlineTsFmtSettings) throws IOException { super(stepName, new NpmConfig( - replaceDevDependencies(NpmResourceHelper.readUtf8StringFromClasspath(TsFmtFormatterStep.class, "/com/diffplug/spotless/npm/tsfmt-package.json.template"), new TreeMap<>(versions)), + replaceDependencies(NpmResourceHelper.readUtf8StringFromClasspath(TsFmtFormatterStep.class, "/com/diffplug/spotless/npm/tsfmt-package.json.template"), new TreeMap<>(versions), dependencyOverrides), NpmResourceHelper.readUtf8StringFromClasspath(PrettierFormatterStep.class, "/com/diffplug/spotless/npm/common-serve.js", "/com/diffplug/spotless/npm/tsfmt-serve.js"), diff --git a/lib/src/main/resources/com/diffplug/spotless/npm/eslint-package.json.template b/lib/src/main/resources/com/diffplug/spotless/npm/eslint-package.json.template index c8e08d19b9..698832b624 100644 --- a/lib/src/main/resources/com/diffplug/spotless/npm/eslint-package.json.template +++ b/lib/src/main/resources/com/diffplug/spotless/npm/eslint-package.json.template @@ -12,7 +12,7 @@ ${devDependencies}, "express": "5.1.0", "http-graceful-shutdown": "3.1.14" }, - "dependencies": {}, + "dependencies": {},${dependencyOverrides} "engines": { "node": ">= 18" } diff --git a/lib/src/main/resources/com/diffplug/spotless/npm/prettier-package.json.template b/lib/src/main/resources/com/diffplug/spotless/npm/prettier-package.json.template index 6ee6ed68a5..a6a7402822 100644 --- a/lib/src/main/resources/com/diffplug/spotless/npm/prettier-package.json.template +++ b/lib/src/main/resources/com/diffplug/spotless/npm/prettier-package.json.template @@ -12,7 +12,7 @@ ${devDependencies}, "express": "5.1.0", "http-graceful-shutdown": "3.1.14" }, - "dependencies": {}, + "dependencies": {},${dependencyOverrides} "engines": { "node": ">= 18" } diff --git a/lib/src/main/resources/com/diffplug/spotless/npm/tsfmt-package.json.template b/lib/src/main/resources/com/diffplug/spotless/npm/tsfmt-package.json.template index f84a257c64..1d0995064c 100644 --- a/lib/src/main/resources/com/diffplug/spotless/npm/tsfmt-package.json.template +++ b/lib/src/main/resources/com/diffplug/spotless/npm/tsfmt-package.json.template @@ -12,7 +12,7 @@ ${devDependencies}, "express": "5.1.0", "http-graceful-shutdown": "3.1.14" }, - "dependencies": {}, + "dependencies": {},${dependencyOverrides} "engines": { "node": ">= 18" } diff --git a/lib/src/test/java/com/diffplug/spotless/npm/NodeServerLayoutTest.java b/lib/src/test/java/com/diffplug/spotless/npm/NodeServerLayoutTest.java index 4a09f54f8b..9b784f9d22 100644 --- a/lib/src/test/java/com/diffplug/spotless/npm/NodeServerLayoutTest.java +++ b/lib/src/test/java/com/diffplug/spotless/npm/NodeServerLayoutTest.java @@ -72,6 +72,6 @@ static String prettierPackageJson(Map dependencies) { .reduce((a, b) -> a + ",\n " + b) .orElse(""); - return templateContent.replace("${devDependencies}", dependenciesList); + return templateContent.replace("${devDependencies}", dependenciesList).replace("${dependencyOverrides}", ""); } } diff --git a/lib/src/test/java/com/diffplug/spotless/npm/NpmPackageJsonRenderingTest.java b/lib/src/test/java/com/diffplug/spotless/npm/NpmPackageJsonRenderingTest.java new file mode 100644 index 0000000000..b87feaa6a8 --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/npm/NpmPackageJsonRenderingTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.npm; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.TreeMap; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class NpmPackageJsonRenderingTest { + + private static final String PRETTIER_TEMPLATE = "/com/diffplug/spotless/npm/prettier-package.json.template"; + + private static String template(String path) { + return NpmResourceHelper.readUtf8StringFromClasspath(NpmPackageJsonRenderingTest.class, path); + } + + private static Map sorted(Map map) { + return new TreeMap<>(map); + } + + @Nested + @DisplayName("given no dependency overrides") + class NoOverrides { + + @Test + @DisplayName("when rendering the package.json then it is byte-identical to the output before overrides existed") + void rendersUnchangedPackageJson() { + // given + Map devDependencies = sorted(Map.of("prettier", "3.0.0", "prettier-plugin-sql", "0.18.0")); + + // when + String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), devDependencies, Map.of()); + + // then + assertThat(packageJson).isEqualTo(""" + { + "name": "spotless-prettier", + "version": "4.0.1", + "description": "Spotless formatter step for running prettier as a rest service.", + "repository": "https://github.com/diffplug/spotless", + "license": "Apache-2.0", + "scripts": { + "start": "node serve.js" + }, + "devDependencies": { + "prettier": "3.0.0", + "prettier-plugin-sql": "0.18.0", + "express": "5.1.0", + "http-graceful-shutdown": "3.1.14" + }, + "dependencies": {}, + "engines": { + "node": ">= 18" + } + } + """.replace("\t\t\t\t\t", "")); + } + + @Test + @DisplayName("when using the legacy devDependencies-only method then the result equals rendering with empty overrides") + void legacyMethodIsEquivalent() { + Map devDependencies = sorted(Map.of("prettier", "3.0.0")); + + assertThat(NpmFormatterStepStateBase.replaceDevDependencies(template(PRETTIER_TEMPLATE), devDependencies)) + .isEqualTo(NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), devDependencies, Map.of())); + } + } + + @Nested + @DisplayName("given dependency overrides") + class WithOverrides { + + @Test + @DisplayName("when rendering then an overrides block is written with entries sorted by name") + void rendersSortedOverridesBlock() { + // given + Map devDependencies = sorted(Map.of("prettier", "3.0.0", "prettier-plugin-sql", "0.18.0")); + Map overrides = Map.of("sql-formatter", "15.0.2", "nan", "2.22.0"); + + // when + String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), devDependencies, overrides); + + // then + assertThat(packageJson).contains(""" + "dependencies": {}, + "overrides": { + "nan": "2.22.0", + "sql-formatter": "15.0.2" + }, + "engines": { + """.replace("\t\t\t\t\t", "")); + } + + @Test + @DisplayName("when a value is an npm $reference then it is written verbatim") + void keepsDollarReferences() { + String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), + sorted(Map.of("prettier", "3.0.0")), Map.of("prettier", "$prettier")); + + assertThat(packageJson).contains("\t\t\"prettier\": \"$prettier\"\n"); + } + + @Test + @DisplayName("when a key or value contains JSON special characters then they are escaped") + void escapesJsonSpecialCharacters() { + String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), + sorted(Map.of("prettier", "3.0.0")), Map.of("we\"ird", "1.0.0\\x")); + + assertThat(packageJson).contains("\t\t\"we\\\"ird\": \"1.0.0\\\\x\"\n"); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "/com/diffplug/spotless/npm/prettier-package.json.template", + "/com/diffplug/spotless/npm/eslint-package.json.template", + "/com/diffplug/spotless/npm/tsfmt-package.json.template"}) + @DisplayName("when rendering any npm formatter template then the overrides block is present and no placeholder is left") + void supportedByAllNpmTemplates(String templatePath) { + String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(templatePath), + sorted(Map.of("some-formatter", "1.0.0")), Map.of("transitive-lib", "2.0.0")); + + assertThat(packageJson) + .contains("\t\"overrides\": {\n\t\t\"transitive-lib\": \"2.0.0\"\n\t},\n") + .doesNotContain("${"); + } + } +} diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 5aaef36403..631de19781 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`). ## [Unreleased] +### Added +- Add `dependencyOverrides(Map)` to `prettier()`, `eslint()` and `tsfmt()` to pin transitive npm dependencies via npm `overrides`. ([#2534](https://github.com/diffplug/spotless/issues/2534)) ### Fixed - GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019)) diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 019abb7008..2ae8e5e791 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -99,7 +99,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui - [Shell](#shell) - [Gherkin](#gherkin) - Multiple languages - - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching `npm install` results](#caching-results-of-npm-install)) + - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching `npm install` results](#caching-results-of-npm-install), [pinning transitive dependencies](#pinning-transitive-npm-dependencies)) - [clang-format](#clang-format) - [eclipse web tools platform](#eclipse-web-tools-platform) - [Biome](#biome) ([binary detection](#biome-binary), [config file](#biome-configuration-file), [input language](#biome-input-language)) @@ -1112,7 +1112,7 @@ spotless { **Prerequisite: tsfmt requires a working NodeJS version** -For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection) and [caching results of `npm install`](#caching-results-of-npm-install) sections of prettier, which apply also to tsfmt. +For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching results of `npm install`](#caching-results-of-npm-install) and [pinning transitive npm dependencies](#pinning-transitive-npm-dependencies) sections of prettier, which apply also to tsfmt. ### ESLint (Typescript) @@ -1161,7 +1161,7 @@ spotless { **Prerequisite: ESLint requires a working NodeJS version** -For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection) and [caching results of `npm install`](#caching-results-of-npm-install) sections of prettier, which apply also to ESLint. +For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching results of `npm install`](#caching-results-of-npm-install) and [pinning transitive npm dependencies](#pinning-transitive-npm-dependencies) sections of prettier, which apply also to ESLint. ## Javascript @@ -1224,7 +1224,7 @@ spotless { **Prerequisite: ESLint requires a working NodeJS version** -For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection) and [caching results of `npm install`](#caching-results-of-npm-install) sections of prettier, which apply also to ESLint. +For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching results of `npm install`](#caching-results-of-npm-install) and [pinning transitive npm dependencies](#pinning-transitive-npm-dependencies) sections of prettier, which apply also to ESLint. ## JSON @@ -1609,6 +1609,25 @@ spotless { Depending on your filesystem and the location of the cache directory, spotless will use hardlinks when caching the npm packages. If that is not possible, it will fall back to copying the files. +### Pinning transitive npm dependencies + +Formatters and their plugins often depend on other npm packages using version ranges (e.g. `prettier-plugin-sql` depends on `sql-formatter@^15.x`). +A new release of such a transitive dependency can change the formatting output, even though none of your configured versions changed. +To keep the formatting stable, you can pin transitive dependencies to exact versions. Spotless writes them to the +[`overrides`](https://docs.npmjs.com/cli/configuring-npm/package-json#overrides) field of the `package.json` it generates. +This works for every npm-based formatter (prettier, tsfmt and ESLint). + +```gradle +spotless { + format 'sql', { + target 'src/**/*.sql' + prettier(['prettier': '3.3.3', 'prettier-plugin-sql': '0.20.0']) + .config(['plugins': ['prettier-plugin-sql'], 'parser': 'sql']) + .dependencyOverrides(['sql-formatter': '15.7.0']) // pin the transitive dependency +``` + +Only the flat `'package': 'version'` form of npm overrides is supported (including references such as `'$prettier'`). + ## clang-format [homepage](https://clang.llvm.org/docs/ClangFormat.html). [changelog](https://releases.llvm.org/download.html). `clang-format` is a formatter for c, c++, c#, objective-c, protobuf, javascript, and java. You can use clang-format in any language-specific format, but usually you will be creating a generic format. diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java index e1cc198067..3b28ecee08 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java @@ -699,6 +699,8 @@ public abstract static class NpmStepConfig> { @Nullable protected Object npmrcFile; + protected Map dependencyOverrides = Map.of(); + protected Project project; private Consumer replaceStep; @@ -728,6 +730,17 @@ public T npmrc(final Object npmrcFile) { return (T) this; } + /** + * Writes the given entries to the npm {@code overrides} + * field of the generated {@code package.json}, e.g. to pin a transitive dependency of a formatter plugin to an exact version. + */ + @SuppressWarnings("unchecked") + public T dependencyOverrides(final Map dependencyOverrides) { + this.dependencyOverrides = new TreeMap<>(requireNonNull(dependencyOverrides)); + replaceStep(); + return (T) this; + } + public T npmInstallCache(final Object npmInstallCache) { this.npmInstallCache = npmInstallCache; replaceStep(); @@ -797,7 +810,7 @@ public PrettierConfig config(final Map prettierConfig) { @Override protected FormatterStep createStep() { final Project project = getProject(); - return PrettierFormatterStep.create(devDependencies, provisioner(), project.getProjectDir(), + return PrettierFormatterStep.create(devDependencies, dependencyOverrides, provisioner(), project.getProjectDir(), project.getLayout().getBuildDirectory().getAsFile().get(), npmModulesCacheOrNull(), new NpmPathResolver(npmFileOrNull(), nodeFileOrNull(), npmrcFileOrNull(), Arrays.asList(project.getProjectDir(), project.getRootDir())), diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavascriptExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavascriptExtension.java index 972c60155e..0acacbd4dc 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavascriptExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavascriptExtension.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -103,7 +103,7 @@ public JavascriptEslintConfig(Map devDependencies) { public FormatterStep createStep() { final Project project = getProject(); - return EslintFormatterStep.create(devDependencies, provisioner(), project.getProjectDir(), + return EslintFormatterStep.create(devDependencies, dependencyOverrides, provisioner(), project.getProjectDir(), project.getLayout().getBuildDirectory().getAsFile().get(), npmModulesCacheOrNull(), new NpmPathResolver(npmFileOrNull(), nodeFileOrNull(), npmrcFileOrNull(), Arrays.asList(project.getProjectDir(), project.getRootDir())), diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TypescriptExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TypescriptExtension.java index a05fc44eeb..2499fcf643 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TypescriptExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TypescriptExtension.java @@ -117,7 +117,7 @@ public FormatterStep createStep() { final Project project = getProject(); return TsFmtFormatterStep - .create(devDependencies, provisioner(), project.getProjectDir(), + .create(devDependencies, dependencyOverrides, provisioner(), project.getProjectDir(), project.getLayout().getBuildDirectory().getAsFile().get(), npmModulesCacheOrNull(), new NpmPathResolver(npmFileOrNull(), nodeFileOrNull(), npmrcFileOrNull(), Arrays.asList(project.getProjectDir(), project.getRootDir())), @@ -212,7 +212,7 @@ public TypescriptEslintConfig tsconfigFile(Object path) { public FormatterStep createStep() { final Project project = getProject(); - return EslintFormatterStep.create(devDependencies, provisioner(), project.getProjectDir(), + return EslintFormatterStep.create(devDependencies, dependencyOverrides, provisioner(), project.getProjectDir(), project.getLayout().getBuildDirectory().getAsFile().get(), npmModulesCacheOrNull(), new NpmPathResolver(npmFileOrNull(), nodeFileOrNull(), npmrcFileOrNull(), Arrays.asList(project.getProjectDir(), project.getRootDir())), diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PrettierIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PrettierIntegrationTest.java index 9b6edf526a..cc13c2e723 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PrettierIntegrationTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PrettierIntegrationTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,9 +19,11 @@ import org.assertj.core.api.Assertions; import org.gradle.testkit.runner.BuildResult; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import com.diffplug.spotless.npm.InstalledNpmPackages; import com.diffplug.spotless.npm.PrettierFormatterStep; import com.diffplug.spotless.tag.NpmTest; @@ -414,4 +416,26 @@ void pickupNpmrcFileConfig(String prettierVersion) throws IOException { final BuildResult spotlessApply = gradleRunner().withArguments("--stacktrace", "spotlessApply").buildAndFail(); Assertions.assertThat(spotlessApply.getOutput()).containsPattern("Running npm command.*npm install.* failed with exit code: 1"); } + + @Test + void dependencyOverridesPinTransitiveDependencies() throws IOException { + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "repositories { mavenCentral() }", + "spotless {", + " format 'sql', {", + " target 'test.sql'", + " prettier(['prettier': '3.3.3', 'prettier-plugin-sql': '0.20.0'])", + " .config(['plugins': ['prettier-plugin-sql'], 'parser': 'sql'])", + " .dependencyOverrides(['sql-formatter': '15.7.0'])", + " }", + "}"); + setFile("test.sql").toContent("select a, b from t where x = 1"); + final BuildResult spotlessApply = gradleRunner().withArguments("--stacktrace", "spotlessApply").build(); + Assertions.assertThat(spotlessApply.getOutput()).contains("BUILD SUCCESSFUL"); + assertFile("test.sql").hasContent("select\n a,\n b\nfrom\n t\nwhere\n x = 1\n"); + Assertions.assertThat(InstalledNpmPackages.versionInstalledBelow(newFile("build"), "sql-formatter")).isEqualTo("15.7.0"); + } } diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 4081c6671e..028a53dfff 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +- Add `` to ``, `` and `` to pin transitive npm dependencies via npm `overrides`. ([#2534](https://github.com/diffplug/spotless/issues/2534)) ### Fixed - GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019)) diff --git a/plugin-maven/README.md b/plugin-maven/README.md index 3fae9b808d..99cb6bd433 100644 --- a/plugin-maven/README.md +++ b/plugin-maven/README.md @@ -60,7 +60,7 @@ user@machine repo % mvn spotless:check - [RDF](#RDF) - [Protobuf](#protobuf) ([buf](#buf), [clang-format](#clang)) - Multiple languages - - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching `npm install` results](#caching-results-of-npm-install)) + - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching `npm install` results](#caching-results-of-npm-install), [pinning transitive dependencies](#pinning-transitive-npm-dependencies)) - [eclipse web tools platform](#eclipse-web-tools-platform) - [Biome](#biome) ([binary detection](#biome-binary), [config file](#biome-configuration-file), [input language](#biome-input-language)) - [IntelliJ IDEA](#intellij-idea) @@ -1040,7 +1040,7 @@ The auto-discovery of config files (up the file tree) will not work when using t **Prerequisite: tsfmt requires a working NodeJS version** -For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection) and [caching results of `npm install`](#caching-results-of-npm-install) sections of prettier, which apply also to tsfmt. +For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching results of `npm install`](#caching-results-of-npm-install) and [pinning transitive npm dependencies](#pinning-transitive-npm-dependencies) sections of prettier, which apply also to tsfmt. ### ESLint (typescript) @@ -1096,7 +1096,7 @@ reference to a `tsconfig.json` is required. **Prerequisite: ESLint requires a working NodeJS version** -For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection) and [caching results of `npm install`](#caching-results-of-npm-install) sections of prettier, which apply also to ESLint. +For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching results of `npm install`](#caching-results-of-npm-install) and [pinning transitive npm dependencies](#pinning-transitive-npm-dependencies) sections of prettier, which apply also to ESLint. ## Javascript @@ -1173,7 +1173,7 @@ The configuration is very similar to the [ESLint (Typescript)](#eslint-typescrip **Prerequisite: ESLint requires a working NodeJS version** -For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection) and [caching results of `npm install`](#caching-results-of-npm-install) sections of prettier, which apply also to ESLint. +For details, see the [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching results of `npm install`](#caching-results-of-npm-install) and [pinning transitive npm dependencies](#pinning-transitive-npm-dependencies) sections of prettier, which apply also to ESLint. ## JSON @@ -1676,6 +1676,32 @@ it will not need to download the packages again, but instead reuse the cached ve Depending on your filesystem and the location of the cache directory, spotless will use hardlinks when caching the npm packages. If that is not possible, it will fall back to copying the files. +### Pinning transitive npm dependencies + +Formatters and their plugins often depend on other npm packages using version ranges (e.g. `prettier-plugin-sql` depends on `sql-formatter@^15.x`). +A new release of such a transitive dependency can change the formatting output, even though none of your configured versions changed. +To keep the formatting stable, you can pin transitive dependencies to exact versions. Spotless writes them to the +[`overrides`](https://docs.npmjs.com/cli/configuring-npm/package-json#overrides) field of the `package.json` it generates. +This works for every npm-based formatter (prettier, tsfmt and ESLint). + +```xml + + + 3.3.3 + 0.20.0 + + + 15.7.0 + + + prettier-plugin-sql + sql + + +``` + +Only the flat `version` form of npm overrides is supported (including references such as `$prettier`). + ## Eclipse web tools platform diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/Prettier.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/Prettier.java index 7c2894b76a..f32f57c228 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/Prettier.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/Prettier.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -108,7 +108,7 @@ public FormatterStep newFormatterStep(FormatterStepConfig stepConfig) { File cacheDir = cacheDir(stepConfig); PrettierConfig prettierConfig = new PrettierConfig(configFileHandler, configInline); NpmPathResolver npmPathResolver = npmPathResolver(stepConfig); - return PrettierFormatterStep.create(devDependencies, stepConfig.getProvisioner(), baseDir, buildDir, cacheDir, npmPathResolver, prettierConfig); + return PrettierFormatterStep.create(devDependencies, dependencyOverrides(), stepConfig.getProvisioner(), baseDir, buildDir, cacheDir, npmPathResolver, prettierConfig); } private static IllegalArgumentException onlyOneConfig() { diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/javascript/AbstractEslint.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/javascript/AbstractEslint.java index ad113de833..8e0af61ecb 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/javascript/AbstractEslint.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/javascript/AbstractEslint.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,7 +69,7 @@ public FormatterStep newFormatterStep(FormatterStepConfig stepConfig) { File baseDir = baseDir(stepConfig); File cacheDir = cacheDir(stepConfig); NpmPathResolver npmPathResolver = npmPathResolver(stepConfig); - return EslintFormatterStep.create(devDependencies, stepConfig.getProvisioner(), baseDir, buildDir, cacheDir, npmPathResolver, eslintConfig(stepConfig)); + return EslintFormatterStep.create(devDependencies, dependencyOverrides(), stepConfig.getProvisioner(), baseDir, buildDir, cacheDir, npmPathResolver, eslintConfig(stepConfig)); } private static IllegalArgumentException onlyOneConfig() { diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/npm/AbstractNpmFormatterStepFactory.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/npm/AbstractNpmFormatterStepFactory.java index 8a9c3990c9..148a86c43a 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/npm/AbstractNpmFormatterStepFactory.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/npm/AbstractNpmFormatterStepFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,6 +48,14 @@ public abstract class AbstractNpmFormatterStepFactory implements FormatterStepFa @Parameter private String npmInstallCache; + /** Written to the npm {@code overrides} field of the generated {@code package.json}, e.g. to pin transitive dependencies. */ + @Parameter + private Map dependencyOverrides; + + protected Map dependencyOverrides() { + return dependencyOverrides != null ? dependencyOverrides : Map.of(); + } + protected File npm(FormatterStepConfig stepConfig) { return npmExecutable != null ? stepConfig.getFileLocator().locateFile(npmExecutable) : null; } diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/typescript/Tsfmt.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/typescript/Tsfmt.java index 396c688635..e9a4de4941 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/typescript/Tsfmt.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/typescript/Tsfmt.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -113,7 +113,7 @@ public FormatterStep newFormatterStep(FormatterStepConfig stepConfig) { File baseDir = baseDir(stepConfig); File cacheDir = cacheDir(stepConfig); NpmPathResolver npmPathResolver = npmPathResolver(stepConfig); - return TsFmtFormatterStep.create(devDependencies, stepConfig.getProvisioner(), baseDir, buildDir, cacheDir, npmPathResolver, configFile, configInline); + return TsFmtFormatterStep.create(devDependencies, dependencyOverrides(), stepConfig.getProvisioner(), baseDir, buildDir, cacheDir, npmPathResolver, configFile, configInline); } private static IllegalArgumentException onlyOneConfig() { diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/prettier/PrettierFormatStepTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/prettier/PrettierFormatStepTest.java index abba35e72c..8e5b7ad0a6 100644 --- a/plugin-maven/src/test/java/com/diffplug/spotless/maven/prettier/PrettierFormatStepTest.java +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/prettier/PrettierFormatStepTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import com.diffplug.spotless.ProcessRunner; import com.diffplug.spotless.maven.MavenIntegrationHarness; import com.diffplug.spotless.maven.generic.Prettier; +import com.diffplug.spotless.npm.InstalledNpmPackages; import com.diffplug.spotless.tag.NpmTest; @NpmTest @@ -279,4 +280,26 @@ void select_configured_npmrc_file() throws Exception { ProcessRunner.Result result = runExpectingError("typescript", suffix); assertThat(result.stdOutUtf8()).containsPattern("Running npm command.*npm install.* failed with exit code: 1"); } + + @Test + void dependencyOverrides_pin_transitive_dependencies() throws Exception { + writePomWithPrettierSteps("src/main/sql/*.sql", + "", + " ", + " 3.3.3", + " 0.20.0", + " ", + " ", + " 15.7.0", + " ", + " ", + " prettier-plugin-sql", + " sql", + " ", + ""); + setFile("src/main/sql/test.sql").toContent("select a, b from t where x = 1"); + mavenRunner().withArguments("spotless:apply").runNoError(); + assertFile("src/main/sql/test.sql").hasContent("select\n a,\n b\nfrom\n t\nwhere\n x = 1\n"); + assertThat(InstalledNpmPackages.versionInstalledBelow(newFile("target"), "sql-formatter")).isEqualTo("15.7.0"); + } } diff --git a/testlib/src/main/java/com/diffplug/spotless/npm/InstalledNpmPackages.java b/testlib/src/main/java/com/diffplug/spotless/npm/InstalledNpmPackages.java new file mode 100644 index 0000000000..7644bf9879 --- /dev/null +++ b/testlib/src/main/java/com/diffplug/spotless/npm/InstalledNpmPackages.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.npm; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Test helper to inspect which npm packages a npm-based formatter step installed. + */ +public final class InstalledNpmPackages { + + private static final Pattern VERSION = Pattern.compile("\"version\"\\s*:\\s*\"([^\"]+)\""); + + private InstalledNpmPackages() { + // no instance + } + + /** + * Returns the version of the top-level (hoisted) installation of {@code packageName}, i.e. of + * {@code //node_modules//package.json}. + * + * @throws IllegalStateException if there is not exactly one such installation + */ + public static String versionInstalledBelow(File buildDir, String packageName) { + final Path root = buildDir.toPath(); + final Path relativePackageJson = Path.of("node_modules", packageName, "package.json"); + final List candidates; + try (Stream files = Files.walk(root)) { + candidates = files.filter(path -> path.endsWith(relativePackageJson)) + .filter(path -> root.relativize(path).getNameCount() == relativePackageJson.getNameCount() + 1) + .toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + if (candidates.size() != 1) { + throw new IllegalStateException("Expected exactly one top-level installation of " + packageName + " below " + buildDir + ", but found " + candidates); + } + try { + final Matcher version = VERSION.matcher(Files.readString(candidates.get(0))); + if (!version.find()) { + throw new IllegalStateException("No version in " + candidates.get(0)); + } + return version.group(1); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/testlib/src/test/java/com/diffplug/spotless/npm/EslintFormatterStepTest.java b/testlib/src/test/java/com/diffplug/spotless/npm/EslintFormatterStepTest.java index da00e37d35..a313db6c69 100644 --- a/testlib/src/test/java/com/diffplug/spotless/npm/EslintFormatterStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/npm/EslintFormatterStepTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import com.diffplug.common.collect.ImmutableMap; import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.ResourceHarness; +import com.diffplug.spotless.SerializableEqualityTester; import com.diffplug.spotless.StepHarnessWithFile; import com.diffplug.spotless.TestProvisioner; import com.diffplug.spotless.tag.NpmTest; @@ -170,4 +171,37 @@ void formattingUsingInlineXoConfig() throws Exception { } } } + + @Nested + class DependencyOverridesEquality extends NpmFormatterStepCommonTests { + + @Test + void givenDifferentDependencyOverrides_whenComparingSteps_thenTheyAreDifferent() { + new SerializableEqualityTester() { + Map dependencyOverrides = Map.of(); + + @Override + protected void setupTest(API api) { + api.areDifferentThan(); + dependencyOverrides = Map.of("some-transitive-lib", "1.0.0"); + api.areDifferentThan(); + dependencyOverrides = Map.of("some-transitive-lib", "1.0.1"); + api.areDifferentThan(); + } + + @Override + protected FormatterStep create() { + return EslintFormatterStep.create( + EslintFormatterStep.defaultDevDependencies(), + dependencyOverrides, + TestProvisioner.mavenCentral(), + projectDir(), + buildDir(), + null, + npmPathResolver(), + new EslintConfig(null, "{}")); + } + }.testEquals(); + } + } } diff --git a/testlib/src/test/java/com/diffplug/spotless/npm/PrettierFormatterStepTest.java b/testlib/src/test/java/com/diffplug/spotless/npm/PrettierFormatterStepTest.java index 54ce09e7e8..6bf7167b27 100644 --- a/testlib/src/test/java/com/diffplug/spotless/npm/PrettierFormatterStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/npm/PrettierFormatterStepTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,8 +15,11 @@ */ package com.diffplug.spotless.npm; +import static org.assertj.core.api.Assertions.assertThat; + import java.io.File; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Nested; @@ -190,11 +193,41 @@ private String major(String semVer) { } } + @NpmTest + @Nested + class DependencyOverrides extends NpmFormatterStepCommonTests { + + private static final String PINNED_SQL_FORMATTER = "15.7.0"; + + @Test + void givenTransitiveDependencyOverride_whenFormatting_thenPinnedVersionIsInstalled() throws Exception { + // given: prettier-plugin-sql@0.20.0 depends on sql-formatter@^15.7.0, which npm would resolve to the newest 15.x + final FormatterStep formatterStep = PrettierFormatterStep.create( + Map.of("prettier", "3.3.3", "prettier-plugin-sql", "0.20.0"), // plugin needs prettier ^3.0.3 + Map.of("sql-formatter", PINNED_SQL_FORMATTER), + TestProvisioner.mavenCentral(), + projectDir(), + buildDir(), + null, + npmPathResolver(), + new PrettierConfig(null, Map.of("plugins", List.of("prettier-plugin-sql"), "parser", "sql"))); + + // when + try (StepHarness stepHarness = StepHarness.forStep(formatterStep)) { + stepHarness.test("select a, b from t where x = 1", "select\n a,\n b\nfrom\n t\nwhere\n x = 1\n"); + } + + // then + assertThat(InstalledNpmPackages.versionInstalledBelow(buildDir(), "sql-formatter")).isEqualTo(PINNED_SQL_FORMATTER); + } + } + @Test void equality() { new SerializableEqualityTester() { String prettierVersion = "3.0.0"; PrettierConfig config = new PrettierConfig(null, Map.of("parser", "typescript")); + Map dependencyOverrides = Map.of(); @Override protected void setupTest(API api) { @@ -205,12 +238,18 @@ protected void setupTest(API api) { api.areDifferentThan(); config = new PrettierConfig(null, Map.of("parser", "css")); api.areDifferentThan(); + // pinning a transitive dependency makes it different + dependencyOverrides = Map.of("some-transitive-lib", "1.0.0"); + api.areDifferentThan(); + dependencyOverrides = Map.of("some-transitive-lib", "1.0.1"); + api.areDifferentThan(); } @Override protected FormatterStep create() { return PrettierFormatterStep.create( ImmutableMap.of("prettier", prettierVersion), + dependencyOverrides, TestProvisioner.mavenCentral(), projectDir(), buildDir(), diff --git a/testlib/src/test/java/com/diffplug/spotless/npm/TsFmtFormatterStepTest.java b/testlib/src/test/java/com/diffplug/spotless/npm/TsFmtFormatterStepTest.java index 66fe6e05ac..d19f82f30a 100644 --- a/testlib/src/test/java/com/diffplug/spotless/npm/TsFmtFormatterStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/npm/TsFmtFormatterStepTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.Collections; +import java.util.Map; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -27,6 +28,7 @@ import com.diffplug.common.collect.ImmutableMap; import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.SerializableEqualityTester; import com.diffplug.spotless.StepHarness; import com.diffplug.spotless.TestProvisioner; import com.diffplug.spotless.tag.NpmTest; @@ -93,4 +95,38 @@ void formattingUsingInlineConfigWorks() throws Exception { } } } + + @Nested + class DependencyOverridesEquality extends NpmFormatterStepCommonTests { + + @Test + void givenDifferentDependencyOverrides_whenComparingSteps_thenTheyAreDifferent() { + new SerializableEqualityTester() { + Map dependencyOverrides = Map.of(); + + @Override + protected void setupTest(API api) { + api.areDifferentThan(); + dependencyOverrides = Map.of("some-transitive-lib", "1.0.0"); + api.areDifferentThan(); + dependencyOverrides = Map.of("some-transitive-lib", "1.0.1"); + api.areDifferentThan(); + } + + @Override + protected FormatterStep create() { + return TsFmtFormatterStep.create( + TsFmtFormatterStep.defaultDevDependencies(), + dependencyOverrides, + TestProvisioner.mavenCentral(), + projectDir(), + buildDir(), + null, + npmPathResolver(), + null, + Map.of("indentSize", 2)); + } + }.testEquals(); + } + } }