From f3f4488a629b03341ca3baf3e6b86209a553fa3c Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Fri, 19 Jun 2026 15:08:48 +0100 Subject: [PATCH] CCM-18990: Set up PR overrides workflow in nhs-notify-shared-modules --- .github/actions/check-overrides/action.yaml | 56 ++++ .../check-overrides/check-overrides.sh | 32 +++ docs/github-actions.md | 1 + docs/github-actions/check-overrides.md | 69 +++++ .../config/vocabularies/words/accept.txt | 4 +- tools/check-overrides/eslint.config.mjs | 60 +++++ tools/check-overrides/jest.config.ts | 28 ++ tools/check-overrides/package.json | 36 +++ .../src/__tests__/check.test.ts | 164 ++++++++++++ .../src/__tests__/lockfile.test.ts | 117 +++++++++ .../src/__tests__/parse-overrides.test.ts | 199 ++++++++++++++ .../src/__tests__/report.test.ts | 207 +++++++++++++++ .../src/__tests__/resolve.test.ts | 244 ++++++++++++++++++ .../src/__tests__/workspace-file-io.test.ts | 72 ++++++ .../src/__tests__/workspace-file.test.ts | 67 +++++ tools/check-overrides/src/check.ts | 136 ++++++++++ tools/check-overrides/src/index.ts | 91 +++++++ tools/check-overrides/src/lockfile.ts | 85 ++++++ tools/check-overrides/src/parse-overrides.ts | 128 +++++++++ tools/check-overrides/src/report.ts | 102 ++++++++ tools/check-overrides/src/resolve.ts | 97 +++++++ tools/check-overrides/src/types.ts | 58 +++++ tools/check-overrides/src/workspace-file.ts | 54 ++++ tools/check-overrides/tsconfig.json | 21 ++ 24 files changed, 2127 insertions(+), 1 deletion(-) create mode 100644 .github/actions/check-overrides/action.yaml create mode 100755 .github/actions/check-overrides/check-overrides.sh create mode 100644 docs/github-actions/check-overrides.md create mode 100644 tools/check-overrides/eslint.config.mjs create mode 100644 tools/check-overrides/jest.config.ts create mode 100644 tools/check-overrides/package.json create mode 100644 tools/check-overrides/src/__tests__/check.test.ts create mode 100644 tools/check-overrides/src/__tests__/lockfile.test.ts create mode 100644 tools/check-overrides/src/__tests__/parse-overrides.test.ts create mode 100644 tools/check-overrides/src/__tests__/report.test.ts create mode 100644 tools/check-overrides/src/__tests__/resolve.test.ts create mode 100644 tools/check-overrides/src/__tests__/workspace-file-io.test.ts create mode 100644 tools/check-overrides/src/__tests__/workspace-file.test.ts create mode 100644 tools/check-overrides/src/check.ts create mode 100644 tools/check-overrides/src/index.ts create mode 100644 tools/check-overrides/src/lockfile.ts create mode 100644 tools/check-overrides/src/parse-overrides.ts create mode 100644 tools/check-overrides/src/report.ts create mode 100644 tools/check-overrides/src/resolve.ts create mode 100644 tools/check-overrides/src/types.ts create mode 100644 tools/check-overrides/src/workspace-file.ts create mode 100644 tools/check-overrides/tsconfig.json diff --git a/.github/actions/check-overrides/action.yaml b/.github/actions/check-overrides/action.yaml new file mode 100644 index 00000000..e45b3370 --- /dev/null +++ b/.github/actions/check-overrides/action.yaml @@ -0,0 +1,56 @@ +name: "Check pnpm Overrides" +description: "Discover and optionally remove stale pnpm overrides from pnpm-workspace.yaml" + +inputs: + project-dir: + description: "Path to the project root containing pnpm-workspace.yaml" + required: false + default: "." + apply: + description: "Whether to apply removals and raise a PR (true/false)" + required: false + default: "false" + node-version: + description: "Node.js version to use" + required: false + default: "22" + +runs: + using: "composite" + steps: + - name: "Set up Node.js" + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + + - name: "Install pnpm" + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: 10 + + - name: "Run check-overrides" + shell: bash + env: + PROJECT_DIR: ${{ inputs.project-dir }} + APPLY: ${{ inputs.apply }} + TOOL_DIR: ${{ github.action_path }}/../../../tools/check-overrides + run: ${{ github.action_path }}/check-overrides.sh + + - name: "Create or update pull request" + if: ${{ !env.ACT && inputs.apply == 'true' }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 + with: + token: ${{ github.token }} + commit-message: "remove stale pnpm overrides" + branch: feature/remove-stale-pnpm-overrides + delete-branch: true + title: "remove stale pnpm overrides" + body-path: ${{ inputs.project-dir }}/.tmp/pr-body.md + add-paths: | + pnpm-workspace.yaml + pnpm-lock.yaml + sign-commits: true + labels: | + dependencies + automation + draft: false diff --git a/.github/actions/check-overrides/check-overrides.sh b/.github/actions/check-overrides/check-overrides.sh new file mode 100755 index 00000000..7da74b55 --- /dev/null +++ b/.github/actions/check-overrides/check-overrides.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -euo pipefail + +function main() { + if [[ -z "${TOOL_DIR:-}" ]]; then + echo "ERROR: TOOL_DIR is not set" >&2 + exit 1 + fi + + if [[ ! -d "${TOOL_DIR}" ]]; then + echo "ERROR: Tool directory does not exist: ${TOOL_DIR}" >&2 + exit 1 + fi + + local project_dir="${PROJECT_DIR:-.}" + local apply="${APPLY:-false}" + + echo "Installing check-overrides dependencies..." + (cd "${TOOL_DIR}" && pnpm install --no-frozen-lockfile) + + local args=("--project-dir" "${project_dir}") + + if [[ "${apply}" == "true" ]]; then + args+=("--apply") + fi + + echo "Running check-overrides..." + (cd "${TOOL_DIR}" && pnpm run check "${args[@]}") +} + +main "$@" diff --git a/docs/github-actions.md b/docs/github-actions.md index 33a0aa5c..baee8e8f 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -16,6 +16,7 @@ This repository provides reusable composite actions for NHS Notify projects. - [Check English Usage](github-actions/check-english-usage.html) - Validates writing style using Vale - [Check File Format](github-actions/check-file-format.html) - Validates file formatting standards - [Check Markdown Format](github-actions/check-markdown-format.html) - Checks Markdown files with markdownlint +- [Check pnpm Overrides](github-actions/check-overrides.html) - Discovers and removes stale pnpm overrides - [Check PR Title Format](github-actions/check-pr-title-format.html) - Validates PR titles against regex - [Check TODO Usage](github-actions/check-todo-usage.html) - Validates TODO comment format - [Create Lines of Code Report](github-actions/create-lines-of-code-report.html) - Counts lines of code diff --git a/docs/github-actions/check-overrides.md b/docs/github-actions/check-overrides.md new file mode 100644 index 00000000..a8634cb4 --- /dev/null +++ b/docs/github-actions/check-overrides.md @@ -0,0 +1,69 @@ +--- +layout: default +title: Check pnpm overrides +parent: GitHub Actions +grand_parent: Home +nav_order: 17 +--- + + + +## Check pnpm overrides + +Discovers and optionally removes stale `pnpm.overrides` entries from `pnpm-workspace.yaml`. + +### Description + +This composite action scans the `overrides` section of `pnpm-workspace.yaml` to identify entries that are no longer required to mitigate transitive dependency vulnerabilities. For each override (or chain of linked overrides), it: + +1. Temporarily removes the override from `pnpm-workspace.yaml` +2. Regenerates the lockfile using `pnpm update --lockfile-only` +3. Checks whether the dependency still resolves to a version that satisfies the original minimum version constraint +4. Reports which overrides are removable, simplifiable, or still required + +When run in apply mode, it also modifies `pnpm-workspace.yaml` and raises a pull request with the changes. + +### Prerequisites + +The consuming repository must have a `pnpm-workspace.yaml` with an `overrides` section. The action requires `contents: write` and `pull-requests: write` permissions to create pull requests. + +### Inputs + +| Input | Required | Default | Description | +| -------------- | -------- | ------- | --------------------------------------------------------- | +| `project-dir` | No | `.` | Path to the project root containing `pnpm-workspace.yaml` | +| `apply` | No | `false` | Whether to apply removals and raise a pull request | +| `node-version` | No | `22` | Node.js version to use | + +### Usage + +```yaml +name: Check pnpm overrides + +on: + schedule: + - cron: "0 8 * * 1-5" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + check-overrides: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ # v4 + + - name: Check and remove stale overrides + uses: NHSDigital/nhs-notify-shared-modules/.github/actions/check-overrides@main + with: + apply: "true" +``` + +### Behaviour + +- In **check mode** (default), the action reports findings to the workflow log without modifying any files. +- In **apply mode** (`apply: "true"`), the action modifies `pnpm-workspace.yaml` and `pnpm-lock.yaml`, then raises a pull request targeting `feature/remove-stale-pnpm-overrides`. If a pull request for that branch already exists, it is updated. +- The pull request body contains a full report listing which overrides were removed or simplified and why. +- The pull request creation step is skipped when running locally with [`act`](https://github.com/nektos/act). diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 88d99fcd..0d569d63 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,4 +1,3 @@ -[A-Z]+s [Bb]undler Bitwarden bot @@ -21,17 +20,20 @@ Gitleaks Grype idempotence Jira +lockfile markdownlint npm OAuth Octokit onboarding Podman +[Pp]npm Python rawContent relative_url [Rr]epo sed +simplifiable Syft toolchain Trivy diff --git a/tools/check-overrides/eslint.config.mjs b/tools/check-overrides/eslint.config.mjs new file mode 100644 index 00000000..b20cbcb8 --- /dev/null +++ b/tools/check-overrides/eslint.config.mjs @@ -0,0 +1,60 @@ +import js from "@eslint/js"; +import jest from "eslint-plugin-jest"; +import security from "eslint-plugin-security"; +import sonarjs from "eslint-plugin-sonarjs"; +import unicorn from "eslint-plugin-unicorn"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "**/coverage/**", + "**/.build/**", + "**/node_modules/**", + "**/dist/**", + ], + }, + { + files: ["**/*.{js,mjs,cjs,ts}"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + globals: globals.node, + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ["**/*.{js,mjs,cjs,ts}"], + extends: [security.configs.recommended], + }, + { + files: ["**/*.{js,mjs,cjs,ts}"], + plugins: { sonarjs }, + rules: sonarjs.configs.recommended.rules, + }, + { + files: ["**/*.{js,mjs,cjs,ts}"], + extends: [unicorn.configs.recommended], + rules: { + "unicorn/prevent-abbreviations": "off", + "unicorn/no-null": "off", + "unicorn/no-useless-undefined": "off", + "unicorn/prefer-module": "off", + }, + }, + { + files: ["**/__tests__/**", "**/*.test.*", "**/*.spec.*"], + extends: [jest.configs["flat/recommended"]], + rules: { + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-explicit-any": "off", + }, + }, +); diff --git a/tools/check-overrides/jest.config.ts b/tools/check-overrides/jest.config.ts new file mode 100644 index 00000000..0142949c --- /dev/null +++ b/tools/check-overrides/jest.config.ts @@ -0,0 +1,28 @@ +import type { Config } from "jest"; + +const jestConfig: Config = { + preset: "ts-jest", + clearMocks: true, + silent: true, + collectCoverage: true, + coverageDirectory: "./.reports/unit/coverage", + coverageProvider: "v8", + coveragePathIgnorePatterns: ["/__tests__/", "/node_modules/"], + transform: { "^.+\\.ts$": "ts-jest" }, + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + testEnvironment: "node", + moduleNameMapper: { + "^src/(.*)$": "/src/$1", + }, + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}; + +export default jestConfig; diff --git a/tools/check-overrides/package.json b/tools/check-overrides/package.json new file mode 100644 index 00000000..7371611b --- /dev/null +++ b/tools/check-overrides/package.json @@ -0,0 +1,36 @@ +{ + "engines": { + "node": ">=22.0.0" + }, + "name": "check-overrides", + "version": "0.0.1", + "private": true, + "scripts": { + "check": "tsx ./src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "semver": "^7.7.0", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/jest": "^30.0.0", + "@types/node": "^25.9.1", + "@types/semver": "^7.5.8", + "eslint": "^10.4.0", + "eslint-plugin-jest": "^29.15.2", + "eslint-plugin-security": "^4.0.0", + "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-unicorn": "^64.0.0", + "globals": "^17.6.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.22.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1" + } +} diff --git a/tools/check-overrides/src/__tests__/check.test.ts b/tools/check-overrides/src/__tests__/check.test.ts new file mode 100644 index 00000000..965f1520 --- /dev/null +++ b/tools/check-overrides/src/__tests__/check.test.ts @@ -0,0 +1,164 @@ +/* eslint-disable jest/no-conditional-expect */ + +import { checkAllChains, checkChain } from "src/check"; +import { buildChains, parseOverrides } from "src/parse-overrides"; + +import type { OverrideChain } from "src/types"; + +jest.mock("src/resolve", () => { + const actual = jest.requireActual("src/resolve"); + return { + ...actual, + regenerateLockfile: jest.fn().mockResolvedValue(undefined), + readResolvedVersion: jest.fn(), + withOriginalFiles: jest.fn( + async (_dir: string, fn: () => Promise) => fn(), + ), + }; +}); + +jest.mock("src/workspace-file", () => { + const actual = jest.requireActual("src/workspace-file"); + return { + ...actual, + writeWorkspaceFile: jest.fn().mockResolvedValue(undefined), + }; +}); + +jest.mock("node:fs/promises", () => ({ + readFile: jest.fn().mockResolvedValue(""), +})); + +const resolve = jest.requireMock("src/resolve"); + +const buildSingleChain = (key: string, spec: string): OverrideChain => { + const overrides = parseOverrides({ [key]: spec }); + return buildChains(overrides)[0]; +}; + +const buildTwoLinkChain = (): OverrideChain => { + const overrides = parseOverrides({ + "@scope/root-pkg>mid-pkg": "^5.7.0", + "mid-pkg>leaf-pkg": "^1.1.7", + }); + return buildChains(overrides)[0]; +}; + +describe("checkChain", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns removable when a standalone override resolves to >= minimum", async () => { + const chain = buildSingleChain("solo-pkg", "^3.4.0"); + (resolve.readResolvedVersion as jest.Mock).mockResolvedValue("3.5.0"); + + const result = await checkChain("/proj", chain); + + expect(result.status).toBe("removable"); + if (result.status === "removable") { + expect(result.resolved["solo-pkg"]).toBe("3.5.0"); + } + }); + + it("returns needed when a standalone override resolves below minimum", async () => { + const chain = buildSingleChain("solo-pkg", "^3.4.0"); + (resolve.readResolvedVersion as jest.Mock).mockResolvedValue("3.3.0"); + + const result = await checkChain("/proj", chain); + + expect(result.status).toBe("needed"); + }); + + it("returns needed when the package is absent from the lockfile", async () => { + const chain = buildSingleChain("solo-pkg", "^3.4.0"); + (resolve.readResolvedVersion as jest.Mock).mockResolvedValue(undefined); + + const result = await checkChain("/proj", chain); + + expect(result.status).toBe("needed"); + if (result.status === "needed") { + expect(result.failures[0].reason).toContain("not resolved"); + } + }); + + it("returns removable for a chain where full removal satisfies all overrides", async () => { + const chain = buildTwoLinkChain(); + (resolve.readResolvedVersion as jest.Mock) + .mockResolvedValueOnce("5.8.0") + .mockResolvedValueOnce("1.2.0"); + + const result = await checkChain("/proj", chain); + + expect(result.status).toBe("removable"); + }); + + it("returns simplifiable (remove leaf) when leaf is naturally safe but root still needs override", async () => { + const chain = buildTwoLinkChain(); + (resolve.readResolvedVersion as jest.Mock) + .mockResolvedValueOnce("4.5.0") + .mockResolvedValueOnce("1.2.0") + .mockResolvedValueOnce("5.7.3") + .mockResolvedValueOnce("1.2.0"); + + const result = await checkChain("/proj", chain); + + expect(result.status).toBe("simplifiable"); + if (result.status === "simplifiable") { + expect(result.remove[0].key).toBe("mid-pkg>leaf-pkg"); + expect(result.keep[0].key).toBe("@scope/root-pkg>mid-pkg"); + } + }); + + it("returns simplifiable (remove root) when root is naturally safe but leaf still needs override", async () => { + const chain = buildTwoLinkChain(); + (resolve.readResolvedVersion as jest.Mock) + .mockResolvedValueOnce("5.8.0") + .mockResolvedValueOnce("1.0.5") + .mockResolvedValueOnce("5.8.0") + .mockResolvedValueOnce("1.0.5") + .mockResolvedValueOnce("5.8.0") + .mockResolvedValueOnce("1.2.0"); + + const result = await checkChain("/proj", chain); + + expect(result.status).toBe("simplifiable"); + if (result.status === "simplifiable") { + expect(result.remove[0].key).toBe("@scope/root-pkg>mid-pkg"); + expect(result.keep[0].key).toBe("mid-pkg>leaf-pkg"); + } + }); + + it("returns needed when no scenario satisfies the chain", async () => { + const chain = buildTwoLinkChain(); + (resolve.readResolvedVersion as jest.Mock).mockResolvedValue("1.0.0"); + + const result = await checkChain("/proj", chain); + + expect(result.status).toBe("needed"); + }); +}); + +describe("checkAllChains", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns one result per chain in order", async () => { + const chains = buildChains( + parseOverrides({ + "solo-pkg": "^3.4.0", + "parent-a>child-a": "^3.1.2", + }), + ); + (resolve.readResolvedVersion as jest.Mock) + .mockResolvedValueOnce("3.5.0") + .mockResolvedValueOnce("3.2.0"); + + const results = await checkAllChains("/proj", chains); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe("removable"); + expect(results[1].status).toBe("removable"); + }); +}); diff --git a/tools/check-overrides/src/__tests__/lockfile.test.ts b/tools/check-overrides/src/__tests__/lockfile.test.ts new file mode 100644 index 00000000..6479211a --- /dev/null +++ b/tools/check-overrides/src/__tests__/lockfile.test.ts @@ -0,0 +1,117 @@ +import { + findGlobalResolvedVersion, + findScopedResolvedVersion, + lockfilePath, + readLockfile, +} from "src/lockfile"; + +import { readFile } from "node:fs/promises"; + +jest.mock("node:fs/promises", () => ({ readFile: jest.fn() })); + +const mockedReadFile = readFile as jest.MockedFunction; + +const lockfile = { + lockfileVersion: "9.0", + snapshots: { + "mid-pkg@5.7.3": { + dependencies: { + "leaf-pkg": "1.2.0", + "extra-dep": "2.3.0", + }, + }, + "mid-pkg@4.5.1": { + dependencies: { + "leaf-pkg": "1.0.5", + }, + }, + "leaf-pkg@1.2.0": {}, + "leaf-pkg@1.0.5": {}, + "@scope/root-pkg@3.1039.0": { + dependencies: { + "mid-pkg": "5.7.3", + }, + }, + "peer-dep-pkg@19.0.0(peer@1.0.0)": {}, + }, +}; + +describe("findGlobalResolvedVersion", () => { + it("returns the highest version of a package across snapshots", () => { + expect(findGlobalResolvedVersion(lockfile, "mid-pkg")).toBe("5.7.3"); + }); + + it("strips peer-dep suffixes from versions", () => { + expect(findGlobalResolvedVersion(lockfile, "peer-dep-pkg")).toBe("19.0.0"); + }); + + it("handles scoped package names", () => { + expect(findGlobalResolvedVersion(lockfile, "@scope/root-pkg")).toBe( + "3.1039.0", + ); + }); + + it("returns undefined when the package is absent", () => { + expect( + findGlobalResolvedVersion(lockfile, "not-installed"), + ).toBeUndefined(); + }); +}); + +describe("findScopedResolvedVersion", () => { + it("returns the lowest resolved child version under a parent (worst-case floor)", () => { + expect(findScopedResolvedVersion(lockfile, "mid-pkg", "leaf-pkg")).toBe( + "1.0.5", + ); + }); + + it("returns the resolved version when only one parent snapshot exists", () => { + expect( + findScopedResolvedVersion(lockfile, "@scope/root-pkg", "mid-pkg"), + ).toBe("5.7.3"); + }); + + it("returns undefined when the parent has no such dependency", () => { + expect( + findScopedResolvedVersion(lockfile, "mid-pkg", "nonexistent"), + ).toBeUndefined(); + }); + + it("returns undefined when the parent is not in the lockfile", () => { + expect( + findScopedResolvedVersion(lockfile, "nonexistent", "leaf-pkg"), + ).toBeUndefined(); + }); + + it("skips snapshot keys that have no version separator", () => { + const weird = { + lockfileVersion: "9.0", + snapshots: { + "no-at-sign-here": { dependencies: { foo: "1.0.0" } }, + "mid-pkg@1.0.0": { dependencies: { foo: "2.0.0" } }, + }, + }; + expect(findScopedResolvedVersion(weird, "mid-pkg", "foo")).toBe("2.0.0"); + expect(findGlobalResolvedVersion(weird, "no-at-sign-here")).toBeUndefined(); + }); +}); + +describe("lockfilePath", () => { + it("joins the project directory with pnpm-lock.yaml", () => { + expect(lockfilePath("/proj")).toBe("/proj/pnpm-lock.yaml"); + }); +}); + +describe("readLockfile", () => { + it("parses the lockfile YAML at the project path", async () => { + mockedReadFile.mockResolvedValue( + "lockfileVersion: '9.0'\nsnapshots:\n foo@1.0.0: {}\n", + ); + + const result = await readLockfile("/proj"); + + expect(result.lockfileVersion).toBe("9.0"); + expect(result.snapshots).toEqual({ "foo@1.0.0": {} }); + expect(mockedReadFile).toHaveBeenCalledWith("/proj/pnpm-lock.yaml", "utf8"); + }); +}); diff --git a/tools/check-overrides/src/__tests__/parse-overrides.test.ts b/tools/check-overrides/src/__tests__/parse-overrides.test.ts new file mode 100644 index 00000000..045cd203 --- /dev/null +++ b/tools/check-overrides/src/__tests__/parse-overrides.test.ts @@ -0,0 +1,199 @@ +import { buildChains, parseOverrides } from "src/parse-overrides"; + +describe("parseOverrides", () => { + it("parses unscoped overrides", () => { + const result = parseOverrides({ + "loner-pkg": "^1.0.3", + "solo-pkg": "^3.4.0", + }); + + expect(result).toEqual([ + { + key: "loner-pkg", + parent: undefined, + package: "loner-pkg", + versionSelector: undefined, + versionSpec: "^1.0.3", + minVersion: "1.0.3", + }, + { + key: "solo-pkg", + parent: undefined, + package: "solo-pkg", + versionSelector: undefined, + versionSpec: "^3.4.0", + minVersion: "3.4.0", + }, + ]); + }); + + it("parses scoped overrides with parent>child syntax", () => { + const result = parseOverrides({ + "parent-a>child-a": "^3.1.2", + "mid-pkg>leaf-pkg": "^1.1.7", + }); + + expect(result[0]).toMatchObject({ + key: "parent-a>child-a", + parent: "parent-a", + package: "child-a", + minVersion: "3.1.2", + }); + expect(result[1]).toMatchObject({ + key: "mid-pkg>leaf-pkg", + parent: "mid-pkg", + package: "leaf-pkg", + minVersion: "1.1.7", + }); + }); + + it("parses scoped parents (e.g. @scope/root-pkg>mid-pkg)", () => { + const [result] = parseOverrides({ + "@scope/root-pkg>mid-pkg": "^5.7.0", + }); + + expect(result).toMatchObject({ + key: "@scope/root-pkg>mid-pkg", + parent: "@scope/root-pkg", + package: "mid-pkg", + minVersion: "5.7.0", + }); + }); + + it("treats exact pins as the minimum version", () => { + const [result] = parseOverrides({ + "host-pkg>peer-dep-pkg": "19.0.0", + }); + + expect(result.minVersion).toBe("19.0.0"); + }); + + it("throws if a version spec has no parseable minimum", () => { + expect(() => parseOverrides({ foo: "not-a-version" })).toThrow( + /Cannot determine minimum/, + ); + }); + + it("parses overrides with a version selector in the key", () => { + const result = parseOverrides({ + "uuid@<11.1.1": ">=11.1.1", + }); + + expect(result).toEqual([ + { + key: "uuid@<11.1.1", + parent: undefined, + package: "uuid", + versionSelector: "<11.1.1", + versionSpec: ">=11.1.1", + minVersion: "11.1.1", + }, + ]); + }); + + it("parses scoped package with a version selector", () => { + const [result] = parseOverrides({ + "@scope/pkg@<2.0.0": ">=2.0.0", + }); + + expect(result).toMatchObject({ + key: "@scope/pkg@<2.0.0", + parent: undefined, + package: "@scope/pkg", + versionSelector: "<2.0.0", + minVersion: "2.0.0", + }); + }); + + it("parses parent>child with a version selector on the child", () => { + const [result] = parseOverrides({ + "parent-pkg>uuid@<11.1.1": ">=11.1.1", + }); + + expect(result).toMatchObject({ + key: "parent-pkg>uuid@<11.1.1", + parent: "parent-pkg", + package: "uuid", + versionSelector: "<11.1.1", + minVersion: "11.1.1", + }); + }); + + it("strips version selectors from the parent", () => { + const [result] = parseOverrides({ + "qar@1>zoo": "^2.0.0", + }); + + expect(result).toMatchObject({ + key: "qar@1>zoo", + parent: "qar", + package: "zoo", + minVersion: "2.0.0", + }); + }); + + it("strips version selectors from a scoped parent", () => { + const [result] = parseOverrides({ + "@scope/qar@^1.0.0>zoo": "^2.0.0", + }); + + expect(result).toMatchObject({ + key: "@scope/qar@^1.0.0>zoo", + parent: "@scope/qar", + package: "zoo", + minVersion: "2.0.0", + }); + }); + + it("skips removal overrides (dash value)", () => { + const result = parseOverrides({ + "foo@1.0.0>bar": "-", + "real-pkg": "^1.0.0", + }); + + expect(result).toHaveLength(1); + expect(result[0].package).toBe("real-pkg"); + }); + + it("skips $-reference overrides", () => { + const result = parseOverrides({ + bar: "$foo", + "real-pkg": "^2.0.0", + }); + + expect(result).toHaveLength(1); + expect(result[0].package).toBe("real-pkg"); + }); + + it("skips npm: alias overrides", () => { + const result = parseOverrides({ + quux: "npm:@myorg/quux@^1.0.0", + "real-pkg": "^3.0.0", + }); + + expect(result).toHaveLength(1); + expect(result[0].package).toBe("real-pkg"); + }); + + it("skips link: and file: protocol overrides", () => { + const result = parseOverrides({ + "link-pkg": "link:../local", + "file-pkg": "file:../local.tgz", + "real-pkg": "^1.0.0", + }); + + expect(result).toHaveLength(1); + expect(result[0].package).toBe("real-pkg"); + }); + + it("skips workspace: and catalog: protocol overrides", () => { + const result = parseOverrides({ + "ws-pkg": "workspace:*", + "cat-pkg": "catalog:default", + "real-pkg": "^1.0.0", + }); + + expect(result).toHaveLength(1); + expect(result[0].package).toBe("real-pkg"); + }); +}); diff --git a/tools/check-overrides/src/__tests__/report.test.ts b/tools/check-overrides/src/__tests__/report.test.ts new file mode 100644 index 00000000..9e1edb7d --- /dev/null +++ b/tools/check-overrides/src/__tests__/report.test.ts @@ -0,0 +1,207 @@ +import { + overridesToRemove, + renderHumanSummary, + renderPrBody, + reportPath, +} from "src/report"; + +import type { ChainCheckResult, Override, OverrideReport } from "src/types"; + +const rootOverride: Override = { + key: "@scope/root-pkg>mid-pkg", + parent: "@scope/root-pkg", + package: "mid-pkg", + versionSpec: "^5.7.0", + minVersion: "5.7.0", +}; + +const leafOverride: Override = { + key: "mid-pkg>leaf-pkg", + parent: "mid-pkg", + package: "leaf-pkg", + versionSpec: "^1.1.7", + minVersion: "1.1.7", +}; + +const soloOverride: Override = { + key: "solo-pkg", + package: "solo-pkg", + versionSpec: "^3.4.0", + minVersion: "3.4.0", +}; + +const buildReport = (results: ChainCheckResult[]): OverrideReport => ({ + generatedAt: "2026-05-18T00:00:00.000Z", + results, + hasChanges: overridesToRemove(results).length > 0, +}); + +describe("overridesToRemove", () => { + it("collects all overrides from a removable chain", () => { + const removals = overridesToRemove([ + { + status: "removable", + chain: { id: "chain", overrides: [rootOverride, leafOverride] }, + resolved: {}, + }, + ]); + + expect(removals).toEqual([rootOverride, leafOverride]); + }); + + it("collects only the `remove` set from a simplifiable chain", () => { + const removals = overridesToRemove([ + { + status: "simplifiable", + chain: { id: "chain", overrides: [rootOverride, leafOverride] }, + remove: [leafOverride], + keep: [rootOverride], + resolved: {}, + }, + ]); + + expect(removals).toEqual([leafOverride]); + }); + + it("ignores chains that are still needed", () => { + const removals = overridesToRemove([ + { + status: "needed", + chain: { id: "chain", overrides: [soloOverride] }, + failures: [], + }, + ]); + + expect(removals).toEqual([]); + }); +}); + +describe("renderHumanSummary", () => { + it("reports when no overrides are found", () => { + expect(renderHumanSummary(buildReport([]))).toContain("No overrides found"); + }); + + it("includes the chain id, status and resolved versions for a removable chain", () => { + const report = buildReport([ + { + status: "removable", + chain: { id: "solo-pkg", overrides: [soloOverride] }, + resolved: { "solo-pkg": "3.5.0" }, + }, + ]); + + const summary = renderHumanSummary(report); + expect(summary).toContain("solo-pkg"); + expect(summary).toContain("REMOVABLE"); + expect(summary).toContain("3.5.0"); + expect(summary).toContain("Actionable findings"); + }); + + it("reports still-needed chains with their failure reasons", () => { + const report = buildReport([ + { + status: "needed", + chain: { id: "solo-pkg", overrides: [soloOverride] }, + failures: [ + { + override: soloOverride, + resolved: "3.3.0", + reason: "resolved 3.3.0 < required 3.4.0", + }, + ], + }, + ]); + + const summary = renderHumanSummary(report); + expect(summary).toContain("STILL NEEDED"); + expect(summary).toContain("resolved 3.3.0 < required 3.4.0"); + expect(summary).toContain("all overrides are still required"); + }); + + it("reports simplifiable chains with remove/keep sets", () => { + const report = buildReport([ + { + status: "simplifiable", + chain: { id: "chain", overrides: [rootOverride, leafOverride] }, + remove: [leafOverride], + keep: [rootOverride], + resolved: {}, + }, + ]); + + const summary = renderHumanSummary(report); + expect(summary).toContain("SIMPLIFIABLE"); + expect(summary).toContain("mid-pkg>leaf-pkg"); + expect(summary).toContain("@scope/root-pkg>mid-pkg"); + }); +}); + +describe("renderPrBody", () => { + it("renders markdown sections for removable, simplifiable, and still-needed", () => { + const report = buildReport([ + { + status: "removable", + chain: { id: "solo-pkg", overrides: [soloOverride] }, + resolved: { "solo-pkg": "3.5.0" }, + }, + { + status: "simplifiable", + chain: { id: "chain", overrides: [rootOverride, leafOverride] }, + remove: [leafOverride], + keep: [rootOverride], + resolved: {}, + }, + { + status: "needed", + chain: { id: "needed-chain", overrides: [soloOverride] }, + failures: [ + { + override: soloOverride, + resolved: "3.3.0", + reason: "below minimum", + }, + ], + }, + ]); + + const body = renderPrBody(report); + expect(body).toContain("## Changes"); + expect(body).toContain("### Removable: solo-pkg"); + expect(body).toContain("### Simplifiable: chain"); + expect(body).toContain("## Overrides still required"); + expect(body).toContain("### Still needed: needed-chain"); + }); + + it("omits the Changes section when there are no actionable findings", () => { + const report = buildReport([ + { + status: "needed", + chain: { id: "solo-pkg", overrides: [soloOverride] }, + failures: [], + }, + ]); + + const body = renderPrBody(report); + expect(body).not.toContain("## Changes"); + expect(body).toContain("## Overrides still required"); + }); + + it("falls back to '?' when a removable chain entry has no resolved version", () => { + const report = buildReport([ + { + status: "removable", + chain: { id: "solo-pkg", overrides: [soloOverride] }, + resolved: {}, + }, + ]); + + const body = renderPrBody(report); + expect(body).toContain("`?`"); + }); +}); + +describe("reportPath", () => { + it("joins the project directory with .tmp/override-report.json", () => { + expect(reportPath("/proj")).toBe("/proj/.tmp/override-report.json"); + }); +}); diff --git a/tools/check-overrides/src/__tests__/resolve.test.ts b/tools/check-overrides/src/__tests__/resolve.test.ts new file mode 100644 index 00000000..962083e8 --- /dev/null +++ b/tools/check-overrides/src/__tests__/resolve.test.ts @@ -0,0 +1,244 @@ +/* eslint-disable unicorn/prefer-event-target */ + +import { EventEmitter } from "node:events"; + +import { + applyRemovals, + readResolvedVersion, + regenerateLockfile, + withOriginalFiles, +} from "src/resolve"; + +import type { Override } from "src/types"; + +jest.mock("node:child_process", () => ({ + spawn: jest.fn(), +})); + +jest.mock("node:fs/promises", () => ({ + readFile: jest.fn(), + writeFile: jest.fn(), +})); + +jest.mock("src/lockfile", () => ({ + lockfilePath: jest.fn((dir: string) => `${dir}/pnpm-lock.yaml`), + readLockfile: jest.fn(), + findGlobalResolvedVersion: jest.fn(), + findScopedResolvedVersion: jest.fn(), +})); + +jest.mock("src/workspace-file", () => ({ + workspaceFilePath: jest.fn((dir: string) => `${dir}/pnpm-workspace.yaml`), + writeWorkspaceFile: jest.fn().mockResolvedValue(undefined), + removeOverridesFromYaml: jest.fn((content: string) => `${content}#stripped`), +})); + +const { spawn } = + jest.requireMock("node:child_process"); +const fsPromises = + jest.requireMock("node:fs/promises"); +const lockfile = + jest.requireMock("src/lockfile"); +const workspaceFile = + jest.requireMock("src/workspace-file"); + +type FakeChild = EventEmitter & { + stderr: EventEmitter; +}; + +const makeChild = ( + exitCode: number, + stderrText = "", + errorToEmit?: Error, +): FakeChild => { + const stderr = new EventEmitter(); + const child = Object.assign(new EventEmitter(), { stderr }) as FakeChild; + setImmediate(() => { + if (errorToEmit) { + child.emit("error", errorToEmit); + return; + } + if (stderrText) { + stderr.emit("data", Buffer.from(stderrText, "utf8")); + } + child.emit("close", exitCode); + }); + return child; +}; + +describe("regenerateLockfile", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("is a no-op when no packages need updating", async () => { + await regenerateLockfile("/proj", []); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("spawns pnpm update with the named packages and resolves on exit 0", async () => { + (spawn as jest.Mock).mockReturnValue(makeChild(0)); + + await regenerateLockfile("/proj", ["foo", "bar"]); + + expect(spawn).toHaveBeenCalledWith( + "pnpm", + ["update", "--lockfile-only", "-r", "foo", "bar"], + expect.objectContaining({ cwd: "/proj" }), + ); + }); + + it("rejects with stderr included when exit code is non-zero", async () => { + (spawn as jest.Mock).mockReturnValue(makeChild(1, "boom")); + + await expect(regenerateLockfile("/proj", ["foo"])).rejects.toThrow( + /exit code 1[\s\S]*boom/, + ); + }); + + it("rejects when the spawned process emits an error", async () => { + (spawn as jest.Mock).mockReturnValue(makeChild(0, "", new Error("ENOENT"))); + + await expect(regenerateLockfile("/proj", ["foo"])).rejects.toThrow( + /ENOENT/, + ); + }); +}); + +describe("readResolvedVersion", () => { + beforeEach(() => { + jest.clearAllMocks(); + (lockfile.readLockfile as jest.Mock).mockResolvedValue({}); + }); + + it("reads a global resolution when the override has no parent", async () => { + (lockfile.findGlobalResolvedVersion as jest.Mock).mockReturnValue("3.5.0"); + const override: Override = { + key: "solo-pkg", + package: "solo-pkg", + versionSpec: "^3.4.0", + minVersion: "3.4.0", + }; + + const result = await readResolvedVersion("/proj", override); + + expect(result).toBe("3.5.0"); + expect(lockfile.findGlobalResolvedVersion).toHaveBeenCalled(); + expect(lockfile.findScopedResolvedVersion).not.toHaveBeenCalled(); + }); + + it("reads a scoped resolution when the override has a parent", async () => { + (lockfile.findScopedResolvedVersion as jest.Mock).mockReturnValue("5.8.0"); + const override: Override = { + key: "@scope/root-pkg>mid-pkg", + parent: "@scope/root-pkg", + package: "mid-pkg", + versionSpec: "^5.7.0", + minVersion: "5.7.0", + }; + + const result = await readResolvedVersion("/proj", override); + + expect(result).toBe("5.8.0"); + expect(lockfile.findScopedResolvedVersion).toHaveBeenCalledWith( + {}, + "@scope/root-pkg", + "mid-pkg", + ); + }); +}); + +describe("withOriginalFiles", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("restores both files after fn resolves", async () => { + (fsPromises.readFile as jest.Mock) + .mockResolvedValueOnce("ws-original") + .mockResolvedValueOnce("lock-original"); + + const fn = jest.fn().mockResolvedValue("ok"); + const result = await withOriginalFiles("/proj", fn); + + expect(result).toBe("ok"); + expect(fsPromises.writeFile).toHaveBeenCalledWith( + "/proj/pnpm-workspace.yaml", + "ws-original", + "utf8", + ); + expect(fsPromises.writeFile).toHaveBeenCalledWith( + "/proj/pnpm-lock.yaml", + "lock-original", + "utf8", + ); + }); + + it("still restores both files when fn throws", async () => { + (fsPromises.readFile as jest.Mock) + .mockResolvedValueOnce("ws-original") + .mockResolvedValueOnce("lock-original"); + + const fn = jest.fn().mockRejectedValue(new Error("boom")); + + await expect(withOriginalFiles("/proj", fn)).rejects.toThrow("boom"); + expect(fsPromises.writeFile).toHaveBeenCalledTimes(2); + }); +}); + +describe("applyRemovals", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("is a no-op when there are no overrides to remove", async () => { + await applyRemovals("/proj", []); + expect(fsPromises.readFile).not.toHaveBeenCalled(); + expect(workspaceFile.writeWorkspaceFile).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("rewrites the workspace file and regenerates the lockfile", async () => { + (fsPromises.readFile as jest.Mock).mockResolvedValue("ws-content"); + (spawn as jest.Mock).mockReturnValue(makeChild(0)); + + const overrides: Override[] = [ + { + key: "solo-pkg", + package: "solo-pkg", + versionSpec: "^3.4.0", + minVersion: "3.4.0", + }, + { + key: "@scope/root-pkg>mid-pkg", + parent: "@scope/root-pkg", + package: "mid-pkg", + versionSpec: "^5.7.0", + minVersion: "5.7.0", + }, + ]; + + await applyRemovals("/proj", overrides); + + expect(workspaceFile.removeOverridesFromYaml).toHaveBeenCalledWith( + "ws-content", + ["solo-pkg", "@scope/root-pkg>mid-pkg"], + ); + expect(workspaceFile.writeWorkspaceFile).toHaveBeenCalledWith( + "/proj", + "ws-content#stripped", + ); + expect(spawn).toHaveBeenCalledWith( + "pnpm", + [ + "update", + "--lockfile-only", + "-r", + "solo-pkg", + "mid-pkg", + "@scope/root-pkg", + ], + expect.objectContaining({ cwd: "/proj" }), + ); + }); +}); diff --git a/tools/check-overrides/src/__tests__/workspace-file-io.test.ts b/tools/check-overrides/src/__tests__/workspace-file-io.test.ts new file mode 100644 index 00000000..7c229da5 --- /dev/null +++ b/tools/check-overrides/src/__tests__/workspace-file-io.test.ts @@ -0,0 +1,72 @@ +import { readFile, writeFile } from "node:fs/promises"; + +import { + readWorkspaceOverrides, + workspaceFilePath, + writeWorkspaceFile, +} from "src/workspace-file"; + +jest.mock("node:fs/promises", () => ({ + readFile: jest.fn(), + writeFile: jest.fn(), +})); + +const mockedReadFile = readFile as jest.MockedFunction; +const mockedWriteFile = writeFile as jest.MockedFunction; + +describe("workspaceFilePath", () => { + it("joins the project directory with pnpm-workspace.yaml", () => { + expect(workspaceFilePath("/my/proj")).toBe("/my/proj/pnpm-workspace.yaml"); + }); +}); + +describe("readWorkspaceOverrides", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns the overrides map from pnpm-workspace.yaml", async () => { + mockedReadFile.mockResolvedValue( + `overrides:\n solo-pkg: "^3.4.0"\n "parent-a>child-a": "^3.1.2"\n`, + ); + + const result = await readWorkspaceOverrides("/proj"); + + expect(result).toEqual({ + "solo-pkg": "^3.4.0", + "parent-a>child-a": "^3.1.2", + }); + }); + + it("returns an empty object when there is no overrides block", async () => { + mockedReadFile.mockResolvedValue(`packages:\n - "tools/*"\n`); + + const result = await readWorkspaceOverrides("/proj"); + + expect(result).toEqual({}); + }); + + it("returns an empty object when the overrides block is empty", async () => { + mockedReadFile.mockResolvedValue(`overrides:\n`); + + const result = await readWorkspaceOverrides("/proj"); + + expect(result).toEqual({}); + }); +}); + +describe("writeWorkspaceFile", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("writes UTF-8 content to pnpm-workspace.yaml in the project dir", async () => { + await writeWorkspaceFile("/proj", 'overrides:\n solo-pkg: "^3.4.0"\n'); + + expect(mockedWriteFile).toHaveBeenCalledWith( + "/proj/pnpm-workspace.yaml", + 'overrides:\n solo-pkg: "^3.4.0"\n', + "utf8", + ); + }); +}); diff --git a/tools/check-overrides/src/__tests__/workspace-file.test.ts b/tools/check-overrides/src/__tests__/workspace-file.test.ts new file mode 100644 index 00000000..920676c9 --- /dev/null +++ b/tools/check-overrides/src/__tests__/workspace-file.test.ts @@ -0,0 +1,67 @@ +import { removeOverridesFromYaml } from "src/workspace-file"; + +const sampleYaml = `packages: + - "tools/*" + +catalogs: + app: + some-normal-package: "^17.7.2" + +overrides: + "parent-a>child-a": "^3.1.2" + loner-pkg: "^1.0.3" + "mid-pkg>leaf-pkg": "^1.1.7" +trustPolicy: no-downgrade +`; + +describe("removeOverridesFromYaml", () => { + it("removes a single named override and leaves the rest intact", () => { + const result = removeOverridesFromYaml(sampleYaml, ["loner-pkg"]); + + expect(result).toContain('"parent-a>child-a": "^3.1.2"'); + expect(result).toContain('"mid-pkg>leaf-pkg": "^1.1.7"'); + expect(result).not.toContain("loner-pkg"); + }); + + it("removes multiple overrides", () => { + const result = removeOverridesFromYaml(sampleYaml, [ + "loner-pkg", + "parent-a>child-a", + ]); + + expect(result).not.toContain("loner-pkg"); + expect(result).not.toContain("parent-a>child-a"); + expect(result).toContain('"mid-pkg>leaf-pkg": "^1.1.7"'); + }); + + it("removes the overrides block entirely when emptied", () => { + const result = removeOverridesFromYaml(sampleYaml, [ + "parent-a>child-a", + "loner-pkg", + "mid-pkg>leaf-pkg", + ]); + + expect(result).not.toContain("overrides:"); + }); + + it("preserves the catalogs and packages sections", () => { + const result = removeOverridesFromYaml(sampleYaml, ["loner-pkg"]); + + expect(result).toContain("packages:"); + expect(result).toContain('"tools/*"'); + expect(result).toContain("catalogs:"); + expect(result).toContain('some-normal-package: "^17.7.2"'); + }); + + it("is a no-op when there is no overrides block", () => { + const noOverrides = `packages:\n - "tools/*"\n`; + expect(removeOverridesFromYaml(noOverrides, ["foo"])).toBe(noOverrides); + }); + + it("ignores keys that are not present in overrides", () => { + const result = removeOverridesFromYaml(sampleYaml, ["does-not-exist"]); + + expect(result).toContain('"parent-a>child-a": "^3.1.2"'); + expect(result).toContain("loner-pkg"); + }); +}); diff --git a/tools/check-overrides/src/check.ts b/tools/check-overrides/src/check.ts new file mode 100644 index 00000000..7cb7be9d --- /dev/null +++ b/tools/check-overrides/src/check.ts @@ -0,0 +1,136 @@ +import { readFile } from "node:fs/promises"; + +import { gte } from "semver"; + +import { + readResolvedVersion, + regenerateLockfile, + withOriginalFiles, +} from "src/resolve"; +import { + removeOverridesFromYaml, + workspaceFilePath, + writeWorkspaceFile, +} from "src/workspace-file"; + +import type { + ChainCheckResult, + Override, + OverrideChain, + ResolutionFailure, +} from "src/types"; + +type ScenarioOutcome = { + satisfied: boolean; + resolved: Record; + failures: ResolutionFailure[]; +}; + +const runScenario = async ( + projectDir: string, + overridesToRemove: Override[], + overridesToVerify: Override[], +): Promise => + withOriginalFiles(projectDir, async () => { + const path = workspaceFilePath(projectDir); + const current = await readFile(path, "utf8"); + const updated = removeOverridesFromYaml( + current, + overridesToRemove.map((o) => o.key), + ); + await writeWorkspaceFile(projectDir, updated); + + const affectedPackages = [ + ...new Set(overridesToRemove.flatMap((o) => [o.package, o.parent ?? ""])), + ].filter((name) => name.length > 0); + await regenerateLockfile(projectDir, affectedPackages); + + const resolved: Record = {}; + const failures: ResolutionFailure[] = []; + + for (const override of overridesToVerify) { + const version = await readResolvedVersion(projectDir, override); + if (version) { + resolved[override.key] = version; + if (!gte(version, override.minVersion)) { + failures.push({ + override, + resolved: version, + reason: `resolved ${version} < required ${override.minVersion}`, + }); + } + } else { + failures.push({ + override, + reason: "package not resolved in lockfile", + }); + } + } + + return { + satisfied: failures.length === 0, + resolved, + failures, + }; + }); + +export const checkChain = async ( + projectDir: string, + chain: OverrideChain, +): Promise => { + const fullOutcome = await runScenario( + projectDir, + chain.overrides, + chain.overrides, + ); + if (fullOutcome.satisfied) { + return { + status: "removable", + chain, + resolved: fullOutcome.resolved, + }; + } + + if (chain.overrides.length > 1) { + const [root, ...leaves] = chain.overrides; + + const leafOutcome = await runScenario(projectDir, leaves, chain.overrides); + if (leafOutcome.satisfied) { + return { + status: "simplifiable", + chain, + remove: leaves, + keep: [root], + resolved: leafOutcome.resolved, + }; + } + + const rootOutcome = await runScenario(projectDir, [root], chain.overrides); + if (rootOutcome.satisfied) { + return { + status: "simplifiable", + chain, + remove: [root], + keep: leaves, + resolved: rootOutcome.resolved, + }; + } + } + + return { + status: "needed", + chain, + failures: fullOutcome.failures, + }; +}; + +export const checkAllChains = async ( + projectDir: string, + chains: OverrideChain[], +): Promise => { + const results: ChainCheckResult[] = []; + for (const chain of chains) { + results.push(await checkChain(projectDir, chain)); + } + return results; +}; diff --git a/tools/check-overrides/src/index.ts b/tools/check-overrides/src/index.ts new file mode 100644 index 00000000..7bd3a77e --- /dev/null +++ b/tools/check-overrides/src/index.ts @@ -0,0 +1,91 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { parseArgs } from "node:util"; + +import { checkAllChains } from "src/check"; +import { buildChains, parseOverrides } from "src/parse-overrides"; +import { applyRemovals } from "src/resolve"; +import { + overridesToRemove, + renderHumanSummary, + renderPrBody, + reportPath, +} from "src/report"; +import { readWorkspaceOverrides } from "src/workspace-file"; + +import type { OverrideReport } from "src/types"; + +type CliOptions = { + projectDir: string; + apply: boolean; +}; + +const parseCliArgs = (argv: string[]): CliOptions => { + const { values } = parseArgs({ + args: argv, + options: { + apply: { type: "boolean", default: false }, + "project-dir": { type: "string" }, + }, + }); + return { + apply: values.apply ?? false, + projectDir: path.resolve(values["project-dir"] ?? process.cwd()), + }; +}; + +const writeReportFiles = async ( + projectDir: string, + report: OverrideReport, +): Promise => { + const jsonPath = reportPath(projectDir); + await mkdir(path.dirname(jsonPath), { recursive: true }); + await writeFile(jsonPath, JSON.stringify(report, undefined, 2), "utf8"); + const prBodyPath = path.join(projectDir, ".tmp", "pr-body.md"); + await writeFile(prBodyPath, renderPrBody(report), "utf8"); +}; + +export const run = async (argv: string[]): Promise => { + const options = parseCliArgs(argv); + + const overridesMap = await readWorkspaceOverrides(options.projectDir); + const overrides = parseOverrides(overridesMap); + const chains = buildChains(overrides); + + const results = await checkAllChains(options.projectDir, chains); + const removals = overridesToRemove(results); + + const report: OverrideReport = { + generatedAt: new Date().toISOString(), + results, + hasChanges: removals.length > 0, + }; + + process.stdout.write(`${JSON.stringify(report, undefined, 2)}\n`); + process.stderr.write(`${renderHumanSummary(report)}\n`); + + await writeReportFiles(options.projectDir, report); + + if (options.apply && removals.length > 0) { + process.stderr.write( + `\nApplying ${removals.length} removal(s) to pnpm-workspace.yaml...\n`, + ); + await applyRemovals(options.projectDir, removals); + process.stderr.write("Apply complete.\n"); + } +}; + +const isMain = (): boolean => { + const entryPoint = process.argv[1]; + if (!entryPoint) { + return false; + } + return entryPoint.endsWith("index.ts") || entryPoint.endsWith("index.js"); +}; + +if (isMain()) { + run(process.argv.slice(2)).catch((error: unknown) => { + process.stderr.write(`${(error as Error).stack ?? String(error)}\n`); + process.exit(1); + }); +} diff --git a/tools/check-overrides/src/lockfile.ts b/tools/check-overrides/src/lockfile.ts new file mode 100644 index 00000000..09ab0d50 --- /dev/null +++ b/tools/check-overrides/src/lockfile.ts @@ -0,0 +1,85 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { parse as parseYaml } from "yaml"; + +const LOCKFILE = "pnpm-lock.yaml"; + +type Snapshot = { + dependencies?: Record; +}; + +type Lockfile = { + lockfileVersion: string; + snapshots?: Record; +}; + +export const lockfilePath = (projectDir: string): string => + path.join(projectDir, LOCKFILE); + +export const readLockfile = async (projectDir: string): Promise => { + const content = await readFile(lockfilePath(projectDir), "utf8"); + return parseYaml(content) as Lockfile; +}; + +const baseVersion = (raw: string): string => { + const parenIndex = raw.indexOf("("); + return parenIndex === -1 ? raw : raw.slice(0, parenIndex); +}; + +const parseSnapshotKey = ( + key: string, +): { name: string; version: string } | undefined => { + const startSearch = key.startsWith("@") ? 1 : 0; + const atIndex = key.indexOf("@", startSearch); + if (atIndex === -1) { + return undefined; + } + return { + name: key.slice(0, atIndex), + version: baseVersion(key.slice(atIndex + 1)), + }; +}; + +export const findGlobalResolvedVersion = ( + lockfile: Lockfile, + packageName: string, +): string | undefined => { + const snapshots = lockfile.snapshots ?? {}; + const versions = Object.keys(snapshots) + .map((key) => parseSnapshotKey(key)) + .filter( + (parsed): parsed is { name: string; version: string } => + parsed !== undefined && parsed.name === packageName, + ) + .map((parsed) => parsed.version); + + if (versions.length === 0) { + return undefined; + } + return versions.toSorted((a, b) => (a < b ? 1 : -1))[0]; +}; + +export const findScopedResolvedVersion = ( + lockfile: Lockfile, + parentName: string, + packageName: string, +): string | undefined => { + const snapshots = lockfile.snapshots ?? {}; + const childVersions: string[] = []; + + for (const [key, snapshot] of Object.entries(snapshots)) { + const parsed = parseSnapshotKey(key); + if (parsed && parsed.name === parentName) { + const dep = snapshot.dependencies?.[packageName]; + if (dep) { + childVersions.push(baseVersion(dep)); + } + } + } + + if (childVersions.length === 0) { + return undefined; + } + return childVersions.toSorted((a, b) => (a < b ? -1 : 1))[0]; +}; diff --git a/tools/check-overrides/src/parse-overrides.ts b/tools/check-overrides/src/parse-overrides.ts new file mode 100644 index 00000000..62d1dc9a --- /dev/null +++ b/tools/check-overrides/src/parse-overrides.ts @@ -0,0 +1,128 @@ +import { minVersion } from "semver"; + +import type { Override, OverrideChain } from "src/types"; + +const stripVersionSelector = ( + raw: string, +): { name: string; versionSelector?: string } => { + const startSearch = raw.startsWith("@") ? 1 : 0; + const atIndex = raw.indexOf("@", startSearch); + if (atIndex === -1) { + return { name: raw }; + } + return { + name: raw.slice(0, atIndex), + versionSelector: raw.slice(atIndex + 1), + }; +}; + +const parseKey = ( + key: string, +): { parent?: string; package: string; versionSelector?: string } => { + const separatorIndex = key.lastIndexOf(">"); + if (separatorIndex === -1) { + const { name, versionSelector } = stripVersionSelector(key); + return { package: name, versionSelector }; + } + const rawParent = key.slice(0, separatorIndex); + const rawPackage = key.slice(separatorIndex + 1); + const { name: parentName } = stripVersionSelector(rawParent); + const { name, versionSelector } = stripVersionSelector(rawPackage); + return { + parent: parentName, + package: name, + versionSelector, + }; +}; + +const NON_EVALUABLE_PREFIXES = [ + "npm:", + "link:", + "file:", + "workspace:", + "catalog:", +]; + +const isEvaluableSpec = (versionSpec: string): boolean => { + if (versionSpec === "-" || versionSpec.startsWith("$")) { + return false; + } + return !NON_EVALUABLE_PREFIXES.some((prefix) => + versionSpec.startsWith(prefix), + ); +}; + +const toOverride = (key: string, versionSpec: string): Override | undefined => { + if (!isEvaluableSpec(versionSpec)) { + return undefined; + } + const { package: pkg, parent, versionSelector } = parseKey(key); + let minSemver; + try { + minSemver = minVersion(versionSpec); + } catch { + minSemver = null; + } + if (!minSemver) { + throw new Error( + `Cannot determine minimum version for override "${key}": "${versionSpec}"`, + ); + } + return { + key, + parent, + package: pkg, + versionSelector, + versionSpec, + minVersion: minSemver.version, + }; +}; + +export const parseOverrides = ( + overridesMap: Record, +): Override[] => + Object.entries(overridesMap) + .map(([key, spec]) => toOverride(key, spec)) + .filter((override): override is Override => override !== undefined); + +export const buildChains = (overrides: Override[]): OverrideChain[] => { + const childByParent = new Map(); + for (const override of overrides) { + if (override.parent) { + childByParent.set(override.parent, override); + } + } + + const visited = new Set(); + const chains: OverrideChain[] = []; + + for (const override of overrides) { + if (visited.has(override.key)) { + continue; + } + + let root = override; + const findParentOf = (child: Override): Override | undefined => + overrides.find((o) => o.package === child.parent); + let parentOverride = findParentOf(root); + while (parentOverride && !visited.has(parentOverride.key)) { + root = parentOverride; + parentOverride = findParentOf(root); + } + + const chainOverrides: Override[] = []; + let current: Override | undefined = root; + while (current && !visited.has(current.key)) { + chainOverrides.push(current); + visited.add(current.key); + current = childByParent.get(current.package); + } + + chains.push({ + id: chainOverrides.map((o) => o.key).join(" → "), + overrides: chainOverrides, + }); + } + + return chains; +}; diff --git a/tools/check-overrides/src/report.ts b/tools/check-overrides/src/report.ts new file mode 100644 index 00000000..9c8bc55d --- /dev/null +++ b/tools/check-overrides/src/report.ts @@ -0,0 +1,102 @@ +import path from "node:path"; + +import type { ChainCheckResult, Override, OverrideReport } from "src/types"; + +const summariseResult = (result: ChainCheckResult): string => { + const header = `Chain: ${result.chain.id}`; + + if (result.status === "removable") { + const resolved = Object.entries(result.resolved) + .map(([key, version]) => ` - ${key}: resolves to ${version}`) + .join("\n"); + return `${header}\n Status: REMOVABLE — entire chain can be deleted\n${resolved}`; + } + + if (result.status === "simplifiable") { + const remove = result.remove.map((o) => ` - ${o.key}`).join("\n"); + const keep = result.keep.map((o) => ` - ${o.key}`).join("\n"); + return `${header}\n Status: SIMPLIFIABLE\n Remove:\n${remove}\n Keep:\n${keep}`; + } + + const failures = result.failures + .map((f) => ` - ${f.override.key}: ${f.reason}`) + .join("\n"); + return `${header}\n Status: STILL NEEDED\n${failures}`; +}; + +export const renderHumanSummary = (report: OverrideReport): string => { + if (report.results.length === 0) { + return "No overrides found in pnpm-workspace.yaml."; + } + const sections = report.results.map((r) => summariseResult(r)).join("\n\n"); + const footer = report.hasChanges + ? "\nActionable findings present — see results above." + : "\nNo actionable findings — all overrides are still required."; + return `${sections}\n${footer}`; +}; + +const renderResultMarkdown = (result: ChainCheckResult): string => { + if (result.status === "removable") { + const lines = result.chain.overrides.map( + (o) => + `- \`${o.key}\` — resolves to \`${result.resolved[o.key] ?? "?"}\` (required \`${o.versionSpec}\`)`, + ); + return `### Removable: ${result.chain.id}\n\nThe entire chain is no longer required. Remove:\n\n${lines.join("\n")}`; + } + + if (result.status === "simplifiable") { + const removeLines = result.remove.map((o) => `- \`${o.key}\``).join("\n"); + const keepLines = result.keep.map((o) => `- \`${o.key}\``).join("\n"); + return `### Simplifiable: ${result.chain.id}\n\nRemove:\n\n${removeLines}\n\nKeep:\n\n${keepLines}`; + } + + const failures = result.failures + .map((f) => `- \`${f.override.key}\` — ${f.reason}`) + .join("\n"); + return `### Still needed: ${result.chain.id}\n\n${failures}`; +}; + +export const renderPrBody = (report: OverrideReport): string => { + const actionable = report.results.filter((r) => r.status !== "needed"); + const stillNeeded = report.results.filter((r) => r.status === "needed"); + + const summary = [ + "## Automated pnpm override review", + "", + "This PR removes or simplifies `pnpm.overrides` entries in `pnpm-workspace.yaml` that are no longer required to mitigate transitive vulnerabilities. Each change has been verified by removing the override and confirming the dependency still resolves to a safe version via `pnpm update --lockfile-only`.", + "", + ]; + + if (actionable.length > 0) { + summary.push("## Changes", ""); + for (const result of actionable) { + summary.push(renderResultMarkdown(result), ""); + } + } + + if (stillNeeded.length > 0) { + summary.push("## Overrides still required", ""); + for (const result of stillNeeded) { + summary.push(renderResultMarkdown(result), ""); + } + } + + summary.push("---", "_Generated by `tools/check-overrides`._"); + + return summary.join("\n"); +}; + +export const overridesToRemove = (results: ChainCheckResult[]): Override[] => { + const removals: Override[] = []; + for (const result of results) { + if (result.status === "removable") { + removals.push(...result.chain.overrides); + } else if (result.status === "simplifiable") { + removals.push(...result.remove); + } + } + return removals; +}; + +export const reportPath = (projectDir: string): string => + path.join(projectDir, ".tmp", "override-report.json"); diff --git a/tools/check-overrides/src/resolve.ts b/tools/check-overrides/src/resolve.ts new file mode 100644 index 00000000..cfdb15aa --- /dev/null +++ b/tools/check-overrides/src/resolve.ts @@ -0,0 +1,97 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; + +import { + findGlobalResolvedVersion, + findScopedResolvedVersion, + lockfilePath, + readLockfile, +} from "src/lockfile"; +import { + removeOverridesFromYaml, + workspaceFilePath, + writeWorkspaceFile, +} from "src/workspace-file"; + +import type { Override } from "src/types"; + +export const regenerateLockfile = async ( + projectDir: string, + packagesToUpdate: string[], +): Promise => { + if (packagesToUpdate.length === 0) { + return; + } + const args = ["update", "--lockfile-only", "-r", ...packagesToUpdate]; + await new Promise((resolve, reject) => { + const child = spawn("pnpm", args, { + cwd: projectDir, + stdio: ["ignore", "ignore", "pipe"], + }); + const stderrChunks: Buffer[] = []; + child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + reject( + new Error( + `pnpm ${args.join(" ")} failed with exit code ${code}\n${stderr}`, + ), + ); + }); + }); +}; + +export const readResolvedVersion = async ( + projectDir: string, + override: Override, +): Promise => { + const lockfile = await readLockfile(projectDir); + return override.parent + ? findScopedResolvedVersion(lockfile, override.parent, override.package) + : findGlobalResolvedVersion(lockfile, override.package); +}; + +export const withOriginalFiles = async ( + projectDir: string, + fn: () => Promise, +): Promise => { + const workspacePath = workspaceFilePath(projectDir); + const lockPath = lockfilePath(projectDir); + const [originalWorkspace, originalLock] = await Promise.all([ + readFile(workspacePath, "utf8"), + readFile(lockPath, "utf8"), + ]); + try { + return await fn(); + } finally { + await Promise.all([ + writeFile(workspacePath, originalWorkspace, "utf8"), + writeFile(lockPath, originalLock, "utf8"), + ]); + } +}; + +export const applyRemovals = async ( + projectDir: string, + overridesToRemove: Override[], +): Promise => { + if (overridesToRemove.length === 0) { + return; + } + const workspacePath = workspaceFilePath(projectDir); + const current = await readFile(workspacePath, "utf8"); + const updated = removeOverridesFromYaml( + current, + overridesToRemove.map((o) => o.key), + ); + await writeWorkspaceFile(projectDir, updated); + const affectedPackages = [ + ...new Set(overridesToRemove.flatMap((o) => [o.package, o.parent ?? ""])), + ].filter((name) => name.length > 0); + await regenerateLockfile(projectDir, affectedPackages); +}; diff --git a/tools/check-overrides/src/types.ts b/tools/check-overrides/src/types.ts new file mode 100644 index 00000000..9ac6c282 --- /dev/null +++ b/tools/check-overrides/src/types.ts @@ -0,0 +1,58 @@ +export type Override = { + /** The full key as it appears in pnpm-workspace.yaml, e.g. "fast-xml-parser>fast-xml-builder" */ + key: string; + /** The parent package selector if scoped, e.g. "fast-xml-parser" */ + parent?: string; + /** The package being overridden, e.g. "fast-xml-builder" */ + package: string; + /** The version selector from the key, e.g. "<11.1.1" for "uuid@<11.1.1" */ + versionSelector?: string; + /** The full semver spec, e.g. "^1.1.7" */ + versionSpec: string; + /** The minimum acceptable version extracted from the spec, e.g. "1.1.7" */ + minVersion: string; +}; + +export type OverrideChain = { + /** Stable identifier for the chain, derived from the keys */ + id: string; + /** Overrides in the chain, ordered root → leaf */ + overrides: Override[]; +}; + +export type ResolutionFailure = { + override: Override; + resolved?: string; + reason: string; +}; + +export type RemovableResult = { + status: "removable"; + chain: OverrideChain; + resolved: Record; +}; + +export type SimplifiableResult = { + status: "simplifiable"; + chain: OverrideChain; + remove: Override[]; + keep: Override[]; + resolved: Record; +}; + +export type NeededResult = { + status: "needed"; + chain: OverrideChain; + failures: ResolutionFailure[]; +}; + +export type ChainCheckResult = + | RemovableResult + | SimplifiableResult + | NeededResult; + +export type OverrideReport = { + generatedAt: string; + results: ChainCheckResult[]; + hasChanges: boolean; +}; diff --git a/tools/check-overrides/src/workspace-file.ts b/tools/check-overrides/src/workspace-file.ts new file mode 100644 index 00000000..2905ec3e --- /dev/null +++ b/tools/check-overrides/src/workspace-file.ts @@ -0,0 +1,54 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { parseDocument } from "yaml"; + +const WORKSPACE_FILE = "pnpm-workspace.yaml"; + +export const workspaceFilePath = (projectDir: string): string => + path.join(projectDir, WORKSPACE_FILE); + +export const readWorkspaceOverrides = async ( + projectDir: string, +): Promise> => { + const content = await readFile(workspaceFilePath(projectDir), "utf8"); + const doc = parseDocument(content); + const overrides = doc.get("overrides"); + if (!overrides) { + return {}; + } + const overridesJs = (overrides as { toJSON: () => unknown }).toJSON(); + if (!overridesJs || typeof overridesJs !== "object") { + return {}; + } + return overridesJs as Record; +}; + +export const removeOverridesFromYaml = ( + yamlContent: string, + keysToRemove: string[], +): string => { + const doc = parseDocument(yamlContent); + const overrides = doc.get("overrides"); + if (!overrides) { + return yamlContent; + } + for (const key of keysToRemove) { + (overrides as { delete: (k: string) => void }).delete(key); + } + const remaining = (overrides as { toJSON: () => unknown }).toJSON(); + if ( + !remaining || + (typeof remaining === "object" && Object.keys(remaining).length === 0) + ) { + doc.delete("overrides"); + } + return doc.toString(); +}; + +export const writeWorkspaceFile = async ( + projectDir: string, + content: string, +): Promise => { + await writeFile(workspaceFilePath(projectDir), content, "utf8"); +}; diff --git a/tools/check-overrides/tsconfig.json b/tools/check-overrides/tsconfig.json new file mode 100644 index 00000000..ae9f0eef --- /dev/null +++ b/tools/check-overrides/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "isolatedModules": true, + "outDir": "dist", + "paths": { + "src/*": [ + "./src/*" + ] + }, + "rootDir": ".", + "types": [ + "jest", + "node" + ] + }, + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*" + ] +}