diff --git a/.eslintrc.js b/.eslintrc.js index 5db9f81..f21d26e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,6 +10,9 @@ const localConfigs = readdir(__dirname) module.exports = { root: true, + ignorePatterns: [ + 'tap-testdir*/', + ], extends: [ '@npmcli', ...localConfigs, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8da2a45..69312df 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,7 @@ updates: directory: / schedule: interval: daily + target-branch: "main" allow: - dependency-type: direct versioning-strategy: increase-if-necessary diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..c742871 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,27 @@ +# This file is automatically added by @npmcli/template-oss. Do not edit. + +repository: + allow_merge_commit: false + allow_rebase_merge: true + allow_squash_merge: true + squash_merge_commit_title: PR_TITLE + squash_merge_commit_message: PR_BODY + delete_branch_on_merge: true + enable_automated_security_fixes: true + enable_vulnerability_alerts: true + +branches: + - name: main + protection: + required_status_checks: null + enforce_admins: true + block_creations: true + required_pull_request_reviews: + required_approving_review_count: 1 + require_code_owner_reviews: true + require_last_push_approval: true + dismiss_stale_reviews: true + restrictions: + apps: [] + users: [] + teams: [ "cli-team" ] diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 60bb334..908ae16 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -25,13 +25,44 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: - node-version: 18.x - - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + node-version: 20.x + check-latest: contains('20.x', '.x') + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies run: npm i --ignore-scripts --no-audit --no-fund --package-lock - - name: Run Audit - run: npm audit + - name: Run Production Audit + run: npm audit --omit=dev + - name: Run Full Audit + run: npm audit --audit-level=none diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 9cc6b28..2006738 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -3,6 +3,12 @@ name: CI - Release on: + workflow_dispatch: + inputs: + ref: + required: true + type: string + default: main workflow_call: inputs: ref: @@ -21,21 +27,49 @@ jobs: run: shell: bash steps: + - name: Get Workflow Job + uses: actions/github-script@v6 + if: inputs.check-sha + id: check-output + env: + JOB_NAME: "Lint All" + MATRIX_NAME: "" + with: + script: | + const { owner, repo } = context.repo + + const { data } = await github.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: context.runId, + per_page: 100 + }) + + const jobName = process.env.JOB_NAME + process.env.MATRIX_NAME + const job = data.jobs.find(j => j.name.endsWith(jobName)) + const jobUrl = job?.html_url + + const shaUrl = `${context.serverUrl}/${owner}/${repo}/commit/${{ inputs.check-sha }}` + + let summary = `This check is assosciated with ${shaUrl}\n\n` + + if (jobUrl) { + summary += `For run logs, click here: ${jobUrl}` + } else { + summary += `Run logs could not be found for a job with name: "${jobName}"` + } + + return { summary } - name: Create Check - uses: LouisBrunner/checks-action@v1.3.1 + uses: LouisBrunner/checks-action@v1.6.0 id: check - + if: inputs.check-sha with: token: ${{ secrets.GITHUB_TOKEN }} status: in_progress name: Lint All sha: ${{ inputs.check-sha }} - # XXX: this does not work when using the default GITHUB_TOKEN. - # Instead we post the main job url to the PR as a comment which - # will link to all the other checks. To work around this we would - # need to create a GitHub that would create on-demand tokens. - # https://github.com/LouisBrunner/checks-action/issues/18 - # details_url: + output: ${{ steps.check-output.outputs.result }} - name: Checkout uses: actions/checkout@v3 with: @@ -46,10 +80,39 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: - node-version: 18.x - - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + node-version: 20.x + check-latest: contains('20.x', '.x') + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies @@ -59,8 +122,8 @@ jobs: - name: Post Lint run: npm run postlint --ignore-scripts - name: Conclude Check - uses: LouisBrunner/checks-action@v1.3.1 - if: always() + uses: LouisBrunner/checks-action@v1.6.0 + if: steps.check.outputs.check_id && always() with: token: ${{ secrets.GITHUB_TOKEN }} conclusion: ${{ job.status }} @@ -89,26 +152,55 @@ jobs: - 16.x - 18.0.0 - 18.x + - 20.x runs-on: ${{ matrix.platform.os }} defaults: run: shell: ${{ matrix.platform.shell }} steps: + - name: Get Workflow Job + uses: actions/github-script@v6 + if: inputs.check-sha + id: check-output + env: + JOB_NAME: "Test All" + MATRIX_NAME: " - ${{ matrix.platform.name }} - ${{ matrix.node-version }}" + with: + script: | + const { owner, repo } = context.repo + + const { data } = await github.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: context.runId, + per_page: 100 + }) + + const jobName = process.env.JOB_NAME + process.env.MATRIX_NAME + const job = data.jobs.find(j => j.name.endsWith(jobName)) + const jobUrl = job?.html_url + + const shaUrl = `${context.serverUrl}/${owner}/${repo}/commit/${{ inputs.check-sha }}` + + let summary = `This check is assosciated with ${shaUrl}\n\n` + + if (jobUrl) { + summary += `For run logs, click here: ${jobUrl}` + } else { + summary += `Run logs could not be found for a job with name: "${jobName}"` + } + + return { summary } - name: Create Check - uses: LouisBrunner/checks-action@v1.3.1 + uses: LouisBrunner/checks-action@v1.6.0 id: check - + if: inputs.check-sha with: token: ${{ secrets.GITHUB_TOKEN }} status: in_progress name: Test All - ${{ matrix.platform.name }} - ${{ matrix.node-version }} sha: ${{ inputs.check-sha }} - # XXX: this does not work when using the default GITHUB_TOKEN. - # Instead we post the main job url to the PR as a comment which - # will link to all the other checks. To work around this we would - # need to create a GitHub that would create on-demand tokens. - # https://github.com/LouisBrunner/checks-action/issues/18 - # details_url: + output: ${{ steps.check-output.outputs.result }} - name: Checkout uses: actions/checkout@v3 with: @@ -119,11 +211,19 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: node-version: ${{ matrix.node-version }} + check-latest: contains(matrix.node-version, '.x') + + # node 10/12/14 ship with npm@6, which is known to fail when updating itself in windows - name: Update Windows npm - # node 12 and 14 ship with npm@6, which is known to fail when updating itself in windows - if: matrix.platform.os == 'windows-latest' && (startsWith(matrix.node-version, '12.') || startsWith(matrix.node-version, '14.')) + if: | + matrix.platform.os == 'windows-latest' && ( + startsWith(steps.node.outputs.node-version, 'v10.') || + startsWith(steps.node.outputs.node-version, 'v12.') || + startsWith(steps.node.outputs.node-version, 'v14.') + ) run: | curl -sO https://registry.npmjs.org/npm/-/npm-7.5.4.tgz tar xf npm-7.5.4.tgz @@ -131,12 +231,35 @@ jobs: node lib/npm.js install --no-fund --no-audit -g ..\npm-7.5.4.tgz cd .. rmdir /s /q package - - name: Install npm@7 - if: startsWith(matrix.node-version, '10.') - run: npm i --prefer-online --no-fund --no-audit -g npm@7 - - name: Install npm@latest - if: ${{ !startsWith(matrix.node-version, '10.') }} - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies @@ -146,8 +269,8 @@ jobs: - name: Test run: npm test --ignore-scripts - name: Conclude Check - uses: LouisBrunner/checks-action@v1.3.1 - if: always() + uses: LouisBrunner/checks-action@v1.6.0 + if: steps.check.outputs.check_id && always() with: token: ${{ secrets.GITHUB_TOKEN }} conclusion: ${{ job.status }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6c934a..56c6a67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,62 +8,11 @@ on: push: branches: - main - - latest schedule: # "At 09:00 UTC (02:00 PT) on Monday" https://crontab.guru/#0_9_*_*_1 - cron: "0 9 * * 1" jobs: - engines: - name: Engines - ${{ matrix.platform.name }} - ${{ matrix.node-version }} - if: github.repository_owner == 'npm' - strategy: - fail-fast: false - matrix: - platform: - - name: Linux - os: ubuntu-latest - shell: bash - node-version: - - 14.17.0 - - 16.13.0 - - 18.0.0 - runs-on: ${{ matrix.platform.os }} - defaults: - run: - shell: ${{ matrix.platform.shell }} - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Setup Git User - run: | - git config --global user.email "npm-cli+bot@github.com" - git config --global user.name "npm CLI robot" - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - name: Update Windows npm - # node 12 and 14 ship with npm@6, which is known to fail when updating itself in windows - if: matrix.platform.os == 'windows-latest' && (startsWith(matrix.node-version, '12.') || startsWith(matrix.node-version, '14.')) - run: | - curl -sO https://registry.npmjs.org/npm/-/npm-7.5.4.tgz - tar xf npm-7.5.4.tgz - cd package - node lib/npm.js install --no-fund --no-audit -g ..\npm-7.5.4.tgz - cd .. - rmdir /s /q package - - name: Install npm@7 - if: startsWith(matrix.node-version, '10.') - run: npm i --prefer-online --no-fund --no-audit -g npm@7 - - name: Install npm@latest - if: ${{ !startsWith(matrix.node-version, '10.') }} - run: npm i --prefer-online --no-fund --no-audit -g npm@latest - - name: npm Version - run: npm -v - - name: Install Dependencies - run: npm i --ignore-scripts --no-audit --no-fund --engines-strict - lint: name: Lint if: github.repository_owner == 'npm' @@ -80,10 +29,39 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: - node-version: 18.x - - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + node-version: 20.x + check-latest: contains('20.x', '.x') + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies @@ -116,6 +94,7 @@ jobs: - 16.x - 18.0.0 - 18.x + - 20.x runs-on: ${{ matrix.platform.os }} defaults: run: @@ -129,11 +108,19 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: node-version: ${{ matrix.node-version }} + check-latest: contains(matrix.node-version, '.x') + + # node 10/12/14 ship with npm@6, which is known to fail when updating itself in windows - name: Update Windows npm - # node 12 and 14 ship with npm@6, which is known to fail when updating itself in windows - if: matrix.platform.os == 'windows-latest' && (startsWith(matrix.node-version, '12.') || startsWith(matrix.node-version, '14.')) + if: | + matrix.platform.os == 'windows-latest' && ( + startsWith(steps.node.outputs.node-version, 'v10.') || + startsWith(steps.node.outputs.node-version, 'v12.') || + startsWith(steps.node.outputs.node-version, 'v14.') + ) run: | curl -sO https://registry.npmjs.org/npm/-/npm-7.5.4.tgz tar xf npm-7.5.4.tgz @@ -141,12 +128,35 @@ jobs: node lib/npm.js install --no-fund --no-audit -g ..\npm-7.5.4.tgz cd .. rmdir /s /q package - - name: Install npm@7 - if: startsWith(matrix.node-version, '10.') - run: npm i --prefer-online --no-fund --no-audit -g npm@7 - - name: Install npm@latest - if: ${{ !startsWith(matrix.node-version, '10.') }} - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 66b9498..f7e691d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -6,11 +6,9 @@ on: push: branches: - main - - latest pull_request: branches: - main - - latest schedule: # "At 10:00 UTC (03:00 PT) on Monday" https://crontab.guru/#0_10_*_*_1 - cron: "0 10 * * 1" diff --git a/.github/workflows/post-dependabot.yml b/.github/workflows/post-dependabot.yml index 88ac403..9a4b761 100644 --- a/.github/workflows/post-dependabot.yml +++ b/.github/workflows/post-dependabot.yml @@ -26,10 +26,39 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: - node-version: 18.x - - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + node-version: 20.x + check-latest: contains('20.x', '.x') + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies @@ -48,11 +77,11 @@ jobs: run: | dependabot_dir="${{ steps.metadata.outputs.directory }}" if [[ "$dependabot_dir" == "/" ]]; then - echo "::set-output name=workspace::-iwr" + echo "workspace=-iwr" >> $GITHUB_OUTPUT else # strip leading slash from directory so it works as a # a path to the workspace flag - echo "::set-output name=workspace::-w ${dependabot_dir#/}" + echo "workspace=-w ${dependabot_dir#/}" >> $GITHUB_OUTPUT fi - name: Apply Changes @@ -61,17 +90,17 @@ jobs: run: | npm run template-oss-apply ${{ steps.flags.outputs.workspace }} if [[ `git status --porcelain` ]]; then - echo "::set-output name=changes::true" + echo "changes=true" >> $GITHUB_OUTPUT fi # This only sets the conventional commit prefix. This workflow can't reliably determine # what the breaking change is though. If a BREAKING CHANGE message is required then # this PR check will fail and the commit will be amended with stafftools - if [[ "${{ steps.dependabot-metadata.outputs.update-type }}" == "version-update:semver-major" ]]; then + if [[ "${{ steps.metadata.outputs.update-type }}" == "version-update:semver-major" ]]; then prefix='feat!' else - prefix='chore!' + prefix='chore' fi - echo "::set-output name=message::$prefix: postinstall for dependabot template-oss PR" + echo "message=$prefix: postinstall for dependabot template-oss PR" >> $GITHUB_OUTPUT # This step will fail if template-oss has made any workflow updates. It is impossible # for a workflow to update other workflows. In the case it does fail, we continue @@ -90,7 +119,7 @@ jobs: # and attempt to commit and push again. This is helpful because we will have a commit # with the correct prefix that we can then --amend with @npmcli/stafftools later. - name: Push All Changes Except Workflows - if: steps.apply.outputs.changes && steps.push-all.outcome == 'failure' + if: steps.apply.outputs.changes && steps.push.outcome == 'failure' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1a1d1ee..3418d4c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -29,10 +29,39 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: - node-version: 18.x - - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + node-version: 20.x + check-latest: contains('20.x', '.x') + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies @@ -41,8 +70,10 @@ jobs: id: commit continue-on-error: true run: | - npx --offline commitlint -V --from origin/${{ github.base_ref }} --to ${{ github.event.pull_request.head.sha }} + npx --offline commitlint -V --from 'origin/${{ github.base_ref }}' --to ${{ github.event.pull_request.head.sha }} - name: Run Commitlint on PR Title if: steps.commit.outcome == 'failure' + env: + PR_TITLE: ${{ github.event.pull_request.title }} run: | - echo ${{ github.event.pull_request.title }} | npx --offline commitlint -V + echo "$PR_TITLE" | npx --offline commitlint -V diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ed3865..175a86d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,14 @@ name: Release on: + workflow_dispatch: + inputs: + release-pr: + description: a release PR number to rerun release jobs on + type: string push: branches: - main - - latest permissions: contents: write @@ -17,8 +21,8 @@ jobs: release: outputs: pr: ${{ steps.release.outputs.pr }} + release: ${{ steps.release.outputs.release }} releases: ${{ steps.release.outputs.releases }} - release-flags: ${{ steps.release.outputs.release-flags }} branch: ${{ steps.release.outputs.pr-branch }} pr-number: ${{ steps.release.outputs.pr-number }} comment-id: ${{ steps.pr-comment.outputs.result }} @@ -38,10 +42,39 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: - node-version: 18.x - - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + node-version: 20.x + check-latest: contains('20.x', '.x') + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies @@ -51,49 +84,82 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx --offline template-oss-release-please ${{ github.ref_name }} + npx --offline template-oss-release-please "${{ github.ref_name }}" "${{ inputs.release-pr }}" - name: Post Pull Request Comment if: steps.release.outputs.pr-number uses: actions/github-script@v6 id: pr-comment env: PR_NUMBER: ${{ steps.release.outputs.pr-number }} + REF_NAME: ${{ github.ref_name }} with: script: | - const repo = { owner: context.repo.owner, repo: context.repo.repo } - const issue = { ...repo, issue_number: process.env.PR_NUMBER } + const { REF_NAME, PR_NUMBER: issue_number } = process.env + const { runId, repo: { owner, repo } } = context - const { data: workflow } = await github.rest.actions.getWorkflowRun({ ...repo, run_id: context.runId }) + const { data: workflow } = await github.rest.actions.getWorkflowRun({ owner, repo, run_id: runId }) let body = '## Release Manager\n\n' - const comments = await github.paginate(github.rest.issues.listComments, issue) - let commentId = comments?.find(c => c.user.login === 'github-actions[bot]' && c.body.startsWith(body))?.id + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }) + let commentId = comments.find(c => c.user.login === 'github-actions[bot]' && c.body.startsWith(body))?.id + + body += `Release workflow run: ${workflow.html_url}\n\n#### Force CI to Update This Release\n\n` + body += `This PR will be updated and CI will run for every non-\`chore:\` commit that is pushed to \`${REF_NAME}\`. ` + body += `To force CI to update this PR, run this command:\n\n` + body += `\`\`\`\ngh workflow run release.yml -r ${REF_NAME} -R ${owner}/${repo} -f release-pr=${issue_number}\n\`\`\`` - body += `- Release workflow run: ${workflow.html_url}` if (commentId) { - await github.rest.issues.updateComment({ ...repo, comment_id: commentId, body }) + await github.rest.issues.updateComment({ owner, repo, comment_id: commentId, body }) } else { - const { data: comment } = await github.rest.issues.createComment({ ...issue, body }) + const { data: comment } = await github.rest.issues.createComment({ owner, repo, issue_number, body }) commentId = comment?.id } return commentId + - name: Get Workflow Job + uses: actions/github-script@v6 + if: steps.release.outputs.pr-sha + id: check-output + env: + JOB_NAME: "Release" + MATRIX_NAME: "" + with: + script: | + const { owner, repo } = context.repo + + const { data } = await github.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: context.runId, + per_page: 100 + }) + + const jobName = process.env.JOB_NAME + process.env.MATRIX_NAME + const job = data.jobs.find(j => j.name.endsWith(jobName)) + const jobUrl = job?.html_url + + const shaUrl = `${context.serverUrl}/${owner}/${repo}/commit/${{ steps.release.outputs.pr-sha }}` + + let summary = `This check is assosciated with ${shaUrl}\n\n` + + if (jobUrl) { + summary += `For run logs, click here: ${jobUrl}` + } else { + summary += `Run logs could not be found for a job with name: "${jobName}"` + } + + return { summary } - name: Create Check - uses: LouisBrunner/checks-action@v1.3.1 + uses: LouisBrunner/checks-action@v1.6.0 id: check - if: steps.release.outputs.pr-number + if: steps.release.outputs.pr-sha with: token: ${{ secrets.GITHUB_TOKEN }} status: in_progress name: Release sha: ${{ steps.release.outputs.pr-sha }} - # XXX: this does not work when using the default GITHUB_TOKEN. - # Instead we post the main job url to the PR as a comment which - # will link to all the other checks. To work around this we would - # need to create a GitHub that would create on-demand tokens. - # https://github.com/LouisBrunner/checks-action/issues/18 - # details_url: + output: ${{ steps.check-output.outputs.result }} update: needs: release @@ -118,10 +184,39 @@ jobs: git config --global user.name "npm CLI robot" - name: Setup Node uses: actions/setup-node@v3 + id: node with: - node-version: 18.x - - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest + node-version: 20.x + check-latest: contains('20.x', '.x') + + - name: Install Latest npm + shell: bash + env: + NODE_VERSION: ${{ steps.node.outputs.node-version }} + run: | + MATCH="" + SPECS=("latest" "next-10" "next-9" "next-8" "next-7" "next-6") + + echo "node@$NODE_VERSION" + + for SPEC in ${SPECS[@]}; do + ENGINES=$(npm view npm@$SPEC --json | jq -r '.engines.node') + echo "Checking if node@$NODE_VERSION satisfies npm@$SPEC ($ENGINES)" + + if npx semver -r "$ENGINES" "$NODE_VERSION" > /dev/null; then + MATCH=$SPEC + echo "Found compatible version: npm@$MATCH" + break + fi + done + + if [ -z $MATCH ]; then + echo "Could not find a compatible version of npm for node@$NODE_VERSION" + exit 1 + fi + + npm i --prefer-online --no-fund --no-audit -g npm@$MATCH + - name: npm Version run: npm -v - name: Install Dependencies @@ -132,7 +227,7 @@ jobs: RELEASE_COMMENT_ID: ${{ needs.release.outputs.comment-id }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npm exec --offline -- template-oss-release-manager + npm exec --offline -- template-oss-release-manager --lockfile=false --publish=true npm run rp-pull-request --ignore-scripts --if-present - name: Commit id: commit @@ -141,25 +236,53 @@ jobs: run: | git commit --all --amend --no-edit || true git push --force-with-lease - echo "::set-output name=sha::$(git rev-parse HEAD)" + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + - name: Get Workflow Job + uses: actions/github-script@v6 + if: steps.commit.outputs.sha + id: check-output + env: + JOB_NAME: "Update - Release" + MATRIX_NAME: "" + with: + script: | + const { owner, repo } = context.repo + + const { data } = await github.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: context.runId, + per_page: 100 + }) + + const jobName = process.env.JOB_NAME + process.env.MATRIX_NAME + const job = data.jobs.find(j => j.name.endsWith(jobName)) + const jobUrl = job?.html_url + + const shaUrl = `${context.serverUrl}/${owner}/${repo}/commit/${{ steps.commit.outputs.sha }}` + + let summary = `This check is assosciated with ${shaUrl}\n\n` + + if (jobUrl) { + summary += `For run logs, click here: ${jobUrl}` + } else { + summary += `Run logs could not be found for a job with name: "${jobName}"` + } + + return { summary } - name: Create Check - uses: LouisBrunner/checks-action@v1.3.1 + uses: LouisBrunner/checks-action@v1.6.0 id: check - + if: steps.commit.outputs.sha with: token: ${{ secrets.GITHUB_TOKEN }} status: in_progress name: Release sha: ${{ steps.commit.outputs.sha }} - # XXX: this does not work when using the default GITHUB_TOKEN. - # Instead we post the main job url to the PR as a comment which - # will link to all the other checks. To work around this we would - # need to create a GitHub that would create on-demand tokens. - # https://github.com/LouisBrunner/checks-action/issues/18 - # details_url: + output: ${{ steps.check-output.outputs.result }} - name: Conclude Check - uses: LouisBrunner/checks-action@v1.3.1 - if: always() + uses: LouisBrunner/checks-action@v1.6.0 + if: needs.release.outputs.check-id && always() with: token: ${{ secrets.GITHUB_TOKEN }} conclusion: ${{ job.status }} @@ -194,10 +317,10 @@ jobs: else result="success" fi - echo "::set-output name=result::$result" + echo "result=$result" >> $GITHUB_OUTPUT - name: Conclude Check - uses: LouisBrunner/checks-action@v1.3.1 - if: always() + uses: LouisBrunner/checks-action@v1.6.0 + if: needs.update.outputs.check-id && always() with: token: ${{ secrets.GITHUB_TOKEN }} conclusion: ${{ steps.needs-result.outputs.result }} @@ -211,25 +334,122 @@ jobs: defaults: run: shell: bash + steps: + - name: Create Release PR Comment + uses: actions/github-script@v6 + env: + RELEASES: ${{ needs.release.outputs.releases }} + with: + script: | + const releases = JSON.parse(process.env.RELEASES) + const { runId, repo: { owner, repo } } = context + const issue_number = releases[0].prNumber + + let body = '## Release Workflow\n\n' + for (const { pkgName, version, url } of releases) { + body += `- \`${pkgName}@${version}\` ${url}\n` + } + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }) + .then(cs => cs.map(c => ({ id: c.id, login: c.user.login, body: c.body }))) + console.log(`Found comments: ${JSON.stringify(comments, null, 2)}`) + const releaseComments = comments.filter(c => c.login === 'github-actions[bot]' && c.body.includes('Release is at')) + + for (const comment of releaseComments) { + console.log(`Release comment: ${JSON.stringify(comment, null, 2)}`) + await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }) + } + + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}` + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: `${body}- Workflow run: :arrows_counterclockwise: ${runUrl}`, + }) + + release-integration: + needs: release + name: Release Integration + if: needs.release.outputs.release + runs-on: ubuntu-latest + defaults: + run: + shell: bash + permissions: + deployments: write + id-token: write steps: - name: Checkout uses: actions/checkout@v3 - - name: Setup Git User - run: | - git config --global user.email "npm-cli+bot@github.com" - git config --global user.name "npm CLI robot" + with: + ref: ${{ fromJSON(needs.release.outputs.release).tagName }} - name: Setup Node uses: actions/setup-node@v3 with: node-version: 18.x - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest - - name: npm Version - run: npm -v - - name: Install Dependencies - run: npm i --ignore-scripts --no-audit --no-fund - - name: Run Post Release Actions + run: | + npm i --prefer-online --no-fund --no-audit -g npm@latest + npm config set '//registry.npmjs.org/:_authToken'=\${PUBLISH_TOKEN} + - name: Publish env: - RELEASES: ${{ needs.release.outputs.releases }} + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + run: npm publish --provenance --tag=latest + + post-release-integration: + needs: [ release, release-integration ] + name: Post Release Integration - Release + if: github.repository_owner == 'npm' && needs.release.outputs.release && always() + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Get Needs Result + id: needs-result run: | - npm run rp-release --ignore-scripts --if-present ${{ join(fromJSON(needs.release.outputs.release-flags), ' ') }} + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + result="x" + elif [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + result="heavy_multiplication_x" + else + result="white_check_mark" + fi + echo "result=$result" >> $GITHUB_OUTPUT + - name: Update Release PR Comment + uses: actions/github-script@v6 + env: + PR_NUMBER: ${{ fromJSON(needs.release.outputs.release).prNumber }} + RESULT: ${{ steps.needs-result.outputs.result }} + with: + script: | + const { PR_NUMBER: issue_number, RESULT } = process.env + const { runId, repo: { owner, repo } } = context + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }) + const updateComment = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## Release Workflow\n\n') && + c.body.includes(runId) + ) + + if (updateComment) { + console.log('Found comment to update:', JSON.stringify(updateComment, null, 2)) + let body = updateComment.body.replace(/Workflow run: :[a-z_]+:/, `Workflow run: :${RESULT}:`) + const tagCodeowner = RESULT !== 'white_check_mark' + if (tagCodeowner) { + body += `\n\n:rotating_light:` + body += ` @npm/cli-team: The post-release workflow failed for this release.` + body += ` Manual steps may need to be taken after examining the workflow output` + body += ` from the above workflow run. :rotating_light:` + } + await github.rest.issues.updateComment({ + owner, + repo, + body, + comment_id: updateComment.id, + }) + } else { + console.log('No matching comments found:', JSON.stringify(comments, null, 2)) + } diff --git a/.gitignore b/.gitignore index 0ec3c84..773cada 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # ignore everything in the root /* +# transient test directories +tap-testdir*/ # keep these !**/.gitignore @@ -15,6 +17,7 @@ !/bin/ !/CHANGELOG* !/CODE_OF_CONDUCT.md +!/CONTRIBUTING.md !/docs/ !/lib/ !/LICENSE* @@ -26,3 +29,4 @@ !/SECURITY.md !/tap-snapshots/ !/test/ +!/tsconfig.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d4f6f29..d6f5405 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.0.0" + ".": "3.0.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0544a4e..3c2b33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [3.0.1](https://github.com/npm/json-parse-even-better-errors/compare/v3.0.0...v3.0.1) (2023-11-27) + +### Bug Fixes + +* [`1e54107`](https://github.com/npm/json-parse-even-better-errors/commit/1e54107648d0b9a86f6c64aac538252726e501c4) [#25](https://github.com/npm/json-parse-even-better-errors/pull/25) refactor for new error style in node 20 (@lukekarrys) + ## [3.0.0](https://github.com/npm/json-parse-even-better-errors/compare/v2.3.1...v3.0.0) (2022-10-10) ### ⚠️ BREAKING CHANGES diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..69e8878 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ + + +# Contributing + +## Code of Conduct + +All interactions in the **npm** organization on GitHub are considered to be covered by our standard [Code of Conduct](https://docs.npmjs.com/policies/conduct). + +## Reporting Bugs + +Before submitting a new bug report please search for an existing or similar report. + +Use one of our existing issue templates if you believe you've come across a unique problem. + +Duplicate issues, or issues that don't use one of our templates may get closed without a response. + +## Pull Request Conventions + +### Commits + +We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). + +When opening a pull request please be sure that either the pull request title, or each commit in the pull request, has one of the following prefixes: + + - `feat`: For when introducing a new feature. The result will be a new semver minor version of the package when it is next published. + - `fix`: For bug fixes. The result will be a new semver patch version of the package when it is next published. + - `docs`: For documentation updates. The result will be a new semver patch version of the package when it is next published. + - `chore`: For changes that do not affect the published module. Often these are changes to tests. The result will be *no* change to the version of the package when it is next published (as the commit does not affect the published version). + +### Test Coverage + +Pull requests made against this repo will run `npm test` automatically. Please make sure tests pass locally before submitting a PR. + +Every new feature or bug fix should come with a corresponding test or tests that validate the solutions. Testing also reports on code coverage and will fail if code coverage drops. + +### Linting + +Linting is also done automatically once tests pass. `npm run lintfix` will fix most linting errors automatically. + +Please make sure linting passes before submitting a PR. + +## What _not_ to contribute? + +### Dependencies + +It should be noted that our team does not accept third-party dependency updates/PRs. If you submit a PR trying to update our dependencies we will close it with or without a reference to these contribution guidelines. + +### Tools/Automation + +Our core team is responsible for the maintenance of the tooling/automation in this project and we ask contributors to not make changes to these when contributing (e.g. `.github/*`, `.eslintrc.json`, `.licensee.json`). Most of those files also have a header at the top to remind folks they are automatically generated. Pull requests that alter these will not be accepted. diff --git a/SECURITY.md b/SECURITY.md index a93106d..9cd2dea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,13 @@ -Please send vulnerability reports through [hackerone](https://hackerone.com/github). +GitHub takes the security of our software products and services seriously, including the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). + +If you believe you have found a security vulnerability in this GitHub-owned open source repository, you can report it to us in one of two ways. + +If the vulnerability you have found is *not* [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) or if you do not wish to be considered for a bounty reward, please report the issue to us directly through [opensource-security@github.com](mailto:opensource-security@github.com). + +If the vulnerability you have found is [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) and you would like for your finding to be considered for a bounty reward, please submit the vulnerability to us through [HackerOne](https://hackerone.com/github) in order to be eligible to receive a bounty award. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Thanks for helping make GitHub safe for everyone. diff --git a/lib/index.js b/lib/index.js index 2b9f3c2..c21dd64 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,55 +1,78 @@ 'use strict' -const hexify = char => { +const INDENT = Symbol.for('indent') +const NEWLINE = Symbol.for('newline') + +const DEFAULT_NEWLINE = '\n' +const DEFAULT_INDENT = ' ' +const BOM = /^\uFEFF/ + +// only respect indentation if we got a line break, otherwise squash it +// things other than objects and arrays aren't indented, so ignore those +// Important: in both of these regexps, the $1 capture group is the newline +// or undefined, and the $2 capture group is the indent, or undefined. +const FORMAT = /^\s*[{[]((?:\r?\n)+)([\s\t]*)/ +const EMPTY = /^(?:\{\}|\[\])((?:\r?\n)+)?$/ + +// Node 20 puts single quotes around the token and a comma after it +const UNEXPECTED_TOKEN = /^Unexpected token '?(.)'?(,)? /i + +const hexify = (char) => { const h = char.charCodeAt(0).toString(16).toUpperCase() - return '0x' + (h.length % 2 ? '0' : '') + h + return `0x${h.length % 2 ? '0' : ''}${h}` } -const parseError = (e, txt, context) => { +// Remove byte order marker. This catches EF BB BF (the UTF-8 BOM) +// because the buffer-to-string conversion in `fs.readFileSync()` +// translates it to FEFF, the UTF-16 BOM. +const stripBOM = (txt) => String(txt).replace(BOM, '') + +const makeParsedError = (msg, parsing, position = 0) => ({ + message: `${msg} while parsing ${parsing}`, + position, +}) + +const parseError = (e, txt, context = 20) => { + let msg = e.message + if (!txt) { - return { - message: e.message + ' while parsing empty string', - position: 0, - } + return makeParsedError(msg, 'empty string') } - const badToken = e.message.match(/^Unexpected token (.) .*position\s+(\d+)/i) - const errIdx = badToken ? +badToken[2] - : e.message.match(/^Unexpected end of JSON.*/i) ? txt.length - 1 - : null - const msg = badToken ? e.message.replace(/^Unexpected token ./, `Unexpected token ${ - JSON.stringify(badToken[1]) - } (${hexify(badToken[1])})`) - : e.message + const badTokenMatch = msg.match(UNEXPECTED_TOKEN) + const badIndexMatch = msg.match(/ position\s+(\d+)/i) - if (errIdx !== null && errIdx !== undefined) { - const start = errIdx <= context ? 0 - : errIdx - context + if (badTokenMatch) { + msg = msg.replace( + UNEXPECTED_TOKEN, + `Unexpected token ${JSON.stringify(badTokenMatch[1])} (${hexify(badTokenMatch[1])})$2 ` + ) + } - const end = errIdx + context >= txt.length ? txt.length - : errIdx + context + let errIdx + if (badIndexMatch) { + errIdx = +badIndexMatch[1] + } else if (msg.match(/^Unexpected end of JSON.*/i)) { + errIdx = txt.length - 1 + } - const slice = (start === 0 ? '' : '...') + - txt.slice(start, end) + - (end === txt.length ? '' : '...') + if (errIdx == null) { + return makeParsedError(msg, `'${txt.slice(0, context * 2)}'`) + } - const near = txt === slice ? '' : 'near ' + const start = errIdx <= context ? 0 : errIdx - context + const end = errIdx + context >= txt.length ? txt.length : errIdx + context + const slice = `${start ? '...' : ''}${txt.slice(start, end)}${end === txt.length ? '' : '...'}` - return { - message: msg + ` while parsing ${near}${JSON.stringify(slice)}`, - position: errIdx, - } - } else { - return { - message: msg + ` while parsing '${txt.slice(0, context * 2)}'`, - position: 0, - } - } + return makeParsedError( + msg, + `${txt === slice ? '' : 'near '}${JSON.stringify(slice)}`, + errIdx + ) } class JSONParseError extends SyntaxError { constructor (er, txt, context, caller) { - context = context || 20 const metadata = parseError(er, txt, context) super(metadata.message) Object.assign(this, metadata) @@ -63,67 +86,50 @@ class JSONParseError extends SyntaxError { } set name (n) {} + get [Symbol.toStringTag] () { return this.constructor.name } } -const kIndent = Symbol.for('indent') -const kNewline = Symbol.for('newline') -// only respect indentation if we got a line break, otherwise squash it -// things other than objects and arrays aren't indented, so ignore those -// Important: in both of these regexps, the $1 capture group is the newline -// or undefined, and the $2 capture group is the indent, or undefined. -const formatRE = /^\s*[{[]((?:\r?\n)+)([\s\t]*)/ -const emptyRE = /^(?:\{\}|\[\])((?:\r?\n)+)?$/ - -const parseJson = (txt, reviver, context) => { - const parseText = stripBOM(txt) - context = context || 20 - try { +const parseJson = (txt, reviver) => { + const result = JSON.parse(txt, reviver) + if (result && typeof result === 'object') { // get the indentation so that we can save it back nicely // if the file starts with {" then we have an indent of '', ie, none - // otherwise, pick the indentation of the next line after the first \n - // If the pattern doesn't match, then it means no indentation. - // JSON.stringify ignores symbols, so this is reasonably safe. - // if the string is '{}' or '[]', then use the default 2-space indent. - const [, newline = '\n', indent = ' '] = parseText.match(emptyRE) || - parseText.match(formatRE) || - [null, '', ''] - - const result = JSON.parse(parseText, reviver) - if (result && typeof result === 'object') { - result[kNewline] = newline - result[kIndent] = indent - } - return result + // otherwise, pick the indentation of the next line after the first \n If the + // pattern doesn't match, then it means no indentation. JSON.stringify ignores + // symbols, so this is reasonably safe. if the string is '{}' or '[]', then + // use the default 2-space indent. + const match = txt.match(EMPTY) || txt.match(FORMAT) || [null, '', ''] + result[NEWLINE] = match[1] ?? DEFAULT_NEWLINE + result[INDENT] = match[2] ?? DEFAULT_INDENT + } + return result +} + +const parseJsonError = (raw, reviver, context) => { + const txt = stripBOM(raw) + try { + return parseJson(txt, reviver) } catch (e) { - if (typeof txt !== 'string' && !Buffer.isBuffer(txt)) { - const isEmptyArray = Array.isArray(txt) && txt.length === 0 - throw Object.assign(new TypeError( - `Cannot parse ${isEmptyArray ? 'an empty array' : String(txt)}` - ), { - code: 'EJSONPARSE', - systemError: e, - }) + if (typeof raw !== 'string' && !Buffer.isBuffer(raw)) { + const msg = Array.isArray(raw) && raw.length === 0 ? 'an empty array' : String(raw) + throw Object.assign( + new TypeError(`Cannot parse ${msg}`), + { code: 'EJSONPARSE', systemError: e } + ) } - - throw new JSONParseError(e, parseText, context, parseJson) + throw new JSONParseError(e, txt, context, parseJsonError) } } -// Remove byte order marker. This catches EF BB BF (the UTF-8 BOM) -// because the buffer-to-string conversion in `fs.readFileSync()` -// translates it to FEFF, the UTF-16 BOM. -const stripBOM = txt => String(txt).replace(/^\uFEFF/, '') - -module.exports = parseJson -parseJson.JSONParseError = JSONParseError - -parseJson.noExceptions = (txt, reviver) => { +module.exports = parseJsonError +parseJsonError.JSONParseError = JSONParseError +parseJsonError.noExceptions = (raw, reviver) => { try { - return JSON.parse(stripBOM(txt), reviver) - } catch (e) { + return parseJson(stripBOM(raw), reviver) + } catch { // no exceptions } } diff --git a/package.json b/package.json index c496ecb..5d0a1d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-parse-even-better-errors", - "version": "3.0.0", + "version": "3.0.1", "description": "JSON.parse with context information on error", "main": "lib/index.js", "files": [ @@ -10,7 +10,7 @@ "scripts": { "test": "tap", "snap": "tap", - "lint": "eslint \"**/*.js\"", + "lint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"", "postlint": "template-oss-check", "template-oss-apply": "template-oss-apply --force", "lintfix": "npm run lint -- --fix", @@ -27,8 +27,8 @@ "author": "GitHub Inc.", "license": "MIT", "devDependencies": { - "@npmcli/eslint-config": "^3.1.0", - "@npmcli/template-oss": "4.5.1", + "@npmcli/eslint-config": "^4.0.0", + "@npmcli/template-oss": "4.20.0", "tap": "^16.3.0" }, "tap": { @@ -43,6 +43,7 @@ }, "templateOSS": { "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", - "version": "4.5.1" + "version": "4.20.0", + "publish": true } } diff --git a/test/index.js b/test/index.js index 4b1b562..081096c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,10 +1,39 @@ 'use strict' const t = require('tap') - const parseJson = require('..') -t.test('parses JSON', t => { +const currentNodeMajor = +process.version.split('.')[0].slice(1) + +// Given an object where keys are major versions of node, this will return the +// value where the current major version is >= the latest key. eg: in node 24, +// for the input {20:1, 22:2}, this will return 2 if not match is found it will +// return the value of the `default` key. +const getLatestMatchingNode = ({ default: defaultNode, ...majors }) => { + for (const major of Object.keys(majors).sort((a, b) => b - a)) { + if (currentNodeMajor >= major) { + return majors[major] + } + } + return defaultNode +} + +// This will join all args into a regexp that can be used to assert a match. +// Each argument can be a string, regexp or an object passed to getLatestMatchingNode +const expectMessage = (...args) => new RegExp(args.map((rawValue) => { + const value = rawValue.constructor === Object ? getLatestMatchingNode(rawValue) : rawValue + return value instanceof RegExp ? value.source : value +}).join('')) + +const jsonThrows = (t, data, ...args) => { + let context + if (typeof args[0] === 'number') { + context = args.shift() + } + return t.throws(() => parseJson(data, null, context), ...args) +} + +t.test('parses JSON', (t) => { const cases = Object.entries({ object: { foo: 1, @@ -24,7 +53,7 @@ t.test('parses JSON', t => { } }) -t.test('preserves indentation and newline styles', t => { +t.test('preserves indentation and newline styles', (t) => { const kIndent = Symbol.for('indent') const kNewline = Symbol.for('newline') const object = { name: 'object', version: '1.2.3' } @@ -34,7 +63,7 @@ t.test('preserves indentation and newline styles', t => { for (const [type, obj] of Object.entries({ object, array })) { const n = JSON.stringify({ type, newline, indent }) const txt = JSON.stringify(obj, null, indent).replace(/\n/g, newline) - t.test(n, t => { + t.test(n, (t) => { const res = parseJson(txt) // no newline if no indentation t.equal(res[kNewline], indent && newline, 'preserved newline') @@ -47,7 +76,7 @@ t.test('preserves indentation and newline styles', t => { t.end() }) -t.test('indentation is the default when object/array is empty', t => { +t.test('indentation is the default when object/array is empty', (t) => { const kIndent = Symbol.for('indent') const kNewline = Symbol.for('newline') const obj = '{}' @@ -55,7 +84,7 @@ t.test('indentation is the default when object/array is empty', t => { for (const newline of ['', '\n', '\r\n', '\n\n', '\r\n\r\n']) { const expect = newline || '\n' for (const str of [obj, arr]) { - t.test(JSON.stringify({ str, newline, expect }), t => { + t.test(JSON.stringify({ str, newline, expect }), (t) => { const res = parseJson(str + newline) t.equal(res[kNewline], expect, 'got expected newline') t.equal(res[kIndent], ' ', 'got expected default indentation') @@ -66,7 +95,7 @@ t.test('indentation is the default when object/array is empty', t => { t.end() }) -t.test('parses JSON if it is a Buffer, removing BOM bytes', t => { +t.test('parses JSON if it is a Buffer, removing BOM bytes', (t) => { const str = JSON.stringify({ foo: 1, bar: { @@ -74,131 +103,188 @@ t.test('parses JSON if it is a Buffer, removing BOM bytes', t => { }, }) const data = Buffer.from(str) - const bom = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), data]) + const bom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), data]) t.same(parseJson(data), JSON.parse(str)) t.same(parseJson(bom), JSON.parse(str), 'strips the byte order marker') t.end() }) -t.test('better errors when faced with \\b and other malarky', t => { +t.test('better errors when faced with \\b and other malarky', (t) => { const str = JSON.stringify({ foo: 1, bar: { baz: [1, 2, 3, 'four'], }, }) - const data = Buffer.from(str) - const bombom = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF, 0xEF, 0xBB, 0xBF]), data]) - t.throws(() => parseJson(bombom), { - message: /\(0xFEFF\) in JSON at position 0/, - }, 'only strips a single BOM, not multiple') - const bs = str + '\b\b\b\b\b\b\b\b\b\b\b\b' - t.throws(() => parseJson(bs), { - message: /^Unexpected token "\\b" \(0x08\) in JSON at position.*\\b"$/, + const bombom = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf, 0xef, 0xbb, 0xbf]), + Buffer.from(str), + ]) + + jsonThrows( + t, + bombom, + { + message: /Unexpected token "." \(0xFEFF\)/, + }, + 'only strips a single BOM, not multiple' + ) + + jsonThrows(t, str + '\b\b\b\b\b\b\b\b\b\b\b\b', { + message: expectMessage( + 'Unexpected ', + { + 20: 'non-whitespace character after JSON', + default: /token "\\b" \(0x08\) in JSON/, + }, + / at position.*\\b"/ + ), }) + t.end() }) -t.test('throws SyntaxError for unexpected token', t => { +t.test('throws SyntaxError for unexpected token', (t) => { const data = 'foo' - t.throws( - () => parseJson(data), - { - message: 'Unexpected token "o" (0x6F) in JSON at position 1 while parsing "foo"', - code: 'EJSONPARSE', - position: 1, - name: 'JSONParseError', - systemError: SyntaxError, - } - ) + jsonThrows(t, data, { + message: expectMessage( + /Unexpected token "o" \(0x6F\)/, + { + 20: ', "foo" is not valid JSON', + default: ' in JSON at position 1', + }, + / while parsing .foo./ + ), + code: 'EJSONPARSE', + position: getLatestMatchingNode({ 20: 0, default: 1 }), + name: 'JSONParseError', + systemError: SyntaxError, + }) t.end() }) -t.test('throws SyntaxError for unexpected end of JSON', t => { +t.test('throws SyntaxError for unexpected end of JSON', (t) => { const data = '{"foo: bar}' - t.throws( - () => parseJson(data), - { - message: 'Unexpected end of JSON input while parsing "{\\"foo: bar}"', - code: 'EJSONPARSE', - position: 10, - name: 'JSONParseError', - systemError: SyntaxError, - } - ) + jsonThrows(t, data, { + message: expectMessage( + { + 20: /Unterminated string in JSON at position \d+/, + default: /Unexpected end of JSON input/, + }, + / while parsing "{\\"foo: bar}"/ + ), + code: 'EJSONPARSE', + position: getLatestMatchingNode({ 20: 11, default: 10 }), + name: 'JSONParseError', + systemError: SyntaxError, + }) t.end() }) -t.test('throws SyntaxError for unexpected number', t => { +t.test('throws SyntaxError for unexpected number', (t) => { const data = '[[1,2],{3,3,3,3,3}]' - t.throws( - () => parseJson(data), - { - message: 'Unexpected number in JSON at position 8', - code: 'EJSONPARSE', - position: 0, - name: 'JSONParseError', - systemError: SyntaxError, - } - ) + jsonThrows(t, data, { + message: expectMessage( + { + 20: "Expected property name or '}'", + default: 'Unexpected number', + }, + ' in JSON at position 8' + ), + code: 'EJSONPARSE', + position: 8, + name: 'JSONParseError', + systemError: SyntaxError, + }) t.end() }) -t.test('SyntaxError with less context (limited start)', t => { +t.test('SyntaxError with less context (limited start)', (t) => { const data = '{"6543210' - t.throws( - () => parseJson(data, null, 3), - { - message: 'Unexpected end of JSON input while parsing near "...3210"', - code: 'EJSONPARSE', - position: 8, - name: 'JSONParseError', - systemError: SyntaxError, - }) + jsonThrows(t, data, 3, { + message: expectMessage( + { + 20: 'Unterminated string in JSON at position 9', + default: 'Unexpected end of JSON input', + }, + ' while parsing near "...', + { + 20: '210', + default: '3210', + } + ), + code: 'EJSONPARSE', + position: getLatestMatchingNode({ 20: 9, default: 8 }), + name: 'JSONParseError', + systemError: SyntaxError, + }) t.end() }) -t.test('SyntaxError with less context (limited end)', t => { +t.test('SyntaxError with less context (limited end)', (t) => { const data = 'abcde' - t.throws( - () => parseJson(data, null, 2), - { - message: 'Unexpected token "a" (0x61) in JSON at position 0 while parsing near "ab..."', - code: 'EJSONPARSE', - position: 0, - name: 'JSONParseError', - systemError: SyntaxError, - } - ) + jsonThrows(t, data, 2, { + message: expectMessage( + /Unexpected token "a" \(0x61\)/, + { + 20: ', "abcde" is not valid JSON', + default: ' in JSON at position 0', + }, + ' while parsing ', + { + 20: "'abcd'", + default: 'near "ab..."', + } + ), + code: 'EJSONPARSE', + position: 0, + name: 'JSONParseError', + systemError: SyntaxError, + }) t.end() }) -t.test('throws TypeError for undefined', t => { - t.throws( - () => parseJson(undefined), - new TypeError('Cannot parse undefined') - ) +t.test('throws for end of input', (t) => { + const data = '{"a":1,""' + jsonThrows(t, data, 2, { + message: expectMessage('Unexpected end of JSON input while parsing'), + code: 'EJSONPARSE', + position: 8, + name: 'JSONParseError', + systemError: SyntaxError, + }) t.end() }) -t.test('throws TypeError for non-strings', t => { - t.throws( - () => parseJson(new Map()), - new TypeError('Cannot parse [object Map]') +t[currentNodeMajor >= 20 ? 'test' : 'skip']('coverage on node 20', (t) => { + t.match( + new parseJson.JSONParseError( + { message: `Unexpected token \b at position 2` }, + 'a'.repeat(4), + 1 + ).message, + /Unexpected token/ ) t.end() }) -t.test('throws TypeError for empty arrays', t => { - t.throws( - () => parseJson([]), - new TypeError('Cannot parse an empty array') - ) +t.test('throws TypeError for undefined', (t) => { + jsonThrows(t, undefined, new TypeError('Cannot parse undefined')) t.end() }) -t.test('handles empty string helpfully', t => { - t.throws(() => parseJson(''), { +t.test('throws TypeError for non-strings', (t) => { + jsonThrows(t, new Map(), new TypeError('Cannot parse [object Map]')) + t.end() +}) + +t.test('throws TypeError for empty arrays', (t) => { + jsonThrows(t, [], new TypeError('Cannot parse an empty array')) + t.end() +}) + +t.test('handles empty string helpfully', (t) => { + jsonThrows(t, '', { message: 'Unexpected end of JSON input while parsing empty string', name: 'JSONParseError', position: 0, @@ -208,12 +294,19 @@ t.test('handles empty string helpfully', t => { t.end() }) -t.test('json parse error class', t => { +t.test('json parse error class', (t) => { t.type(parseJson.JSONParseError, 'function') + // we already checked all the various index checking logic above const poop = new Error('poop') + const fooShouldNotShowUpInStackTrace = () => { - return new parseJson.JSONParseError(poop, 'this is some json', undefined, bar) + return new parseJson.JSONParseError( + poop, + 'this is some json', + undefined, + bar + ) } const bar = () => fooShouldNotShowUpInStackTrace() const err1 = bar() @@ -224,6 +317,7 @@ t.test('json parse error class', t => { err1.name = 'something else' t.equal(err1.name, 'JSONParseError') t.notMatch(err1.stack, /fooShouldNotShowUpInStackTrace/) + // calling it directly, tho, it does const fooShouldShowUpInStackTrace = () => { return new parseJson.JSONParseError(poop, 'this is some json') @@ -237,7 +331,7 @@ t.test('json parse error class', t => { t.end() }) -t.test('parse without exception', t => { +t.test('parse without exception', (t) => { const bad = 'this is not json' t.equal(parseJson.noExceptions(bad), undefined, 'does not throw') const obj = { this: 'is json' } @@ -245,7 +339,7 @@ t.test('parse without exception', t => { t.same(parseJson.noExceptions(good), obj, 'parses json string') const buf = Buffer.from(good) t.same(parseJson.noExceptions(buf), obj, 'parses json buffer') - const bom = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), buf]) + const bom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), buf]) t.same(parseJson.noExceptions(bom), obj, 'parses json buffer with bom') t.end() }) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy