Skip to content

Add dependencyOverrides to npm-based formatters to pin transitive dependencies (#2534) - #3178

Open
Pankraz76 wants to merge 1 commit into
diffplug:mainfrom
Pankraz76:feat/2534-npm-dependency-overrides
Open

Pankraz76 wants to merge 1 commit into
diffplug:mainfrom
Pankraz76:feat/2534-npm-dependency-overrides

Conversation

@Pankraz76

Copy link
Copy Markdown

Closes #2534

Why

npm-based formatters and their plugins often depend on other packages through version ranges. A new release of such a transitive dependency can silently change the formatting output, even though none of the versions configured in Spotless changed. The issue's example: prettier-plugin-sql depends on sql-formatter@^15.x; a new sql-formatter release changed the output and broke builds with checked-in SQL files.

Today there is no way to pin those transitive dependencies, because Spotless generates the package.json itself.

What

As suggested in the issue discussion, a dependencyOverrides option for all npm-based formatters (prettier, ESLint, tsfmt), configured just like devDependencies. The entries are written to npm's overrides field of the generated package.json.

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'])
  }
}

Maven

<prettier>
  <devDependencies>
    <prettier>3.3.3</prettier>
    <prettier-plugin-sql>0.20.0</prettier-plugin-sql>
  </devDependencies>
  <dependencyOverrides>
    <sql-formatter>15.7.0</sql-formatter>
  </dependencyOverrides>
  ...
</prettier>

Details

  • lib: PrettierFormatterStep, EslintFormatterStep and TsFmtFormatterStep get a create(...) overload with a dependencyOverrides map. The existing signatures (including the public TsFmtFormatterStep.State constructor) are kept and delegate with an empty map, so there are no breaking API changes.
  • No change for existing users: if no overrides are configured, the generated package.json is byte-identical to before. Existing node_modules directories (whose name is a hash of the package.json) and cached step state stay valid, so there is no forced reinstall. A characterization test asserts this.
  • Up-to-date checks: the overrides become part of the generated package.json and therefore of the step state. Changing them makes the step different and triggers a fresh npm install into a new directory.
  • Entries are sorted (deterministic output) and JSON-escaped.
  • Small fix along the way: placeholders in the package.json template were replaced with String.replaceAll, which treats $ in the replacement as a group reference. Values like npm's override references ("$prettier") would have thrown. The replacement is now literal.
  • Docs: new "Pinning transitive npm dependencies" section in both READMEs, linked from the tsfmt and ESLint sections. CHANGES.md entries for lib, plugin-gradle and plugin-maven.

Scope / limitations

  • Only the flat name -> version form of npm overrides is supported (including $ references), not nested override objects. That covers the use case of the issue; nested overrides could be added later if needed.
  • Maven: like <devDependencies>, <dependencyOverrides> can't express scoped package names (@scope/pkg), because @ is not valid in an XML element name. A dependencyOverrideProperties counterpart to devDependencyProperties could be added if you'd like. I kept it out to keep this PR focused.

Tests

Written test-first. Every new test was run and seen failing for the right reason before the implementation:

Scenario Test
No overrides → package.json byte-identical to before NpmPackageJsonRenderingTest (lib)
Overrides → sorted overrides block; $ references kept; JSON escaping; present in all three templates NpmPackageJsonRenderingTest (lib)
Real npm install: prettier + prettier-plugin-sql, sql-formatter pinned to 15.7.0 → 15.7.0 installed and formatting works (without the fix npm installs 15.9.0) PrettierFormatterStepTest.DependencyOverrides
Different overrides → steps are not equal (prettier, ESLint, tsfmt) PrettierFormatterStepTest.equality, EslintFormatterStepTest, TsFmtFormatterStepTest
Gradle DSL end to end PrettierIntegrationTest.dependencyOverridesPinTransitiveDependencies
Maven end to end PrettierFormatStepTest.dependencyOverrides_pin_transitive_dependencies

Ran locally:

  • :lib:test: 97/97
  • :testlib:testNpm: 45/45
  • :plugin-gradle:testNpm: 57/58 passed, 1 skipped
  • :plugin-maven:testNpm: 37/38 passed, 3 skipped
  • spotlessCheck for lib, testlib, plugin-gradle and plugin-maven: clean

The two failures look unrelated to this PR: TypescriptExtensionTest.useEslintStandardWithTypescriptRules and TypescriptFormatStepTest.eslintStyleguideStandardWithTypescript both fail with Failed to load parser '@typescript-eslint/parser' … (reading 'BarBarToken'). I re-ran the Gradle one on unmodified main and it fails the same way there. The cause is a newer TypeScript resolved through a version range, the very drift this PR makes pinnable.

Side note, possibly worth a separate issue: locally, ./gradlew testNpm reports NO-SOURCE for me on Gradle 9. The custom test<Tag> tasks registered in spotless.special-tests.gradle.kts don't set testClassesDirs/classpath, so they may not run any tests (CI runs ./gradlew testNpm). I wired them up with a local init script to run the suites above. Happy to open a separate issue or PR if you can confirm.

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: `<dependencyOverrides>` on prettier/eslint/tsfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTr23xWrpXPm9jbQGavZ8E

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow npm-Based Formatters to Pin Transitive Dependencies by Populating overrides in package.json

1 participant