├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md └── workflows │ ├── auto-merge.yml │ ├── codeql-analysis.yml │ ├── eslint-plugin-diff.yml │ └── nodejs-ci.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .ncurc.js ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .vscode └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTORS.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── cspell.json ├── generate-contributors.js ├── jest.config.js ├── lint-staged.config.js ├── package.json ├── src ├── Range.test.ts ├── Range.ts ├── __fixtures__ │ ├── diff.ts │ └── postprocessArguments.ts ├── __snapshots__ │ ├── Range.test.ts.snap │ ├── git.test.ts.snap │ ├── index.test.ts.snap │ └── processors.test.ts.snap ├── ci.test-d.ts ├── ci.test.ts ├── ci.ts ├── git.test.ts ├── git.ts ├── index-ci.test.ts ├── index.test.ts ├── index.ts ├── processors.test.ts └── processors.ts ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.eslint.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | !.eslintrc.js 2 | /coverage/ 3 | /dist/ 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const typescriptProjects = ["./tsconfig.json", "./tsconfig.eslint.json"]; 2 | 3 | /** @type import("eslint").Linter.Config */ 4 | module.exports = { 5 | root: true, 6 | extends: ["@paleite", "plugin:diff/ci"], 7 | parserOptions: { project: typescriptProjects, tsconfigRootDir: __dirname }, 8 | overrides: [ 9 | { 10 | files: [".*.js", "*.js"], 11 | 12 | rules: { 13 | "@typescript-eslint/no-var-requires": "off", 14 | "@typescript-eslint/no-unsafe-assignment": "off", 15 | }, 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | dependabot: 9 | runs-on: ubuntu-latest 10 | if: ${{ github.actor == 'dependabot[bot]' }} 11 | steps: 12 | - name: Dependabot metadata 13 | id: metadata 14 | uses: dependabot/fetch-metadata@v1.1.1 15 | with: 16 | github-token: "${{ secrets.GITHUB_TOKEN }}" 17 | - name: Enable auto-merge for Dependabot PRs 18 | run: gh pr merge --auto --merge "$PR_URL" 19 | env: 20 | PR_URL: ${{github.event.pull_request.html_url}} 21 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 22 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "36 3 * * 1" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["javascript"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/eslint-plugin-diff.yml: -------------------------------------------------------------------------------- 1 | name: Run ESLint on your changes only 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**.md" 7 | 8 | env: 9 | HUSKY: 0 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | cache: "yarn" 20 | - name: Install modules 21 | run: yarn install --frozen-lockfile 22 | - name: Link the plugin 23 | run: | 24 | yarn link 25 | yarn link "eslint-plugin-diff" 26 | - name: Run ESLint on your changes only 27 | run: yarn run lint 28 | -------------------------------------------------------------------------------- /.github/workflows/nodejs-ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | paths-ignore: 8 | - "**.md" 9 | pull_request: 10 | paths-ignore: 11 | - "**.md" 12 | 13 | env: 14 | HUSKY: 0 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 2 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: 16 26 | cache: "yarn" 27 | - name: Install modules 28 | run: yarn install --frozen-lockfile 29 | - run: yarn test --runInBand 30 | - run: curl -Os https://uploader.codecov.io/latest/linux/codecov 31 | - run: chmod +x codecov 32 | - run: ./codecov 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.tsbuildinfo 2 | /coverage/ 3 | /dist/ 4 | node_modules -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install pretty-quick --staged 5 | npx --no-install lint-staged 6 | -------------------------------------------------------------------------------- /.ncurc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * npm-check-update 3 | * 4 | * Use npm-check-update to upgrade all packages in package.json 5 | * 6 | * Run `ncu`. 7 | */ 8 | 9 | module.exports = { 10 | upgrade: true, 11 | dep: "prod,dev", 12 | packageManager: "yarn", 13 | }; 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.yarnpkg.com 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | __fixtures__ 2 | /coverage/ 3 | /dist/ 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "name": "vscode-jest-tests", 6 | "request": "launch", 7 | "console": "integratedTerminal", 8 | "internalConsoleOptions": "neverOpen", 9 | "disableOptimisticBPs": true, 10 | "program": "${workspaceFolder}/node_modules/.bin/jest", 11 | "cwd": "${workspaceFolder}", 12 | "args": ["--runInBand", "--watchAll=false"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | - ![paleite](https://avatars.githubusercontent.com/u/4430528?v=4&s=50) [paleite](https://github.com/paleite) 4 | - ![DaVarga](https://avatars.githubusercontent.com/u/3964986?v=4&s=50) [DaVarga](https://github.com/DaVarga) 5 | - ![IsLand-x](https://avatars.githubusercontent.com/u/50228788?v=4&s=50) [IsLand-x](https://github.com/IsLand-x) 6 | - ![ezequiel](https://avatars.githubusercontent.com/u/368069?v=4&s=50) [ezequiel](https://github.com/ezequiel) 7 | - ![burtonator](https://avatars.githubusercontent.com/u/45447?v=4&s=50) [burtonator](https://github.com/burtonator) 8 | - ![flupke](https://avatars.githubusercontent.com/u/188962?v=4&s=50) [flupke](https://github.com/flupke) 9 | - ![mathieutu](https://avatars.githubusercontent.com/u/11351322?v=4&s=50) [mathieutu](https://github.com/mathieutu) 10 | - ![JosephLing](https://avatars.githubusercontent.com/u/8035792?v=4&s=50) [JosephLing](https://github.com/JosephLing) 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Patrick Eriksson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-diff 2 | 3 | ![](https://img.shields.io/npm/dt/eslint-plugin-diff?style=flat-square&logo=npm&logoColor=white) [![codecov](https://codecov.io/gh/paleite/eslint-plugin-diff/branch/main/graph/badge.svg?token=W0LPKHZCF5)](https://codecov.io/gh/paleite/eslint-plugin-diff) 4 | 5 | You've got changes, we've got checks. Run ESLint on your modified lines only. 6 | 7 | ## What's the big deal? 8 | 9 | Imagine a world where your developers receive feedback that's laser-focused on the changes they've made. Traditional setups can't offer this. But with our plugin, you can run ESLint on your changed lines only. This means all warnings and errors are **directly relevant to you**, saving you from drowning in a sea of linter errors. 10 | 11 | ### 💰 Protect your budget 12 | 13 | Updating your linter or its dependencies can trigger a flood of new linter warnings and errors. Fixing them all can skyrocket your project costs. But with our plugin, you can run ESLint on only the changed lines of your code. This means new errors won't pop up in code that other developers have already reviewed and approved. 14 | 15 | ### 🚀 Boost your team's velocity 16 | 17 | A healthy, high-quality code-base is the fuel for high velocity. But too many errors in your linter's output can slow you down. Our plugin ensures your linter runs on only the changed lines of your code. This keeps your developers from feeling overwhelmed, your code-base healthy, and your team productive. 18 | 19 | ### 🧠 Keep your developers focused 20 | 21 | Developers are constantly bombarded with errors and notifications. If a linter has too much output, it can be hard to tell if their changes caused an issue or if it's just old code. With our plugin, all the linter output your developers see will be related to their changes, making it easier to focus on the task at hand. 22 | 23 | ### How does it work? 24 | 25 | When creating pull-requests, this plugin enables you to run ESLint on only the changed lines. This sharpens the focus of your code review and reduces the time spent on it, while still maintaining a high-quality code base. 26 | 27 | As a bonus, introducing new ESLint rules (or updating 3rd party configs) in a large codebase becomes a breeze, because you avoid getting blocked by new ESLint issues in already-approved code. 28 | 29 | ## Installation 30 | 31 | Get the plugin and extend your ESLint config. 32 | 33 | ### Install 34 | 35 | ```sh 36 | npm install --save-dev eslint eslint-plugin-diff 37 | yarn add -D eslint eslint-plugin-diff 38 | pnpm add -D eslint eslint-plugin-diff 39 | ``` 40 | 41 | ### Extend config 42 | 43 | Extend your ESLint config with one of our configs. 44 | 45 | #### `"plugin:diff/diff"` (recommended) 46 | 47 | Only lint changes 48 | 49 | ```json 50 | { 51 | "extends": ["plugin:diff/diff"] 52 | } 53 | ``` 54 | 55 | #### `"plugin:diff/ci"` 56 | 57 | In a CI-environment, only lint changes. Locally, skip the plugin (i.e. lint everything). 58 | 59 | > NOTE: This requires the environment variable `CI` to be defined, which most CI-providers set automatically. 60 | 61 | ```json 62 | { 63 | "extends": ["plugin:diff/ci"] 64 | } 65 | ``` 66 | 67 | #### `"plugin:diff/staged"` 68 | 69 | Only lint the changes you've staged for an upcoming commit. 70 | 71 | ```json 72 | { 73 | "extends": ["plugin:diff/staged"] 74 | } 75 | ``` 76 | 77 | ## CI Setup 78 | 79 | To lint all the changes of a pull-request, you only have to set 80 | `ESLINT_PLUGIN_DIFF_COMMIT` before running ESLint. 81 | 82 | ### For GitHub Actions 83 | 84 | ```yml 85 | name: Run ESLint on your changes only 86 | on: 87 | pull_request: 88 | jobs: 89 | build: 90 | runs-on: ubuntu-latest 91 | steps: 92 | - uses: actions/checkout@v3 93 | - name: Install modules 94 | run: npm install 95 | - name: Fetch the base branch, so we can use `git diff` 96 | run: git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }} 97 | - name: Run ESLint on your changes only 98 | env: 99 | ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event.pull_request.base.ref }} 100 | run: npx --no-install eslint --ext .js,.jsx,.ts,.tsx . 101 | ``` 102 | 103 | ### For BitBucket Pipelines 104 | 105 | ```sh 106 | export ESLINT_PLUGIN_DIFF_COMMIT="origin/$BITBUCKET_PR_DESTINATION_BRANCH"; 107 | npx --no-install eslint --ext .js,.ts,.tsx . 108 | ``` 109 | 110 | ## Note 111 | 112 | - You can use any valid commit syntax for `ESLINT_PLUGIN_DIFF_COMMIT`. See [git's official documentation on the syntax](https://git-scm.com/docs/git-diff#Documentation/git-diff.txt-emgitdiffemltoptionsgtltcommitgt--ltpathgt82308203) 113 | - You can choose to lint all changes (using `"plugin:diff/diff"`) or staged changes only (using `"plugin:diff/staged"`). 114 | - We recommend using `"plugin:diff/diff"`, which is equivalent to running `git diff HEAD`. 115 | - `"plugin:diff/staged"` is equivalent to running `git diff HEAD --staged` 116 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Versions of this project currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 1.0.x | :white_check_mark: | 10 | | < 1.0 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Please raise an issue if you find a vulnerability. The aim is to respond to any security issues within 1 week. 15 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "words": ["ahmadnassri", "codecov", "Eriksson", "paleite", "Unstaged"], 4 | "ignoreWords": ["pinst", "Wercker"] 5 | } 6 | -------------------------------------------------------------------------------- /generate-contributors.js: -------------------------------------------------------------------------------- 1 | const https = require("https"); 2 | const fs = require("fs"); 3 | 4 | const options = { 5 | hostname: "api.github.com", 6 | port: 443, 7 | path: "/repos/paleite/eslint-plugin-diff/contributors", 8 | method: "GET", 9 | headers: { 10 | "User-Agent": "node.js", // GitHub requires a user-agent header 11 | }, 12 | }; 13 | 14 | let rawData = ""; 15 | 16 | const req = https.request(options, (res) => { 17 | res.setEncoding("utf8"); 18 | res.on("data", (chunk) => { 19 | rawData += chunk; 20 | }); 21 | res.on("end", () => { 22 | try { 23 | /** @type {{login: string, avatar_url: string, html_url: string}[]} */ 24 | const parsedData = JSON.parse(rawData); 25 | 26 | const contributorsList = parsedData 27 | .filter(({ login }) => !login.includes("[bot]")) 28 | .map( 29 | (contributor) => 30 | `- ![${contributor.login}](${contributor.avatar_url}&s=50) [${contributor.login}](${contributor.html_url})` 31 | ); 32 | 33 | fs.writeFileSync( 34 | "CONTRIBUTORS.md", 35 | ["# Contributors", contributorsList.join("\n")].join("\n\n") 36 | ); 37 | } catch (e) { 38 | console.error(e.message); 39 | } 40 | }); 41 | }); 42 | 43 | req.on("error", (e) => { 44 | console.error(`Problem with request: ${e.message}`); 45 | }); 46 | 47 | req.end(); 48 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { base } = require("@paleite/jest-config"); 2 | 3 | /** @typedef {import('ts-jest')} */ 4 | /** @type {import('@jest/types').Config.InitialOptions} */ 5 | module.exports = { 6 | ...base, 7 | coveragePathIgnorePatterns: [".test-d.ts", "/__fixtures__/"], 8 | coverageThreshold: { 9 | global: { 10 | statements: 80, 11 | branches: 80, 12 | functions: 80, 13 | lines: 80, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.ts": [() => "yarn run typecheck"], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-diff", 3 | "version": "2.0.3", 4 | "description": "Run ESLint on your changes only", 5 | "keywords": [ 6 | "eslint", 7 | "eslintplugin", 8 | "eslint-plugin", 9 | "diff" 10 | ], 11 | "homepage": "https://github.com/paleite/eslint-plugin-diff#readme", 12 | "bugs": { 13 | "url": "https://github.com/paleite/eslint-plugin-diff/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/paleite/eslint-plugin-diff.git" 18 | }, 19 | "license": "MIT", 20 | "author": "Patrick Eriksson (https://github.com/paleite)", 21 | "main": "dist/index.js", 22 | "types": "dist/index.d.ts", 23 | "files": [ 24 | ".github/ISSUE_TEMPLATE/feature_request.md", 25 | "/dist/", 26 | "CODE_OF_CONDUCT.md", 27 | "SECURITY.md" 28 | ], 29 | "scripts": { 30 | "build": "tsc --build tsconfig.build.json", 31 | "clean": "tsc --build tsconfig.build.json --clean", 32 | "format": "prettier --write .", 33 | "lint": "eslint --cache --ext .js,.ts --fix .", 34 | "prepare": "husky install", 35 | "prepublishOnly": "pinst --disable", 36 | "prepublish": "yarn run clean && yarn run build", 37 | "postpublish": "pinst --enable", 38 | "release": "np", 39 | "test": "jest --coverage", 40 | "typecheck": "tsc --project tsconfig.json --noEmit" 41 | }, 42 | "devDependencies": { 43 | "@paleite/eslint-config": "^1.0.9", 44 | "@paleite/eslint-config-base": "^1.0.9", 45 | "@paleite/eslint-config-typescript": "^1.0.9", 46 | "@paleite/jest-config": "^1.0.9", 47 | "@paleite/prettier-config": "^1.0.9", 48 | "@paleite/tsconfig-node16": "^1.0.9", 49 | "@types/eslint": "^8.4.10", 50 | "@types/jest": "^29.2.6", 51 | "@types/node": "^18.11.18", 52 | "@typescript-eslint/eslint-plugin": "^5.48.2", 53 | "@typescript-eslint/parser": "^5.48.2", 54 | "eslint": "^8.32.0", 55 | "eslint-config-prettier": "^8.6.0", 56 | "eslint-import-resolver-typescript": "^3.5.3", 57 | "eslint-plugin-import": "^2.27.5", 58 | "eslint-plugin-promise": "^6.1.1", 59 | "husky": "^8.0.3", 60 | "jest": "^29.3.1", 61 | "lint-staged": "^13.1.0", 62 | "np": "^7.7.0", 63 | "pinst": "^3.0.0", 64 | "prettier": "^2.8.3", 65 | "pretty-quick": "^3.1.3", 66 | "ts-jest": "^29.0.5", 67 | "tsd": "^0.25.0", 68 | "typescript": "^4.9.4", 69 | "uuid": "^9.0.0" 70 | }, 71 | "peerDependencies": { 72 | "eslint": ">=6.7.0" 73 | }, 74 | "engines": { 75 | "node": ">=14.0.0" 76 | }, 77 | "resolutions": { 78 | "cosmiconfig": "^8.1.3" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Range.test.ts: -------------------------------------------------------------------------------- 1 | import { Range } from "./Range"; 2 | 3 | describe("range", () => { 4 | it("should instantiate with correct parameters", () => { 5 | const range: Range = new Range(0, 1); 6 | expect(range).toBeInstanceOf(Range); 7 | }); 8 | 9 | it("should throw TypeError when parameters are flipped", () => { 10 | expect(() => new Range(1, 0)).toThrowErrorMatchingSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/Range.ts: -------------------------------------------------------------------------------- 1 | class Range { 2 | private readonly inclusiveLowerBound: Readonly; 3 | private readonly exclusiveUpperBound: Readonly; 4 | 5 | constructor( 6 | inclusiveLowerBound: Readonly, 7 | exclusiveUpperBound: Readonly 8 | ) { 9 | if (inclusiveLowerBound >= exclusiveUpperBound) { 10 | throw TypeError( 11 | `inclusiveLowerBound must be strictly less than exclusiveUpperBound: ${inclusiveLowerBound} >= ${exclusiveUpperBound}` 12 | ); 13 | } 14 | 15 | this.inclusiveLowerBound = inclusiveLowerBound; 16 | this.exclusiveUpperBound = exclusiveUpperBound; 17 | } 18 | 19 | isWithinRange(n: Readonly): boolean { 20 | return this.inclusiveLowerBound <= n && n < this.exclusiveUpperBound; 21 | } 22 | } 23 | 24 | export { Range }; 25 | -------------------------------------------------------------------------------- /src/__fixtures__/diff.ts: -------------------------------------------------------------------------------- 1 | const diff = `diff --git a/fixme.js b/fixme.js 2 | index 4886604..83c3014 100644 3 | --- a/fixme.js 4 | +++ b/fixme.js 5 | @@ -1,0 +2,2 @@ if (new Date().getTime()) console.log("curly"); 6 | +if (new Date().getTime()) console.log("curly"); 7 | +if (new Date().getTime()) console.log("curly");`; 8 | 9 | const staged = `diff --git a/fixme.js b/fixme.js 10 | index 4886604..3238811 100644 11 | --- a/fixme.js 12 | +++ b/fixme.js 13 | @@ -1,0 +2 @@ if (new Date().getTime()) console.log("curly"); 14 | +if (new Date().getTime()) console.log("curly");`; 15 | 16 | const hunks = `diff --git a/dirty.js b/dirty.js 17 | index 4d2637c..99dc494 100644 18 | --- a/dirty.js 19 | +++ b/dirty.js 20 | @@ -1 +0 @@ 21 | - 22 | @@ -3,7 +2,4 @@ function myFunc() { 23 | - 24 | - 25 | - 26 | -if (!myFunc(true == myVar)) 27 | - myRes = false, 28 | - someotherThing = null 29 | - unrelated = true; 30 | + if (true == myVar) 31 | + if (true == myVar) 32 | + if (true == myVar) if (true == myVar) if (true == myVar) return; 33 | +} 34 | @@ -10,0 +7,2 @@ if (!myFunc(true == myVar)) 35 | +if (!myFunc(true == myVar)) (myRes = false), (someotherThing = null); 36 | +unrelated = true;`; 37 | 38 | const includingOnlyRemovals = `diff --git a/dirty.js b/dirty.js 39 | index cb3c131..874b8f9 100644 40 | --- a/dirty.js 41 | +++ b/dirty.js 42 | @@ -1,0 +2,2 @@ if (new Date().getTime()) console.log("curly"); 43 | +if (new Date().getTime()) console.log("curly"); 44 | +if (new Date().getTime()) console.log("curly"); 45 | @@ -17,2 +16,0 @@ import { a } from "../components/a"; 46 | -import { b } from "../context/b"; 47 | -import { c } from "../context/c";`; 48 | 49 | const diffFileList = "file1\nfile2\nfile3\n"; 50 | 51 | export { 52 | diff, 53 | staged, 54 | hunks, 55 | includingOnlyRemovals, 56 | diffFileList, 57 | }; 58 | -------------------------------------------------------------------------------- /src/__fixtures__/postprocessArguments.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from "eslint"; 2 | 3 | const postprocessArguments: [ 4 | [[Linter.LintMessage, ...Linter.LintMessage[]], ...Linter.LintMessage[][]], 5 | string 6 | ] = [ 7 | [ 8 | [ 9 | { 10 | ruleId: "curly", 11 | severity: 1, 12 | message: "Expected { after 'if' condition.", 13 | line: 1, 14 | column: 1, 15 | nodeType: "IfStatement", 16 | messageId: "missingCurlyAfterCondition", 17 | }, 18 | { 19 | ruleId: "curly", 20 | severity: 1, 21 | message: "Expected { after 'if' condition.", 22 | line: 2, 23 | column: 1, 24 | nodeType: "IfStatement", 25 | messageId: "missingCurlyAfterCondition", 26 | }, 27 | { 28 | ruleId: "curly", 29 | severity: 1, 30 | message: "Expected { after 'if' condition.", 31 | line: 3, 32 | column: 1, 33 | nodeType: "IfStatement", 34 | messageId: "missingCurlyAfterCondition", 35 | }, 36 | ], 37 | ], 38 | '/mock filename with quotes ", semicolons ; and spaces.js', 39 | ]; 40 | 41 | export { postprocessArguments }; 42 | -------------------------------------------------------------------------------- /src/__snapshots__/Range.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`range should throw TypeError when parameters are flipped 1`] = `"inclusiveLowerBound must be strictly less than exclusiveUpperBound: 1 >= 0"`; 4 | -------------------------------------------------------------------------------- /src/__snapshots__/git.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getRangesForDiff should find the ranges of each staged file 1`] = ` 4 | [ 5 | Range { 6 | "exclusiveUpperBound": 1, 7 | "inclusiveLowerBound": 0, 8 | }, 9 | Range { 10 | "exclusiveUpperBound": 6, 11 | "inclusiveLowerBound": 2, 12 | }, 13 | Range { 14 | "exclusiveUpperBound": 9, 15 | "inclusiveLowerBound": 7, 16 | }, 17 | ] 18 | `; 19 | 20 | exports[`getRangesForDiff should work for hunks which include only-removal-ranges 1`] = ` 21 | [ 22 | Range { 23 | "exclusiveUpperBound": 4, 24 | "inclusiveLowerBound": 2, 25 | }, 26 | ] 27 | `; 28 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`plugin should match expected export 1`] = ` 4 | { 5 | "ci": { 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*", 10 | ], 11 | "processor": "diff/ci", 12 | }, 13 | ], 14 | "plugins": [ 15 | "diff", 16 | ], 17 | }, 18 | "diff": { 19 | "overrides": [ 20 | { 21 | "files": [ 22 | "*", 23 | ], 24 | "processor": "diff/diff", 25 | }, 26 | ], 27 | "plugins": [ 28 | "diff", 29 | ], 30 | }, 31 | "staged": { 32 | "overrides": [ 33 | { 34 | "files": [ 35 | "*", 36 | ], 37 | "processor": "diff/staged", 38 | }, 39 | ], 40 | "plugins": [ 41 | "diff", 42 | ], 43 | }, 44 | } 45 | `; 46 | 47 | exports[`plugin should match expected export 2`] = ` 48 | { 49 | "ci": { 50 | "postprocess": [Function], 51 | "preprocess": [Function], 52 | "supportsAutofix": true, 53 | }, 54 | "diff": { 55 | "postprocess": [Function], 56 | "preprocess": [Function], 57 | "supportsAutofix": true, 58 | }, 59 | "staged": { 60 | "postprocess": [Function], 61 | "preprocess": [Function], 62 | "supportsAutofix": true, 63 | }, 64 | } 65 | `; 66 | -------------------------------------------------------------------------------- /src/__snapshots__/processors.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`configs diff 1`] = ` 4 | { 5 | "overrides": [ 6 | { 7 | "files": [ 8 | "*", 9 | ], 10 | "processor": "diff/diff", 11 | }, 12 | ], 13 | "plugins": [ 14 | "diff", 15 | ], 16 | } 17 | `; 18 | 19 | exports[`configs staged 1`] = ` 20 | { 21 | "overrides": [ 22 | { 23 | "files": [ 24 | "*", 25 | ], 26 | "processor": "diff/staged", 27 | }, 28 | ], 29 | "plugins": [ 30 | "diff", 31 | ], 32 | } 33 | `; 34 | 35 | exports[`processors diff postprocess 1`] = ` 36 | [ 37 | { 38 | "column": 1, 39 | "line": 2, 40 | "message": "Expected { after 'if' condition.", 41 | "messageId": "missingCurlyAfterCondition", 42 | "nodeType": "IfStatement", 43 | "ruleId": "curly", 44 | "severity": 1, 45 | }, 46 | { 47 | "column": 1, 48 | "line": 3, 49 | "message": "Expected { after 'if' condition.", 50 | "messageId": "missingCurlyAfterCondition", 51 | "nodeType": "IfStatement", 52 | "ruleId": "curly", 53 | "severity": 1, 54 | }, 55 | ] 56 | `; 57 | 58 | exports[`processors staged postprocess 1`] = ` 59 | [ 60 | { 61 | "column": 1, 62 | "line": 2, 63 | "message": "Expected { after 'if' condition.", 64 | "messageId": "missingCurlyAfterCondition", 65 | "nodeType": "IfStatement", 66 | "ruleId": "curly", 67 | "severity": 1, 68 | }, 69 | ] 70 | `; 71 | -------------------------------------------------------------------------------- /src/ci.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from "tsd"; 2 | import type { CiProvider, CiProviderName } from "./ci"; 3 | import { PROVIDERS } from "./ci"; 4 | 5 | expectType>(PROVIDERS); 6 | -------------------------------------------------------------------------------- /src/ci.test.ts: -------------------------------------------------------------------------------- 1 | const OLD_ENV = process.env; 2 | 3 | beforeEach(() => { 4 | jest.resetModules(); // Most important - it clears the cache 5 | process.env = { ...OLD_ENV }; // Make a copy 6 | 7 | // When running in CI, we want to avoid triggering the "Too many CI providers 8 | // found"-error, so we delete all known `diffBranch`-occurrences here. 9 | delete process.env.SYSTEM_PULLREQUEST_TARGETBRANCH; 10 | delete process.env.bamboo_repository_pr_targetBranch; 11 | delete process.env.BITBUCKET_PR_DESTINATION_BRANCH; 12 | delete process.env.BUDDY_EXECUTION_PULL_REQUEST_BASE_BRANCH; 13 | delete process.env.DRONE_TARGET_BRANCH; 14 | delete process.env.GITHUB_BASE_REF; 15 | delete process.env.APPVEYOR_PULL_REQUEST_NUMBER; 16 | delete process.env.APPVEYOR_REPO_BRANCH; 17 | delete process.env.CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME; 18 | delete process.env.TRAVIS_BRANCH; 19 | }); 20 | 21 | describe("guessBranch", () => { 22 | it("ensure the branch is guessed if ESLINT_PLUGIN_COMMIT is not already set", async () => { 23 | delete process.env.ESLINT_PLUGIN_COMMIT; 24 | const { guessBranch } = await import("./ci"); 25 | expect(() => guessBranch()).not.toThrowError(/ESLINT_PLUGIN_COMMIT/u); 26 | }); 27 | 28 | it("ensure the branch is not guessed if ESLINT_PLUGIN_COMMIT is already set", async () => { 29 | process.env.ESLINT_PLUGIN_COMMIT = "origin/main"; 30 | const { guessBranch } = await import("./ci"); 31 | expect(() => guessBranch()).toThrowError(/ESLINT_PLUGIN_COMMIT/u); 32 | }); 33 | 34 | it("fails when too many providers were found as candidates", async () => { 35 | process.env.SYSTEM_PULLREQUEST_TARGETBRANCH = "CORRECT"; 36 | process.env.bamboo_repository_pr_targetBranch = "CORRECT"; 37 | process.env.BITBUCKET_PR_DESTINATION_BRANCH = "CORRECT"; 38 | process.env.BUDDY_EXECUTION_PULL_REQUEST_BASE_BRANCH = "CORRECT"; 39 | process.env.DRONE_TARGET_BRANCH = "CORRECT"; 40 | process.env.GITHUB_BASE_REF = "CORRECT"; 41 | process.env.APPVEYOR_PULL_REQUEST_NUMBER = "0"; 42 | process.env.APPVEYOR_REPO_BRANCH = "CORRECT"; 43 | process.env.CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME = "CORRECT"; 44 | process.env.TRAVIS_BRANCH = "CORRECT"; 45 | const { guessBranch } = await import("./ci"); 46 | expect(() => guessBranch()).toThrowError(/Too many CI providers found/u); 47 | }); 48 | }); 49 | 50 | describe("simple supported providers", () => { 51 | it("AzurePipelines", async () => { 52 | process.env.SYSTEM_PULLREQUEST_TARGETBRANCH = "CORRECT"; 53 | const { guessBranch } = await import("./ci"); 54 | expect(guessBranch()).toBe("CORRECT"); 55 | }); 56 | 57 | it("Bamboo", async () => { 58 | process.env.bamboo_repository_pr_targetBranch = "CORRECT"; 59 | const { guessBranch } = await import("./ci"); 60 | expect(guessBranch()).toBe("CORRECT"); 61 | }); 62 | 63 | it("BitbucketPipelines", async () => { 64 | process.env.BITBUCKET_PR_DESTINATION_BRANCH = "CORRECT"; 65 | const { guessBranch } = await import("./ci"); 66 | expect(guessBranch()).toBe("CORRECT"); 67 | }); 68 | 69 | it("Buddy", async () => { 70 | process.env.BUDDY_EXECUTION_PULL_REQUEST_BASE_BRANCH = "CORRECT"; 71 | const { guessBranch } = await import("./ci"); 72 | expect(guessBranch()).toBe("CORRECT"); 73 | }); 74 | 75 | it("Drone", async () => { 76 | process.env.DRONE_TARGET_BRANCH = "CORRECT"; 77 | const { guessBranch } = await import("./ci"); 78 | expect(guessBranch()).toBe("CORRECT"); 79 | }); 80 | 81 | it("GitHubActions", async () => { 82 | process.env.GITHUB_BASE_REF = "CORRECT"; 83 | const { guessBranch } = await import("./ci"); 84 | expect(guessBranch()).toBe("CORRECT"); 85 | }); 86 | }); 87 | 88 | describe("complex supported providers", () => { 89 | it("AppVeyor", async () => { 90 | // APPVEYOR_PULL_REQUEST_NUMBER is non-empty, so we can find the repo in 91 | // APPVEYOR_REPO_BRANCH 92 | process.env.APPVEYOR_PULL_REQUEST_NUMBER = "0"; 93 | process.env.APPVEYOR_REPO_BRANCH = "CORRECT"; 94 | 95 | const { guessBranch } = await import("./ci"); 96 | expect(guessBranch()).toBe("CORRECT"); 97 | }); 98 | 99 | it("doesn't return the guessed branch when APPVEYOR_PULL_REQUEST_NUMBER is empty", async () => { 100 | // APPVEYOR_PULL_REQUEST_NUMBER is non-empty iff we're in a pull-request. 101 | delete process.env.APPVEYOR_PULL_REQUEST_NUMBER; 102 | // Scenario: A regular commit to main, not a pull-request. 103 | process.env.APPVEYOR_REPO_BRANCH = "main"; 104 | 105 | const { guessBranch } = await import("./ci"); 106 | expect(guessBranch()).toBe(undefined); 107 | }); 108 | 109 | it("GitLab with CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME", async () => { 110 | delete process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME; 111 | process.env.CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME = "CORRECT"; 112 | const { guessBranch } = await import("./ci"); 113 | expect(guessBranch()).toBe("CORRECT"); 114 | }); 115 | 116 | it("GitLab with CI_MERGE_REQUEST_TARGET_BRANCH_NAME", async () => { 117 | delete process.env.CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME; 118 | process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME = "CORRECT"; 119 | const { guessBranch } = await import("./ci"); 120 | expect(guessBranch()).toBe("CORRECT"); 121 | }); 122 | 123 | it("Travis", async () => { 124 | delete process.env.TRAVIS_PULL_REQUEST; 125 | process.env.TRAVIS_BRANCH = "CORRECT"; 126 | const { guessBranch } = await import("./ci"); 127 | expect(guessBranch()).toBe("CORRECT"); 128 | }); 129 | 130 | it("doesn't return the guessed branch when TRAVIS_PULL_REQUEST is explicitly 'false'", async () => { 131 | process.env.TRAVIS_PULL_REQUEST = "false"; 132 | process.env.TRAVIS_BRANCH = "CORRECT"; 133 | const { guessBranch } = await import("./ci"); 134 | expect(guessBranch()).toBe(undefined); 135 | }); 136 | }); 137 | 138 | export {}; 139 | -------------------------------------------------------------------------------- /src/ci.ts: -------------------------------------------------------------------------------- 1 | type CiProviderCommon = { name: T }; 2 | 3 | type CiProvider = 4 | CiProviderCommon & 5 | ( 6 | | { isSupported: false } 7 | | { isSupported: true; diffBranch: string | undefined } 8 | ); 9 | 10 | const PROVIDERS = { 11 | AppVeyor: { 12 | name: "AppVeyor", 13 | isSupported: true, 14 | diffBranch: 15 | (process.env.APPVEYOR_PULL_REQUEST_NUMBER ?? "") !== "" 16 | ? "APPVEYOR_REPO_BRANCH" 17 | : undefined, 18 | }, 19 | AzurePipelines: { 20 | name: "AzurePipelines", 21 | isSupported: true, 22 | diffBranch: "SYSTEM_PULLREQUEST_TARGETBRANCH", 23 | }, 24 | Bamboo: { 25 | name: "Bamboo", 26 | isSupported: true, 27 | diffBranch: "bamboo_repository_pr_targetBranch", 28 | }, 29 | BitbucketPipelines: { 30 | name: "BitbucketPipelines", 31 | isSupported: true, 32 | diffBranch: "BITBUCKET_PR_DESTINATION_BRANCH", 33 | }, 34 | Buddy: { 35 | name: "Buddy", 36 | isSupported: true, 37 | diffBranch: "BUDDY_EXECUTION_PULL_REQUEST_BASE_BRANCH", 38 | }, 39 | Drone: { 40 | name: "Drone", 41 | isSupported: true, 42 | diffBranch: "DRONE_TARGET_BRANCH", 43 | }, 44 | GitHubActions: { 45 | name: "GitHubActions", 46 | isSupported: true, 47 | diffBranch: "GITHUB_BASE_REF", 48 | }, 49 | GitLab: { 50 | name: "GitLab", 51 | isSupported: true, 52 | diffBranch: 53 | (process.env.CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME ?? "") !== "" 54 | ? "CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME" 55 | : "CI_MERGE_REQUEST_TARGET_BRANCH_NAME", 56 | }, 57 | Travis: { 58 | name: "Travis", 59 | isSupported: true, 60 | diffBranch: 61 | (process.env.TRAVIS_PULL_REQUEST ?? "") !== "false" 62 | ? "TRAVIS_BRANCH" 63 | : undefined, 64 | }, 65 | AwsCodeBuild: { name: "AwsCodeBuild", isSupported: false }, 66 | Circle: { name: "Circle", isSupported: false }, 67 | Codeship: { name: "Codeship", isSupported: false }, 68 | Continuousphp: { name: "Continuousphp", isSupported: false }, 69 | Jenkins: { name: "Jenkins", isSupported: false }, 70 | SourceHut: { name: "SourceHut", isSupported: false }, 71 | TeamCity: { name: "TeamCity", isSupported: false }, 72 | Wercker: { name: "Wercker", isSupported: false }, 73 | } as const; 74 | 75 | type CiProviderName = keyof typeof PROVIDERS; 76 | 77 | const guessProviders = () => 78 | Object.values(PROVIDERS).reduce<{ name: CiProviderName; branch: string }[]>( 79 | (acc, { name, ...cur }) => { 80 | if (!cur.isSupported || cur.diffBranch === undefined) { 81 | return acc; 82 | } 83 | 84 | const branch = process.env[cur.diffBranch] ?? ""; 85 | if (branch === "") { 86 | return acc; 87 | } 88 | 89 | return [...acc, { name, branch }]; 90 | }, 91 | [] 92 | ); 93 | 94 | const guessBranch = (): string | undefined => { 95 | if ((process.env.ESLINT_PLUGIN_COMMIT ?? "").length > 0) { 96 | throw Error("ESLINT_PLUGIN_COMMIT already set"); 97 | } 98 | 99 | const guessedProviders = guessProviders(); 100 | if (guessedProviders.length > 1) { 101 | throw Error( 102 | `Too many CI providers found (${guessedProviders 103 | .map(({ name }) => name) 104 | .join( 105 | ", " 106 | )}). Please specify your target branch explicitly instead, e.g. ESLINT_PLUGIN_COMMIT="main"` 107 | ); 108 | } 109 | 110 | return guessedProviders[0]?.branch; 111 | }; 112 | 113 | export type { CiProvider, CiProviderName }; 114 | export { guessBranch, PROVIDERS }; 115 | -------------------------------------------------------------------------------- /src/git.test.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from "child_process"; 2 | import path from "path"; 3 | import { 4 | getDiffFileList, 5 | getDiffForFile, 6 | getRangesForDiff, 7 | getUntrackedFileList, 8 | hasCleanIndex, 9 | } from "./git"; 10 | import { 11 | diffFileList, 12 | hunks, 13 | includingOnlyRemovals, 14 | } from "./__fixtures__/diff"; 15 | 16 | jest.mock("child_process"); 17 | 18 | const mockedChildProcess = jest.mocked(child_process, { shallow: true }); 19 | 20 | const OLD_ENV = process.env; 21 | 22 | beforeEach(() => { 23 | jest.resetModules(); // Most important - it clears the cache 24 | process.env = { ...OLD_ENV }; // Make a copy 25 | }); 26 | 27 | afterAll(() => { 28 | process.env = OLD_ENV; // Restore old environment 29 | }); 30 | 31 | describe("getRangesForDiff", () => { 32 | it("should find the ranges of each staged file", () => { 33 | expect(getRangesForDiff(hunks)).toMatchSnapshot(); 34 | }); 35 | 36 | it("should work for hunks which include only-removal-ranges", () => { 37 | expect(getRangesForDiff(includingOnlyRemovals)).toMatchSnapshot(); 38 | }); 39 | 40 | it("should work for hunks which include only-removal-ranges", () => { 41 | expect(() => 42 | getRangesForDiff("@@ invalid hunk header @@") 43 | ).toThrowErrorMatchingInlineSnapshot( 44 | `"Couldn't match regex with line '@@ invalid hunk header @@'"` 45 | ); 46 | }); 47 | }); 48 | 49 | describe("getDiffForFile", () => { 50 | it("should get the staged diff of a file", () => { 51 | mockedChildProcess.execFileSync.mockReturnValueOnce(Buffer.from(hunks)); 52 | process.env.ESLINT_PLUGIN_DIFF_COMMIT = "1234567"; 53 | 54 | const diffFromFile = getDiffForFile("./mockfile.js", true); 55 | 56 | const expectedCommand = "git"; 57 | const expectedArgs = 58 | "diff --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --staged --unified=0 1234567"; 59 | 60 | const lastCall = mockedChildProcess.execFileSync.mock.calls.at(-1); 61 | const [command, argsIncludingFile = []] = lastCall ?? [""]; 62 | const args = argsIncludingFile.slice(0, -2); 63 | 64 | expect(command).toBe(expectedCommand); 65 | expect(args.join(" ")).toEqual(expectedArgs); 66 | expect(diffFromFile).toContain("diff --git"); 67 | expect(diffFromFile).toContain("@@"); 68 | }); 69 | 70 | it("should work when using staged = false", () => { 71 | mockedChildProcess.execFileSync.mockReturnValueOnce(Buffer.from(hunks)); 72 | process.env.ESLINT_PLUGIN_DIFF_COMMIT = "1234567"; 73 | 74 | const diffFromFile = getDiffForFile("./mockfile.js", false); 75 | 76 | const expectedCommand = "git"; 77 | const expectedArgs = 78 | "diff --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --unified=0 1234567"; 79 | 80 | const lastCall = mockedChildProcess.execFileSync.mock.calls.at(-1); 81 | const [command, argsIncludingFile = []] = lastCall ?? [""]; 82 | const args = argsIncludingFile.slice(0, -2); 83 | 84 | expect(command).toBe(expectedCommand); 85 | expect(args.join(" ")).toEqual(expectedArgs); 86 | expect(diffFromFile).toContain("diff --git"); 87 | expect(diffFromFile).toContain("@@"); 88 | }); 89 | 90 | it("should use HEAD when no commit was defined", () => { 91 | mockedChildProcess.execFileSync.mockReturnValueOnce(Buffer.from(hunks)); 92 | process.env.ESLINT_PLUGIN_DIFF_COMMIT = undefined; 93 | 94 | const diffFromFile = getDiffForFile("./mockfile.js", false); 95 | 96 | const expectedCommand = "git"; 97 | const expectedArgs = 98 | "diff --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --unified=0 HEAD"; 99 | 100 | const lastCall = mockedChildProcess.execFileSync.mock.calls.at(-1); 101 | const [command, argsIncludingFile = []] = lastCall ?? [""]; 102 | const args = argsIncludingFile.slice(0, -2); 103 | 104 | expect(command).toBe(expectedCommand); 105 | expect(args.join(" ")).toEqual(expectedArgs); 106 | expect(diffFromFile).toContain("diff --git"); 107 | expect(diffFromFile).toContain("@@"); 108 | }); 109 | }); 110 | 111 | describe("hasCleanIndex", () => { 112 | it("returns false instead of throwing", () => { 113 | jest.mock("child_process").resetAllMocks(); 114 | mockedChildProcess.execFileSync.mockImplementationOnce(() => { 115 | throw Error("mocked error"); 116 | }); 117 | expect(hasCleanIndex("")).toEqual(false); 118 | expect(mockedChildProcess.execFileSync).toHaveBeenCalled(); 119 | }); 120 | 121 | it("returns true otherwise", () => { 122 | jest.mock("child_process").resetAllMocks(); 123 | mockedChildProcess.execFileSync.mockReturnValue(Buffer.from("")); 124 | expect(hasCleanIndex("")).toEqual(true); 125 | expect(mockedChildProcess.execFileSync).toHaveBeenCalled(); 126 | }); 127 | }); 128 | 129 | describe("getDiffFileList", () => { 130 | it("should get the list of staged files", () => { 131 | jest.mock("child_process").resetAllMocks(); 132 | mockedChildProcess.execFileSync.mockReturnValueOnce( 133 | Buffer.from(diffFileList) 134 | ); 135 | expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(0); 136 | const fileListA = getDiffFileList(false); 137 | 138 | expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(1); 139 | expect(fileListA).toEqual( 140 | ["file1", "file2", "file3"].map((p) => path.resolve(p)) 141 | ); 142 | }); 143 | }); 144 | 145 | describe("getUntrackedFileList", () => { 146 | it("should get the list of untracked files", () => { 147 | jest.mock("child_process").resetAllMocks(); 148 | mockedChildProcess.execFileSync.mockReturnValueOnce( 149 | Buffer.from(diffFileList) 150 | ); 151 | expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(0); 152 | const fileListA = getUntrackedFileList(false); 153 | expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(1); 154 | 155 | mockedChildProcess.execFileSync.mockReturnValueOnce( 156 | Buffer.from(diffFileList) 157 | ); 158 | const staged = false; 159 | const fileListB = getUntrackedFileList(staged); 160 | // `getUntrackedFileList` uses a cache, so the number of calls to 161 | // `execFileSync` will not have increased. 162 | expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(1); 163 | 164 | expect(fileListA).toEqual( 165 | ["file1", "file2", "file3"].map((p) => path.resolve(p)) 166 | ); 167 | expect(fileListA).toEqual(fileListB); 168 | }); 169 | 170 | it("should not get a list when looking when using staged", () => { 171 | const staged = true; 172 | expect(getUntrackedFileList(staged)).toEqual([]); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from "child_process"; 2 | import { resolve } from "path"; 3 | import { Range } from "./Range"; 4 | 5 | const COMMAND = "git"; 6 | const OPTIONS = { maxBuffer: 1024 * 1024 * 100 }; 7 | 8 | const getDiffForFile = (filePath: string, staged: boolean): string => { 9 | const args = [ 10 | "diff", 11 | "--diff-algorithm=histogram", 12 | "--diff-filter=ACM", 13 | "--find-renames=100%", 14 | "--no-ext-diff", 15 | "--relative", 16 | staged && "--staged", 17 | "--unified=0", 18 | process.env.ESLINT_PLUGIN_DIFF_COMMIT ?? "HEAD", 19 | "--", 20 | resolve(filePath), 21 | ].reduce( 22 | (acc, cur) => (typeof cur === "string" ? [...acc, cur] : acc), 23 | [] 24 | ); 25 | 26 | return child_process.execFileSync(COMMAND, args, OPTIONS).toString(); 27 | }; 28 | 29 | const getDiffFileList = (staged: boolean): string[] => { 30 | const args = [ 31 | "diff", 32 | "--diff-algorithm=histogram", 33 | "--diff-filter=ACM", 34 | "--find-renames=100%", 35 | "--name-only", 36 | "--no-ext-diff", 37 | "--relative", 38 | staged && "--staged", 39 | process.env.ESLINT_PLUGIN_DIFF_COMMIT ?? "HEAD", 40 | "--", 41 | ].reduce( 42 | (acc, cur) => (typeof cur === "string" ? [...acc, cur] : acc), 43 | [] 44 | ); 45 | 46 | return child_process 47 | .execFileSync(COMMAND, args, OPTIONS) 48 | .toString() 49 | .trim() 50 | .split("\n") 51 | .map((filePath) => resolve(filePath)); 52 | }; 53 | 54 | const hasCleanIndex = (filePath: string): boolean => { 55 | const args = [ 56 | "diff", 57 | "--no-ext-diff", 58 | "--quiet", 59 | "--relative", 60 | "--unified=0", 61 | "--", 62 | resolve(filePath), 63 | ]; 64 | 65 | try { 66 | child_process.execFileSync(COMMAND, args, OPTIONS); 67 | } catch (err: unknown) { 68 | return false; 69 | } 70 | 71 | return true; 72 | }; 73 | 74 | const fetchFromOrigin = (branch: string) => { 75 | const args = ["fetch", "--quiet", "origin", branch]; 76 | 77 | child_process.execFileSync(COMMAND, args, OPTIONS); 78 | }; 79 | 80 | let untrackedFileListCache: string[] | undefined; 81 | const getUntrackedFileList = ( 82 | staged: boolean, 83 | shouldRefresh = false 84 | ): string[] => { 85 | if (staged) { 86 | return []; 87 | } 88 | 89 | if (untrackedFileListCache === undefined || shouldRefresh) { 90 | const args = ["ls-files", "--exclude-standard", "--others"]; 91 | 92 | untrackedFileListCache = child_process 93 | .execFileSync(COMMAND, args, OPTIONS) 94 | .toString() 95 | .trim() 96 | .split("\n") 97 | .map((filePath) => resolve(filePath)); 98 | } 99 | 100 | return untrackedFileListCache; 101 | }; 102 | 103 | const isHunkHeader = (input: string) => { 104 | const hunkHeaderRE = /^@@ [^@]* @@/u; 105 | 106 | return hunkHeaderRE.exec(input); 107 | }; 108 | 109 | const getRangeForChangedLines = (line: string) => { 110 | /** 111 | * Example values of the RegExp's group: 112 | * 113 | * start: '7', 114 | * linesCountDelimiter: ',2', 115 | * linesCount: '2', 116 | */ 117 | const rangeRE = 118 | /^@@ .* \+(?\d+)(?,(?\d+))? @@/u; 119 | const range = rangeRE.exec(line); 120 | if (range === null) { 121 | throw Error(`Couldn't match regex with line '${line}'`); 122 | } 123 | 124 | const groups = { 125 | // Fallback value to ensure hasAddedLines resolves to false 126 | start: "0", 127 | linesCountDelimiter: ",0", 128 | linesCount: "0", 129 | ...range.groups, 130 | }; 131 | 132 | const linesCount: number = 133 | groups.linesCountDelimiter && groups.linesCount 134 | ? parseInt(groups.linesCount) 135 | : 1; 136 | 137 | const hasAddedLines = linesCount !== 0; 138 | const start: number = parseInt(groups.start); 139 | const end = start + linesCount; 140 | 141 | return hasAddedLines ? new Range(start, end) : null; 142 | }; 143 | 144 | const getRangesForDiff = (diff: string): Range[] => 145 | diff.split("\n").reduce((ranges, line) => { 146 | if (!isHunkHeader(line)) { 147 | return ranges; 148 | } 149 | 150 | const range = getRangeForChangedLines(line); 151 | if (range === null) { 152 | return ranges; 153 | } 154 | 155 | return [...ranges, range]; 156 | }, []); 157 | 158 | export { 159 | fetchFromOrigin, 160 | getDiffFileList, 161 | getDiffForFile, 162 | getRangesForDiff, 163 | getUntrackedFileList, 164 | hasCleanIndex, 165 | }; 166 | -------------------------------------------------------------------------------- /src/index-ci.test.ts: -------------------------------------------------------------------------------- 1 | process.env.ESLINT_PLUGIN_DIFF_COMMIT = "some-branch"; 2 | process.env.CI = "true"; 3 | import * as child_process from "child_process"; 4 | 5 | jest.mock("child_process"); 6 | const mockedChildProcess = jest.mocked(child_process, { shallow: true }); 7 | mockedChildProcess.execFileSync.mockReturnValue( 8 | Buffer.from("line1\nline2\nline3") 9 | ); 10 | 11 | import "./index"; 12 | 13 | describe("CI", () => { 14 | it("should diff against origin", () => { 15 | expect(process.env.CI).toBeDefined(); 16 | expect(process.env.ESLINT_PLUGIN_DIFF_COMMIT).toEqual("origin/some-branch"); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | process.env.CI = "true"; 2 | import * as child_process from "child_process"; 3 | 4 | jest.mock("child_process"); 5 | const mockedChildProcess = jest.mocked(child_process, { shallow: true }); 6 | mockedChildProcess.execFileSync.mockReturnValue( 7 | Buffer.from("line1\nline2\nline3") 8 | ); 9 | 10 | import { configs, processors } from "./index"; 11 | 12 | describe("plugin", () => { 13 | it("should match expected export", () => { 14 | expect(configs).toMatchSnapshot(); 15 | expect(processors).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ci, 3 | ciConfig, 4 | diff, 5 | diffConfig, 6 | staged, 7 | stagedConfig, 8 | } from "./processors"; 9 | 10 | const configs = { 11 | ci: ciConfig, 12 | diff: diffConfig, 13 | staged: stagedConfig, 14 | }; 15 | const processors = { ci, diff, staged }; 16 | 17 | module.exports = { configs, processors }; 18 | 19 | export { configs, processors }; 20 | -------------------------------------------------------------------------------- /src/processors.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("./git", () => ({ 2 | ...jest.requireActual("./git"), 3 | getUntrackedFileList: jest.fn(), 4 | getDiffFileList: jest.fn(), 5 | getDiffForFile: jest.fn(), 6 | hasCleanIndex: jest.fn(), 7 | })); 8 | 9 | import type { Linter } from "eslint"; 10 | import * as git from "./git"; 11 | import { 12 | diff as fixtureDiff, 13 | staged as fixtureStaged, 14 | } from "./__fixtures__/diff"; 15 | import { postprocessArguments } from "./__fixtures__/postprocessArguments"; 16 | 17 | const [messages, filename] = postprocessArguments; 18 | const untrackedFilename = "an-untracked-file.js"; 19 | 20 | const gitMocked: jest.MockedObjectDeep = jest.mocked(git); 21 | gitMocked.getDiffFileList.mockReturnValue([filename]); 22 | gitMocked.getUntrackedFileList.mockReturnValue([untrackedFilename]); 23 | 24 | describe("processors", () => { 25 | it("preprocess (diff and staged)", async () => { 26 | // The preprocessor does not depend on `staged` being true or false, so it's 27 | // sufficient to only test one of them. 28 | const validFilename = filename; 29 | const sourceCode = "/** Some source code */"; 30 | 31 | const { diff: diffProcessors } = await import("./processors"); 32 | 33 | expect(diffProcessors.preprocess(sourceCode, validFilename)).toEqual([ 34 | sourceCode, 35 | ]); 36 | }); 37 | 38 | it("diff postprocess", async () => { 39 | gitMocked.getDiffForFile.mockReturnValue(fixtureDiff); 40 | 41 | const { diff: diffProcessors } = await import("./processors"); 42 | 43 | expect(diffProcessors.postprocess(messages, filename)).toMatchSnapshot(); 44 | }); 45 | 46 | it("diff postprocess with no messages", async () => { 47 | gitMocked.getDiffForFile.mockReturnValue(fixtureDiff); 48 | 49 | const { diff: diffProcessors } = await import("./processors"); 50 | 51 | const noMessages: Linter.LintMessage[][] = []; 52 | expect(diffProcessors.postprocess(noMessages, filename)).toEqual( 53 | noMessages 54 | ); 55 | }); 56 | 57 | it("diff postprocess for untracked files with messages", async () => { 58 | gitMocked.getDiffForFile.mockReturnValue(fixtureDiff); 59 | 60 | const { staged: stagedProcessors } = await import("./processors"); 61 | 62 | const untrackedFilesMessages: Linter.LintMessage[] = [ 63 | { ruleId: "mock", severity: 1, message: "mock msg", line: 1, column: 1 }, 64 | ]; 65 | 66 | expect( 67 | stagedProcessors.postprocess([untrackedFilesMessages], untrackedFilename) 68 | ).toEqual(untrackedFilesMessages); 69 | }); 70 | 71 | it("staged postprocess", async () => { 72 | gitMocked.hasCleanIndex.mockReturnValueOnce(true); 73 | gitMocked.getDiffForFile.mockReturnValueOnce(fixtureStaged); 74 | 75 | const { staged: stagedProcessors } = await import("./processors"); 76 | 77 | expect(stagedProcessors.postprocess(messages, filename)).toMatchSnapshot(); 78 | }); 79 | 80 | it("should report fatal errors", async () => { 81 | gitMocked.getDiffForFile.mockReturnValue(fixtureDiff); 82 | const [[firstMessage, ...restMessage], ...restMessageArray] = messages; 83 | const messagesWithFatal: Linter.LintMessage[][] = [ 84 | [{ ...firstMessage, fatal: true }, ...restMessage], 85 | ...restMessageArray, 86 | ]; 87 | 88 | const { diff: diffProcessors } = await import("./processors"); 89 | 90 | expect(diffProcessors.postprocess(messages, filename)).toHaveLength(2); 91 | expect( 92 | diffProcessors.postprocess(messagesWithFatal, filename) 93 | ).toHaveLength(3); 94 | }); 95 | 96 | it("should report fatal errors for staged postprocess with unclean index", async () => { 97 | gitMocked.hasCleanIndex.mockReturnValueOnce(false); 98 | gitMocked.getDiffForFile.mockReturnValueOnce(fixtureStaged); 99 | 100 | const { staged: stagedProcessors } = await import("./processors"); 101 | 102 | const fileWithDirtyIndex = "file-with-dirty-index.js"; 103 | const [errorMessage] = stagedProcessors.postprocess( 104 | messages, 105 | fileWithDirtyIndex 106 | ); 107 | 108 | expect(errorMessage?.fatal).toBe(true); 109 | expect(errorMessage?.message).toMatchInlineSnapshot( 110 | `"file-with-dirty-index.js has unstaged changes. Please stage or remove the changes."` 111 | ); 112 | }); 113 | }); 114 | 115 | describe("configs", () => { 116 | it("diff", async () => { 117 | const { diffConfig } = await import("./processors"); 118 | expect(diffConfig).toMatchSnapshot(); 119 | }); 120 | 121 | it("staged", async () => { 122 | const { stagedConfig } = await import("./processors"); 123 | expect(stagedConfig).toMatchSnapshot(); 124 | }); 125 | }); 126 | 127 | describe("fatal error-message", () => { 128 | it("getUnstagedChangesError", async () => { 129 | const { getUnstagedChangesError } = await import("./processors"); 130 | 131 | const [result] = getUnstagedChangesError("mock filename.ts"); 132 | expect(result.fatal).toBe(true); 133 | expect(result.message).toMatchInlineSnapshot( 134 | '"mock filename.ts has unstaged changes. Please stage or remove the changes."' 135 | ); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/processors.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from "eslint"; 2 | import { guessBranch } from "./ci"; 3 | import { 4 | fetchFromOrigin, 5 | getDiffFileList, 6 | getDiffForFile, 7 | getRangesForDiff, 8 | getUntrackedFileList, 9 | hasCleanIndex, 10 | } from "./git"; 11 | import type { Range } from "./Range"; 12 | 13 | if (process.env.CI !== undefined) { 14 | const branch = process.env.ESLINT_PLUGIN_DIFF_COMMIT ?? guessBranch(); 15 | if (branch !== undefined) { 16 | const branchWithoutOrigin = branch.replace(/^origin\//, ""); 17 | const branchWithOrigin = `origin/${branchWithoutOrigin}`; 18 | fetchFromOrigin(branchWithoutOrigin); 19 | process.env.ESLINT_PLUGIN_DIFF_COMMIT = branchWithOrigin; 20 | } 21 | } 22 | 23 | /** 24 | * Exclude unchanged files from being processed 25 | * 26 | * Since we're excluding unchanged files in the post-processor, we can exclude 27 | * them from being processed in the first place, as a performance optimization. 28 | * This is increasingly useful the more files there are in the repository. 29 | */ 30 | const getPreProcessor = 31 | (diffFileList: string[], staged: boolean) => 32 | (text: string, filename: string) => { 33 | let untrackedFileList = getUntrackedFileList(staged); 34 | const shouldRefresh = 35 | !diffFileList.includes(filename) && !untrackedFileList.includes(filename); 36 | if (shouldRefresh) { 37 | untrackedFileList = getUntrackedFileList(staged, true); 38 | } 39 | const shouldBeProcessed = 40 | process.env.VSCODE_PID !== undefined || 41 | diffFileList.includes(filename) || 42 | untrackedFileList.includes(filename); 43 | 44 | return shouldBeProcessed ? [text] : []; 45 | }; 46 | 47 | const isLineWithinRange = (line: number) => (range: Range) => 48 | range.isWithinRange(line); 49 | 50 | /** 51 | * @internal 52 | */ 53 | const getUnstagedChangesError = (filename: string): [Linter.LintMessage] => { 54 | // When we only want to diff staged files, but the file is partially 55 | // staged, the ranges of the staged diff might not match the ranges of the 56 | // unstaged diff and could cause a conflict, so we return a fatal 57 | // error-message instead. 58 | 59 | const fatal = true; 60 | const message = `${filename} has unstaged changes. Please stage or remove the changes.`; 61 | const severity: Linter.Severity = 2; 62 | const fatalError: Linter.LintMessage = { 63 | fatal, 64 | message, 65 | severity, 66 | column: 0, 67 | line: 0, 68 | ruleId: null, 69 | }; 70 | 71 | return [fatalError]; 72 | }; 73 | 74 | const getPostProcessor = 75 | (staged: boolean) => 76 | ( 77 | messages: Linter.LintMessage[][], 78 | filename: string 79 | ): Linter.LintMessage[] => { 80 | if (messages.length === 0) { 81 | // No need to filter, just return 82 | return []; 83 | } 84 | const untrackedFileList = getUntrackedFileList(staged); 85 | if (untrackedFileList.includes(filename)) { 86 | // We don't need to filter the messages of untracked files because they 87 | // would all be kept anyway, so we return them as-is. 88 | return messages.flat(); 89 | } 90 | 91 | if (staged && !hasCleanIndex(filename)) { 92 | return getUnstagedChangesError(filename); 93 | } 94 | 95 | const rangesForDiff = getRangesForDiff(getDiffForFile(filename, staged)); 96 | 97 | return messages.flatMap((message) => { 98 | const filteredMessage = message.filter(({ fatal, line }) => { 99 | if (fatal === true) { 100 | return true; 101 | } 102 | 103 | const isLineWithinSomeRange = rangesForDiff.some( 104 | isLineWithinRange(line) 105 | ); 106 | 107 | return isLineWithinSomeRange; 108 | }); 109 | 110 | return filteredMessage; 111 | }); 112 | }; 113 | 114 | type ProcessorType = "diff" | "staged" | "ci"; 115 | 116 | const getProcessors = ( 117 | processorType: ProcessorType 118 | ): Required => { 119 | const staged = processorType === "staged"; 120 | const diffFileList = getDiffFileList(staged); 121 | 122 | return { 123 | preprocess: getPreProcessor(diffFileList, staged), 124 | postprocess: getPostProcessor(staged), 125 | supportsAutofix: true, 126 | }; 127 | }; 128 | 129 | const ci = process.env.CI !== undefined ? getProcessors("ci") : {}; 130 | const diff = getProcessors("diff"); 131 | const staged = getProcessors("staged"); 132 | 133 | const diffConfig: Linter.BaseConfig = { 134 | plugins: ["diff"], 135 | overrides: [ 136 | { 137 | files: ["*"], 138 | processor: "diff/diff", 139 | }, 140 | ], 141 | }; 142 | 143 | const ciConfig: Linter.BaseConfig = 144 | process.env.CI === undefined 145 | ? {} 146 | : { 147 | plugins: ["diff"], 148 | overrides: [ 149 | { 150 | files: ["*"], 151 | processor: "diff/ci", 152 | }, 153 | ], 154 | }; 155 | 156 | const stagedConfig: Linter.BaseConfig = { 157 | plugins: ["diff"], 158 | overrides: [ 159 | { 160 | files: ["*"], 161 | processor: "diff/staged", 162 | }, 163 | ], 164 | }; 165 | 166 | export { 167 | ci, 168 | ciConfig, 169 | diff, 170 | diffConfig, 171 | staged, 172 | stagedConfig, 173 | getUnstagedChangesError, 174 | }; 175 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@paleite/tsconfig-node16", 3 | "compilerOptions": { 4 | // #region Support running on Node 14 until EOL, April 30th, 2023 5 | // Recommended Node TSConfig settings (Node Target Mapping): 6 | // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping#recommended-node-tsconfig-settings 7 | // Node release EOLs: 8 | // https://nodejs.org/en/about/releases/ 9 | "lib": ["ES2020"], 10 | "target": "ES2020" 11 | // #endregion 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": false, 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "resolveJsonModule": true 9 | }, 10 | 11 | "include": ["src"], 12 | "exclude": ["**/*.test-d.*", "**/*.test.*", "**/__fixtures__"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [".*.js", "*.js"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "rootDir": "." 5 | }, 6 | 7 | "include": ["."], 8 | "exclude": ["dist"] 9 | } 10 | --------------------------------------------------------------------------------