diff --git a/.github/workflows/lint-aliases.yml b/.github/workflows/lint-aliases.yml index cb49c50d..be3730c0 100644 --- a/.github/workflows/lint-aliases.yml +++ b/.github/workflows/lint-aliases.yml @@ -1,4 +1,4 @@ -name: "Linter: aliases" +name: 'Linter: aliases' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'aliases/**' + - 'aliases/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w aliases - run: npm install -w aliases - - name: Run npm run lint -w aliases - run: npm run lint -w aliases + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w aliases --include-workspace-root + run: npm install -w aliases --include-workspace-root + - name: Run npm run lint -w aliases + run: npm run lint -w aliases diff --git a/.github/workflows/lint-core.yml b/.github/workflows/lint-core.yml index 90153c09..c7ae4c33 100644 --- a/.github/workflows/lint-core.yml +++ b/.github/workflows/lint-core.yml @@ -1,4 +1,4 @@ -name: "Linter: core" +name: 'Linter: core' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'core/**' + - 'core/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w core - run: npm install -w core - - name: Run npm run lint -w core - run: npm run lint -w core + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w core --include-workspace-root + run: npm install -w core --include-workspace-root + - name: Run npm run lint -w core + run: npm run lint -w core diff --git a/.github/workflows/lint-fetchindex.yml b/.github/workflows/lint-fetchindex.yml index 52da9a12..f5829d71 100644 --- a/.github/workflows/lint-fetchindex.yml +++ b/.github/workflows/lint-fetchindex.yml @@ -1,4 +1,4 @@ -name: "Linter: fetchindex" +name: 'Linter: fetchindex' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'fetchindex/**' + - 'fetchindex/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w fetchindex - run: npm install -w fetchindex - - name: Run npm run lint -w fetchindex - run: npm run lint -w fetchindex + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w fetchindex --include-workspace-root + run: npm install -w fetchindex --include-workspace-root + - name: Run npm run lint -w fetchindex + run: npm run lint -w fetchindex diff --git a/.github/workflows/lint-newest.yml b/.github/workflows/lint-newest.yml index dfba38a1..dd506362 100644 --- a/.github/workflows/lint-newest.yml +++ b/.github/workflows/lint-newest.yml @@ -1,4 +1,4 @@ -name: "Linter: newest" +name: 'Linter: newest' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'newest/**' + - 'newest/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w newest - run: npm install -w newest - - name: Run npm run lint -w newest - run: npm run lint -w newest + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w newest --include-workspace-root + run: npm install -w newest --include-workspace-root + - name: Run npm run lint -w newest + run: npm run lint -w newest diff --git a/.github/workflows/lint-oldest.yml b/.github/workflows/lint-oldest.yml index 61b74242..93dc0383 100644 --- a/.github/workflows/lint-oldest.yml +++ b/.github/workflows/lint-oldest.yml @@ -1,4 +1,4 @@ -name: "Linter: oldest" +name: 'Linter: oldest' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'oldest/**' + - 'oldest/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w oldest - run: npm install -w oldest - - name: Run npm run lint -w oldest - run: npm run lint -w oldest + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w oldest --include-workspace-root + run: npm install -w oldest --include-workspace-root + - name: Run npm run lint -w oldest + run: npm run lint -w oldest diff --git a/.github/workflows/lint-opt.yml b/.github/workflows/lint-opt.yml index 3b0c8e64..de7d5d0e 100644 --- a/.github/workflows/lint-opt.yml +++ b/.github/workflows/lint-opt.yml @@ -1,4 +1,4 @@ -name: "Linter: opt" +name: 'Linter: opt' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'opt/**' + - 'opt/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w opt - run: npm install -w opt - - name: Run npm run lint -w opt - run: npm run lint -w opt + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w opt --include-workspace-root + run: npm install -w opt --include-workspace-root + - name: Run npm run lint -w opt + run: npm run lint -w opt diff --git a/.github/workflows/lint-parsefiles.yml b/.github/workflows/lint-parsefiles.yml index 80a1bbbc..f6d94eb6 100644 --- a/.github/workflows/lint-parsefiles.yml +++ b/.github/workflows/lint-parsefiles.yml @@ -1,4 +1,4 @@ -name: "Linter: parsefiles" +name: 'Linter: parsefiles' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'parsefiles/**' + - 'parsefiles/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w parsefiles - run: npm install -w parsefiles - - name: Run npm run lint -w parsefiles - run: npm run lint -w parsefiles + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w parsefiles --include-workspace-root + run: npm install -w parsefiles --include-workspace-root + - name: Run npm run lint -w parsefiles + run: npm run lint -w parsefiles diff --git a/.github/workflows/lint-ranges.yml b/.github/workflows/lint-ranges.yml index e366f5ef..597d91f4 100644 --- a/.github/workflows/lint-ranges.yml +++ b/.github/workflows/lint-ranges.yml @@ -1,4 +1,4 @@ -name: "Linter: ranges" +name: 'Linter: ranges' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'ranges/**' + - 'ranges/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w ranges - run: npm install -w ranges - - name: Run npm run lint -w ranges - run: npm run lint -w ranges + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w ranges --include-workspace-root + run: npm install -w ranges --include-workspace-root + - name: Run npm run lint -w ranges + run: npm run lint -w ranges diff --git a/.github/workflows/lint-static.yml b/.github/workflows/lint-static.yml index a77c91c2..5510dff1 100644 --- a/.github/workflows/lint-static.yml +++ b/.github/workflows/lint-static.yml @@ -1,4 +1,4 @@ -name: "Linter: static" +name: 'Linter: static' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'static/**' + - 'static/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w static - run: npm install -w static - - name: Run npm run lint -w static - run: npm run lint -w static + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w static --include-workspace-root + run: npm install -w static --include-workspace-root + - name: Run npm run lint -w static + run: npm run lint -w static diff --git a/.github/workflows/lint-translate.yml b/.github/workflows/lint-translate.yml index ce4f67f4..6015aedc 100644 --- a/.github/workflows/lint-translate.yml +++ b/.github/workflows/lint-translate.yml @@ -1,4 +1,4 @@ -name: "Linter: translate" +name: 'Linter: translate' on: push: @@ -8,7 +8,7 @@ on: - main pull_request: paths: - - 'translate/**' + - 'translate/**' branches: - main workflow_dispatch: @@ -19,13 +19,13 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install -w translate - run: npm install -w translate - - name: Run npm run lint -w translate - run: npm run lint -w translate + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install -w translate --include-workspace-root + run: npm install -w translate --include-workspace-root + - name: Run npm run lint -w translate + run: npm run lint -w translate diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 128639f7..0aaa4668 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,11 +1,15 @@ -name: "Linter: all" +name: 'Linter: all' on: push: paths: - 'package.json' - '*/package.json' - - 'biome.json' + - '.oxlintrc.json' + - '.oxfmtrc.json' + - 'tools/**' + - '.github/workflows/**' + - '*.md' - '.npmrc' branches: - main @@ -13,7 +17,11 @@ on: paths: - 'package.json' - '*/package.json' - - 'biome.json' + - '.oxlintrc.json' + - '.oxfmtrc.json' + - 'tools/**' + - '.github/workflows/**' + - '*.md' - '.npmrc' branches: - main @@ -25,27 +33,26 @@ jobs: if: github.repository == 'cutenode/nodevu' runs-on: ubuntu-latest steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: current - - name: Run npm install - run: npm install -ws - - name: Run npm lint - run: npm run lint - # npm rejects a provenance publish when repository.url doesn't match the - # repo the release workflow runs in, so every package release.yml - # publishes has to name it - - name: Check published packages name this repository - run: | - status=0 - for ws in aliases core fetchindex newest oldest parsefiles ranges static; do - if ! jq -e '.repository.url == "git+https://github.com/cutenode/nodevu.git"' "$ws/package.json" > /dev/null; then - echo "::error file=$ws/package.json::repository.url must be git+https://github.com/cutenode/nodevu.git" - status=1 - fi - done - exit "$status" - + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: current + - name: Run npm install + run: npm install --workspaces=false + - name: Run npm lint + run: npm run lint + # npm rejects a provenance publish when repository.url doesn't match the + # repo the release workflow runs in, so every package release.yml + # publishes has to name it + - name: Check published packages name this repository + run: | + status=0 + for ws in aliases core fetchindex newest oldest parsefiles ranges static; do + if ! jq -e '.repository.url == "git+https://github.com/cutenode/nodevu.git"' "$ws/package.json" > /dev/null; then + echo "::error file=$ws/package.json::repository.url must be git+https://github.com/cutenode/nodevu.git" + status=1 + fi + done + exit "$status" diff --git a/.github/workflows/nightly-static-build.yaml b/.github/workflows/nightly-static-build.yaml index 26e4d9b2..64b16550 100644 --- a/.github/workflows/nightly-static-build.yaml +++ b/.github/workflows/nightly-static-build.yaml @@ -1,15 +1,15 @@ -name: "Nightly Build: static" +name: 'Nightly Build: static' on: schedule: - - cron: "0 0 * * *" + - cron: '0 0 * * *' workflow_dispatch: jobs: run-build: runs-on: ubuntu-latest permissions: - contents: write # push the `nightly` branch - pull-requests: write # open or update the data PR and assign it + contents: write # push the `nightly` branch + pull-requests: write # open or update the data PR and assign it steps: - name: Checkout the repository uses: actions/checkout@v7 @@ -18,7 +18,7 @@ jobs: with: node-version: latest - name: Run npm install - run: npm install -ws + run: npm install -ws --include-workspace-root - name: Run build:default run: npm run build:default -w static - name: Run build:releases @@ -32,9 +32,9 @@ jobs: - name: Create or Update Pull Request uses: peter-evans/create-pull-request@v8 with: - title: "feat: update data" - body: "Updates the static data based on a nightly (or manual) run of the build step in GitHub Actions. Unless the data is invalid, this should be good to go." - branch: "nightly" - commit-message: "feat: update data" - author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + title: 'feat: update data' + body: 'Updates the static data based on a nightly (or manual) run of the build step in GitHub Actions. Unless the data is invalid, this should be good to go.' + branch: 'nightly' + commit-message: 'feat: update data' + author: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' assignees: bnb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3650a47a..f785d4e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,8 @@ jobs: if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest permissions: - contents: write # push the `release/vX.Y.Z` branch and delete superseded ones - pull-requests: write # create a release PR, update its body, close superseded PRs + contents: write # push the `release/vX.Y.Z` branch and delete superseded ones + pull-requests: write # create a release PR, update its body, close superseded PRs steps: - uses: danielroe/uppt/pr@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 with: @@ -36,7 +36,7 @@ jobs: static !opt !translate - + # The release PR was merged: tag the squash commit, cut a GitHub release # from the PR body, and dispatch the publish workflow. The `release/v` # head-ref guard keeps regular feature-PR merges from triggering this; @@ -52,8 +52,8 @@ jobs: group: release-${{ github.event.pull_request.number }} cancel-in-progress: false permissions: - contents: write # push the `vX.Y.Z` tag and create the GitHub release - actions: write # `gh workflow run release.yml --ref vX.Y.Z` chained dispatch + contents: write # push the `vX.Y.Z` tag and create the GitHub release + actions: write # `gh workflow run release.yml --ref vX.Y.Z` chained dispatch steps: - uses: danielroe/uppt/release@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 with: @@ -122,8 +122,8 @@ jobs: group: publish-${{ github.ref }} cancel-in-progress: false permissions: - id-token: write # OIDC claim for npm trusted publisher - environment: npm # must match the trusted-publisher entry on npmjs.com + id-token: write # OIDC claim for npm trusted publisher + environment: npm # must match the trusted-publisher entry on npmjs.com steps: - uses: danielroe/uppt/publish@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 with: diff --git a/.github/workflows/tests-core.yml b/.github/workflows/tests-core.yml index 89ddb3d5..f07d61cf 100644 --- a/.github/workflows/tests-core.yml +++ b/.github/workflows/tests-core.yml @@ -1,4 +1,4 @@ -name: "Test Suite: core" +name: 'Test Suite: core' on: pull_request: @@ -17,15 +17,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w core - run: npm install -w core - - name: Run npm test -w core - run: npm test -w core \ No newline at end of file + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w core + run: npm install -w core + - name: Run npm test -w core + run: npm test -w core diff --git a/.github/workflows/tests-fetchindex.yml b/.github/workflows/tests-fetchindex.yml index c3c3475d..271a2ed9 100644 --- a/.github/workflows/tests-fetchindex.yml +++ b/.github/workflows/tests-fetchindex.yml @@ -1,4 +1,4 @@ -name: "Test Suite: fetchindex" +name: 'Test Suite: fetchindex' on: pull_request: @@ -17,15 +17,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w fetchindex - run: npm install -w fetchindex - - name: Run npm test -w fetchindex - run: npm test -w fetchindex + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w fetchindex + run: npm install -w fetchindex + - name: Run npm test -w fetchindex + run: npm test -w fetchindex diff --git a/.github/workflows/tests-newest.yml b/.github/workflows/tests-newest.yml index f4c3f808..5614d3a9 100644 --- a/.github/workflows/tests-newest.yml +++ b/.github/workflows/tests-newest.yml @@ -1,4 +1,4 @@ -name: "Test Suite: newest" +name: 'Test Suite: newest' on: pull_request: @@ -18,15 +18,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w newest - run: npm install -w newest - - name: Run npm test -w newest - run: npm test -w newest + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w newest + run: npm install -w newest + - name: Run npm test -w newest + run: npm test -w newest diff --git a/.github/workflows/tests-oldest.yml b/.github/workflows/tests-oldest.yml index 31036e2a..c1bffa26 100644 --- a/.github/workflows/tests-oldest.yml +++ b/.github/workflows/tests-oldest.yml @@ -1,4 +1,4 @@ -name: "Test Suite: oldest" +name: 'Test Suite: oldest' on: pull_request: @@ -18,15 +18,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w oldest - run: npm install -w oldest - - name: Run npm test -w oldest - run: npm test -w oldest + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w oldest + run: npm install -w oldest + - name: Run npm test -w oldest + run: npm test -w oldest diff --git a/.github/workflows/tests-opt.yml b/.github/workflows/tests-opt.yml index 3ae23b01..a8d69d08 100644 --- a/.github/workflows/tests-opt.yml +++ b/.github/workflows/tests-opt.yml @@ -1,4 +1,4 @@ -name: "Test Suite: opt" +name: 'Test Suite: opt' on: pull_request: @@ -17,15 +17,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w opt - run: npm install -w opt - - name: Run npm test -w opt - run: npm test -w opt + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w opt + run: npm install -w opt + - name: Run npm test -w opt + run: npm test -w opt diff --git a/.github/workflows/tests-parsefiles.yml b/.github/workflows/tests-parsefiles.yml index 157aaace..83e842c5 100644 --- a/.github/workflows/tests-parsefiles.yml +++ b/.github/workflows/tests-parsefiles.yml @@ -1,4 +1,4 @@ -name: "Test Suite: parsefiles" +name: 'Test Suite: parsefiles' on: pull_request: @@ -17,15 +17,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w parsefiles - run: npm install -w parsefiles - - name: Run npm test -w parsefiles - run: npm test -w parsefiles + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w parsefiles + run: npm install -w parsefiles + - name: Run npm test -w parsefiles + run: npm test -w parsefiles diff --git a/.github/workflows/tests-ranges.yml b/.github/workflows/tests-ranges.yml index 7ffdba73..7c3396eb 100644 --- a/.github/workflows/tests-ranges.yml +++ b/.github/workflows/tests-ranges.yml @@ -1,4 +1,4 @@ -name: "Test Suite: ranges" +name: 'Test Suite: ranges' on: pull_request: @@ -19,15 +19,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w ranges - run: npm install -w ranges - - name: Run npm test -w ranges - run: npm test -w ranges \ No newline at end of file + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w ranges + run: npm install -w ranges + - name: Run npm test -w ranges + run: npm test -w ranges diff --git a/.github/workflows/tests-static.yml b/.github/workflows/tests-static.yml index f8124329..b3162fce 100644 --- a/.github/workflows/tests-static.yml +++ b/.github/workflows/tests-static.yml @@ -1,4 +1,4 @@ -name: "Test Suite: static" +name: 'Test Suite: static' on: pull_request: @@ -17,15 +17,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w static - run: npm install -w static - - name: Run npm test -w static - run: npm test -w static \ No newline at end of file + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w static + run: npm install -w static + - name: Run npm test -w static + run: npm test -w static diff --git a/.github/workflows/tests-translate.yml b/.github/workflows/tests-translate.yml index 719895d1..d8470d76 100644 --- a/.github/workflows/tests-translate.yml +++ b/.github/workflows/tests-translate.yml @@ -1,4 +1,4 @@ -name: "Test Suite: translate" +name: 'Test Suite: translate' on: pull_request: @@ -20,15 +20,15 @@ jobs: matrix: node-version: [current, lts/*, lts/-1] steps: - - name: Checkout the repository - uses: actions/checkout@v7 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - - name: Install most recent npm - run: npm install -g npm - - name: Run npm install -w translate - run: npm install -w translate - - name: Run npm test -w translate - run: npm test -w translate + - name: Checkout the repository + uses: actions/checkout@v7 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install most recent npm + run: npm install -g npm + - name: Run npm install -w translate + run: npm install -w translate + - name: Run npm test -w translate + run: npm test -w translate diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..4c2faa65 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,22 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "useTabs": true, + "printWidth": 80, + "singleQuote": true, + "sortImports": {}, + "sortPackageJson": false, + "ignorePatterns": [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + "tools/oxlint/anti-slop/**" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..e5776f04 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,42 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + "tools/oxlint/anti-slop/**" + ], + "jsPlugins": [ + { "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" } + ], + "rules": { + "no-unused-vars": ["warn", { "ignoreRestSiblings": true }], + "oxc/no-accumulating-spread": "error", + "anti-slop/no-array-filter-map": "error", + "anti-slop/no-reduce-accumulator-copy": "error", + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-readable-spacing": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error" + } +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index ce722740..136202e1 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,4 +1,3 @@ - # Contributor Covenant Code of Conduct ## Our Pledge @@ -18,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or advances of +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -131,4 +130,3 @@ For answers to common questions about this code of conduct, see the FAQ at [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations - diff --git a/README.md b/README.md index 16b6a626..65127b4e 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,15 @@ nodevu is a Node.js Version Utility meant to collect, merge, and reason about No nodevu is composed of a set of modules: - Foundational Modules - * [core](./core/): this is the core, online-only module. It calls out to sources that have version information about Node.js and does the heavy lifting of coercion, merging, and reasoning about that data into a format that is easily accessible and (hopefully!) extremely useful. It transparently provides data from the sources it fetches from, but also adds additional useufl context that it figures out based on time, context, and other signals. - * [static](./static/): this is the offline-only version of nodevu. It's simply an interface to both the full context of [core](./core/), in addition to a few subsets of the data core provides. It is automatically updated when there's new information available, though there aren't gaurantees on when that will be available. This is mostly useful if you're not particularly picky about when your version information is availble. + - [core](./core/): this is the core, online-only module. It calls out to sources that have version information about Node.js and does the heavy lifting of coercion, merging, and reasoning about that data into a format that is easily accessible and (hopefully!) extremely useful. It transparently provides data from the sources it fetches from, but also adds additional useufl context that it figures out based on time, context, and other signals. + - [static](./static/): this is the offline-only version of nodevu. It's simply an interface to both the full context of [core](./core/), in addition to a few subsets of the data core provides. It is automatically updated when there's new information available, though there aren't gaurantees on when that will be available. This is mostly useful if you're not particularly picky about when your version information is availble. - Additional Utilities - * [oldest](./oldest/): this is a utility for finding the oldest LTS or Security release in a Node.js release line. - * [newest](./newest/): this is a utility for finding the newest LTS or Security release in a Node.js release line. - * [ranges](./ranges/): a module that provides information about ranges of Node.js versions. - * [aliases](./aliases/): a module that provides information about aliases for Node.js versions. - * [translate](./translate/): a module that translates from the version structure that the Node.js project proposed for the `support` property in `package.json` to [aliases](./aliases/). + - [oldest](./oldest/): this is a utility for finding the oldest LTS or Security release in a Node.js release line. + - [newest](./newest/): this is a utility for finding the newest LTS or Security release in a Node.js release line. + - [ranges](./ranges/): a module that provides information about ranges of Node.js versions. + - [aliases](./aliases/): a module that provides information about aliases for Node.js versions. + - [translate](./translate/): a module that translates from the version structure that the Node.js project proposed for the `support` property in `package.json` to [aliases](./aliases/). - Helper Modules - * [parsefiles](./parsefiles/): this is a utility for parsing the `files` property of the [Node.js distributions JSON](https://nodejs.org/dist/index.json) file to understand the available files for any given set of identifiers from that list. It also provides additional, potentially useful information. - * [opt](./opt/): this is a utility for generating a usable `options` for `@nodevu/core`. - * [fetchindex](./fetchindex/): this is a utility for fetching `/dist/index.json` from `nodejs.org` for `@nodevu/core`. Might be useful for other projects. \ No newline at end of file + - [parsefiles](./parsefiles/): this is a utility for parsing the `files` property of the [Node.js distributions JSON](https://nodejs.org/dist/index.json) file to understand the available files for any given set of identifiers from that list. It also provides additional, potentially useful information. + - [opt](./opt/): this is a utility for generating a usable `options` for `@nodevu/core`. + - [fetchindex](./fetchindex/): this is a utility for fetching `/dist/index.json` from `nodejs.org` for `@nodevu/core`. Might be useful for other projects. diff --git a/aliases/README.md b/aliases/README.md index e8b566b0..ca4804a3 100644 --- a/aliases/README.md +++ b/aliases/README.md @@ -5,10 +5,10 @@ This is a module that provides an array of aliases for ranges of Node.js release ## Usage ```js -const aliases = require('@nodevu/aliases') +const aliases = require('@nodevu/aliases'); for (const alias of aliases) { - console.log(alias) + console.log(alias); } ``` @@ -22,4 +22,4 @@ This module returns an `Array` of aliases: - 'lts/active' - This represents the active LTS release lines. - 'lts/maintenance' - This represents the maintenance LTS release line, if any. - 'eol' - This represents the End of Life release lines. -- 'all' - This represents all release lines. \ No newline at end of file +- 'all' - This represents all release lines. diff --git a/aliases/biome.json b/aliases/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/aliases/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/aliases/package.json b/aliases/package.json index d9e19a19..afbfc434 100644 --- a/aliases/package.json +++ b/aliases/package.json @@ -18,13 +18,10 @@ }, "homepage": "https://github.com/cutenode/nodevu#readme", "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "", "updates:check": "npx npm-check-updates", "updates:update": "npx npm-check-updates -u" - }, - "devDependencies": { - "@biomejs/biome": "2.5.15" } } diff --git a/biome.json b/biome.json deleted file mode 100644 index 88b80e48..00000000 --- a/biome.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single" - } - }, - "linter": { - "enabled": true, - "rules": { - "preset": "recommended" - } - } -} diff --git a/core/README.md b/core/README.md index d7f6c200..5419747b 100644 --- a/core/README.md +++ b/core/README.md @@ -5,14 +5,14 @@ The core, online-only utility of nodevu. ## Usage ```js -const nodevu = require('@nodevu/core') +const nodevu = require('@nodevu/core'); async function getData() { - const data = await nodevu() // returns a lot of data! - console.log(data) + const data = await nodevu(); // returns a lot of data! + console.log(data); } -getData() +getData(); ``` ## API @@ -43,7 +43,7 @@ The data returned by this module follows a pretty specific structure. Here's a d - `modules` (`Object`) - `version` (`String`) - the ABI (Application Binary Interface) version number of Node.js, used to determine which version of Node.js compiled C++ add-on binaries can be loaded in to without needing to be re-compiled. - `dependencies` (`Object`) - the bundled dependencies in this version of Node.js: - - `npm` (`String`) - version of the bundled npm. + - `npm` (`String`) - version of the bundled npm. - `v8` (`String`) - version of the bundled v8. - `uv` (`String`) - version of the bundled libuv. - `zlib` (`String`) - version of the bundled zlib. @@ -71,7 +71,7 @@ The data returned by this module follows a pretty specific structure. Here's a d - `id` (`String`) - the initial identifier from `available` that was passed in. - `files` (`Array`) - an `Array` of associated filenames for the ID. - `architecture` (`String`) - the name of the architecture for the files in `files`. - - `windows` (`Array`): + - `windows` (`Array`): - `id` (`String`) - the initial identifier from `available` that was passed in. - `files` (`Array`) - An `Array` of associated filenames for the ID. - `architecture` (`String`) - The name of the architecture for the files in `files`. @@ -82,7 +82,7 @@ The data returned by this module follows a pretty specific structure. Here's a d - `security` (`Object`): - `isSecurity` (Boolean) - `true` if the release is a security release, `false` if it's not. - `support` (`Object`) - An`Object`to provide information about the project's support of the given release line, including dates, status, LTS codename, and LTS version info: - - `codename` (`String`) - **Only present if applicable to the given release.** A `String` of the LTS release line's codename. + - `codename` (`String`) - **Only present if applicable to the given release.** A `String` of the LTS release line's codename. - `lts` (`Object`) - **Only present if the release line has an LTS date in schedule.json or a release flagged as LTS in index.json.** An`Object`that provides LTS versioning information. - `newest` (`String`) - The newest release in the release line that is LTS. _Usually_, this will be the latest release in the line. - `oldest` (`String`) - **Only present if applicable to the given release.** The oldest (first) release in the release line that was flagged as LTS. @@ -100,4 +100,4 @@ The data returned by this module follows a pretty specific structure. Here's a d ### Example output -You can find an example of the output [here](https://gist.github.com/bnb/df2cd4329e9f402bbf8dc9bf8364aa34). It's big. Like, really big. GitHub will struggle to render it. It's probably worth viewing [raw](https://gist.githubusercontent.com/bnb/df2cd4329e9f402bbf8dc9bf8364aa34/raw/2602ad0738bb9b8d0f41c3ad110e8f8a58f12197/data.json) or curl'ing the raw output. \ No newline at end of file +You can find an example of the output [here](https://gist.github.com/bnb/df2cd4329e9f402bbf8dc9bf8364aa34). It's big. Like, really big. GitHub will struggle to render it. It's probably worth viewing [raw](https://gist.githubusercontent.com/bnb/df2cd4329e9f402bbf8dc9bf8364aa34/raw/2602ad0738bb9b8d0f41c3ad110e8f8a58f12197/data.json) or curl'ing the raw output. diff --git a/core/biome.json b/core/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/core/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/core/index.js b/core/index.js index eda4a80b..34e08813 100644 --- a/core/index.js +++ b/core/index.js @@ -24,6 +24,7 @@ async function core(options) { const versionSemver = semver.coerce(versions[version].version, { includePrerelease: true, }); + const name = versionSemver.major !== 0 ? `v${versionSemver.major}` @@ -80,10 +81,12 @@ async function core(options) { // TODO: parse versions[version].files and convert them to URLs that can be directly accessed data[name].releases[`v${versionSemver.version}`].files.available = versions[version].files ?? undefined; + const availableShorthand = data[name].releases[`v${versionSemver.version}`].files.available; // since we're going to be writing this a lot for assignments, it's nice to have shorthand for readability data[name].releases[`v${versionSemver.version}`].files.links = {}; + const linksShorthand = data[name].releases[`v${versionSemver.version}`].files.links; // since we're going to be writing this a lot for assignments, it's nice to have shorthand for readability @@ -92,6 +95,7 @@ async function core(options) { data[name].releases[`v${versionSemver.version}`].files.available[ filename ]; + const parsedFile = parsefiles(id, versionSemver.version); if (!linksShorthand[parsedFile.type]) { @@ -136,6 +140,7 @@ async function core(options) { schedule[name]?.maintenance ?? undefined; data[name].support.phases.dates.end = schedule[name]?.end ?? undefined; } + data[name].support.phases.current = determineCurrentReleasePhase(now, data[name].support.phases.dates) ?? {}; @@ -247,6 +252,7 @@ function determineCurrentReleasePhase(now, dates = {}) { // where DATE is your date value function isInPast(number) { const sign = Math.sign(number); + if (sign === -1 || sign === 0) { return true; } diff --git a/core/package.json b/core/package.json index 357224e3..89e63894 100644 --- a/core/package.json +++ b/core/package.json @@ -4,8 +4,8 @@ "description": "nodevu core API: comprehensive node.js version tooling", "main": "index.js", "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test", "test:update": "npm run test:update:static && npm run test:update:now", "test:update:static": "node ./util/dev/updateStaticData.js", @@ -27,7 +27,6 @@ "semver": "^7.6.3" }, "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2", "undici": "^6.20.1" }, diff --git a/core/test/README.md b/core/test/README.md index 1cbd6431..6c82d5f1 100644 --- a/core/test/README.md +++ b/core/test/README.md @@ -1,3 +1,3 @@ # @nodevu/core tests -This directory contains the tests for the @nodevu/core package. They're split into individual files because of how Node.js's test runner works, loggging every file into its own "test". \ No newline at end of file +This directory contains the tests for the @nodevu/core package. They're split into individual files because of how Node.js's test runner works, loggging every file into its own "test". diff --git a/core/test/alpha.js b/core/test/alpha.js index 9d51f0fe..59e8d74d 100644 --- a/core/test/alpha.js +++ b/core/test/alpha.js @@ -77,6 +77,7 @@ describe('check to make sure that Alpha releases work as expected', async () => it('should have correct files for an Alpha release', async () => { const data = await nodevu({ fetch: mockFetch }); + const { linux, source } = data.v27.releases['v27.0.0-alpha.1.0.0'].files.links; diff --git a/core/test/combos.js b/core/test/combos.js index 73fd35a9..b51653c4 100644 --- a/core/test/combos.js +++ b/core/test/combos.js @@ -27,6 +27,7 @@ function check(data) { } const staticNow = require('./data/static/now.json'); + const now = JSON.parse(staticNow); describe('check to make sure that combining options works as expected', async () => { @@ -42,6 +43,7 @@ describe('check to make sure that combining options works as expected', async () const urls = { index: 'https://bnb.im/dist/index.json', }; + const staticData = await nodevu({ now, urls }); check(staticData); @@ -51,6 +53,7 @@ describe('check to make sure that combining options works as expected', async () const urls = { schedule: 'https://bnb.im/dist/schedule.json', }; + const staticData = await nodevu({ now, urls }); check(staticData); @@ -61,6 +64,7 @@ describe('check to make sure that combining options works as expected', async () index: 'https://bnb.im/dist/index.json', schedule: 'https://bnb.im/dist/schedule.json', }; + const staticData = await nodevu({ now, urls }); check(staticData); diff --git a/core/test/dynamic-values.js b/core/test/dynamic-values.js index a7533f96..f4875c96 100644 --- a/core/test/dynamic-values.js +++ b/core/test/dynamic-values.js @@ -5,6 +5,7 @@ const { describe, it, beforeEach } = require('node:test'); const beforeEachTemplate = require('../util/dev/beforeEachTemplate'); const staticNow = require('./data/static/now.json'); + const now = JSON.parse(staticNow); // this is a set of tests that only test against static data effectively frozen at a point in time. diff --git a/core/test/errors.js b/core/test/errors.js index fca5c0a5..2bb35e90 100644 --- a/core/test/errors.js +++ b/core/test/errors.js @@ -5,6 +5,7 @@ const { describe, it } = require('node:test'); describe('errors while building the data', async () => { it('should reject when index.json has a version that cannot be parsed', async () => { const index = [{ version: 'not-a-version', files: [] }]; + const fetch = async (url) => Response.json(url.endsWith('schedule.json') ? {} : index); diff --git a/core/test/lts.js b/core/test/lts.js index 98930882..c0d5ccff 100644 --- a/core/test/lts.js +++ b/core/test/lts.js @@ -35,6 +35,7 @@ describe('LTS data when schedule.json and index.json disagree', async () => { release('v27.0.0', '2027-04-20', false), ], ); + assert.deepStrictEqual(data.v27.support.lts, { newest: '27.2.0', oldest: '27.1.0', @@ -50,6 +51,7 @@ describe('LTS data when schedule.json and index.json disagree', async () => { release('v27.0.0', '2027-04-20', 'Somename'), ], ); + assert.deepStrictEqual(data.v27.support.lts, { oldest: '27.0.0' }); }); @@ -81,6 +83,7 @@ describe('LTS data when schedule.json and index.json disagree', async () => { release('v22.10.0', '2024-10-16', false), ], ); + assert.deepStrictEqual(data.v23.support.lts, undefined); assert.deepStrictEqual(data.v22.support.lts, { newest: '22.11.0', diff --git a/core/test/parseOptions.js b/core/test/parseOptions.js index 79359a6b..10ad2241 100644 --- a/core/test/parseOptions.js +++ b/core/test/parseOptions.js @@ -52,6 +52,7 @@ describe('the parseOptions module should still work when defaults are changed', const defaultParsedOptions = parseOptions({ urls: { index: 'https://example.com' }, }); + deepStrictEqual(defaultParsedOptions.urls.index, 'https://example.com'); }); @@ -59,6 +60,7 @@ describe('the parseOptions module should still work when defaults are changed', const defaultParsedOptions = parseOptions({ urls: { schedule: 'https://example.com' }, }); + deepStrictEqual(defaultParsedOptions.urls.schedule, 'https://example.com'); }); }); diff --git a/core/test/schedule.js b/core/test/schedule.js index 944e5608..b0491ddc 100644 --- a/core/test/schedule.js +++ b/core/test/schedule.js @@ -1,4 +1,4 @@ -const { deepStrictEqual } = require('node:assert'); +const { match } = require('node:assert'); const { describe, it } = require('node:test'); const { fetch: undiciFetch } = require('undici'); const schedule = require('../util/prod/schedule'); @@ -8,8 +8,12 @@ const optionsParser = require('../util/prod/optionsParser'); function check(data) { for (const metadata of Object.values(data)) { for (const [metadataKey, metadataValue] of Object.entries(metadata)) { - deepStrictEqual(typeof metadataKey, 'string'); - deepStrictEqual(typeof metadataValue, 'string'); + // every value is a date except codename, which is empty for + // release lines that never had LTS + const valuePattern = + metadataKey === 'codename' ? /^([A-Z][a-z]+)?$/ : /^\d{4}-\d{2}-\d{2}$/; + + match(metadataValue, valuePattern); } } } @@ -47,6 +51,7 @@ describe('schedule should work with optionsParser', async () => { const parsedOptions = optionsParser({ fetch: undiciFetch, }); + const data = await schedule(parsedOptions); check(data); }); diff --git a/core/test/static-values.js b/core/test/static-values.js index 79e0ec32..1ebf4e1b 100644 --- a/core/test/static-values.js +++ b/core/test/static-values.js @@ -109,6 +109,7 @@ describe('check that we get the values we expect from values that should not eve it('should have correct support values for a security ', async () => { const staticData = await nodevu(); + const v12AllSecurityReleases = [ '12.22.11', '12.22.9', @@ -126,6 +127,7 @@ describe('check that we get the values we expect from values that should not eve '12.14.0', '12.8.1', ]; + assert.deepStrictEqual(staticData.v12.security.all, v12AllSecurityReleases); assert.deepStrictEqual(staticData.v12.security.newest, '12.22.11'); assert.deepStrictEqual(staticData.v12.security.oldest, '12.8.1'); diff --git a/core/test/unique-source.js b/core/test/unique-source.js index f982df02..90b978b7 100644 --- a/core/test/unique-source.js +++ b/core/test/unique-source.js @@ -12,6 +12,7 @@ describe('check to make sure that changing sources works as expected', async () const urls = { index: 'https://bnb.im/dist/index.json', }; + const staticData = await nodevu({ urls }); deepStrictEqual( staticData.v17.releases['v17.0.0'].dependencies.npm, @@ -26,6 +27,7 @@ describe('check to make sure that changing sources works as expected', async () const urls = { schedule: 'https://bnb.im/dist/schedule.json', }; + const staticData = await nodevu({ urls }); deepStrictEqual( staticData.v17.releases['v17.0.0'].dependencies.v8, diff --git a/core/test/versions.js b/core/test/versions.js index c7cf4d35..8f38b4b0 100644 --- a/core/test/versions.js +++ b/core/test/versions.js @@ -1,4 +1,4 @@ -const { deepStrictEqual } = require('node:assert'); +const { deepStrictEqual, match } = require('node:assert'); const { describe, it } = require('node:test'); const { fetch: undiciFetch } = require('undici'); const versions = require('../util/prod/versions'); @@ -6,17 +6,22 @@ const optionsParser = require('../util/prod/optionsParser'); // checks that verify the result of data returned function check(data) { - deepStrictEqual(typeof data[0].version, 'string'); - deepStrictEqual(typeof data[0].date, 'string'); + match(data[0].version, /^v\d+\.\d+\.\d+$/); + match(data[0].date, /^\d{4}-\d{2}-\d{2}$/); deepStrictEqual(Array.isArray(data[0].files), true); - deepStrictEqual(typeof data[0].npm, 'string'); - deepStrictEqual(typeof data[0].v8, 'string'); - deepStrictEqual(typeof data[0].uv, 'string'); - deepStrictEqual(typeof data[0].zlib, 'string'); - deepStrictEqual(typeof data[0].openssl, 'string'); - deepStrictEqual(typeof data[0].modules, 'string'); - deepStrictEqual(typeof data[0].lts, 'boolean'); - deepStrictEqual(typeof data[0].security, 'boolean'); + match(data[0].npm, /^\d+\.\d+\.\d+/); + match(data[0].v8, /^\d+\.\d+\.\d+/); + match(data[0].uv, /^\d+\.\d+\.\d+/); + match(data[0].zlib, /^\d+\.\d+/); + match(data[0].openssl, /^\d+\.\d+\.\d+/); + match(data[0].modules, /^\d+$/); + // index.json has false for non-LTS releases and the line's codename for LTS + // ones + deepStrictEqual( + data[0].lts === false || /^[A-Z][a-z]+$/.test(data[0].lts), + true, + ); + deepStrictEqual([true, false].includes(data[0].security), true); } // set up options object that would normally be passed to the module @@ -51,6 +56,7 @@ describe('versions should work with optionsParser', async () => { const parsedOptions = optionsParser({ fetch: undiciFetch, }); + const data = await versions(parsedOptions); check(data); }); diff --git a/core/util/dev/beforeEachTemplate.js b/core/util/dev/beforeEachTemplate.js index a209d8a5..49e0b4a0 100644 --- a/core/util/dev/beforeEachTemplate.js +++ b/core/util/dev/beforeEachTemplate.js @@ -30,6 +30,7 @@ function beforeEachTemplate() { const defaultScheduleMock = mockAgent.get( 'https://raw.githubusercontent.com', ); + defaultScheduleMock .intercept({ path: '/nodejs/Release/master/schedule.json' }) .reply(200, staticSchedule); diff --git a/core/util/dev/updateStaticData.js b/core/util/dev/updateStaticData.js index 6df623bb..d6aa1311 100644 --- a/core/util/dev/updateStaticData.js +++ b/core/util/dev/updateStaticData.js @@ -3,9 +3,11 @@ const write = require('./write'); async function update() { const rawIndex = await fetch('https://nodejs.org/dist/index.json'); + const rawSchedule = await fetch( 'https://raw.githubusercontent.com/nodejs/Release/master/schedule.json', ); + const index = await rawIndex.json(); const schedule = await rawSchedule.json(); diff --git a/fetchindex/README.md b/fetchindex/README.md index 708095f5..cfa0b3cc 100644 --- a/fetchindex/README.md +++ b/fetchindex/README.md @@ -5,7 +5,7 @@ A tool that fetches the /dist/index.json file from the Node.js website. ## Usage ```js -const fetchindex = require('@nodevu/fetchindex') +const fetchindex = require('@nodevu/fetchindex'); const options = { fetch: globalThis.fetch, // use your own fetch if you want! @@ -18,9 +18,7 @@ const index = await fetchindex(options); // returns a huge JSON object ``` ## API + - `fetchindex(options)` - - `options` (object): Options object. - - `fetch` (function): Fetch function. Default: `globalThis.fetch`. - - `urls` (object): URLs object. - - `index` (string): URL to fetch the index.json file from. Default: `'https://nodejs.org/dist/index.json'`. - - Returns: Promise that resolves with the fetched `index.json` object. \ No newline at end of file + - `options` (object): Options object. - `fetch` (function): Fetch function. Default: `globalThis.fetch`. - `urls` (object): URLs object. - `index` (string): URL to fetch the index.json file from. Default: `'https://nodejs.org/dist/index.json'`. + - Returns: Promise that resolves with the fetched `index.json` object. diff --git a/fetchindex/biome.json b/fetchindex/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/fetchindex/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/fetchindex/package.json b/fetchindex/package.json index 234da0d0..c0d8ef5e 100644 --- a/fetchindex/package.json +++ b/fetchindex/package.json @@ -8,8 +8,8 @@ "LICENSE" ], "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test", "coverage": "c8 node --test", "updates:check": "npx npm-check-updates", @@ -30,7 +30,6 @@ }, "homepage": "https://github.com/cutenode/nodevu#readme", "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2", "luxon": "^3.5.0", "undici": "^6.20.1" diff --git a/fetchindex/test/test.js b/fetchindex/test/test.js index 82abd411..325421f3 100644 --- a/fetchindex/test/test.js +++ b/fetchindex/test/test.js @@ -1,4 +1,4 @@ -const { deepStrictEqual } = require('node:assert'); +const { deepStrictEqual, match } = require('node:assert'); const { describe, it } = require('node:test'); const { fetch: undiciFetch } = require('undici'); const fetchindex = require('../index'); @@ -6,17 +6,22 @@ const optionsParser = require('../../core/util/prod/optionsParser'); // checks that verify the result of data returned function check(data) { - deepStrictEqual(typeof data[0].version, 'string'); - deepStrictEqual(typeof data[0].date, 'string'); + match(data[0].version, /^v\d+\.\d+\.\d+$/); + match(data[0].date, /^\d{4}-\d{2}-\d{2}$/); deepStrictEqual(Array.isArray(data[0].files), true); - deepStrictEqual(typeof data[0].npm, 'string'); - deepStrictEqual(typeof data[0].v8, 'string'); - deepStrictEqual(typeof data[0].uv, 'string'); - deepStrictEqual(typeof data[0].zlib, 'string'); - deepStrictEqual(typeof data[0].openssl, 'string'); - deepStrictEqual(typeof data[0].modules, 'string'); - deepStrictEqual(typeof data[0].lts, 'boolean'); - deepStrictEqual(typeof data[0].security, 'boolean'); + match(data[0].npm, /^\d+\.\d+\.\d+/); + match(data[0].v8, /^\d+\.\d+\.\d+/); + match(data[0].uv, /^\d+\.\d+\.\d+/); + match(data[0].zlib, /^\d+\.\d+/); + match(data[0].openssl, /^\d+\.\d+\.\d+/); + match(data[0].modules, /^\d+$/); + // index.json has false for non-LTS releases and the line's codename for LTS + // ones + deepStrictEqual( + data[0].lts === false || /^[A-Z][a-z]+$/.test(data[0].lts), + true, + ); + deepStrictEqual([true, false].includes(data[0].security), true); } // set up options object that would normally be passed to the module @@ -51,6 +56,7 @@ describe('versions should work with optionsParser', async () => { const parsedOptions = optionsParser({ fetch: undiciFetch, }); + const data = await fetchindex(parsedOptions); check(data); }); diff --git a/newest/README.md b/newest/README.md index 97012a27..48eea585 100644 --- a/newest/README.md +++ b/newest/README.md @@ -5,17 +5,17 @@ A module that returns newest LTS or Security release of a given Node.js release ## Usage ```js -const { newest } = require('@nodevu/newest') +const { newest } = require('@nodevu/newest'); -const newestLts = await newest('v16', 'lts') -const newestSecurity = await newest('v16', 'security') +const newestLts = await newest('v16', 'lts'); +const newestSecurity = await newest('v16', 'security'); ``` ```js -const { lts, security } = require('@nodevu/newest') +const { lts, security } = require('@nodevu/newest'); -const newestLts = await lts('v16') -const newestSecurity = await security('v16') +const newestLts = await lts('v16'); +const newestSecurity = await security('v16'); ``` ## API diff --git a/newest/biome.json b/newest/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/newest/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/newest/index.js b/newest/index.js index fbd33b3d..f7210302 100644 --- a/newest/index.js +++ b/newest/index.js @@ -24,5 +24,7 @@ async function security(name, options) { } module.exports.newest = newest; + module.exports.lts = lts; + module.exports.security = security; diff --git a/newest/package.json b/newest/package.json index 02f00adb..b8f4a2bc 100644 --- a/newest/package.json +++ b/newest/package.json @@ -8,8 +8,8 @@ "LICENSE" ], "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test", "coverage": "c8 node --test", "updates:check": "npx npm-check-updates", @@ -33,7 +33,6 @@ "@nodevu/core": "^1.0.0" }, "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2" } } diff --git a/oldest/README.md b/oldest/README.md index 1c91494b..11a527a6 100644 --- a/oldest/README.md +++ b/oldest/README.md @@ -5,17 +5,17 @@ A module that returns oldest LTS or Security release of a given Node.js release ## Usage ```js -const { oldest } = require('@nodevu/oldest') +const { oldest } = require('@nodevu/oldest'); -const oldestLts = await oldest('v16', 'lts') -const oldestSecurity = await oldest('v16', 'security') +const oldestLts = await oldest('v16', 'lts'); +const oldestSecurity = await oldest('v16', 'security'); ``` ```js -const { lts, security } = require('@nodevu/oldest') +const { lts, security } = require('@nodevu/oldest'); -const oldestLts = await lts('v16') -const oldestSecurity = await security('v16') +const oldestLts = await lts('v16'); +const oldestSecurity = await security('v16'); ``` ## API diff --git a/oldest/biome.json b/oldest/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/oldest/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/oldest/index.js b/oldest/index.js index 36435eaa..186c34a4 100644 --- a/oldest/index.js +++ b/oldest/index.js @@ -24,5 +24,7 @@ async function security(name, options) { } module.exports.oldest = oldest; + module.exports.lts = lts; + module.exports.security = security; diff --git a/oldest/package.json b/oldest/package.json index 1e87f651..a5b71efe 100644 --- a/oldest/package.json +++ b/oldest/package.json @@ -8,8 +8,8 @@ "LICENSE" ], "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test", "coverage": "c8 node --test", "updates:check": "npx npm-check-updates", @@ -33,7 +33,6 @@ "@nodevu/core": "^1.0.0" }, "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2" } } diff --git a/opt/README.md b/opt/README.md index 9e491f57..7363ce71 100644 --- a/opt/README.md +++ b/opt/README.md @@ -5,13 +5,13 @@ A tool that fetches the /dist/index.json file from the Node.js website. ## Usage ```js -const opt = require('@nodevu/opt') +const opt = require('@nodevu/opt'); const parsedOptions = await opt(); // returns a default set of options ``` ```js -const opt = require('@nodevu/opt') +const opt = require('@nodevu/opt'); const options = { fetch: globalThis.fetch, // use your own fetch if you want! @@ -24,10 +24,7 @@ const parsedOptions = await parser(options); // returns a huge JSON object ``` ## API + - `opt(options)` - - `options` (`Object`, optional): Options object. - - `fetch` (`Function`, optional): Fetch function. Default: `globalThis.fetch`. - - `urls` (`Object`, optional): URLs object. - - `index` (`String`, optional): URL to fetch the index.json file from. Default: `'https://nodejs.org/dist/index.json'`. - - `schedule` (`String`, optional): URL to fetch the schedule.json file from. Default: `'https://raw.githubusercontent.com/nodejs/Release/master/schedule.json'`. - - Returns: `Object` with a set of default options in the structure required by `@nodevu/core`. \ No newline at end of file + - `options` (`Object`, optional): Options object. - `fetch` (`Function`, optional): Fetch function. Default: `globalThis.fetch`. - `urls` (`Object`, optional): URLs object. - `index` (`String`, optional): URL to fetch the index.json file from. Default: `'https://nodejs.org/dist/index.json'`. - `schedule` (`String`, optional): URL to fetch the schedule.json file from. Default: `'https://raw.githubusercontent.com/nodejs/Release/master/schedule.json'`. + - Returns: `Object` with a set of default options in the structure required by `@nodevu/core`. diff --git a/opt/biome.json b/opt/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/opt/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/opt/package.json b/opt/package.json index cabfc276..5d6de3d3 100644 --- a/opt/package.json +++ b/opt/package.json @@ -8,8 +8,8 @@ "LICENSE" ], "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test", "coverage": "c8 node --test", "updates:check": "npx npm-check-updates", @@ -30,7 +30,6 @@ }, "homepage": "https://github.com/cutenode/nodevu#readme", "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2", "luxon": "^3.5.0", "undici": "^6.20.1" diff --git a/opt/test/test.js b/opt/test/test.js index 0b975153..53977895 100644 --- a/opt/test/test.js +++ b/opt/test/test.js @@ -61,6 +61,7 @@ describe('the parseOptions module should still work when defaults are changed', const defaultParsedOptions = opt({ urls: { index: 'https://example.com' }, }); + deepStrictEqual(defaultParsedOptions.urls.index, 'https://example.com'); }); @@ -68,6 +69,7 @@ describe('the parseOptions module should still work when defaults are changed', const defaultParsedOptions = opt({ urls: { schedule: 'https://example.com' }, }); + deepStrictEqual(defaultParsedOptions.urls.schedule, 'https://example.com'); }); }); diff --git a/package.json b/package.json index f5df03a5..c4c9e4a4 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "comprehensive node.js version tooling", "main": "index.js", "scripts": { - "lint": "npm run lint --ws", - "lint:write": "npm run lint:write --ws", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "coverage": "npm run coverage --ws", "test": "npm run test --ws", "updates": "npm run updates --ws" @@ -35,5 +35,10 @@ "translate", "fetchindex", "opt" - ] + ], + "devDependencies": { + "@oxlint/plugins": "1.89.0", + "oxfmt": "0.74.0", + "oxlint": "1.89.0" + } } diff --git a/parsefiles/README.md b/parsefiles/README.md index 1dafca52..4eaea6c3 100644 --- a/parsefiles/README.md +++ b/parsefiles/README.md @@ -5,15 +5,35 @@ a utility to parse Node.js's files information from distribution metadata into s ## Usage ```js -const parsefiles = require('@nodevu/parsefiles') - -const version = '17.6.0' -const files = ["aix-ppc64","headers","linux-arm64","linux-armv7l","linux-ppc64le","linux-s390x","linux-x64","osx-arm64-tar","osx-x64-pkg","osx-x64-tar","src","win-x64-7z","win-x64-exe","win-x64-msi","win-x64-zip","win-x86-7z","win-x86-exe","win-x86-msi","win-x86-zip"] - -files.forEach(file => { - const data = parsefiles(file, version) - console.log(data) -}) +const parsefiles = require('@nodevu/parsefiles'); + +const version = '17.6.0'; +const files = [ + 'aix-ppc64', + 'headers', + 'linux-arm64', + 'linux-armv7l', + 'linux-ppc64le', + 'linux-s390x', + 'linux-x64', + 'osx-arm64-tar', + 'osx-x64-pkg', + 'osx-x64-tar', + 'src', + 'win-x64-7z', + 'win-x64-exe', + 'win-x64-msi', + 'win-x64-zip', + 'win-x86-7z', + 'win-x86-exe', + 'win-x86-msi', + 'win-x86-zip', +]; + +files.forEach((file) => { + const data = parsefiles(file, version); + console.log(data); +}); ``` ## The Data @@ -23,4 +43,4 @@ When run, `@nodevu/parsefiles` will return an object with the following properti - `id` (String) - the identifier (file) that was passed in. Examples: `aix-ppc64`, `linux-x64`, `osx-x64-pkg`, `osx-arm64-tar`, `win-x64-zip`, `src`, `headers`. - `files` (Array) - an array of filenames that _should_ exist for the release. There are often more than one. - `type` (String) - indicates either the operating system, or that the files are source/headers, parsed out of the `id`. Examples: `linux`, `macos`, `windows`, `aix`, `sunos`, `source`, `headers`. -- `architecture` (String) - a string that represents the architecture of the files. Examples: `x86`, `x64`, `arm64`, `armv7l`, `armv6l`, `power`, `z` \ No newline at end of file +- `architecture` (String) - a string that represents the architecture of the files. Examples: `x86`, `x64`, `arm64`, `armv7l`, `armv6l`, `power`, `z` diff --git a/parsefiles/biome.json b/parsefiles/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/parsefiles/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/parsefiles/index.js b/parsefiles/index.js index a9d067d3..fccffb5d 100644 --- a/parsefiles/index.js +++ b/parsefiles/index.js @@ -23,9 +23,11 @@ function parseFilename(file, version) { } else if (info.files[0].includes('osx-')) { // macOS filenames, but with architectures. Different than the above. const basemacOSString = `node-v${version}-darwin-`; + if (info.files[0].includes('arm64')) { info.files[0] = basemacOSString.concat('arm64.tar.gz'); } + if (info.files[0].includes('x64')) { info.files[0] = basemacOSString.concat('x64.tar.gz'); } diff --git a/parsefiles/package.json b/parsefiles/package.json index 78db837e..5a6d2c84 100644 --- a/parsefiles/package.json +++ b/parsefiles/package.json @@ -4,8 +4,8 @@ "description": "parse the files identifiers from Node.js", "main": "index.js", "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test", "coverage": "c8 node --test", "updates:check": "npx npm-check-updates", @@ -24,7 +24,6 @@ "author": "Tierney Cyren ", "license": "MIT", "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2" }, "repository": { diff --git a/ranges/README.md b/ranges/README.md index fca1a5ab..cfa0986e 100644 --- a/ranges/README.md +++ b/ranges/README.md @@ -5,12 +5,12 @@ a module that provides information about ranges of Node.js versions. ## Usage ```js -const ranges = require('@nodevu/ranges') +const ranges = require('@nodevu/ranges'); -async function generateData () { - const data = await ranges() +async function generateData() { + const data = await ranges(); - console.log(data) // will be a chunky object! + console.log(data); // will be a chunky object! } ``` @@ -22,7 +22,7 @@ async function generateData () { - `options` (Object, optional): passed straight to [@nodevu/core](../core/), so you can set things like a custom `fetch` or `now`. ```js -const data = await ranges('alpha', { now: '2026-12-15' }) +const data = await ranges('alpha', { now: '2026-12-15' }); ``` ## Structure @@ -72,4 +72,4 @@ This module will return a medium-sized object with the following structure, with - eol: - versions (Array): An array of versions. - newest (String): The newest version. - - oldest (String): The oldest version. \ No newline at end of file + - oldest (String): The oldest version. diff --git a/ranges/biome.json b/ranges/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/ranges/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/ranges/index.js b/ranges/index.js index 5062fc6b..cf45e17f 100644 --- a/ranges/index.js +++ b/ranges/index.js @@ -69,6 +69,9 @@ const defaultRangesObject = { async function generateRanges(filter, options) { let localFilter = filter; + + // filter is untyped public input, so typeof is how it gets validated here + // oxlint-disable-next-line anti-slop/no-runtime-typeof if (typeof localFilter === 'string') { // check to make sure that the filter is valid. if it's not, yeet. if (!aliases.includes(localFilter)) { @@ -87,6 +90,8 @@ async function generateRanges(filter, options) { } } else if (localFilter) { throw new Error( + // names the wrong type in the error, nothing is narrowed + // oxlint-disable-next-line anti-slop/no-runtime-typeof `Unknown value passed as a filter. The passed value: '${localFilter}', with a type of '${typeof localFilter}'. Make sure you are passing a string (or an Array of strings) with valid value(s).`, ); } else { @@ -219,6 +224,7 @@ async function generateRanges(filter, options) { } } } + // define 'eol' data if ( localFilter === 'eol' || diff --git a/ranges/package.json b/ranges/package.json index 9de47bef..b3a4ee1b 100644 --- a/ranges/package.json +++ b/ranges/package.json @@ -8,8 +8,8 @@ "LICENSE" ], "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test test/*.test.js", "coverage": "c8 node --test test/*.test.js", "updates:check": "npx npm-check-updates", @@ -30,7 +30,6 @@ "@nodevu/core": "^1.0.0" }, "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2" } } diff --git a/ranges/test/default.test.js b/ranges/test/default.test.js index cdef3f49..2c98b814 100644 --- a/ranges/test/default.test.js +++ b/ranges/test/default.test.js @@ -1,16 +1,17 @@ const assert = require('node:assert'); const ranges = require('../index'); -const { at } = require('./fixtures'); +const { at, versionPattern } = require('./fixtures'); const { describe, it } = require('node:test'); describe('data should exist as it is defined', async () => { it('versions should have correct types on every property', async () => { const data = await ranges(undefined, at()); + for (const key in data) { assert.deepStrictEqual(Array.isArray(data[key].versions), true); assert.deepStrictEqual(data[key].versions.length > 0, true); assert.deepStrictEqual( - data[key].versions.every((version) => typeof version === 'string'), + data[key].versions.every((version) => versionPattern.test(version)), true, ); } @@ -18,9 +19,10 @@ describe('data should exist as it is defined', async () => { it('both newest and oldest on every property should be the correct type', async () => { const data = await ranges(undefined, at()); + for (const key in data) { - assert.deepStrictEqual(typeof data[key].newest, 'string'); - assert.deepStrictEqual(typeof data[key].oldest, 'string'); + assert.match(data[key].newest, versionPattern); + assert.match(data[key].oldest, versionPattern); } }); }); diff --git a/ranges/test/emptyFields.test.js b/ranges/test/emptyFields.test.js index 582295e0..285079dd 100644 --- a/ranges/test/emptyFields.test.js +++ b/ranges/test/emptyFields.test.js @@ -53,12 +53,14 @@ describe('fields without a value should be undefined, not "undefined"', async () lts, files: ['linux-x64', 'src'], }); + const index = [ release('v99.1.0', '2099-11-01'), release('v99.0.0', '2099-10-25', 'Placeholder'), release('v98.1.0', '2098-11-01'), release('v98.0.0', '2098-10-25', 'Placeholder'), ]; + const schedule = { v98: { start: '2098-04-20', @@ -75,6 +77,7 @@ describe('fields without a value should be undefined, not "undefined"', async () codename: '', }, }; + // round-tripping through JSON to check what ends up in the output const data = JSON.parse( JSON.stringify( @@ -112,6 +115,7 @@ describe('fields without a value should be undefined, not "undefined"', async () snapshotNow, ]) { const data = await ranges(undefined, at(now)); + for (const range in data) { for (const field in data[range]) { deepStrictEqual( diff --git a/ranges/test/fixtures.js b/ranges/test/fixtures.js index 1b2a0945..927660eb 100644 --- a/ranges/test/fixtures.js +++ b/ranges/test/fixtures.js @@ -7,6 +7,9 @@ const schedule = require('../../core/test/data/static/schedule.json'); // empty. const now = '2025-09-01'; +// every version in index.json, like v22.19.0 +const versionPattern = /^v\d+\.\d+\.\d+$/; + // returns options for `ranges` that serve `data` (core's index.json and // schedule.json snapshots by default) instead of fetching them, only including // releases that would be out by `now`, like the real index.json. any other URL @@ -28,4 +31,4 @@ function at(date = now, data = { index, schedule }) { }; } -module.exports = { at }; +module.exports = { at, versionPattern }; diff --git a/ranges/test/known.test.js b/ranges/test/known.test.js index 6c008aec..ca883f01 100644 --- a/ranges/test/known.test.js +++ b/ranges/test/known.test.js @@ -21,6 +21,7 @@ describe('check some values we know should exist', async () => { // lands in the wrong range fails it('should put each release line in the right range', async () => { const data = await ranges(undefined, at()); + const summary = Object.fromEntries( Object.entries(data).map(([alias, { versions, ...range }]) => [ alias, diff --git a/ranges/test/matrixAlias.test.js b/ranges/test/matrixAlias.test.js index 920e311b..945db63f 100644 --- a/ranges/test/matrixAlias.test.js +++ b/ranges/test/matrixAlias.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert'); const ranges = require('../index'); -const { at } = require('./fixtures'); +const { at, versionPattern } = require('./fixtures'); const { describe, it } = require('node:test'); // this is a non-exhaustive list of sets of aliases. @@ -14,8 +14,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/latest'].versions), true); assert.deepStrictEqual(data['lts/latest'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/latest'].versions.every( - (version) => typeof version === 'string', + data['lts/latest'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -27,8 +27,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -44,8 +44,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/latest'].versions), true); assert.deepStrictEqual(data['lts/latest'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/latest'].versions.every( - (version) => typeof version === 'string', + data['lts/latest'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -54,8 +54,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -74,8 +74,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -84,8 +84,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -107,8 +107,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -117,8 +117,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -127,8 +127,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/latest'].versions), true); assert.deepStrictEqual(data['lts/latest'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/latest'].versions.every( - (version) => typeof version === 'string', + data['lts/latest'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -148,8 +148,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -161,8 +161,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -184,7 +184,7 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data.current.versions), true); assert.deepStrictEqual(data.current.versions.length > 0, true); assert.deepStrictEqual( - data.current.versions.every((version) => typeof version === 'string'), + data.current.versions.every((version) => versionPattern.test(version)), true, ); @@ -192,8 +192,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -205,8 +205,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -227,7 +227,7 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data.current.versions), true); assert.deepStrictEqual(data.current.versions.length > 0, true); assert.deepStrictEqual( - data.current.versions.every((version) => typeof version === 'string'), + data.current.versions.every((version) => versionPattern.test(version)), true, ); @@ -235,8 +235,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -248,8 +248,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -270,7 +270,7 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data.current.versions), true); assert.deepStrictEqual(data.current.versions.length > 0, true); assert.deepStrictEqual( - data.current.versions.every((version) => typeof version === 'string'), + data.current.versions.every((version) => versionPattern.test(version)), true, ); @@ -278,8 +278,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -291,8 +291,8 @@ describe('data should exist as it is defined when calling multiple aliases', asy ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); diff --git a/ranges/test/singleAlias.test.js b/ranges/test/singleAlias.test.js index 980310bd..af95eed2 100644 --- a/ranges/test/singleAlias.test.js +++ b/ranges/test/singleAlias.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert'); const ranges = require('../index'); -const { at } = require('./fixtures'); +const { at, versionPattern } = require('./fixtures'); const { describe, it } = require('node:test'); describe('data should exist as it is defined when calling a single alias', async () => { @@ -9,7 +9,7 @@ describe('data should exist as it is defined when calling a single alias', async assert.deepStrictEqual(Array.isArray(data.all.versions), true); assert.deepStrictEqual(data.all.versions.length > 0, true); assert.deepStrictEqual( - data.all.versions.every((version) => typeof version === 'string'), + data.all.versions.every((version) => versionPattern.test(version)), true, ); }); @@ -19,7 +19,7 @@ describe('data should exist as it is defined when calling a single alias', async assert.deepStrictEqual(Array.isArray(data.current.versions), true); assert.deepStrictEqual(data.current.versions.length > 0, true); assert.deepStrictEqual( - data.current.versions.every((version) => typeof version === 'string'), + data.current.versions.every((version) => versionPattern.test(version)), true, ); }); @@ -29,8 +29,8 @@ describe('data should exist as it is defined when calling a single alias', async assert.deepStrictEqual(Array.isArray(data['lts/latest'].versions), true); assert.deepStrictEqual(data['lts/latest'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/latest'].versions.every( - (version) => typeof version === 'string', + data['lts/latest'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -45,8 +45,8 @@ describe('data should exist as it is defined when calling a single alias', async assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/active'].versions.every( - (version) => typeof version === 'string', + data['lts/active'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -64,8 +64,8 @@ describe('data should exist as it is defined when calling a single alias', async ); assert.deepStrictEqual(data['lts/maintenance'].versions.length > 0, true); assert.deepStrictEqual( - data['lts/maintenance'].versions.every( - (version) => typeof version === 'string', + data['lts/maintenance'].versions.every((version) => + versionPattern.test(version), ), true, ); @@ -80,7 +80,7 @@ describe('data should exist as it is defined when calling a single alias', async assert.deepStrictEqual(Array.isArray(data.eol.versions), true); assert.deepStrictEqual(data.eol.versions.length > 0, true); assert.deepStrictEqual( - data.eol.versions.every((version) => typeof version === 'string'), + data.eol.versions.every((version) => versionPattern.test(version)), true, ); }); diff --git a/ranges/test/unknownFilter.test.js b/ranges/test/unknownFilter.test.js index bd4b73b8..d68c9c27 100644 --- a/ranges/test/unknownFilter.test.js +++ b/ranges/test/unknownFilter.test.js @@ -7,6 +7,7 @@ describe('failure states for arguments', async () => { it('should throw an error on a string with an unknown value', async () => { try { const data = await ranges('poopin', at()); + if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -19,6 +20,7 @@ describe('failure states for arguments', async () => { it('should throw an error on a string with an unknown value', async () => { try { const data = await ranges('poopin', at()); + if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -31,6 +33,7 @@ describe('failure states for arguments', async () => { it('should throw an error on a single unknown array entry', async () => { try { const data = await ranges(['poopin'], at()); + if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -43,6 +46,7 @@ describe('failure states for arguments', async () => { it('should throw an error on an unknown array entry where multiple entries exist and it is the first entry', async () => { try { const data = await ranges(['poopin', 'lts/active'], at()); + if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -55,6 +59,7 @@ describe('failure states for arguments', async () => { it('should throw an error on an unknown array entry where multiple entries exist and it is the first entry', async () => { try { const data = await ranges(['lts/active', 'poopin'], at()); + if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -67,6 +72,7 @@ describe('failure states for arguments', async () => { it('should throw an error on passing an Object', async () => { try { const data = await ranges({}, at()); + if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( diff --git a/static/README.md b/static/README.md index c43628a3..db16a86f 100644 --- a/static/README.md +++ b/static/README.md @@ -2,7 +2,6 @@ Static nodevu data. Automatically updated on some kind of schedule. - ## Usage ```js @@ -19,7 +18,7 @@ console.log(support) This module has four exports: -- `default` (Object) - the default, full set of data returned by [`@nodevu/core`](https://github.com/nodevu/core)). +- `default` (Object) - the default, full set of data returned by [`@nodevu/core`](https://github.com/nodevu/core)). - `releases` (Object) - only the `releases` properties from the default set of data returned by [`@nodevu/core`](https://github.com/nodevu/core)), following the same heirarchy. - `security` - only the `security` properties from the default set of data returned by [`@nodevu/core`](https://github.com/nodevu/core)), following the same heirarchy. -- `support` - only the `support` properties from the default set of data returned by [`@nodevu/core`](https://github.com/nodevu/core)), following the same heirarchy. \ No newline at end of file +- `support` - only the `support` properties from the default set of data returned by [`@nodevu/core`](https://github.com/nodevu/core)), following the same heirarchy. diff --git a/static/biome.json b/static/biome.json deleted file mode 100644 index 33f05a4d..00000000 --- a/static/biome.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//", - "files": { - "maxSize": 20971520 - } -} diff --git a/static/index.js b/static/index.js index f5783b53..4f5f415c 100644 --- a/static/index.js +++ b/static/index.js @@ -4,6 +4,9 @@ const securityData = require('./data/security.json'); const supportData = require('./data/support.json'); module.exports.default = defaultData; + module.exports.releases = releasesData; + module.exports.security = securityData; + module.exports.support = supportData; diff --git a/static/package.json b/static/package.json index b2b95b1f..ac0860d0 100644 --- a/static/package.json +++ b/static/package.json @@ -9,8 +9,8 @@ "build:releases": "node build/releases.js", "build:security": "node build/security.js", "build:support": "node build/support.js", - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "prepublish": "npm run build", "test": "node --test", "coverage": "c8 node --test", @@ -38,7 +38,6 @@ }, "homepage": "https://github.com/cutenode/nodevu#readme", "devDependencies": { - "@biomejs/biome": "2.5.15", "c8": "^10.1.2" } } diff --git a/static/util/dev/write.js b/static/util/dev/write.js index 37eac730..d11cc93f 100644 --- a/static/util/dev/write.js +++ b/static/util/dev/write.js @@ -4,6 +4,7 @@ const { resolve } = require('node:path'); async function write(path, data) { const outputJSON = JSON.stringify(data, null, 2); let filepath = resolve(path); + // hack to let us run this script from the root of the repo as a npm script with workspaces commands if (filepath.includes('static/static/')) { filepath = filepath.replace('static/static/', 'static/'); diff --git a/tools/oxlint/anti-slop/UPSTREAM.md b/tools/oxlint/anti-slop/UPSTREAM.md new file mode 100644 index 00000000..de4663d0 --- /dev/null +++ b/tools/oxlint/anti-slop/UPSTREAM.md @@ -0,0 +1,11 @@ +# anti-slop + +Source: [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop), commit [`e6676e8d0bf17c678cb45b9dacb2bd6ca8dea53a`](https://github.com/dmmulroy/anti-slop/tree/e6676e8d0bf17c678cb45b9dacb2bd6ca8dea53a/skills/install-anti-slop/assets/anti-slop), copied from `skills/install-anti-slop/assets/anti-slop` by that skill's `scripts/install.mjs`. The commit was matched by the installed skill's folder hash (`89044d21c75a367eac1ddbaf208e650b1a7d5820`), which is the git tree of `skills/install-anti-slop` at that commit. + +Registered in `.oxlintrc.json` as `anti-slop` from `./tools/oxlint/anti-slop/index.ts`. `vendor/eslint-stylistic/UPSTREAM.md` covers the vendored ESLint Stylistic code. + +## local deviations + +- `effect/` isn't copied. nodevu doesn't depend on Effect, so the opt-in Effect plugin was never registered. +- `package.json` (`{"type": "module"}`) is new. nodevu is CommonJS, and without it Node warns about reparsing the plugin as ESM on every lint run. +- `rules/require-readable-spacing.ts` lets consecutive top-level `require` declarations sit together, the same way it already treats imports. diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 00000000..a3876ec0 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,47 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noArrayFilterMapRule } from "./rules/no-array-filter-map.ts"; +import { noReduceAccumulatorCopyRule } from "./rules/no-reduce-accumulator-copy.ts"; +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noModuleMockingRule } from "./rules/no-module-mocking.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; +import { noReflectGetRule } from "./rules/no-reflect-get.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; +import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; +import { requireReadableSpacingRule } from "./rules/require-readable-spacing.ts"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-array-filter-map": noArrayFilterMapRule, + "no-reduce-accumulator-copy": noReduceAccumulatorCopyRule, + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-returns": noUnknownReturnsRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-readable-spacing": requireReadableSpacingRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/package.json b/tools/oxlint/anti-slop/package.json new file mode 100644 index 00000000..bedb411a --- /dev/null +++ b/tools/oxlint/anti-slop/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tools/oxlint/anti-slop/rules/no-array-filter-map.ts b/tools/oxlint/anti-slop/rules/no-array-filter-map.ts new file mode 100644 index 00000000..dc001b38 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-array-filter-map.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "../shared/array-method.ts"; + +/** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */ +export const noArrayFilterMapRule = defineRule({ + meta: { + type: "suggestion", + docs: { description: "Disallow adjacent array filter/map passes in favor of lazy iterator helpers or a single transformation." }, + messages: { + arrayFilterMap: "Avoid consecutive array `{{first}}` and `{{second}}` passes. Prefer `.values().{{first}}(...).{{second}}(...).toArray()` where iterator helpers are supported, or a single `flatMap`/mutating reducer. Preserve callback ordering, indexes, and filtering semantics.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + const outer = arrayMethodTarget(node.callee); + if (outer === null || (outer.name !== "map" && outer.name !== "filter")) return; + const innerCall = unwrapArrayExpression(outer.object); + if (innerCall.type !== "CallExpression") return; + const inner = arrayMethodTarget(innerCall.callee); + if (inner === null || inner.name !== (outer.name === "map" ? "filter" : "map")) return; + if (!isKnownArrayExpression(context.sourceCode, inner.object)) return; + context.report({ node, messageId: "arrayFilterMap", data: { first: inner.name, second: outer.name } }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 00000000..0d118527 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 00000000..ae7248d3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 00000000..0df39ab8 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,415 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionaryValue, + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +import { resolveVariable } from "../shared/scope.ts"; + +import type { ESTree, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function isFunctionExpression(node: ESTree.Node): node is FunctionExpression { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "TSDeclareFunction" || + node.type === "TSEmptyBodyFunctionExpression" + ); +} + +function localFunctionForCall( + sourceCode: SourceCode, + callee: ESTree.Expression, +): FunctionExpression | null { + const unwrapped = unwrapExpression(callee); + if (isFunctionExpression(unwrapped)) return unwrapped; + if (unwrapped.type !== "Identifier") return null; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) { + return definition.node; + } + if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") { + return null; + } + const initializer = definition.node.init; + if (initializer === null) return null; + const unwrappedInitializer = unwrapExpression(initializer); + return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null; +} + +function variableTypeAnnotation( + sourceCode: SourceCode, + variable: Variable, +): ESTree.TSTypeAnnotation | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if ( + definition.type === "Variable" && + definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" + ) { + return definition.node.id.typeAnnotation ?? null; + } + if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) { + return null; + } + const parameter = definition.node.params.find( + (candidate) => + functionParameterBindingName(candidate, sourceCode) === variable.name, + ); + return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null); +} + +function hasInformativeType( + type: ESTree.TSType, + environment: TypeEnvironment, +): boolean { + return classifyUnsafeDictionaryValue(type, environment) === null; +} + +function hasKnownCallArgumentEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + environment: TypeEnvironment, + visitedVariables = new Set(), +): boolean { + if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") { + return hasInformativeType(expression.typeAnnotation, environment); + } + if (expression.type === "TSSatisfiesExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "CallExpression") { + const owner = localFunctionForCall(sourceCode, expression.callee); + const returnType = owner?.returnType?.typeAnnotation; + return returnType !== undefined && hasInformativeType(returnType, environment); + } + if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression); + const variable = resolveVariable(sourceCode, expression); + if (variable === null || visitedVariables.has(variable)) return false; + const annotation = variableTypeAnnotation(sourceCode, variable); + if (annotation !== null) { + return hasInformativeType(annotation.typeAnnotation, environment); + } + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownCallArgumentEvidence( + sourceCode, + declarator.init, + environment, + visitedVariables, + ); +} + +function typePredicateSubjectIndex( + sourceCode: SourceCode, + owner: FunctionExpression, +): number | null { + const predicate = owner.returnType?.typeAnnotation; + if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") { + return null; + } + const predicateParameterName = predicate.parameterName.name; + const index = owner.params.findIndex( + (parameter) => + functionParameterBindingName(parameter, sourceCode) === predicateParameterName, + ); + return index === -1 ? null : index; +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + CallExpression(node) { + if (environment === null) return; + const owner = localFunctionForCall(context.sourceCode, node.callee); + if (owner === null) return; + const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner); + if (parameterIndex === null) return; + const parameter = owner.params[parameterIndex]; + const argument = node.arguments[parameterIndex]; + if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") { + return; + } + const parameterAnnotation = functionParameterTypeAnnotation(parameter); + if ( + parameterAnnotation === null || + parameterAnnotation === undefined || + !containsUnknownType(parameterAnnotation.typeAnnotation) + ) { + return; + } + if ( + !hasKnownCallArgumentEvidence( + context.sourceCode, + argument, + environment, + ) + ) { + return; + } + context.report({ + node: argument, + messageId: "widening", + data: { + subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``, + target: "unknown", + }, + }); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 00000000..2d60d48c --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,80 @@ +import { defineRule } from "@oxlint/plugins"; + +import { resolveVariable } from "../shared/scope.ts"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +function importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 00000000..6589ebf5 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToObject = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSObjectKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + return ( + resolved.type === "TSUnionType" && resolved.types.some(matches) + ); + }); + + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = functionParameterTypeAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: functionParameterBindingName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts new file mode 100644 index 00000000..47d8e2cf --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts @@ -0,0 +1,109 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, SourceCode, Variable } from "@oxlint/plugins"; + +import { + arrayMethodTarget, + isKnownArrayExpression, + resolveArrayBinding, + unwrapArrayExpression, +} from "../shared/array-method.ts"; + +function enclosingReducer(node: ESTree.Node) { + let parent = node.parent; + while (parent !== null) { + if (parent.type === "FunctionDeclaration") return null; + if (parent.type === "ArrowFunctionExpression" || parent.type === "FunctionExpression") { + const callback = parent; + let owner: ESTree.Node | null = callback.parent; + while (owner !== null && unwrapArrayExpression(owner) === callback) owner = owner.parent; + if (owner?.type !== "CallExpression") return null; + const method = arrayMethodTarget(owner.callee); + const firstArgument = owner.arguments[0]; + if ( + method === null || (method.name !== "reduce" && method.name !== "reduceRight") || + owner.arguments.length > 2 || firstArgument === undefined || + unwrapArrayExpression(firstArgument) !== callback + ) return null; + const firstParameter = callback.params[0]; + const accumulator = firstParameter?.type === "AssignmentPattern" ? firstParameter.left : firstParameter; + if (accumulator?.type !== "Identifier") return null; + return { callback, accumulator, initialValue: owner.arguments[1] }; + } + parent = parent.parent; + } + return null; +} + +function referencesAccumulator( + sourceCode: SourceCode, + node: ESTree.Node, + accumulator: Variable, + visited = new Set(), +): boolean { + const variable = resolveArrayBinding(sourceCode, node); + if (variable === null || visited.has(variable)) return false; + if (variable === accumulator) return true; + visited.add(variable); + if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false; + for (const definition of variable.defs) { + if ( + definition.type === "Variable" && definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" && definition.node.init !== null && + definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const" + ) { + return referencesAccumulator(sourceCode, definition.node.init, accumulator, visited); + } + } + return false; +} + +function isGlobalCopyOwner(sourceCode: SourceCode, node: ESTree.Node, name: string): boolean { + node = unwrapArrayExpression(node); + if (node.type !== "Identifier" || node.name !== name) return false; + const variable = resolveArrayBinding(sourceCode, node); + return variable === null || variable.defs.length === 0; +} + +/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */ +export const noReduceAccumulatorCopyRule = defineRule({ + meta: { + type: "problem", + docs: { description: "Disallow copying growing reducer accumulators with Object.assign, Array.from, or array copy methods." }, + messages: { + accumulatorCopy: "Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + const method = arrayMethodTarget(node.callee); + if (method === null) return; + const reducer = enclosingReducer(node); + if (reducer === null) return; + const accumulator = context.sourceCode.getDeclaredVariables(reducer.callback).find(variable => + variable.identifiers.some(identifier => identifier.start === reducer.accumulator.start), + ); + if (accumulator === undefined) return; + const isAccumulator = (expression: ESTree.Node) => + referencesAccumulator(context.sourceCode, expression, accumulator); + let copiesAccumulator = false; + if (method.name === "assign" && isGlobalCopyOwner(context.sourceCode, method.object, "Object")) { + const target = node.arguments[0]; + copiesAccumulator = ( + target !== undefined && unwrapArrayExpression(target).type === "ObjectExpression" && + node.arguments.slice(1).some(isAccumulator) + ); + } else if (method.name === "from" && isGlobalCopyOwner(context.sourceCode, method.object, "Array")) { + const source = node.arguments[0]; + copiesAccumulator = source !== undefined && isAccumulator(source); + } else if (["concat", "slice", "toSpliced", "toSorted", "toReversed", "with"].includes(method.name)) { + const initialValue = reducer.initialValue; + const arrayAccumulator = initialValue !== undefined && + isKnownArrayExpression(context.sourceCode, initialValue); + copiesAccumulator = arrayAccumulator && isAccumulator(method.object); + } + if (copiesAccumulator) context.report({ node, messageId: "accumulatorCopy" }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 00000000..2cc30451 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 00000000..cf630ecc --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 00000000..43259eb9 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" + ); +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === "TSTypePredicate"; + } + current = current.parent; + } + return false; +} + +/** Return whether typeof safely probes for the existence of a possibly absent binding. */ +function isExistenceProbe(node: ESTree.UnaryExpression): boolean { + const parent = node.parent; + if (parent.type !== "BinaryExpression") return false; + if (!["===", "!==", "==", "!="].includes(parent.operator)) return false; + const other = parent.left === node ? parent.right : parent.left; + return other.type === "Literal" && other.value === "undefined"; +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", + }, + schema: [ + { + type: "object", + properties: { + allowInTypeGuards: { type: "boolean" }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = + typeof option === "object" && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if ( + node.operator === "typeof" && + !isExistenceProbe(node) && + (!allowInTypeGuards || !isInsideTypeGuard(node)) + ) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 00000000..436d2a25 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,46 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Return whether an identifier names a statically accessed member owned by another value. */ +function isBorrowedMemberName(node: ESTree.Node): boolean { + const parent = node.parent; + if (parent === null || parent.type !== "MemberExpression") return false; + return parent.property === node && parent.computed === false; +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name) || isBorrowedMemberName(node)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 00000000..b4a1545a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,69 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function isTypePredicateSubject(owner: ParameterOwner, parameterName: string): boolean { + const predicate = owner.returnType?.typeAnnotation; + return ( + predicate?.type === "TSTypePredicate" && + predicate.parameterName.type === "Identifier" && + predicate.parameterName.name === parameterName + ); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause` and type-predicate subjects; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = functionParameterTypeAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!containsUnknownType(annotation.typeAnnotation)) continue; + const name = functionParameterBindingName(parameter, context.sourceCode); + if (name === "cause" || isTypePredicateSubject(node, name)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 00000000..e1f43f8d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,82 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow functions whose explicit return contract is unknown or Promise.", + }, + messages: { + unknownReturn: + "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToUnknown = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSUnknownKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + if (resolved.type === "TSUnionType") return resolved.types.some(matches); + if ( + resolved.type !== "TSTypeReference" || + resolved.typeName.type !== "Identifier" || + (resolved.typeName.name !== "Promise" && + resolved.typeName.name !== "PromiseLike") + ) { + return false; + } + const value = resolved.typeArguments?.params[0]; + return value !== undefined && matches(value); + }); + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if (!resolvesToUnknown(annotation.typeAnnotation)) return; + context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" }); + }; + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 00000000..af5f08a5 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,54 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToUnknown = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSUnknownKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + return resolved.type === "TSUnionType" && resolved.types.some(matches); + }); + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + TSTypeAliasDeclaration(node) { + if (!resolvesToUnknown(node.typeAnnotation)) return; + context.report({ + node: node.id, + messageId: "unknownAlias", + data: { alias: node.id.name }, + }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 00000000..8cb615a1 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,154 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; +import { visibleTypeAlias } from "../shared/type-alias-resolution.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return ( + name !== null && + visibleTypeAlias(name, node, environment.typeAliases) !== null && + !isInsideTypeAliasDeclaration(node) + ); +} + +function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean { + let child: ESTree.Node = node; + let parent: ESTree.Node | null = child.parent; + while (parent !== null && parent.type !== "Program") { + if (parent.type === "TSTypeParameter" && parent.constraint === child) return true; + child = parent; + parent = child.parent; + } + return false; +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isInsideTypeParameterConstraint(node)) return false; + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 00000000..c5e07f7f --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-readable-spacing.ts b/tools/oxlint/anti-slop/rules/require-readable-spacing.ts new file mode 100644 index 00000000..e037fe3d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-readable-spacing.ts @@ -0,0 +1,53 @@ +import type { CreateRule } from "@oxlint/plugins"; + +import createPaddingLineRule from "../vendor/eslint-stylistic/padding-line-between-statements.ts"; + +const paddingRule = createPaddingLineRule([ + { blankLine: "always", prev: "import", next: "*" }, + { blankLine: "always", prev: "*", next: { selector: "Program > :not(ImportDeclaration)" } }, + { blankLine: "always", prev: { selector: "Program > :not(ImportDeclaration)" }, next: "*" }, + { blankLine: "always", prev: "*", next: ["function", "class", "interface", "type"] }, + { blankLine: "always", prev: ["function", "class", "interface", "type"], next: "*" }, + { + blankLine: "always", + prev: "*", + next: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"], + }, + { + blankLine: "always", + prev: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"], + next: "*", + }, + { blankLine: "always", prev: "*", next: ["return", "if", "switch", "try", "for", "while", "do"] }, + { blankLine: "always", prev: "block-like", next: "*" }, + { blankLine: "any", prev: "import", next: "import" }, + // nodevu is CommonJS, so its top-level require declarations stay grouped like imports + { + blankLine: "any", + prev: { selector: 'Program > VariableDeclaration[declarations.0.init.callee.name="require"]' }, + next: { selector: 'Program > VariableDeclaration[declarations.0.init.callee.name="require"]' }, + }, + { + blankLine: "any", + prev: { + selector: + ':matches(TSDeclareFunction, ExportNamedDeclaration[declaration.type="TSDeclareFunction"])', + }, + next: { + selector: + ':matches(TSDeclareFunction, FunctionDeclaration, ExportNamedDeclaration[declaration.type="TSDeclareFunction"], ExportNamedDeclaration[declaration.type="FunctionDeclaration"])', + }, + }, +]); + +/** Restore structural blank lines with whitespace-only fixes; keep local short bindings and overloads grouped. */ +export const requireReadableSpacingRule: CreateRule = { + ...paddingRule, + meta: { + ...paddingRule.meta, + docs: { + description: "Require readable spacing between declarations and logical statement groups.", + }, + schema: [], + }, +}; diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 00000000..bfea1a23 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,131 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function configuredSafetyMarkers(option: unknown): readonly string[] { + if (typeof option !== "object" || option === null || !("markers" in option)) { + return DEFAULT_SAFETY_MARKERS; + } + const configured = option.markers; + if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS; + const markers = configured.flatMap((marker) => + typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [], + ); + return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS; +} + +function markerPattern(markers: readonly string[]): RegExp { + const alternation = markers + .map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)) + .join("|"); + return new RegExp( + String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`, + "u", + ); +} + +function hasSafetyJustificationBefore( + sourceCode: SourceCode, + owner: ESTree.Node, + assertion: TypeAssertion, + pattern: RegExp, +): boolean { + return sourceCode + .getCommentsBefore(owner) + .some( + (comment) => comment.end <= assertion.start && pattern.test(comment.value), + ); +} + +function hasSafetyComment( + sourceCode: SourceCode, + node: TypeAssertion, + pattern: RegExp, +): boolean { + let current: ESTree.Node = node; + while (true) { + if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true; + if (commentOwnerKinds.has(current.type)) { + const exportDeclaration = current.parent; + return ( + exportDeclaration.type === "ExportNamedDeclaration" && + exportDeclaration.declaration === current && + hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern) + ); + } + if (current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + schema: [ + { + type: "object", + properties: { + markers: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ markers: ["SAFETY"] }], + }, + createOnce(context) { + const patterns = new Map(); + + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node)) return; + const markers = configuredSafetyMarkers(context.options?.[0]); + const patternKey = markers.join("\u0000"); + const pattern = patterns.get(patternKey) ?? markerPattern(markers); + patterns.set(patternKey, pattern); + if (hasSafetyComment(context.sourceCode, node, pattern)) return; + context.report({ + node, + messageId: "missingSafetyComment", + data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] }, + }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/array-method.ts b/tools/oxlint/anti-slop/shared/array-method.ts new file mode 100644 index 00000000..87eb9d84 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/array-method.ts @@ -0,0 +1,94 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +/** Unwrap syntax-only wrappers when inspecting array methods and accumulator references. */ +export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node { + while ( + node.type === "ParenthesizedExpression" || + node.type === "ChainExpression" || + node.type === "TSAsExpression" || + node.type === "TSTypeAssertion" || + node.type === "TSNonNullExpression" || + node.type === "TSSatisfiesExpression" + ) { + node = node.expression; + } + return node; +} + +/** Resolve a local binding by scope, not by identifier spelling. */ +export function resolveArrayBinding(sourceCode: SourceCode, node: ESTree.Node): Variable | null { + node = unwrapArrayExpression(node); + if (node.type !== "Identifier") return null; + let scope: Scope | null = sourceCode.getScope(node); + while (scope !== null) { + const variable = scope.set.get(node.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +/** Read static method names, including computed string literals, without evaluating expressions. */ +export function arrayMethodTarget( + node: ESTree.Node, +): { readonly name: string; readonly object: ESTree.Node } | null { + node = unwrapArrayExpression(node); + if (node.type !== "MemberExpression") return null; + const property = node.property; + if (!node.computed && property.type === "Identifier") { + return { name: property.name, object: node.object }; + } + if (node.computed && property.type === "Literal" && typeof property.value === "string") { + return { name: property.value, object: node.object }; + } + return null; +} + +function isArrayAnnotation(type: ESTree.TSType): boolean { + if (type.type === "TSArrayType" || type.type === "TSTupleType") return true; + if (type.type === "TSParenthesizedType") return isArrayAnnotation(type.typeAnnotation); + if (type.type === "TSTypeOperator" && type.operator === "readonly") { + return isArrayAnnotation(type.typeAnnotation); + } + return ( + type.type === "TSTypeReference" && type.typeName.type === "Identifier" && + (type.typeName.name === "Array" || type.typeName.name === "ReadonlyArray") + ); +} + +/** Recognize local array evidence; unknown receivers and iterator pipelines are deliberately excluded. */ +export function isKnownArrayExpression( + sourceCode: SourceCode, + node: ESTree.Node, + visited = new Set(), +): boolean { + node = unwrapArrayExpression(node); + if (node.type === "ArrayExpression") return true; + if (node.type === "CallExpression") { + const method = arrayMethodTarget(node.callee); + return ( + method !== null && + ["map", "filter", "flatMap", "slice", "concat", "toSorted", "toReversed", "toSpliced"].includes(method.name) && + isKnownArrayExpression(sourceCode, method.object, visited) + ); + } + if (node.type !== "Identifier") return false; + const variable = resolveArrayBinding(sourceCode, node); + if (variable === null || visited.has(variable)) return false; + visited.add(variable); + if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false; + for (const identifier of variable.identifiers) { + const annotation = identifier.typeAnnotation?.typeAnnotation; + if (annotation !== undefined) return isArrayAnnotation(annotation); + } + for (const definition of variable.defs) { + if ( + definition.type === "Variable" && definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" && definition.node.init !== null && + definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const" + ) { + return isKnownArrayExpression(sourceCode, definition.node.init, visited); + } + } + return false; +} diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 00000000..8db73ffa --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,515 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + hasVisibleTypeBinding, + visibleTypeAlias, + type TypeAliasEnvironment as LexicalTypeAliasEnvironment, +} from "./type-alias-resolution.ts"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly interfaces: ReadonlyMap; + readonly typeAliases: LexicalTypeAliasEnvironment; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment( + program: ESTree.Program, + visitorKeys: Readonly>, +): TypeEnvironment { + const interfaces = new Map(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type !== "TSInterfaceDeclaration") continue; + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + } + + return { + interfaces, + typeAliases: createTypeAliasEnvironment(program, visitorKeys), + }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn( + name: string, + use: ESTree.Node, + environment: TypeEnvironment, +): boolean { + return ( + BUILT_INS.has(name) && + !hasVisibleTypeBinding(name, use, environment.typeAliases) + ); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ( + (name === "Pick" || name === "Omit") && + isBuiltIn(name, unwrapped, environment) + ) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, new Map()) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + const resolved = + substitutions === null + ? null + : classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function hasBroadRecordKey( + type: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const key = type.typeArguments?.params[0]; + return key === undefined || isBroadMappedKey(key, environment, substitutions); +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + visitedAliases: ReadonlySet = new Set(), +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some((member) => + isBroadMappedKey(member, environment, substitutions, visitedAliases), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions, visitedAliases); + } + if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true; + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if ( + alias === null || + (alias.typeParameters?.params.length ?? 0) > 0 || + visitedAliases.has(name) + ) { + return false; + } + const nextVisited = new Set(visitedAliases); + nextVisited.add(name); + return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/tools/oxlint/anti-slop/shared/function-parameters.ts b/tools/oxlint/anti-slop/shared/function-parameters.ts new file mode 100644 index 00000000..80de91d7 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/function-parameters.ts @@ -0,0 +1,49 @@ +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +export type FunctionParameter = ESTree.ParamPattern; + +/** Return whether a type is or contains TypeScript's absorbing unknown top type. */ +export function containsUnknownType(type: ESTree.TSType): boolean { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation); + return type.type === "TSUnionType" && type.types.some(containsUnknownType); +} + +/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */ +export function functionParameterTypeAnnotation( + parameter: FunctionParameter, +): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return functionParameterTypeAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left); + } + return parameter.typeAnnotation; +} + +/** Return only a function parameter's local binding, excluding its annotation and default value. */ +export function functionParameterBindingName( + parameter: FunctionParameter, + sourceCode: SourceCode, +): string { + if (parameter.type === "TSParameterProperty") { + return functionParameterBindingName(parameter.parameter, sourceCode); + } + if (parameter.type === "AssignmentPattern") { + return functionParameterBindingName(parameter.left, sourceCode); + } + if (parameter.type === "RestElement") { + return functionParameterBindingName(parameter.argument, sourceCode); + } + if (parameter.type === "Identifier") return parameter.name; + + const sourceText = sourceCode.getText(parameter); + const annotationStart = parameter.typeAnnotation?.start; + return annotationStart === undefined + ? sourceText + : sourceText.slice(0, annotationStart - parameter.start).trimEnd(); +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 00000000..7cdb18c9 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 00000000..43a7cfcc --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,24 @@ +import { resolveVariable } from "./scope.ts"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/tools/oxlint/anti-slop/shared/scope.ts b/tools/oxlint/anti-slop/shared/scope.ts new file mode 100644 index 00000000..602414ab --- /dev/null +++ b/tools/oxlint/anti-slop/shared/scope.ts @@ -0,0 +1,15 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +/** Resolve an identifier to its binding by walking lexical scopes upward. */ +export function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} diff --git a/tools/oxlint/anti-slop/shared/type-alias-resolution.ts b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts new file mode 100644 index 00000000..4744222a --- /dev/null +++ b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts @@ -0,0 +1,250 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts"; + +type VisitorKeys = Readonly>; +type TypeScope = ESTree.Node; + +type TypeBinding = { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; + readonly scope: TypeScope; +}; + +type Substitution = { + readonly substitutions: Substitutions; + readonly type: ESTree.TSType; +}; + +type Substitutions = ReadonlyMap; + +export type TypeAliasEnvironment = { + readonly aliases: readonly ESTree.TSTypeAliasDeclaration[]; + readonly bindingsByName: ReadonlyMap; + readonly visitorKeys: VisitorKeys; +}; + +export type ResolvedTypeMatcher = ( + type: ESTree.TSType, + matches: (child: ESTree.TSType) => boolean, +) => boolean; + +const environmentsByProgram = new WeakMap(); + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function enclosingTypeScope(node: ESTree.Node): TypeScope { + let current: ESTree.Node | null = node.parent; + while (current !== null) { + if ( + current.type === "Program" || + current.type === "BlockStatement" || + current.type === "TSModuleBlock" || + current.type === "StaticBlock" || + current.type === "SwitchStatement" + ) { + return current; + } + current = current.parent; + } + return node; +} + +function declaredTypeBinding(node: ESTree.Node): { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; +} | null { + if (node.type === "TSTypeAliasDeclaration") { + return { alias: node, name: node.id.name }; + } + if ( + node.type === "TSInterfaceDeclaration" || + node.type === "TSEnumDeclaration" || + node.type === "ClassDeclaration" || + node.type === "ClassExpression" + ) { + return node.id === null ? null : { alias: null, name: node.id.name }; + } + if ( + node.type === "ImportSpecifier" || + node.type === "ImportDefaultSpecifier" || + node.type === "ImportNamespaceSpecifier" + ) { + return { alias: null, name: node.local.name }; + } + return null; +} + +function collectTypeBindings( + node: ESTree.Node, + visitorKeys: VisitorKeys, + bindingsByName: Map, + aliases: ESTree.TSTypeAliasDeclaration[], +): void { + const declared = declaredTypeBinding(node); + if (declared !== null) { + const bindings = bindingsByName.get(declared.name) ?? []; + bindings.push({ ...declared, scope: enclosingTypeScope(node) }); + bindingsByName.set(declared.name, bindings); + if (declared.alias !== null) aliases.push(declared.alias); + } + + // SAFETY: Oxlint's visitor keys identify only ESTree child-node properties. + const fields = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = fields[key]; + if (isNode(value)) { + collectTypeBindings(value, visitorKeys, bindingsByName, aliases); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) { + collectTypeBindings(child, visitorKeys, bindingsByName, aliases); + } + } + } +} + +/** Collect every lexical type alias and competing type binding in a program. */ +export function createTypeAliasEnvironment( + program: ESTree.Program, + visitorKeys: VisitorKeys, +): TypeAliasEnvironment { + const cached = environmentsByProgram.get(program); + if (cached !== undefined) return cached; + const bindingsByName = new Map(); + const aliases: ESTree.TSTypeAliasDeclaration[] = []; + collectTypeBindings(program, visitorKeys, bindingsByName, aliases); + const environment = { aliases, bindingsByName, visitorKeys }; + environmentsByProgram.set(program, environment); + return environment; +} + +function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null { + let current: ESTree.Node | null = node; + let distance = 0; + while (current !== null) { + if (current === ancestor) return distance; + current = current.parent; + distance += 1; + } + return null; +} + +function nearestTypeBindings( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): readonly TypeBinding[] { + const candidates = environment.bindingsByName.get(name) ?? []; + let nearestDistance = Number.POSITIVE_INFINITY; + let nearest: TypeBinding[] = []; + for (const candidate of candidates) { + const distance = ancestorDistance(candidate.scope, use); + if (distance === null || distance > nearestDistance) continue; + if (distance === nearestDistance) { + nearest.push(candidate); + continue; + } + nearestDistance = distance; + nearest = [candidate]; + } + return nearest; +} + +/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */ +export function visibleTypeAlias( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): ESTree.TSTypeAliasDeclaration | null { + if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null; + const bindings = nearestTypeBindings(name, use, environment); + return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null; +} + +/** Return whether a local declaration shadows a built-in type at this use. */ +export function hasVisibleTypeBinding( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): boolean { + return ( + lexicalTypeParameterNames(use, environment.visitorKeys).has(name) || + nearestTypeBindings(name, use, environment).length > 0 + ); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function aliasSubstitutions( + alias: ESTree.TSTypeAliasDeclaration, + reference: ESTree.TSTypeReference, + base: Substitutions, +): Substitutions | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = reference.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const explicitArgument = arguments_[index]; + const argument = explicitArgument ?? parameter.default; + if (argument === null || argument === undefined) return null; + const argumentSubstitutions = explicitArgument === undefined ? next : base; + next.set(parameter.name.name, { + type: argument, + substitutions: new Map(argumentSubstitutions), + }); + } + return next; +} + +/** Match a type after resolving visible aliases and substituting their type parameters. */ +export function resolvedTypeMatches( + type: ESTree.TSType, + environment: TypeAliasEnvironment, + matcher: ResolvedTypeMatcher, +): boolean { + const evaluate = ( + current: ESTree.TSType, + substitutions: Substitutions, + resolvingAliases: ReadonlySet, + ): boolean => { + if (current.type === "TSTypeReference") { + const name = typeReferenceName(current); + if (name !== null) { + const substitution = substitutions.get(name); + if (substitution !== undefined && !current.typeArguments?.params.length) { + return evaluate( + substitution.type, + substitution.substitutions, + resolvingAliases, + ); + } + const alias = visibleTypeAlias(name, current, environment); + if (alias !== null && !resolvingAliases.has(alias)) { + const nextSubstitutions = aliasSubstitutions(alias, current, substitutions); + if (nextSubstitutions !== null) { + const nextResolving = new Set(resolvingAliases); + nextResolving.add(alias); + return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving); + } + } + } + } + return matcher(current, (child) => + evaluate(child, substitutions, resolvingAliases), + ); + }; + + return evaluate(type, new Map(), new Set()); +} diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/LICENSE b/tools/oxlint/anti-slop/vendor/eslint-stylistic/LICENSE new file mode 100644 index 00000000..38dbc354 --- /dev/null +++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright OpenJS Foundation and other contributors, +Copyright (c) 2023-PRESENT ESLint Stylistic contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/UPSTREAM.md b/tools/oxlint/anti-slop/vendor/eslint-stylistic/UPSTREAM.md new file mode 100644 index 00000000..3040694e --- /dev/null +++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/UPSTREAM.md @@ -0,0 +1,28 @@ +# Vendored padding-line-between-statements + +Source: [ESLint Stylistic](https://github.com/eslint-stylistic/eslint-stylistic), commit `435c3ea0fd26a5fef9042c4b36b6e165fbbf8d08`. + +Copied files: + +- `packages/eslint-plugin/rules/padding-line-between-statements/padding-line-between-statements.ts` +- `packages/eslint-plugin/rules/padding-line-between-statements/types.d.ts` → `padding-line-options.d.ts` +- Root `LICENSE`, retained verbatim. Both OpenJS Foundation and ESLint Stylistic notices apply. + +The rule is MIT-licensed. Keep `LICENSE` with every redistributed copy, including skill assets. No Stylistic, ESLint, TypeScript-ESLint, or additional parser runtime dependency is required. + +## Local adaptations + +- Replace upstream type aliases with Oxlint's ESTree, context, token/comment, and rule types. Upstream's token type includes comments; Oxlint exposes those separately. +- Replace `AST_NODE_TYPES` enum members with identical string literals. +- Guard indexed reads for consuming repositories with `noUncheckedIndexedAccess`. Impossible missing AST/configuration entries raise explicit invariant errors rather than introducing new non-null assertions. +- Replace the repository-specific `createRule` factory with `createPaddingLineRule(options)`. The anti-slop wrapper supplies typed options directly; it exposes no user configuration options. +- Implement the small required AST helper surface in `padding-line-ast.ts` using Oxlint's public source-code/token API. `isParenthesized` only needs the one-pair check used to exclude parenthesized directive strings, not the upstream general-purpose overloads. +- Retain the upstream statement matchers, scope tracking, comment-aware insertion/removal, selector support, and diagnostic text. Upstream naming and non-null assumptions remain localized here to keep future diffs reviewable; the file is not a model for new application code. + +The opinionated policy lives outside this directory in `../../rules/require-readable-spacing.ts`. It adds spacing without collapsing existing blank lines. Short local bindings, consecutive imports, and adjacent overload signatures/implementation remain grouped. Spacing is syntactic, not an inference of business-logic boundaries. + +## Updating and verification + +Fetch an explicit upstream revision, compare the original rule and types against this revision, and port relevant fixes while retaining the adapters above. Update this record and preserve the license. Run `pnpm check` and `pnpm sync:skill-assets` as required by repository guidance. + +Focused Oxlint RuleTester cases live in `../../rules/require-readable-spacing.test.ts`; they test exact fixes, JSDoc/trailing comments, same-line statements, semicolon-free code, TypeScript exports/overloads, Effect-style generators, and upstream removal behavior. `../../rules/require-readable-spacing-cli.test.ts` verifies the exported plugin through the native Oxlint CLI on multiple files, including rejection, autofix, and repeated-fix stability. The complete upstream JS/TS test suites have not been ported; this is focused compatibility evidence, not a claim of full upstream conformance. diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-ast.ts b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-ast.ts new file mode 100644 index 00000000..10f34cc8 --- /dev/null +++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-ast.ts @@ -0,0 +1,51 @@ +// Local replacements for the upstream helper imports. See UPSTREAM.md. +import type { ESTree, SourceCode, Token as SyntaxToken, Comment, Location } from "@oxlint/plugins"; + +type Token = SyntaxToken | Comment; + +/** Line terminators recognized by the upstream padding matcher. */ +export const LINEBREAKS = new Set(["\r\n", "\r", "\n", "\u2028", "\u2029"]); + +/** Test a closing brace without treating comment text as punctuation. */ +export const isClosingBraceToken = (token: Token): boolean => + token.type === "Punctuator" && token.value === "}"; + +/** Test a semicolon without treating comment text as punctuation. */ +export const isSemicolonToken = (token: Token): boolean => + token.type === "Punctuator" && token.value === ";"; + +/** Filter the optional final semicolon when identifying block-like statements. */ +export const isNotSemicolonToken = (token: Token): boolean => !isSemicolonToken(token); + +/** Compare token/node boundaries, including attached comments. */ +export const isTokenOnSameLine = (left: { loc: Location }, right: { loc: Location }): boolean => + left.loc.end.line === right.loc.start.line; + +/** Recognize declarations and expressions used by the upstream IIFE matcher. */ +export const isFunction = (node: ESTree.Node): boolean => + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression"; + +/** Preserve the upstream multiline statement heuristic. */ +export const isSingleLine = (node: ESTree.Node): boolean => + node.loc.start.line === node.loc.end.line; + +/** Unwrap optional chaining before checking IIFE syntax. */ +export const skipChainExpression = (node: ESTree.Node): ESTree.Node => + node.type === "ChainExpression" ? node.expression : node; + +/** Only a program or function-body expression can begin a directive prologue. */ +export const isTopLevelExpressionStatement = ( + node: ESTree.Node, +): node is ESTree.ExpressionStatement => + node.type === "ExpressionStatement" && + (node.parent.type === "Program" || + (node.parent.type === "BlockStatement" && isFunction(node.parent.parent))); + +/** A single wrapping pair suffices to exclude a string from directive syntax. */ +export function isParenthesized(node: ESTree.Node, sourceCode: SourceCode): boolean { + const before = sourceCode.getTokenBefore(node); + const after = sourceCode.getTokenAfter(node); + return before?.value === "(" && after?.value === ")"; +} diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-between-statements.ts b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-between-statements.ts new file mode 100644 index 00000000..c402db1b --- /dev/null +++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-between-statements.ts @@ -0,0 +1,906 @@ +// Vendored from ESLint Stylistic; see UPSTREAM.md and LICENSE in this directory. +import type { ESTree, Context as RuleContext, SourceCode, Token as SyntaxToken, Comment, CreateRule, Location } from '@oxlint/plugins' +type ASTNode = ESTree.Node +type Token = SyntaxToken | Comment +import type { + RuleOptions, + SelectorOption, + StatementOption, +} from './padding-line-options.d.ts' +import { + isClosingBraceToken, + isFunction, + isNotSemicolonToken, + isParenthesized, + isSemicolonToken, + isSingleLine, + isTokenOnSameLine, + isTopLevelExpressionStatement, + LINEBREAKS, + skipChainExpression, +} from './padding-line-ast.ts' + +const CJS_EXPORT = /^(?:module\s*\.\s*)?exports(?:\s*\.|\s*\[|$)/u +const CJS_IMPORT = /^require\(/u + +/** + * This rule is a replica of padding-line-between-statements. + * + * Ideally we would want to extend the rule support typescript specific support. + * But since not all the state is exposed by the eslint and eslint has frozen stylistic rules, + * (see - https://eslint.org/blog/2020/05/changes-to-rules-policies for details.) + * we are forced to re-implement the rule here. + * + * We have tried to keep the implementation as close as possible to the eslint implementation, to make + * patching easier for future contributors. + * + * Reference rule - https://github.com/eslint/eslint/blob/main/lib/rules/padding-line-between-statements.js + */ + +type NodeTest = ( + node: ASTNode, + sourceCode: SourceCode, +) => boolean + +interface NodeTestObject { + test: NodeTest +} + +const LT = `[${Array.from(LINEBREAKS).join('')}]` +const PADDING_LINE_SEQUENCE = new RegExp( + String.raw`^(\s*?${LT})\s*${LT}(\s*;?)$`, + 'u', +) + +function isSelectorOption(option: StatementOption): option is SelectorOption { + return typeof option === 'object' && !Array.isArray(option) +} + +/** + * Creates tester which check if a node starts with specific keyword with the + * appropriate AST_NODE_TYPES. + * @param keyword The keyword to test. + * @returns the created tester. + * @private + */ +function newKeywordTester( + type: string | string[], + keyword: string, +): NodeTestObject { + return { + test(node, sourceCode): boolean { + const isSameKeyword = sourceCode.getFirstToken(node)?.value === keyword + const isSameType = Array.isArray(type) + ? type.includes(node.type) + : type === node.type + + return isSameKeyword && isSameType + }, + } +} + +/** + * Creates tester which check if a node is specific type. + * @param type The node type to test. + * @returns the created tester. + * @private + */ +function newNodeTypeTester(type: string): NodeTestObject { + return { + test: (node): boolean => node.type === type, + } +} + +/** + * Checks the given node is an expression statement of IIFE. + * @param node The node to check. + * @returns `true` if the node is an expression statement of IIFE. + * @private + */ +function isIIFEStatement(node: ASTNode): boolean { + if (node.type === 'ExpressionStatement') { + let expression = skipChainExpression(node.expression) + if (expression.type === 'UnaryExpression') + expression = skipChainExpression(expression.argument) + + if (expression.type === 'CallExpression') { + let node: ASTNode = expression.callee + while (node.type === 'SequenceExpression') { + const lastExpression = node.expressions.at(-1) + if (lastExpression === undefined) + throw new Error('Padding rule invariant: sequence expression is empty') + node = lastExpression + } + + return isFunction(node) + } + } + return false +} + +/** + * Checks the given node is a CommonJS require statement + * @param node The node to check. + * @returns `true` if the node is a CommonJS require statement. + * @private + */ +function isCJSRequire(node: ASTNode): boolean { + if (node.type === 'VariableDeclaration') { + const declaration = node.declarations[0] + if (declaration?.init) { + let call = declaration?.init + while (call.type === 'MemberExpression') + call = call.object + + if ( + call.type === 'CallExpression' + && call.callee.type === 'Identifier' + ) { + return call.callee.name === 'require' + } + } + } + return false +} + +/** + * Checks whether the given node is a block-like statement. + * This checks the last token of the node is the closing brace of a block. + * @param sourceCode The source code to get tokens. + * @param node The node to check. + * @returns `true` if the node is a block-like statement. + * @private + */ +function isBlockLikeStatement( + node: ASTNode, + sourceCode: SourceCode, +): boolean { + // do-while with a block is a block-like statement. + if ( + node.type === 'DoWhileStatement' + && node.body.type === 'BlockStatement' + ) { + return true + } + + /** + * IIFE is a block-like statement specially from + * JSCS#disallowPaddingNewLinesAfterBlocks. + */ + if (isIIFEStatement(node)) + return true + + // Checks the last token is a closing brace of blocks. + const lastToken = sourceCode.getLastToken(node, isNotSemicolonToken) + const belongingNode + = lastToken && isClosingBraceToken(lastToken) + ? sourceCode.getNodeByRangeIndex(lastToken.range[0]) + : null + + return ( + !!belongingNode + && (belongingNode.type === 'BlockStatement' + || belongingNode.type === 'SwitchStatement') + ) +} + +/** + * Check whether the given node is a directive or not. + * @param node The node to check. + * @param sourceCode The source code object to get tokens. + * @returns `true` if the node is a directive. + */ +function isDirective( + node: ASTNode, + sourceCode: SourceCode, +): boolean { + return ( + isTopLevelExpressionStatement(node) + && node.expression.type === 'Literal' + && typeof node.expression.value === 'string' + && !isParenthesized(node.expression, sourceCode) + ) +} + +/** + * Check whether the given node is a part of directive prologue or not. + * @param node The node to check. + * @param sourceCode The source code object to get tokens. + * @returns `true` if the node is a part of directive prologue. + */ +function isDirectivePrologue( + node: ASTNode, + sourceCode: SourceCode, +): boolean { + if ( + isDirective(node, sourceCode) + && node.parent + && 'body' in node.parent + && Array.isArray(node.parent.body) + ) { + for (const sibling of node.parent.body) { + if (sibling === node) + break + + if (!isDirective(sibling, sourceCode)) + return false + } + return true + } + return false +} + +/** + * Checks the given node is a CommonJS export statement + * @param node The node to check. + * @returns `true` if the node is a CommonJS export statement. + * @private + */ +function isCJSExport(node: ASTNode): boolean { + if (node.type === 'ExpressionStatement') { + const expression = node.expression + if (expression.type === 'AssignmentExpression') { + let left = expression.left + if (left.type === 'MemberExpression') { + while (left.object.type === 'MemberExpression') + left = left.object + + return ( + left.object.type === 'Identifier' + && (left.object.name === 'exports' + || (left.object.name === 'module' + && left.property.type === 'Identifier' + && left.property.name === 'exports')) + ) + } + } + } + return false +} + +/** + * Check whether the given node is an expression + * @param node The node to check. + * @param sourceCode The source code object to get tokens. + * @returns `true` if the node is an expression + */ +function isExpression( + node: ASTNode, + sourceCode: SourceCode, +): boolean { + return ( + node.type === 'ExpressionStatement' + && !isDirectivePrologue(node, sourceCode) + ) +} + +/** + * Gets the actual last token. + * + * If a semicolon is semicolon-less style's semicolon, this ignores it. + * For example: + * + * foo() + * ;[1, 2, 3].forEach(bar) + * @param sourceCode The source code to get tokens. + * @param node The node to get. + * @returns The actual last token. + * @private + */ +function getActualLastToken( + node: ASTNode, + sourceCode: SourceCode, +): Token | null { + const semiToken = sourceCode.getLastToken(node)! + const prevToken = sourceCode.getTokenBefore(semiToken) + const nextToken = sourceCode.getTokenAfter(semiToken) + const isSemicolonLessStyle + = prevToken + && nextToken + && prevToken.range[0] >= node.range[0] + && isSemicolonToken(semiToken) + && !isTokenOnSameLine(prevToken, semiToken) + && isTokenOnSameLine(semiToken, nextToken) + + return isSemicolonLessStyle ? prevToken : semiToken +} + +/** + * This returns the concatenation of the first 2 captured strings. + * @param _ Unused. Whole matched string. + * @param trailingSpaces The trailing spaces of the first line. + * @param indentSpaces The indentation spaces of the last line. + * @returns The concatenation of trailingSpaces and indentSpaces. + * @private + */ +function replacerToRemovePaddingLines( + _: string, + trailingSpaces: string, + indentSpaces: string, +): string { + return trailingSpaces + indentSpaces +} + +function getReportLoc(node: ASTNode, sourceCode: SourceCode): Location { + if (isSingleLine(node)) + return node.loc + + const line = node.loc.start.line + const sourceLine = sourceCode.lines[line - 1] + if (sourceLine === undefined) + throw new Error('Padding rule invariant: statement source line is missing') + + return { + start: node.loc.start, + end: { + line, + column: sourceLine.length, + }, + } +} + +/** + * Check and report statements for `any` configuration. + * It does nothing. + * + * @private + */ +function verifyForAny(): void { + // Empty +} + +/** + * Check and report statements for `never` configuration. + * This autofix removes blank lines between the given 2 statements. + * However, if comments exist between 2 blank lines, it does not remove those + * blank lines automatically. + * @param context The rule context to report. + * @param _ Unused. The previous node to check. + * @param nextNode The next node to check. + * @param paddingLines The array of token pairs that blank + * lines exist between the pair. + * + * @private + */ +function verifyForNever( + context: RuleContext, + _: ASTNode, + nextNode: ASTNode, + paddingLines: [Token, Token][], +): void { + if (paddingLines.length === 0) + return + + context.report({ + node: nextNode, + messageId: 'unexpectedBlankLine', + loc: getReportLoc(nextNode, context.sourceCode), + fix(fixer) { + if (paddingLines.length >= 2) + return null + + const paddingPair = paddingLines[0] + if (paddingPair === undefined) + throw new Error('Padding rule invariant: reported padding pair is missing') + const [prevToken, nextToken] = paddingPair + const start = prevToken.range[1] + const end = nextToken.range[0] + const text = context + .sourceCode + .text + .slice(start, end) + .replace(PADDING_LINE_SEQUENCE, replacerToRemovePaddingLines) + + return fixer.replaceTextRange([start, end], text) + }, + }) +} + +/** + * Check and report statements for `always` configuration. + * This autofix inserts a blank line between the given 2 statements. + * If the `prevNode` has trailing comments, it inserts a blank line after the + * trailing comments. + * @param context The rule context to report. + * @param prevNode The previous node to check. + * @param nextNode The next node to check. + * @param paddingLines The array of token pairs that blank + * lines exist between the pair. + * + * @private + */ +function verifyForAlways( + context: RuleContext, + prevNode: ASTNode, + nextNode: ASTNode, + paddingLines: [Token, Token][], +): void { + if (paddingLines.length > 0) + return + + context.report({ + node: nextNode, + messageId: 'expectedBlankLine', + loc: getReportLoc(nextNode, context.sourceCode), + fix(fixer) { + const sourceCode = context.sourceCode + let prevToken = getActualLastToken(prevNode, sourceCode)! + const nextToken + = sourceCode.getFirstTokenBetween(prevToken, nextNode, { + includeComments: true, + + /** + * Skip the trailing comments of the previous node. + * This inserts a blank line after the last trailing comment. + * + * For example: + * + * foo(); // trailing comment. + * // comment. + * bar(); + * + * Get fixed to: + * + * foo(); // trailing comment. + * + * // comment. + * bar(); + * @param token The token to check. + * @returns `true` if the token is not a trailing comment. + * @private + */ + filter(token) { + if (isTokenOnSameLine(prevToken, token)) { + prevToken = token + return false + } + return true + }, + })! || nextNode + const insertText = isTokenOnSameLine(prevToken, nextToken) + ? '\n\n' + : '\n' + + return fixer.insertTextAfter(prevToken, insertText) + }, + }) +} + +/** + * Types of blank lines. + * `any`, `never`, and `always` are defined. + * Those have `verify` method to check and report statements. + * @private + */ +const PaddingTypes = { + any: { verify: verifyForAny }, + never: { verify: verifyForNever }, + always: { verify: verifyForAlways }, +} + +const MaybeMultilineStatementType: Record = { + 'block-like': { test: isBlockLikeStatement }, + 'expression': { test: isExpression }, + 'return': newKeywordTester('ReturnStatement', 'return'), + 'export': newKeywordTester( + [ + 'ExportAllDeclaration', + 'ExportDefaultDeclaration', + 'ExportNamedDeclaration', + ], + 'export', + ), + 'var': newKeywordTester('VariableDeclaration', 'var'), + 'let': newKeywordTester('VariableDeclaration', 'let'), + 'const': newKeywordTester('VariableDeclaration', 'const'), + 'using': { + test: node => node.type === 'VariableDeclaration' + && (node.kind === 'using' || node.kind === 'await using'), + }, + 'type': newKeywordTester('TSTypeAliasDeclaration', 'type'), +} + +/** + * Types of statements. + * Those have `test` method to check it matches to the given statement. + * @private + */ +const StatementTypes: Record = { + '*': { test: (): boolean => true }, + 'exports': { test: isCJSExport }, + 'require': { test: isCJSRequire }, + 'directive': { test: isDirectivePrologue }, + 'iife': { test: isIIFEStatement }, + + 'block': newNodeTypeTester('BlockStatement'), + 'empty': newNodeTypeTester('EmptyStatement'), + 'function': newNodeTypeTester('FunctionDeclaration'), + 'ts-method': newNodeTypeTester('TSMethodSignature'), + + 'break': newKeywordTester('BreakStatement', 'break'), + 'case': newKeywordTester('SwitchCase', 'case'), + 'class': newKeywordTester('ClassDeclaration', 'class'), + 'continue': newKeywordTester('ContinueStatement', 'continue'), + 'debugger': newKeywordTester('DebuggerStatement', 'debugger'), + 'default': newKeywordTester( + ['SwitchCase', 'ExportDefaultDeclaration'], + 'default', + ), + 'do': newKeywordTester('DoWhileStatement', 'do'), + 'for': newKeywordTester( + [ + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + ], + 'for', + ), + 'if': newKeywordTester('IfStatement', 'if'), + 'import': newKeywordTester('ImportDeclaration', 'import'), + 'switch': newKeywordTester('SwitchStatement', 'switch'), + 'throw': newKeywordTester('ThrowStatement', 'throw'), + 'try': newKeywordTester('TryStatement', 'try'), + 'while': newKeywordTester( + ['WhileStatement', 'DoWhileStatement'], + 'while', + ), + 'with': newKeywordTester('WithStatement', 'with'), + + 'cjs-export': { + test: (node, sourceCode) => node.type === 'ExpressionStatement' + && node.expression.type === 'AssignmentExpression' + && CJS_EXPORT.test(sourceCode.getText(node.expression.left)), + }, + 'cjs-import': { + test: (node, sourceCode) => node.type === 'VariableDeclaration' + && node.declarations.length > 0 + && node.declarations[0]?.init != null + && CJS_IMPORT.test(sourceCode.getText(node.declarations[0].init)), + }, + + 'enum': newKeywordTester( + 'TSEnumDeclaration', + 'enum', + ), + 'interface': newKeywordTester( + 'TSInterfaceDeclaration', + 'interface', + ), + 'function-overload': newNodeTypeTester('TSDeclareFunction'), + ...Object.fromEntries( + Object.entries(MaybeMultilineStatementType) + .flatMap(([key, value]) => [ + [key, value], + [ + `singleline-${key}`, + { + ...value, + test: (node, sourceCode) => value.test(node, sourceCode) && isSingleLine(node), + }, + ], + [ + `multiline-${key}`, + { + ...value, + test: (node, sourceCode) => value.test(node, sourceCode) && !isSingleLine(node), + }, + ], + ]), + ), +} + +/** Build the vendored padding rule with caller-owned, typed policy options. */ +export default function createPaddingLineRule(options: RuleOptions): CreateRule { +return { + meta: { + type: 'layout', + docs: { + description: 'Require or disallow padding lines between statements', + }, + fixable: 'whitespace', + hasSuggestions: false, + // This is intentionally an array schema as you can pass 0..n config objects + schema: { + $defs: { + paddingType: { + type: 'string', + enum: Object.keys(PaddingTypes), + }, + statementType: { + type: 'string', + enum: Object.keys(StatementTypes), + }, + selectorOption: { + type: 'object', + properties: { + selector: { + type: 'string', + }, + lineMode: { + type: 'string', + enum: ['any', 'singleline', 'multiline'], + }, + }, + required: ['selector'], + additionalProperties: false, + }, + statementMatcher: { + anyOf: [ + { $ref: '#/$defs/statementType' }, + { $ref: '#/$defs/selectorOption' }, + ], + }, + statementOption: { + anyOf: [ + { $ref: '#/$defs/statementMatcher' }, + { + type: 'array', + items: { $ref: '#/$defs/statementMatcher' }, + minItems: 1, + uniqueItems: true, + additionalItems: false, + }, + ], + }, + }, + type: 'array', + additionalItems: false, + items: { + type: 'object', + properties: { + blankLine: { $ref: '#/$defs/paddingType' }, + prev: { $ref: '#/$defs/statementOption' }, + next: { $ref: '#/$defs/statementOption' }, + }, + additionalProperties: false, + required: ['blankLine', 'prev', 'next'], + }, + }, + messages: { + unexpectedBlankLine: 'Unexpected blank line before this statement.', + expectedBlankLine: 'Expected blank line before this statement.', + }, + }, + create(context) { + const sourceCode = context.sourceCode + + const selectorMatchedNodes = new Map>() + const pendingPairs: { prevNode: ASTNode, nextNode: ASTNode }[] = [] + + function collectSelectorOption(option: StatementOption): void { + if (Array.isArray(option)) { + for (const item of option) + collectSelectorOption(item) + return + } + + if (!isSelectorOption(option)) + return + + selectorMatchedNodes.set(option.selector, new Set()) + } + + for (const configure of options) { + collectSelectorOption(configure.prev) + collectSelectorOption(configure.next) + } + + type Scope = { + upper: Scope + prevNode: ASTNode | null + } | null + + let scopeInfo: Scope = null + + /** + * Processes to enter to new scope. + * This manages the current previous statement. + * + * @private + */ + function enterScope(): void { + scopeInfo = { + upper: scopeInfo, + prevNode: null, + } + } + + /** + * Processes to exit from the current scope. + * + * @private + */ + function exitScope(): void { + if (scopeInfo) + scopeInfo = scopeInfo.upper + } + + /** + * Checks whether the given node matches the given type. + * @param node The statement node to check. + * @param type The statement type to check. + * @returns `true` if the statement node matched the type. + * @private + */ + function match(node: ASTNode, type: StatementOption): boolean { + let innerStatementNode = node + + while (innerStatementNode.type === 'LabeledStatement') + innerStatementNode = innerStatementNode.body + + if (Array.isArray(type)) + return type.some(match.bind(null, innerStatementNode)) + + if (isSelectorOption(type)) { + const matchedNodes = selectorMatchedNodes.get(type.selector) + if (!matchedNodes?.has(innerStatementNode)) + return false + + const lineMode = type.lineMode + + if (lineMode === 'singleline') + return isSingleLine(innerStatementNode) + else if (lineMode === 'multiline') + return !isSingleLine(innerStatementNode) + + return true + } + else { + const statementType = StatementTypes[type] + if (statementType === undefined) + throw new Error(`Padding rule invariant: unsupported statement type ${type}`) + return statementType.test(innerStatementNode, sourceCode) + } + } + + /** + * Finds the last matched configure from options. + * @param prevNode The previous statement to match. + * @param nextNode The current statement to match. + * @returns The tester of the last matched configure. + * @private + */ + function getPaddingType( + prevNode: ASTNode, + nextNode: ASTNode, + ): (typeof PaddingTypes)[keyof typeof PaddingTypes] { + for (let i = options.length - 1; i >= 0; --i) { + const configure = options[i] + if (configure === undefined) + throw new Error('Padding rule invariant: configuration entry is missing') + if ( + match(prevNode, configure.prev) + && match(nextNode, configure.next) + ) { + return PaddingTypes[configure.blankLine] + } + } + return PaddingTypes.any + } + + /** + * Gets padding line sequences between the given 2 statements. + * Comments are separators of the padding line sequences. + * @param prevNode The previous statement to count. + * @param nextNode The current statement to count. + * @returns The array of token pairs. + * @private + */ + function getPaddingLineSequences( + prevNode: ASTNode, + nextNode: ASTNode, + ): [Token, Token][] { + const pairs: [Token, Token][] = [] + let prevToken: Token = getActualLastToken(prevNode, sourceCode)! + + if (nextNode.loc.start.line - prevToken.loc.end.line >= 2) { + do { + const token: Token = sourceCode.getTokenAfter(prevToken, { + includeComments: true, + })! + + if (token.loc.start.line - prevToken.loc.end.line >= 2) + pairs.push([prevToken, token]) + + prevToken = token + } while (prevToken.range[0] < nextNode.range[0]) + } + + return pairs + } + + /** + * Verify padding lines between the given node and the previous node. + * @param node The node to verify. + * + * @private + */ + function verify(node: ASTNode): void { + if ( + !node.parent + || ![ + 'BlockStatement', + 'Program', + 'StaticBlock', + 'SwitchCase', + 'SwitchStatement', + 'TSInterfaceBody', + 'TSModuleBlock', + 'TSTypeLiteral', + ].includes(node.parent.type) + ) { + return + } + + // Save this node as the current previous statement. + const prevNode = scopeInfo!.prevNode + + // Verify. + if (prevNode) + pendingPairs.push({ prevNode, nextNode: node }) + + scopeInfo!.prevNode = node + } + + function verifyPendingPairs(): void { + for (const { prevNode, nextNode } of pendingPairs) { + const type = getPaddingType(prevNode, nextNode) + const paddingLines = getPaddingLineSequences(prevNode, nextNode) + + type.verify(context, prevNode, nextNode, paddingLines) + } + } + + /** + * Verify padding lines between the given node and the previous node. + * Then process to enter to new scope. + * @param node The node to verify. + * + * @private + */ + function verifyThenEnterScope(node: ASTNode): void { + verify(node) + enterScope() + } + + const selectorMatchListeners = Object.fromEntries( + Array.from(selectorMatchedNodes.keys(), selector => [ + selector, + (node: ASTNode): void => { + selectorMatchedNodes.get(selector)?.add(node) + }, + ]), + ) + + return { + 'Program': enterScope, + 'Program:exit': () => { + verifyPendingPairs() + exitScope() + }, + 'BlockStatement': enterScope, + 'BlockStatement:exit': exitScope, + 'SwitchStatement': enterScope, + 'SwitchStatement:exit': exitScope, + 'SwitchCase': verifyThenEnterScope, + 'SwitchCase:exit': exitScope, + 'StaticBlock': enterScope, + 'StaticBlock:exit': exitScope, + + 'TSInterfaceBody': enterScope, + 'TSInterfaceBody:exit': exitScope, + 'TSModuleBlock': enterScope, + 'TSModuleBlock:exit': exitScope, + 'TSTypeLiteral': enterScope, + 'TSTypeLiteral:exit': exitScope, + 'TSDeclareFunction': verifyThenEnterScope, + 'TSDeclareFunction:exit': exitScope, + 'TSMethodSignature': verifyThenEnterScope, + 'TSMethodSignature:exit': exitScope, + + ':statement': verify, + ...selectorMatchListeners, + } + }, +} +} diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-options.d.ts b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-options.d.ts new file mode 100644 index 00000000..45068ec5 --- /dev/null +++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-options.d.ts @@ -0,0 +1,87 @@ +/* GENERATED, DO NOT EDIT DIRECTLY */ + +/* @checksum: 3QCTtOH6rJM5_AGJ58rGpeEaBEfaJz17MSCxWB4X_PU */ + +export type PaddingType = 'any' | 'never' | 'always' +export type StatementOption = + | StatementMatcher + | [StatementMatcher, ...StatementMatcher[]] +export type StatementMatcher = + | StatementType + | SelectorOption +export type StatementType = + | '*' + | 'exports' + | 'require' + | 'directive' + | 'iife' + | 'block' + | 'empty' + | 'function' + | 'ts-method' + | 'break' + | 'case' + | 'class' + | 'continue' + | 'debugger' + | 'default' + | 'do' + | 'for' + | 'if' + | 'import' + | 'switch' + | 'throw' + | 'try' + | 'while' + | 'with' + | 'cjs-export' + | 'cjs-import' + | 'enum' + | 'interface' + | 'function-overload' + | 'block-like' + | 'singleline-block-like' + | 'multiline-block-like' + | 'expression' + | 'singleline-expression' + | 'multiline-expression' + | 'return' + | 'singleline-return' + | 'multiline-return' + | 'export' + | 'singleline-export' + | 'multiline-export' + | 'var' + | 'singleline-var' + | 'multiline-var' + | 'let' + | 'singleline-let' + | 'multiline-let' + | 'const' + | 'singleline-const' + | 'multiline-const' + | 'using' + | 'singleline-using' + | 'multiline-using' + | 'type' + | 'singleline-type' + | 'multiline-type' +export type PaddingLineBetweenStatementsSchema0 = { + blankLine: PaddingType + prev: StatementOption + next: StatementOption +}[] + +export interface SelectorOption { + selector: string + lineMode?: 'any' | 'singleline' | 'multiline' +} + +export type PaddingLineBetweenStatementsRuleOptions + = PaddingLineBetweenStatementsSchema0 + +export type RuleOptions + = PaddingLineBetweenStatementsRuleOptions +export type MessageIds = + | 'unexpectedBlankLine' + | 'expectedBlankLine' diff --git a/translate/README.md b/translate/README.md index 2e59c147..31593528 100644 --- a/translate/README.md +++ b/translate/README.md @@ -2,54 +2,50 @@ A translation layer to use the terms previously defined by the Node.js Package Maintenance WG's [Package Support document](https://github.com/nodejs/package-maintenance/blob/main/docs/PACKAGE-SUPPORT.md) to terms that can easily be used by [@nodevu/ranges](https://npm.im/@nodevu/ranges). - ## Usage ```js -const ranges = require('@nodevu/ranges') -const translate = require('@nodevu/translate') - -async function getTranslatedData () { - // translation for the 'current' Package Support term - const current = await ranges(await translate('current')) - - // translation for the 'lts_latest' Package Support term - const lts_latest = await ranges(await translate('lts_latest')) - - // translation for the 'lts' Package Support term - const lts = await ranges(await translate('lts')) - - // translation for the 'supported' Package Support term - const supported = await ranges(await translate('supported')) - - // translation for the 'all' Package Support term - const all = await ranges(await translate('all')) - - // return the data in a single object - return { - current, - lts_latest, - lts, - supported, - all - } +const ranges = require('@nodevu/ranges'); +const translate = require('@nodevu/translate'); + +async function getTranslatedData() { + // translation for the 'current' Package Support term + const current = await ranges(await translate('current')); + + // translation for the 'lts_latest' Package Support term + const lts_latest = await ranges(await translate('lts_latest')); + + // translation for the 'lts' Package Support term + const lts = await ranges(await translate('lts')); + + // translation for the 'supported' Package Support term + const supported = await ranges(await translate('supported')); + + // translation for the 'all' Package Support term + const all = await ranges(await translate('all')); + + // return the data in a single object + return { + current, + lts_latest, + lts, + supported, + all, + }; } -getTranslatedData().then(console.log) +getTranslatedData().then(console.log); ``` ## API - `translate(alias)` - `alias` (string): A Node.js Package Support Alias. - -Possible values for `alias`: - - `current`: returns `['current']` - - `lts_latest`: returns `['lts/latest']` - - `lts`: returns `['lts/active', 'lts/maintenance']` - - `supported`: returns `['current', 'lts/active', 'lts/maintenance']` - - `all`: returns `['current', 'lts/active', 'lts/maintenance', 'eol']` - - +Possible values for `alias`: +- `current`: returns `['current']` +- `lts_latest`: returns `['lts/latest']` +- `lts`: returns `['lts/active', 'lts/maintenance']` +- `supported`: returns `['current', 'lts/active', 'lts/maintenance']` +- `all`: returns `['current', 'lts/active', 'lts/maintenance', 'eol']` diff --git a/translate/biome.json b/translate/biome.json deleted file mode 100644 index 86bda6e2..00000000 --- a/translate/biome.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "root": false, - "extends": "//" -} diff --git a/translate/package.json b/translate/package.json index 23fa47b7..5ab27488 100644 --- a/translate/package.json +++ b/translate/package.json @@ -21,15 +21,14 @@ "author": "Tierney Cyren", "main": "index.js", "scripts": { - "lint": "biome check ./", - "lint:write": "biome check ./ --write", + "lint": "oxlint && oxfmt --check", + "lint:write": "oxlint --fix && oxfmt", "test": "node --test", "coverage": "c8 node --test", "updates:check": "npx npm-check-updates", "updates:update": "npx npm-check-updates -u" }, "devDependencies": { - "@biomejs/biome": "2.5.15", "@nodevu/ranges": "^1.0.0", "c8": "^10.1.2" }, diff --git a/translate/test/ranges.test.js b/translate/test/ranges.test.js index ae8e5323..1e902f26 100644 --- a/translate/test/ranges.test.js +++ b/translate/test/ranges.test.js @@ -28,6 +28,7 @@ describe('test that each alias works with @nodevu/ranges', async () => { ['lts/active', 'lts/maintenance'], options, ); + const translated = await ranges(await translate('lts'), options); assert.deepStrictEqual(untranslated, translated); @@ -38,6 +39,7 @@ describe('test that each alias works with @nodevu/ranges', async () => { ['current', 'lts/active', 'lts/maintenance'], options, ); + const translated = await ranges(await translate('supported'), options); assert.deepStrictEqual(untranslated, translated); @@ -48,6 +50,7 @@ describe('test that each alias works with @nodevu/ranges', async () => { ['current', 'lts/active', 'lts/maintenance', 'eol'], options, ); + const translated = await ranges(await translate('all'), options); assert.deepStrictEqual(untranslated, translated);