diff --git a/.github/actions/use-npmrc/action.yml b/.github/actions/use-npmrc/action.yml index effe5c4..893cd61 100644 --- a/.github/actions/use-npmrc/action.yml +++ b/.github/actions/use-npmrc/action.yml @@ -1,5 +1,5 @@ name: 'Use .npmrc' -description: 'Writes the given .npmrc content to ~/.npmrc' +description: 'Writes the given .npmrc content to ~/.npmrc and warns about lockfile entries outside the cplace npm proxy' inputs: dot-npmrc: description: 'Content of the .npmrc file' @@ -11,4 +11,31 @@ runs: shell: bash env: DOT_NPMRC: ${{ inputs.dot-npmrc }} - run: echo "$DOT_NPMRC" > ~/.npmrc + run: | + echo "$DOT_NPMRC" > ~/.npmrc + # PFM-ISSUE-34453 mitigation. npm's default `replace-registry-host=npmjs` + # rewrites a lockfile's registry.npmjs.org URLs onto the configured + # registry and DROPS that registry's path prefix, producing an E404 that + # is masked as *** because the prefix is the JFROG_URL secret. + # `never` makes npm fetch each `resolved` URL verbatim instead. + # + # This is a no-op on a lockfile that already resolves entirely through + # the proxy, so it is safe to leave in place, and it is removable once + # the warning below stops firing anywhere. + echo 'replace-registry-host=never' >> ~/.npmrc + + - name: Warn on lockfile entries outside the cplace npm proxy + shell: bash + # Advisory only - never fails the build. The warnings are the inventory of + # lockfiles that still need normalizing; when none report, the mitigation + # above can be dropped. + # + # Invoked as `bash "" || true`, not as a bare path. The script's own + # "every precondition is a silent success" contract cannot cover NOT BEING + # REACHABLE - a missing exec bit after checkout, a path containing spaces, + # or a runner where action_path is a backslashed Windows path. That is an + # exit 127 from the shell rather than from the script, and it would fail + # the consumer's job before `npm ci` in all seven workflows using this + # action. `continue-on-error:` is not honoured on composite steps, so + # `|| true` is the mechanism that actually works here. + run: bash "$GITHUB_ACTION_PATH/../../../tools/scripts/lockfile/warn-foreign-registry.sh" || true diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..5be329c --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,61 @@ +name: PR Checks + +# This repository's first `on: pull_request` workflow. Everything under +# .github/workflows/ is otherwise `workflow_call`-only, and the `pull_request` +# trigger lives in .github/workflow-templates/fe/fe-pr.yml, which GitHub never +# executes. +# +# No `paths:` filter on purpose: a path-filtered workflow reports as pending +# rather than success, and would permanently block merges once it becomes a +# required check. +on: + pull_request: + branches: + - '**' + +permissions: + contents: read + +jobs: + lockfile: + name: Lockfile registry invariant + runs-on: ${{ vars.SMALL_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # --prefix-only, NOT a baseline comparison. The ongoing guard must answer + # only "does every entry resolve via the proxy?" - a pull request has to + # stay free to add, remove or update dependencies. + # + # Comparing against the base branch would fail every legitimate dependency + # change, and would then advise running the normalizer, which cannot fix a + # graph difference. Graph invariance belongs to verifying a normalization + # commit (`--baseline HEAD~1`), not to everyday pull requests. + # + # With no baseline there is nothing to fetch, so the default shallow + # checkout is enough. jq is pre-installed on GitHub-hosted ubuntu runners, + # and this job runs no node and no `npm ci` - the guard has to be + # trustworthy precisely when the lockfile is broken. + - name: Check package-lock.json resolved URLs + run: ./tools/scripts/lockfile/check-lockfile.sh --prefix-only + + scripts: + name: Shell scripts + runs-on: ${{ vars.SMALL_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # Never npm devDependencies: adding them would mutate package-lock.json on + # all seven branches and break the invariant this workflow exists to guard. + # shellcheck ships in the ubuntu image, but is installed here anyway so the + # job does not silently depend on image contents. + - name: Install bats and shellcheck + run: | + sudo apt-get update + sudo apt-get install -y bats shellcheck + + - name: shellcheck + run: shellcheck tools/scripts/lockfile/*.sh tools/scripts/lockfile/test-helper.bash + + - name: bats + run: bats tools/scripts/lockfile/ diff --git a/.prettierignore b/.prettierignore index eb79dd5..0433bbc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ node_modules .idea +package-lock.json diff --git a/package-lock.json b/package-lock.json index 7cd6ea3..9cc9054 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ }, "node_modules/@ampproject/remapping": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@ampproject/remapping/-/remapping-2.2.0.tgz", "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", "dev": true, "dependencies": { @@ -57,7 +57,7 @@ }, "node_modules/@ampproject/remapping/node_modules/@jridgewell/gen-mapping": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", "dev": true, "dependencies": { @@ -70,7 +70,7 @@ }, "node_modules/@babel/code-frame": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/code-frame/-/code-frame-7.18.6.tgz", "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", "dev": true, "dependencies": { @@ -82,7 +82,7 @@ }, "node_modules/@babel/compat-data": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/compat-data/-/compat-data-7.19.4.tgz", "integrity": "sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw==", "dev": true, "engines": { @@ -91,7 +91,7 @@ }, "node_modules/@babel/core": { "version": "7.19.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/core/-/core-7.19.3.tgz", "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", "dev": true, "dependencies": { @@ -117,7 +117,7 @@ }, "node_modules/@babel/generator": { "version": "7.19.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/generator/-/generator-7.19.5.tgz", "integrity": "sha512-DxbNz9Lz4aMZ99qPpO1raTbcrI1ZeYh+9NR9qhfkQIbFtVEqotHojEBxHzmxhVONkGt6VyrqVQcgpefMy9pqcg==", "dev": true, "dependencies": { @@ -131,7 +131,7 @@ }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", "dev": true, "dependencies": { @@ -143,7 +143,7 @@ }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz", "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==", "dev": true, "dependencies": { @@ -156,7 +156,7 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.19.3", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz", "integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==", "dev": true, "dependencies": { @@ -174,7 +174,7 @@ }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz", "integrity": "sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==", "dev": true, "dependencies": { @@ -195,7 +195,7 @@ }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz", "integrity": "sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==", "dev": true, "dependencies": { @@ -211,7 +211,7 @@ }, "node_modules/@babel/helper-define-polyfill-provider": { "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==", "dev": true, "dependencies": { @@ -228,7 +228,7 @@ }, "node_modules/@babel/helper-environment-visitor": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", "dev": true, "engines": { @@ -237,7 +237,7 @@ }, "node_modules/@babel/helper-explode-assignable-expression": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz", "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==", "dev": true, "dependencies": { @@ -249,7 +249,7 @@ }, "node_modules/@babel/helper-function-name": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", "dev": true, "dependencies": { @@ -262,7 +262,7 @@ }, "node_modules/@babel/helper-hoist-variables": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", "dev": true, "dependencies": { @@ -274,7 +274,7 @@ }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz", "integrity": "sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==", "dev": true, "dependencies": { @@ -286,7 +286,7 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", "dev": true, "dependencies": { @@ -298,7 +298,7 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz", "integrity": "sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==", "dev": true, "dependencies": { @@ -317,7 +317,7 @@ }, "node_modules/@babel/helper-optimise-call-expression": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", "dev": true, "dependencies": { @@ -338,7 +338,7 @@ }, "node_modules/@babel/helper-remap-async-to-generator": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", "dev": true, "dependencies": { @@ -356,7 +356,7 @@ }, "node_modules/@babel/helper-replace-supers": { "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz", "integrity": "sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==", "dev": true, "dependencies": { @@ -372,7 +372,7 @@ }, "node_modules/@babel/helper-simple-access": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-simple-access/-/helper-simple-access-7.19.4.tgz", "integrity": "sha512-f9Xq6WqBFqaDfbCzn2w85hwklswz5qsKlh7f08w4Y9yhJHpnNC0QemtSkK5YyOY8kPGvyiwdzZksGUhnGdaUIg==", "dev": true, "dependencies": { @@ -384,7 +384,7 @@ }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz", "integrity": "sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==", "dev": true, "dependencies": { @@ -396,7 +396,7 @@ }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", "dev": true, "dependencies": { @@ -426,7 +426,7 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==", "dev": true, "engines": { @@ -435,7 +435,7 @@ }, "node_modules/@babel/helper-wrap-function": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz", "integrity": "sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==", "dev": true, "dependencies": { @@ -450,7 +450,7 @@ }, "node_modules/@babel/helpers": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helpers/-/helpers-7.19.4.tgz", "integrity": "sha512-G+z3aOx2nfDHwX/kyVii5fJq+bgscg89/dJNWpYeKeBv3v9xX8EIabmx1k6u9LS04H7nROFVRVK+e3k0VHp+sw==", "dev": true, "dependencies": { @@ -464,7 +464,7 @@ }, "node_modules/@babel/highlight": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/highlight/-/highlight-7.18.6.tgz", "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", "dev": true, "dependencies": { @@ -490,7 +490,7 @@ }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz", "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", "dev": true, "dependencies": { @@ -505,7 +505,7 @@ }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz", "integrity": "sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==", "dev": true, "dependencies": { @@ -522,7 +522,7 @@ }, "node_modules/@babel/plugin-proposal-async-generator-functions": { "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz", "integrity": "sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==", "dev": true, "dependencies": { @@ -540,7 +540,7 @@ }, "node_modules/@babel/plugin-proposal-class-properties": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", "dev": true, "dependencies": { @@ -556,7 +556,7 @@ }, "node_modules/@babel/plugin-proposal-class-static-block": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz", "integrity": "sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==", "dev": true, "dependencies": { @@ -573,7 +573,7 @@ }, "node_modules/@babel/plugin-proposal-dynamic-import": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==", "dev": true, "dependencies": { @@ -589,7 +589,7 @@ }, "node_modules/@babel/plugin-proposal-export-namespace-from": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", "dev": true, "dependencies": { @@ -605,7 +605,7 @@ }, "node_modules/@babel/plugin-proposal-json-strings": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==", "dev": true, "dependencies": { @@ -621,7 +621,7 @@ }, "node_modules/@babel/plugin-proposal-logical-assignment-operators": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz", "integrity": "sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==", "dev": true, "dependencies": { @@ -637,7 +637,7 @@ }, "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", "dev": true, "dependencies": { @@ -653,7 +653,7 @@ }, "node_modules/@babel/plugin-proposal-numeric-separator": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", "dev": true, "dependencies": { @@ -669,7 +669,7 @@ }, "node_modules/@babel/plugin-proposal-object-rest-spread": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.19.4.tgz", "integrity": "sha512-wHmj6LDxVDnL+3WhXteUBaoM1aVILZODAUjg11kHqG4cOlfgMQGxw6aCgvrXrmaJR3Bn14oZhImyCPZzRpC93Q==", "dev": true, "dependencies": { @@ -688,7 +688,7 @@ }, "node_modules/@babel/plugin-proposal-optional-catch-binding": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", "dev": true, "dependencies": { @@ -704,7 +704,7 @@ }, "node_modules/@babel/plugin-proposal-optional-chaining": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz", "integrity": "sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==", "dev": true, "dependencies": { @@ -721,7 +721,7 @@ }, "node_modules/@babel/plugin-proposal-private-methods": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", "dev": true, "dependencies": { @@ -737,7 +737,7 @@ }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz", "integrity": "sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==", "dev": true, "dependencies": { @@ -755,7 +755,7 @@ }, "node_modules/@babel/plugin-proposal-unicode-property-regex": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", "dev": true, "dependencies": { @@ -771,7 +771,7 @@ }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "dependencies": { @@ -795,7 +795,7 @@ }, "node_modules/@babel/plugin-syntax-class-properties": { "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "dependencies": { @@ -807,7 +807,7 @@ }, "node_modules/@babel/plugin-syntax-class-static-block": { "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "dependencies": { @@ -822,7 +822,7 @@ }, "node_modules/@babel/plugin-syntax-dynamic-import": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", "dev": true, "dependencies": { @@ -834,7 +834,7 @@ }, "node_modules/@babel/plugin-syntax-export-namespace-from": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", "dev": true, "dependencies": { @@ -846,7 +846,7 @@ }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz", "integrity": "sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==", "dev": true, "dependencies": { @@ -873,7 +873,7 @@ }, "node_modules/@babel/plugin-syntax-json-strings": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "dependencies": { @@ -900,7 +900,7 @@ }, "node_modules/@babel/plugin-syntax-logical-assignment-operators": { "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, "dependencies": { @@ -912,7 +912,7 @@ }, "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, "dependencies": { @@ -924,7 +924,7 @@ }, "node_modules/@babel/plugin-syntax-numeric-separator": { "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "dependencies": { @@ -936,7 +936,7 @@ }, "node_modules/@babel/plugin-syntax-object-rest-spread": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "dependencies": { @@ -948,7 +948,7 @@ }, "node_modules/@babel/plugin-syntax-optional-catch-binding": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, "dependencies": { @@ -960,7 +960,7 @@ }, "node_modules/@babel/plugin-syntax-optional-chaining": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "dependencies": { @@ -972,7 +972,7 @@ }, "node_modules/@babel/plugin-syntax-private-property-in-object": { "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "dependencies": { @@ -987,7 +987,7 @@ }, "node_modules/@babel/plugin-syntax-top-level-await": { "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, "dependencies": { @@ -1002,7 +1002,7 @@ }, "node_modules/@babel/plugin-syntax-typescript": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz", "integrity": "sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==", "dev": true, "dependencies": { @@ -1017,7 +1017,7 @@ }, "node_modules/@babel/plugin-transform-arrow-functions": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz", "integrity": "sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==", "dev": true, "dependencies": { @@ -1032,7 +1032,7 @@ }, "node_modules/@babel/plugin-transform-async-to-generator": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz", "integrity": "sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==", "dev": true, "dependencies": { @@ -1049,7 +1049,7 @@ }, "node_modules/@babel/plugin-transform-block-scoped-functions": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", "dev": true, "dependencies": { @@ -1064,7 +1064,7 @@ }, "node_modules/@babel/plugin-transform-block-scoping": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.19.4.tgz", "integrity": "sha512-934S2VLLlt2hRJwPf4MczaOr4hYF0z+VKPwqTNxyKX7NthTiPfhuKFWQZHXRM0vh/wo/VyXB3s4bZUNA08l+tQ==", "dev": true, "dependencies": { @@ -1079,7 +1079,7 @@ }, "node_modules/@babel/plugin-transform-classes": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz", "integrity": "sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==", "dev": true, "dependencies": { @@ -1102,7 +1102,7 @@ }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz", "integrity": "sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==", "dev": true, "dependencies": { @@ -1117,7 +1117,7 @@ }, "node_modules/@babel/plugin-transform-destructuring": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.19.4.tgz", "integrity": "sha512-t0j0Hgidqf0aM86dF8U+vXYReUgJnlv4bZLsyoPnwZNrGY+7/38o8YjaELrvHeVfTZao15kjR0PVv0nju2iduA==", "dev": true, "dependencies": { @@ -1132,7 +1132,7 @@ }, "node_modules/@babel/plugin-transform-dotall-regex": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", "dev": true, "dependencies": { @@ -1148,7 +1148,7 @@ }, "node_modules/@babel/plugin-transform-duplicate-keys": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", "dev": true, "dependencies": { @@ -1163,7 +1163,7 @@ }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", "dev": true, "dependencies": { @@ -1179,7 +1179,7 @@ }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.18.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz", "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==", "dev": true, "dependencies": { @@ -1194,7 +1194,7 @@ }, "node_modules/@babel/plugin-transform-function-name": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", "dev": true, "dependencies": { @@ -1211,7 +1211,7 @@ }, "node_modules/@babel/plugin-transform-literals": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", "dev": true, "dependencies": { @@ -1226,7 +1226,7 @@ }, "node_modules/@babel/plugin-transform-member-expression-literals": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", "dev": true, "dependencies": { @@ -1241,7 +1241,7 @@ }, "node_modules/@babel/plugin-transform-modules-amd": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz", "integrity": "sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==", "dev": true, "dependencies": { @@ -1258,7 +1258,7 @@ }, "node_modules/@babel/plugin-transform-modules-commonjs": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz", "integrity": "sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==", "dev": true, "dependencies": { @@ -1276,7 +1276,7 @@ }, "node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz", "integrity": "sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==", "dev": true, "dependencies": { @@ -1295,7 +1295,7 @@ }, "node_modules/@babel/plugin-transform-modules-umd": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", "dev": true, "dependencies": { @@ -1311,7 +1311,7 @@ }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz", "integrity": "sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==", "dev": true, "dependencies": { @@ -1327,7 +1327,7 @@ }, "node_modules/@babel/plugin-transform-new-target": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==", "dev": true, "dependencies": { @@ -1342,7 +1342,7 @@ }, "node_modules/@babel/plugin-transform-object-super": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", "dev": true, "dependencies": { @@ -1358,7 +1358,7 @@ }, "node_modules/@babel/plugin-transform-parameters": { "version": "7.18.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz", "integrity": "sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==", "dev": true, "dependencies": { @@ -1373,7 +1373,7 @@ }, "node_modules/@babel/plugin-transform-property-literals": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", "dev": true, "dependencies": { @@ -1388,7 +1388,7 @@ }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz", "integrity": "sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==", "dev": true, "dependencies": { @@ -1404,7 +1404,7 @@ }, "node_modules/@babel/plugin-transform-reserved-words": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", "dev": true, "dependencies": { @@ -1419,7 +1419,7 @@ }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", "dev": true, "dependencies": { @@ -1434,7 +1434,7 @@ }, "node_modules/@babel/plugin-transform-spread": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz", "integrity": "sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==", "dev": true, "dependencies": { @@ -1450,7 +1450,7 @@ }, "node_modules/@babel/plugin-transform-sticky-regex": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", "dev": true, "dependencies": { @@ -1465,7 +1465,7 @@ }, "node_modules/@babel/plugin-transform-template-literals": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", "dev": true, "dependencies": { @@ -1480,7 +1480,7 @@ }, "node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", "dev": true, "dependencies": { @@ -1495,7 +1495,7 @@ }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.19.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz", "integrity": "sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w==", "dev": true, "dependencies": { @@ -1512,7 +1512,7 @@ }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.18.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz", "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==", "dev": true, "dependencies": { @@ -1527,7 +1527,7 @@ }, "node_modules/@babel/plugin-transform-unicode-regex": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", "dev": true, "dependencies": { @@ -1543,7 +1543,7 @@ }, "node_modules/@babel/preset-env": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/preset-env/-/preset-env-7.19.4.tgz", "integrity": "sha512-5QVOTXUdqTCjQuh2GGtdd7YEhoRXBMVGROAtsBeLGIbIz3obCBIfRMT1I3ZKkMgNzwkyCkftDXSSkHxnfVf4qg==", "dev": true, "dependencies": { @@ -1632,7 +1632,7 @@ }, "node_modules/@babel/preset-modules": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/preset-modules/-/preset-modules-0.1.5.tgz", "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", "dev": true, "dependencies": { @@ -1648,7 +1648,7 @@ }, "node_modules/@babel/preset-typescript": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz", "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==", "dev": true, "dependencies": { @@ -1665,7 +1665,7 @@ }, "node_modules/@babel/runtime": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/runtime/-/runtime-7.19.4.tgz", "integrity": "sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==", "dev": true, "dependencies": { @@ -1677,7 +1677,7 @@ }, "node_modules/@babel/template": { "version": "7.18.10", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/template/-/template-7.18.10.tgz", "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", "dev": true, "dependencies": { @@ -1691,7 +1691,7 @@ }, "node_modules/@babel/traverse": { "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/traverse/-/traverse-7.19.4.tgz", "integrity": "sha512-w3K1i+V5u2aJUOXBFFC5pveFLmtq1s3qcdDNC2qRI6WPBQIDaKFqXxDEqDO/h1dQ3HjsZoZMyIy6jGLq0xtw+g==", "dev": true, "dependencies": { @@ -2369,7 +2369,7 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", "dev": true, "dependencies": { @@ -2383,7 +2383,7 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "dev": true, "engines": { @@ -2392,7 +2392,7 @@ }, "node_modules/@jridgewell/set-array": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", "dev": true, "engines": { @@ -2401,7 +2401,7 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, @@ -2587,7 +2587,7 @@ }, "node_modules/@types/jest": { "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/jest/-/jest-29.5.14.tgz", "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "dependencies": { @@ -2652,7 +2652,7 @@ }, "node_modules/ansi-styles": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "dependencies": { @@ -2686,7 +2686,7 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { @@ -2799,7 +2799,7 @@ }, "node_modules/babel-plugin-dynamic-import-node": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", "dev": true, "dependencies": { @@ -2855,7 +2855,7 @@ }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.3.3", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==", "dev": true, "dependencies": { @@ -2869,7 +2869,7 @@ }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==", "dev": true, "dependencies": { @@ -2882,7 +2882,7 @@ }, "node_modules/babel-plugin-polyfill-regenerator": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==", "dev": true, "dependencies": { @@ -2961,7 +2961,7 @@ }, "node_modules/browserslist": { "version": "4.21.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/browserslist/-/browserslist-4.21.4.tgz", "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", "dev": true, "dependencies": { @@ -2979,7 +2979,7 @@ }, "node_modules/bs-logger": { "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/bs-logger/-/bs-logger-0.2.6.tgz", "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", "dev": true, "dependencies": { @@ -3006,7 +3006,7 @@ }, "node_modules/call-bind": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", "dev": true, "dependencies": { @@ -3034,13 +3034,13 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001418", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz", "integrity": "sha512-oIs7+JL3K9JRQ3jPZjlH6qyYDp+nBTCais7hjh0s+fuBwufc7uZ7hPYMXrDOJhV360KGMTcczMRObk0/iMqZRg==", "dev": true }, "node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "dependencies": { @@ -3108,7 +3108,7 @@ }, "node_modules/color-convert": { "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "dependencies": { @@ -3117,13 +3117,13 @@ }, "node_modules/color-name": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dependencies": { "delayed-stream": "~1.0.0" @@ -3140,13 +3140,13 @@ }, "node_modules/convert-source-map": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, "node_modules/core-js-compat": { "version": "3.25.5", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/core-js-compat/-/core-js-compat-3.25.5.tgz", "integrity": "sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA==", "dev": true, "dependencies": { @@ -3254,7 +3254,7 @@ }, "node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "dependencies": { @@ -3294,7 +3294,7 @@ }, "node_modules/define-properties": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/define-properties/-/define-properties-1.1.4.tgz", "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", "dev": true, "dependencies": { @@ -3307,7 +3307,7 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "engines": { "node": ">=0.4.0" @@ -3333,7 +3333,7 @@ }, "node_modules/electron-to-chromium": { "version": "1.4.276", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.276.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/electron-to-chromium/-/electron-to-chromium-1.4.276.tgz", "integrity": "sha512-EpuHPqu8YhonqLBXHoU6hDJCD98FCe6KDoet3/gY1qsQ6usjJoHqBH2YIVs8FXaAtHwVL8Uqa/fsYao/vq9VWQ==", "dev": true }, @@ -3363,7 +3363,7 @@ }, "node_modules/escalade": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", "dev": true, "engines": { @@ -3372,7 +3372,7 @@ }, "node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "engines": { @@ -3394,7 +3394,7 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "engines": { @@ -3453,7 +3453,7 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, @@ -3512,13 +3512,13 @@ }, "node_modules/function-bind": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "engines": { @@ -3536,7 +3536,7 @@ }, "node_modules/get-intrinsic": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/get-intrinsic/-/get-intrinsic-1.1.3.tgz", "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", "dev": true, "dependencies": { @@ -3582,7 +3582,7 @@ }, "node_modules/globals": { "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true, "engines": { @@ -3597,7 +3597,7 @@ }, "node_modules/has": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "dev": true, "dependencies": { @@ -3609,7 +3609,7 @@ }, "node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "engines": { @@ -3618,7 +3618,7 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", "dev": true, "dependencies": { @@ -3627,7 +3627,7 @@ }, "node_modules/has-symbols": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", "dev": true, "engines": { @@ -3698,7 +3698,7 @@ }, "node_modules/is-core-module": { "version": "2.10.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/is-core-module/-/is-core-module-2.10.0.tgz", "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==", "dev": true, "dependencies": { @@ -5325,7 +5325,7 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, @@ -5344,7 +5344,7 @@ }, "node_modules/jsesc": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true, "bin": { @@ -5410,7 +5410,7 @@ }, "node_modules/lodash.debounce": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, @@ -5470,7 +5470,7 @@ }, "node_modules/make-error": { "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, @@ -5504,7 +5504,7 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "engines": { "node": ">= 0.6" @@ -5512,7 +5512,7 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dependencies": { "mime-db": "1.52.0" @@ -5544,7 +5544,7 @@ }, "node_modules/ms": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, @@ -5562,7 +5562,7 @@ }, "node_modules/node-releases": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/node-releases/-/node-releases-2.0.6.tgz", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, @@ -5589,7 +5589,7 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "engines": { @@ -5598,7 +5598,7 @@ }, "node_modules/object.assign": { "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/object.assign/-/object.assign-4.1.4.tgz", "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", "dev": true, "dependencies": { @@ -5769,13 +5769,13 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, "node_modules/picocolors": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, @@ -5811,7 +5811,7 @@ }, "node_modules/prettier": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/prettier/-/prettier-2.8.1.tgz", "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", "dev": true, "bin": { @@ -5879,13 +5879,13 @@ }, "node_modules/regenerate": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", "dev": true }, "node_modules/regenerate-unicode-properties": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", "dev": true, "dependencies": { @@ -5897,13 +5897,13 @@ }, "node_modules/regenerator-runtime": { "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", "dev": true }, "node_modules/regenerator-transform": { "version": "0.15.0", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/regenerator-transform/-/regenerator-transform-0.15.0.tgz", "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", "dev": true, "dependencies": { @@ -5912,7 +5912,7 @@ }, "node_modules/regexpu-core": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/regexpu-core/-/regexpu-core-5.2.1.tgz", "integrity": "sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ==", "dev": true, "dependencies": { @@ -5929,13 +5929,13 @@ }, "node_modules/regjsgen": { "version": "0.7.1", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/regjsgen/-/regjsgen-0.7.1.tgz", "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==", "dev": true }, "node_modules/regjsparser": { "version": "0.9.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/regjsparser/-/regjsparser-0.9.1.tgz", "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", "dev": true, "dependencies": { @@ -5947,7 +5947,7 @@ }, "node_modules/regjsparser/node_modules/jsesc": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", "dev": true, "bin": { @@ -5965,7 +5965,7 @@ }, "node_modules/resolve": { "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, "dependencies": { @@ -6017,7 +6017,7 @@ }, "node_modules/semver": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { @@ -6180,7 +6180,7 @@ }, "node_modules/supports-color": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "dependencies": { @@ -6192,7 +6192,7 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "engines": { @@ -6221,7 +6221,7 @@ }, "node_modules/to-fast-properties": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", "dev": true, "engines": { @@ -6326,7 +6326,7 @@ }, "node_modules/typescript": { "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { @@ -6355,7 +6355,7 @@ }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", "dev": true, "engines": { @@ -6364,7 +6364,7 @@ }, "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "dev": true, "dependencies": { @@ -6377,7 +6377,7 @@ }, "node_modules/unicode-match-property-value-ecmascript": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==", "dev": true, "engines": { @@ -6386,7 +6386,7 @@ }, "node_modules/unicode-property-aliases-ecmascript": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", "dev": true, "engines": { @@ -6395,7 +6395,7 @@ }, "node_modules/update-browserslist-db": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", "dev": true, "dependencies": { diff --git a/specs/2026-08-10_normalize-package-lock-resolved-urls/consumer-survey.md b/specs/2026-08-10_normalize-package-lock-resolved-urls/consumer-survey.md new file mode 100644 index 0000000..e129c50 --- /dev/null +++ b/specs/2026-08-10_normalize-package-lock-resolved-urls/consumer-survey.md @@ -0,0 +1,162 @@ +--- +date: 2026-08-11 +topic: 'PFM-ISSUE-34454 prep: consumer FE repo lockfile survey' +parent_issue: PFM-ISSUE-34453 +target_issue: PFM-ISSUE-34454 +status: complete +--- + +# Consumer FE lockfile survey — prep for PFM-ISSUE-34454 + +`design.md` noted an out-of-band consumer lockfile survey as prep, "so 34454 starts knowing its blast radius". This is +that survey, run 2026-08-11 while PFM-ISSUE-34453's PR #163 was in review. + +## Method + +**41 non-archived FE repositories** in the `collaborationFactory` org (`gh repo list … | endswith("-fe")`). For each, +every real `release/*`, `master`, `main` and `develop` branch was enumerated via the branches API, then +`package-lock.json` was fetched raw at that ref and counted: + +``` +gh api "repos/collaborationFactory//contents/package-lock.json?ref=" \ + -H "Accept: application/vnd.github.raw" | grep -c 'registry\.npmjs\.org' +``` + +**299 repo/branch combinations** with a lockfile were measured. Script: `survey2.sh` (scratchpad, not committed). + +> **Do not repeat the first pass's mistake.** An initial run surveyed only *default* branches and reported +> "0 affected, 41 clean" — which is wrong twice over. It missed all release-branch contamination, and it counted +> repos whose probe simply failed (missing branch, error payload) as "clean". The corrected script rejects any +> response that does not begin with `{` and reports `MISSING` explicitly. **Default branches are not representative.** + +## Headline result + +| | | +| --- | --- | +| Branches surveyed (with a lockfile) | **299** | +| Affected branches (≥1 `registry.npmjs.org`) | **8** | +| Distinct affected repos | **3** | +| Clean branches | **291** | +| Total npmjs entries across affected branches | **20 537** | + +Consumer lockfiles are overwhelmingly clean — **291 of 299 branches resolve 100 % through the JFrog proxy already.** +The contamination is concentrated, not systemic. + +## Affected repo/branches + +| Repo | Branch | resolved | **npmjs** | jfrog | Character | +| --- | --- | ---: | ---: | ---: | --- | +| `cplace-loomeo-fe` | `release/23.2` | 7386 | **7376** | 10 | essentially un-migrated | +| `cplace-loomeo-fe` | `release/23.3` | 7386 | **7376** | 10 | essentially un-migrated | +| `cplace-loomeo-fe` | `release/22.4` | 2949 | **2901** | 48 | essentially un-migrated | +| `cplace-loomeo-fe` | `release/23.1` | 2988 | **2850** | 138 | essentially un-migrated | +| `cplace-paw-fe` | `release/25.2` | 2576 | **14** | 2562 | migrated, small residue | +| `cplace-paw-fe` | `release/25.3` | 2596 | **14** | 2582 | migrated, small residue | +| `cplace-bayer-prompt-fe` | `release/sprint-56` | 2758 | **3** | 2755 | migrated, small residue | +| `cplace-bayer-prompt-fe` | `release/sprint-57` | 2758 | **3** | 2755 | migrated, small residue | + +Two clearly different populations: + +1. **`cplace-loomeo-fe` on 22.4–23.3** — these branches predate the JFrog migration entirely and are ~97–100 % npmjs. + They are almost certainly dormant. Normalizing them is a different (and much larger) job than the residue case, and + probably not worth doing unless one of those branches is still built. +2. **`cplace-paw-fe` and `cplace-bayer-prompt-fe`** — otherwise fully migrated, with a handful of stragglers. This is + the same failure shape as `github-actions` itself, at smaller scale, and is what the normalizer is built for. + +### The residue is transitive dev dependencies + +`cplace-paw-fe` `release/25.2`, all 14: + +``` +chokidar (×4 paths) readdirp (×4 paths) picomatch (×2) glob-parent (×2) +… under @angular-devkit/architect, @angular/compiler-cli, @compodoc/compodoc, and top level +``` + +Consistent with `github-actions`' own 171: nested transitive packages that a partial `npm install` re-resolved against +the default registry. + +## Why this matters, and the risk it implies + +`fe-install-deps.yml` runs `use-npmrc` and then a **workflow-level `npm ci`** against the *consumer's own* lockfile. On +a branch carrying npmjs URLs, that install is exposed to exactly the PFM-ISSUE-34453 failure mode — npm rewrites the +host onto JFrog and drops the path prefix, giving a masked `E404`. + +### Verified: `cplace-paw-fe` `release/25.2` and `25.3` are broken today, not merely latent + +Checked 2026-08-11. Four independent lines of evidence, and the cache cannot save it: + +**1. The lockfile itself fails.** `release/25.2`'s `package.json` + `package-lock.json` fetched raw, `npm ci` run with +the standard cplace `~/.npmrc` and a cold cache: + +``` +npm error code E404 +npm error 404 Not Found - GET https://cplace.jfrog.io/readdirp/-/readdirp-3.6.0.tgz +``` + +Identical mechanism to PFM-ISSUE-34453 — the `/artifactory/api/npm/cplace-npm` prefix dropped. + +**2. No cache exists on those branches.** `cplace-paw-fe` has 13 caches; **zero** on `refs/heads/release/25.2` or +`release/25.3`. The most recent are on `26.1`, `26.2`, `26.3`, `25.4`, `master` and a few PR merge refs. + +**3. No other branch's cache could match even if one existed.** The key is +`${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}` — content-addressed to the lockfile. A different +branch has a different lockfile, therefore a different key. Cross-branch collision is impossible by construction. + +**4. There are no `restore-keys`.** Verified in `fe-install-deps.yml`, `fe-pr-snapshot.yml` and `fe-licenses.yml` on +`release/25.2`: each `actions/cache` block has `path` and `key` only. So only an *exact* key match sets +`cache-hit: true`; there is no partial-restore path that could skip `Install modules`. + +On GitHub's documented cache scoping — a PR run *can* restore caches from the **base branch**, as well as its own +branch and the default branch (siblings are excluded, and entries unused for 7 days are evicted). So "a feature branch +gets no cache" is **not** what makes this fail; points 2–4 are. Worth stating precisely, because the base-branch rule +is the one people tend to forget. + +**Conclusion:** any pull request into `cplace-paw-fe` `release/25.2` or `release/25.3` that reaches +`fe-install-deps`, `fe-licenses`, `fe-pr-snapshot` or `fe-pr-close` will fail at `Install modules` with a `***`-masked +`E404`. It is unobserved only because no recent PR has targeted those branches. `master` is clean, which is why the +34453 canary pipeline was green. + +The fix is the same 34453 normalizer, pointed at those two branches — 14 entries each, all transitive +(`chokidar`, `readdirp`, `picomatch`, `glob-parent`). + +## Second finding: non-URL `resolved` values exist in the wild + +Four repos have entries whose `resolved` is a **local path**, not a URL: + +| Repo | Count | Example | +| --- | ---: | --- | +| `cplace-paw-fe` | 1 | `node_modules/eslint-plugin-local-custom-rules` → `tools/eslint-rules` | +| `cplace-fe` | 1 | — | +| `cplace-project-planning-fe` | 2 | — | +| `cplace-resource-management-fe` | 1 | — | + +**This matters for reuse.** `assert_resolvable` in `tools/scripts/lockfile/lib.sh` hard-fails on any non-root entry +whose `resolved` is not an `https?://…/-/….tgz` URL. That is correct and deliberate for `github-actions`, which has +**zero** such entries — but it means the tooling **cannot be pointed at those four consumer repos unmodified.** It +would abort naming the package path, which is the designed behaviour, not a bug; it just makes the loosening a +prerequisite for any consumer-side rollout. + +34454 should decide deliberately whether to permit local-path entries (skip them, since they are not registry-hosted +at all) or to keep failing loudly. + +## Recommendations for PFM-ISSUE-34454 + +1. **`cplace-paw-fe` `release/25.2` and `25.3` are already broken — fix them first.** Verified above, not speculative: + the lockfile fails a cold `npm ci`, no cache exists on those branches, the content-addressed key makes a + cross-branch hit impossible, and there are no `restore-keys`. 14 entries each; the 34453 normalizer applies + unchanged. This is arguably not 34454 work at all but a small immediate fix. +2. **Then the remaining residue** — `cplace-bayer-prompt-fe` (`sprint-56`, `sprint-57`), 3 entries each. Check whether + those branches are still built before spending anything. Same method: no cache on a branch plus a content-addressed + key means no masking. +3. **Decide `cplace-loomeo-fe` separately.** Its 22.4–23.3 branches are ~20 500 of the 20 537 total entries and look + dormant. Establish whether they are still built before spending anything on them. +4. **Loosen `assert_resolvable` for local-path entries before any consumer rollout**, or the tooling aborts on four + repos. +5. **Do not rely on default-branch surveys.** Every affected branch here is a `release/*`; all 41 default branches are + clean. + +## Reproducing + +`survey2.sh` lives in the session scratchpad and is not committed — it is a one-shot diagnostic, and re-running it +against a moving org is more reliable than trusting a stale snapshot. The method is fully described above; the whole +survey takes a couple of minutes at 10-way parallelism. diff --git a/specs/2026-08-10_normalize-package-lock-resolved-urls/design.md b/specs/2026-08-10_normalize-package-lock-resolved-urls/design.md new file mode 100644 index 0000000..3033556 --- /dev/null +++ b/specs/2026-08-10_normalize-package-lock-resolved-urls/design.md @@ -0,0 +1,662 @@ +--- +date: 2026-08-10 +git_commit: 7661e9b +branch: fix/PFM-ISSUE-34453-normalize-package-lock-json/25.2 +baseline_branch: release/25.2 +topic: 'PFM-ISSUE-34453: Normalize package-lock.json resolved URLs onto the JFrog npm proxy' +tags: [design, package-lock, npm, jfrog, bash, jq, bats, ci, rollout] +status: complete +last_updated: 2026-08-10 +--- + +# PFM-ISSUE-34453 — Normalize `package-lock.json` resolved URLs onto the JFrog npm proxy — Design Approach + +## Overview + +`collaborationFactory/github-actions` ships a **mixed** `package-lock.json`: 171 non-root entries resolve via +`https://registry.npmjs.org/` while the remainder resolve via `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/`. +The shared composite actions run `npm ci` inside the action directory while the consumer's `~/.npmrc` is active, so npm +rewrites those 171 URLs onto the configured registry host and **drops the registry's path prefix**, producing `E404`. + +This design delivers three things per branch, on all seven affected branches: + +1. A **normalizer** that rewrites the 171 `resolved` prefixes and nothing else. +2. An **invariant check** that proves the dependency graph is unchanged and that every URL points at exactly the one + correct proxy prefix. +3. A **PR guard** — this repo's first `on: pull_request` workflow — that fails when a lockfile containing + `registry.npmjs.org` is proposed. + +Both tools are **bash + jq**, deliberately runnable with nothing installed, because the developer who most needs them +is standing in front of a repository whose `npm ci` is broken. + +## Problem Statement + +npm's `pacote` computes `this.resolved = new URL(resolvedURL.pathname, this.registry).href` +(`node_modules/pacote/lib/remote.js:16`). Because `resolvedURL.pathname` starts with `/`, the registry's own path is +discarded under npm's default `replace-registry-host=npmjs`: + +| | | +| --- | --- | +| lockfile | `https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz` | +| requested | `https://cplace.jfrog.io/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz` → **404** | +| correct | `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz` → 200 | + +The regression trigger was the Node 24 migration replacing `bduff9/use-npmrc@v1.1` (wrote `/.npmrc`, +invisible to the action's own install) with the in-repo `.github/actions/use-npmrc` (writes `~/.npmrc`, active for every +npm invocation on the runner). The failure is hard to diagnose because the broken URL's prefix is the `JFROG_URL` +secret and therefore masked as `***` in logs. + +### Requirements + +- Rewrite all 171 `resolved` prefixes onto the JFrog npm proxy, changing **no** `version`, `integrity`, or graph edge. +- Provide a repeatable, idempotent normalizer — the operation runs at least seven times and again after every upmerge + conflict. +- Provide a machine-checked invariant so correctness does not depend on reviewing a 262 KB diff. +- Provide an ongoing PR-level guard against reintroduction. +- Deliver to all seven branches: `release/25.2`, `25.3`, `25.4`, `26.1`, `26.2`, `26.3`, `master`. + +### Constraints + +- **The repo has no CI of its own.** All 13 `.github/workflows/fe-*.yml` are `workflow_call:`-only; the `pull_request` + trigger exists only in the never-executed template `.github/workflow-templates/fe/fe-pr.yml:2-5`. `jest` and + `prettier --check` are run by nothing ([research §6](./research.md)). +- **The "upmerge" is a Slack notifier, not an upmerge** — `tools/scripts/upmerge/upmerge.ts:22` runs a `--no-push` dry + run and posts to `#frontend-upmerge`. Carrying commits upward is human-driven ([research §7](./research.md)). +- **`master` and `release/26.3` are not downstream of `release/26.2`.** The chain is `25.2 → 25.3 → 25.4 → 26.1 → 26.2` + plus two independent tips; `master` received Node 24 via its own PR `#132`, and `26.3` branched off `master` + ([research §8](./research.md)). `25.2 → 25.3` is already un-upmerged by 2 commits. +- **There is no npm config to tune in this repo** — `use-npmrc` writes the secret verbatim to `~/.npmrc` and sets + nothing; `replace-registry-host` appears nowhere ([research §4](./research.md)). The lockfile is the only lever. +- **Secret masking**: any diagnostic containing the JFrog host is masked, so errors must name package paths. +- **Repo conventions**: `tools/scripts//` with co-located tests; CommonJS TypeScript elsewhere; prettier + `{ singleQuote: true }`; Node 18.19.1 tooling. + +### Evidence gathered during this design session + +Beyond [research.md](./research.md), the following was measured directly and drove three decisions: + +| measurement | result | consequence | +| --- | --- | --- | +| `jq .` round-trip on the real lockfile (jq 1.8.1) | **byte-identical** (262 063 → 262 063) | jq is byte-safe here | +| jq structured rewrite vs. raw textual rewrite | **byte-identical output** (+4788 both, 0 npmjs left) | no reason to prefer a textual rewrite | +| numeric literals in the lockfile | only `"lockfileVersion": 3` | jq cross-version number handling has no surface | +| fingerprint vs. changed tarball filename (t1) | **caught** | graph assertion works | +| fingerprint vs. changed dependency edge (t3) | **caught** | use the **full entry**, not the `version/integrity/tarball/link` subset | +| fingerprint vs. typo'd proxy repo name `cplace-nmp` (t2) | **MISSED** | a second, independent assertion is mandatory | +| fingerprint vs. an entry left on npmjs (t4) | **MISSED** | as above | +| `prettier --check` on the lockfile, before and after normalization | **passes both** | no lockfile formatting risk | +| `prettier --check .` repo-wide | **fails on 20 pre-existing files** | `check-prettier` cannot be enabled here | +| branch protection, all seven branches | six release branches **NOT protected**; `master` protected with `required_status_checks.contexts = []`; **no rulesets** | the guard cannot block; enforcement is a separate deliverable | +| local tooling | bats 1.13.0, shellcheck 0.11.0, jq 1.8.1 present | bash toolchain is viable locally today | +| developer `~/.npmrc` | points at `cplace.jfrog.io/artifactory/api/npm/cplace-npm/` | confirms the bootstrap problem for any node-based tool | + +## Design Decisions Summary + +1. **Normalizer in bash + jq, not TypeScript**: `tools/scripts/lockfile/normalize-lockfile.sh` performs a structured + jq rewrite of `.packages[].resolved`, following the Google Shell Style Guide and kept shellcheck-clean. + - The decisive factor is **bootstrap independence**: `npx ts-node` requires `node_modules`, which requires the + `npm ci` that is broken — so a TypeScript normalizer cannot repair the lockfile it exists to repair. + - jq's output was measured byte-identical to a raw textual rewrite, so the structured form costs no byte-safety. + - Layout still follows the repo's `tools/scripts//` convention with co-located tests. +2. **Invariant check with two independent assertions, both required to pass**: `check-lockfile.sh`. + - **Graph invariance** — the whole document with every `resolved` reduced to a registry-independent tarball path + must equal the baseline. Uses the **full entry**, which catches dependency-edge drift the research prototype's + field subset missed (t3). + - **Prefix exactness** — exactly one distinct `resolved` prefix, equal to the hard-coded constant; zero + `registry.npmjs.org`; zero entries missing `resolved` outside the `""` root. This catches t2 and t4, which graph + invariance provably cannot. + - Failures are reported **by package path**, computed in jq rather than shelled out to `diff`, so they survive `***` + masking. +3. **Baseline from a git ref, defaulting to `HEAD`, with two explicit file paths also accepted**: no committed baseline + artifact. + - Mid-conflict, `git show :2:package-lock.json` (ours) and `:3:` (theirs) are directly addressable. + - The normalizer additionally self-asserts before/after in-process, so it is safe standalone. + - The resolved ref is printed on every run, so a wrong baseline is visible rather than silent. +4. **The proxy prefix is a hard-coded `readonly` constant** in a shared `lib.sh`, not an env var. + - Configurability would make the exactness assertion a tautology: a typo'd prefix supplied to both scripts would + validate itself. + - `JFROG_URL` must **not** be reused — it is the publish target `…/artifactory/cplace-npm-local` + (`tools/scripts/artifacts/configuration.ts:2`), not the install proxy. + - The value is not secret; it is already committed in plaintext in 371 lockfile entries. +5. **Direct script invocation for humans and CI** — no npm scripts, no composite action wrapper. + - Preserves zero-install end to end and avoids adding a branch-pinned `@release/x.y` self-reference to maintain + across seven long-lived branches. + - `tools/scripts/lockfile/README.md` is the documented entry point; every failure message names both the README and + the exact remediation command. +6. **New `.github/workflows/pr-checks.yml`** — this repo's first `on: pull_request` workflow, deliberately not prefixed + `fe-` so that prefix keeps meaning "reusable workflow consumed by other repos." + - Two zero-node jobs: `lockfile` (jq guard) and `scripts` (shellcheck + bats over `tools/scripts/lockfile/`). + - bats and shellcheck are installed in the workflow (`bats-core/bats-action` or `apt-get`), **never** as npm + devDependencies — adding them would mutate `package-lock.json` on all seven branches and break the very invariant + this ticket establishes. + - `on: pull_request: branches: ['**']` with **no `paths:` filter**: a path-filtered workflow reports as pending + rather than success and would permanently block merges once it becomes a required check. +7. **Seven independent PRs, oldest → newest, two commits each** — no reliance on the upmerge. + - Order: `25.2 → 25.3 → 25.4 → 26.1 → 26.2 → master → 26.3`. + - Commit 1 = tooling only; commit 2 = the normalized lockfile alone, so verification is + `check-lockfile.sh --baseline HEAD~1` and the invariant is proven by construction. PRs are squash-merged; the two + commits exist for review. + - Tooling files must be **byte-identical** across branches so future upmerges see a clean add/add; a cross-branch + `sha256sum` comparison is the mitigation. +8. **Pre-merge canary in a consumer FE repo, one per lockfile state**: a PR in e.g. `cplace-remote-filesystem-fe` with + its `uses:` ref temporarily pinned to the fix branch. + - Validates before anything merges into `github-actions`, and creates no tags or releases. + - Must use a `use-npmrc` path — the five workflows that run `npm ci` with no `~/.npmrc` cannot demonstrate the fix + ([research §5](./research.md)). +9. **Enforcement is out of scope and becomes its own follow-up PFM issue, implemented with GitHub Rule Sets.** + - Measured today: six release branches unprotected, `master` protected with no required checks, zero rulesets. + - One ruleset targeting `release/*` + `master` replaces seven per-branch configurations. + - Until it lands, this ticket's guard is honestly described as **"visibly fails the PR"**, not "rejects." + +This means: + +- A developer with a JFrog `~/.npmrc` and a fresh clone can repair a broken lockfile immediately — no install, no + network beyond git, no chicken-and-egg. +- Each PR carries its own proof: 342 changed lines, +4788 bytes, dependency graph identical to its parent commit, one + distinct registry prefix. +- Every branch is guarded the moment its own PR merges, rather than whenever a human next performs an upmerge. +- The bats suite actually runs in CI, which is more than the repo's existing jest suite gets today. +- A conflict during a future upmerge has a mechanical resolution: `git checkout --ours -- package-lock.json`, re-run + the idempotent normalizer, then run the check against `:2:` or `:3:` to prove the resolution. + +Major trade-offs we're accepting: + +1. **Bash + bats instead of TypeScript + jest**: we give up type checking and the repo's established test tooling to + gain independence from the broken `npm ci`. Partially bought back by shellcheck and bats running in CI. +2. **The guard complains but cannot block** until the Rule Sets follow-up lands. We state this rather than claiming + enforcement we do not have — the same failure mode as PFM-ISSUE-33179 ("Does Not Block PRs Despite Missing + Licenses"), now named up front. +3. **Seven PRs instead of three**: the upmerge reaches only five branches, and a `pull_request` workflow protects only + the branch it already lives on. We pay review effort for deterministic delivery. +4. **The five `npm ci`-without-`~/.npmrc` workflows go from ~69 % to 100 % anonymous JFrog resolution.** No new + dependency is created, but the blast radius of the anonymous-access shutdown grows; handed explicitly to + PFM-ISSUE-34454. +5. **This fixes the symptom, not the structure.** The runtime `npm ci` inside a composite action remains the root + fault; pre-bundling `tools/scripts/*` stays a strategic follow-up. +6. **First `.sh` files in a repo that is otherwise 100 % TypeScript under `tools/`** — a real convention deviation, + mitigated by the style guide, shellcheck, and bats. + +What we're NOT doing (out of scope): + +- **Consumer-repo lockfile guarding.** No step is added to `fe-install-deps.yml` or any reusable workflow; a failing + check there would break currently-green consumer pipelines with no migration window. Deferred to PFM-ISSUE-34454, + with an out-of-band survey (clone the FE repos, `grep -c registry.npmjs.org`) noted as prep so 34454 starts knowing + its blast radius. +- **Branch protection / required status checks.** Its own follow-up issue, using GitHub Rule Sets. +- **Enabling `jest` in CI.** Whether the suite passes on any branch is unmeasured; making a time-sensitive lockfile fix + contingent on unrelated debt is the wrong coupling. +- **Enabling `check-prettier` in CI.** Blocked behind 20 pre-existing failures (9 `tools/` sources, 6 `specs/` docs, + 3 workflow YAMLs, `.github/pull_request_template.md`, and one more). Belongs to the same follow-up family. +- **`DOT_NPMRC` standardization and the JFrog anonymous-access shutdown** (PFM-ISSUE-34454). +- **Branches ≤ 24.2** — latent; do not backport `use-npmrc` there before their lockfiles are normalized. +- **Eliminating the runtime `npm ci`** (pre-bundling via esbuild/ncc, or publishing `tools/scripts/*` as a package). +- **Fixing the stale hard-coded `--release 5.17`** in `tools/scripts/upmerge/upmerge.ts:22` — noticed, not touched. + +## Design Decisions — Details + +### Dimension 1 — Normalizer: transformation mechanism and code shape + +**Chosen Approach:** Bash + jq under `tools/scripts/lockfile/`, Google Shell Style Guide, shellcheck-clean. + +**Rationale:** Every node-based option has a bootstrap dependency on the bug it fixes. `npx ts-node` needs +`node_modules`, which needs `npm ci`, which is exactly what fails when the lockfile carries npmjs URLs and `~/.npmrc` +points at JFrog — confirmed to be this developer's actual configuration. Bash + jq runs on a fresh clone with nothing +installed. The usual objection to jq — whole-file reformatting — was measured away: `jq .` round-trips the real +lockfile byte-identically, and the jq rewrite's output is byte-identical to a raw textual rewrite. The only numeric +literal in the file is `"lockfileVersion": 3`, so jq's cross-version number handling has no surface here. jq is +pre-installed on GitHub-hosted ubuntu runners, so the guard needs no setup step at all. + +**Alternatives Considered:** + +- **Structured TypeScript (`JSON.parse` → mutate → `JSON.stringify(obj, null, 2)`)**: rejected for the bootstrap + dependency, and because its byte-identity is an empirical property of today's files — a future npm writing different + formatting would silently reformat 262 KB while still passing the grep-level guard. It was the closest match to + `tools/scripts/artifacts/nx-project.ts:235-271`. +- **Textual TypeScript rewrite**: same bootstrap dependency; its one advantage over jq (reformatting impossible by + construction) turned out to be moot once jq's output was measured identical. +- **`sed -i` one-liner**: rejected — GNU vs BSD `sed -i` divergence breaks the macOS developer who is the primary user, + and it leaves the invariant check nowhere to live. + +**Implications:** + +- First `.sh` files under `tools/`; needs shellcheck and bats in CI to recover static and behavioural safety. +- The scripts must stay byte-identical across seven branches (see Dimension 7). +- jq must be installed locally by developers on macOS (`brew install jq`); it is present on CI runners. + +--- + +### Dimension 2 — Invariant check: baseline source and comparison strength + +**Chosen Approach:** `check-lockfile.sh [--baseline |] []`, defaulting to +`HEAD:package-lock.json` vs. `./package-lock.json`; plus an in-process self-assertion inside the normalizer. Two +independent assertions, both required. + +**Rationale:** A git-ref baseline has nothing to maintain and cannot go stale, and it is addressable exactly where the +check matters most — mid-conflict, against `:2:` (ours) or `:3:` (theirs). Accepting two explicit paths additionally +serves bats fixtures and manual cross-branch comparison. `HEAD` is the right default because the primary flow is +"normalize into the working tree, then prove only prefixes changed." + +The two-assertion structure is not belt-and-braces; it is forced by measurement. A fingerprint that strips host and +proxy path — necessary so a legitimately rehosted entry compares equal — is **structurally blind** to which host the +entry was rehosted onto. Injected drift confirmed this: a changed tarball filename (t1) and a changed dependency edge +(t3) are caught, while a typo'd proxy repo name (t2) and an entry left on npmjs (t4) pass silently. Prefix exactness +covers precisely what graph invariance cannot. + +The fingerprint uses the **full entry** rather than the research prototype's `version / integrity / tarball / link` +subset, because the subset misses t3 and the full form is a shorter filter. + +**Alternatives Considered:** + +- **Committed baseline fingerprint artifact**: rejected — a second source of truth that must be regenerated on every + legitimate dependency change, on seven branches, conflicting on exactly the upmerges this ticket is about. +- **In-process before/after only**: rejected as the primary mechanism — in a bad merge the drift arrives *before* the + normalizer runs, so a self-comparison happily confirms "I changed only prefixes." Retained as a secondary + self-assertion. + +**Implications:** + +- ~~The guard job needs `fetch-depth` sufficient to reach the base commit; `fe-check-upmerge.yml:14` already establishes + the `fetch-depth: 0` idiom.~~ **Superseded 2026-08-11 (`604f95e`):** the PR guard runs `--prefix-only` and compares + against no baseline, so the default shallow checkout suffices. The requirement stands for the by-hand `--baseline` + runs (Flows 1 and 2), which happen on a developer's full clone. See the Deployment section below. +- ~~Entries missing `resolved` outside the `""` root are a hard failure, so a future legitimate `link:`/`file:`/`git+ssh:` + dependency requires a deliberate, visible loosening rather than a silent pass.~~ **Superseded 2026-08-11 (`604f95e`):** + the hard failure holds on the `--baseline` path and in the normalizer, where `assert_resolvable` is a precondition + for fingerprinting. The PR guard runs `--prefix-only`, which deliberately does not run it and passes over non-http + values — without that, the guard hard-failed on exactly the npm-workspace and `link:` entries the mode exists to + allow. Such an entry therefore reaches `main` without a visible loosening; the deliberate-review requirement now + rests on the normalizer and on review, not on CI. Measured safe today: `no resolved = 0`, `other protocol = 0` on + all seven branches. + +--- + +### Dimension 3 — Registry prefix: hard-coded vs configurable + +**Chosen Approach:** One `readonly JFROG_NPM_PROXY='https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/'` in a +shared `lib.sh`, sourced by both scripts. The check additionally asserts that **exactly one** distinct prefix occurs +across all entries. + +**Rationale:** The constant *is* the invariant. If the prefix were an input, the check would validate the lockfile +against whatever the caller supplied, so the t2 typo class becomes unfalsifiable. The value is not secret — it is +already committed in plaintext 371 times — so there is no masking argument for parameterizing it. It must also be +byte-identical across seven branches, which a constant makes structural rather than operational. + +**Alternatives Considered:** + +- **Env var with default (`NPM_PROXY_URL`)**: matches `configuration.ts:2`'s idiom most literally, but weakens the + invariant and adds a silent-misconfiguration path where a workflow-level override rewrites 171 URLs onto the wrong + host and still passes its own check. +- **Derive from `JFROG_URL` or `~/.npmrc`**: wrong by construction. `JFROG_URL` is the publish target + (`…/cplace-npm-local`); `~/.npmrc` is opaque secret text with no guaranteed `registry=` line. + +**Implications:** + +- A future proxy rename means editing the scripts on all seven branches — but the 171 URLs would need rewriting anyway, + so this adds no real cost. +- Error text must avoid echoing the host (masked as `***`) and name package paths instead. + +--- + +### Dimension 4 — Invocation surface + +**Chosen Approach:** Direct script paths for both humans and CI: +`./tools/scripts/lockfile/normalize-lockfile.sh`, `./tools/scripts/lockfile/check-lockfile.sh`. No npm scripts, no +composite action. `tools/scripts/lockfile/README.md` documents the three flows (per-branch rollout, conflict +resolution, interpreting a guard failure), and every failure message names both the README and the remediation command. + +**Rationale:** The repo's convention — always `npx ts-node …` from a composite — exists because those scripts are +TypeScript needing a `ts-node` bootstrap; the rationale does not transfer to bash. Calling a path inside its own +checkout keeps the zero-install property in CI as well as locally and, importantly, adds **no branch-pinned +`@release/x.y` self-reference**, avoiding the internal-ref-update chore the Node 24 design doc recorded across seven +long-lived branches. + +**Alternatives Considered:** + +- **Composite action wrapper for CI**: matches the dominant idiom but buys indirection with exactly one caller, since + consumer-side guarding is out of scope. Promoting the script into a composite later is mechanical, so deferring costs + nothing. +- **npm scripts as the front door**: best discoverability and it does not break the bootstrap property (`npm run` works + without `node_modules`), but it contradicts the documented convention, adds a `package.json` edit to seven branches + for a pure alias, and creates two ways to invoke the same thing. + +**Implications:** + +- Discoverability is solved by documentation, not by an alias — so the README and the failure messages carry real + weight and must be written, not stubbed. + +--- + +### Dimension 5 — The PR guard + +**Chosen Approach:** New `.github/workflows/pr-checks.yml`, `on: pull_request: branches: ['**']`, no `paths:` filter, +two jobs: + +- `lockfile` — checkout, run `check-lockfile.sh`. jq only; no `setup-node`, no `npm ci`. +- `scripts` — shellcheck + bats over `tools/scripts/lockfile/`, installed via `bats-core/bats-action` or `apt-get`. + +**Rationale:** The acceptance criterion cannot be satisfied by editing an existing workflow — every file in +`.github/workflows/` is `workflow_call`-only, and the `pull_request` trigger lives in a template GitHub never executes. +Running shellcheck and bats alongside the guard means the new shell scripts are actually protected against a bad +upmerge of themselves, instead of repeating the repo's existing "tests exist but run nowhere" pattern. Both jobs stay +node-free, so the workflow that must be trustworthy when the lockfile is broken never depends on installing it. + +Naming avoids the `fe-` prefix, which in this repo denotes a reusable workflow consumed by other repositories. + +**Alternatives Considered:** + +- **Lockfile guard only**: smallest diff, but leaves the new shell scripts untested in CI on all seven branches. +- **Also enabling `jest` and `check-prettier`**: rejected on measurement. `prettier --check .` fails on 20 pre-existing + files, and whether `npm test` passes is unmeasured — either would convert a red ✗ into a rollout blocker for a fix + that itself blocks PFM-ISSUE-34454. `prettier --check` on the lockfile is measured safe both before and after + normalization, so the lockfile is not the obstacle; the other 20 files are. + +**Implications:** + +- One third-party action to pin by SHA, following the convention in + `specs/2026-06-05_node24-workflow-migration/sha-pins.md`. +- `.prettierignore` gains `package-lock.json` as **forward-looking insurance only** — prettier currently leaves the + file byte-unchanged both before and after normalization, so this protects against a future npm writing differently + formatted JSON once prettier eventually runs in CI. +- The guard's failure message names `./tools/scripts/lockfile/normalize-lockfile.sh` and the check command, so a + developer never has to find the README first. + +--- + +### Dimension 6 — Rollout + +**Chosen Approach:** Seven independent PRs, oldest → newest +(`25.2 → 25.3 → 25.4 → 26.1 → 26.2 → master → 26.3`), two commits each (tooling, then the normalized lockfile alone), +squash-merged. Byte-identical tooling files across branches. A pre-merge canary per lockfile state. + +**Rationale:** The ticket's "land it at 25.2 and let the upmerge carry it" holds for at most five branches: `master` and +`26.3` are not downstream of `26.2`, and the upmerge itself is a human process that the Node 24 migration shows can +stall (its five follow-on PRs were scoped as "Migration Notes" and not executed). More decisively, a `pull_request` +workflow runs from the **base branch's** copy, so a branch is unguarded until the file exists on it — waiting on +upmerges leaves an unbounded window during which the lockfiles stay broken while this ticket blocks 34454. Independent +PRs also make the procedure uniform across all seven branches instead of two procedures for two topologies, and +identical tooling blobs merge cleanly (git treats add/add of identical content as no conflict), so the extra PRs create +no upmerge debt. + +The two-commit split makes the invariant provable by construction: the lockfile commit's parent *is* the baseline, so +verification is exactly `check-lockfile.sh --baseline HEAD~1`, with no remembered ref to get wrong. + +**Alternatives Considered:** + +- **The ticket's plan, corrected (three PRs plus four human upmerges)**: fewest PRs and matches convention, but + delivery time is unbounded and the work per branch is identical — the same conflict resolution, done later, by + someone with less context. +- **Gating each lockfile state on a canary**: adopted, but as a sequencing rule layered on this option rather than as + an alternative delivery model. + +**Implications:** + +- Tooling drift across branches would reintroduce upmerge conflicts; mitigate with a cross-branch `sha256sum` + comparison of `tools/scripts/lockfile/*` and `pr-checks.yml` before each PR. +- The already-pending `25.2 → 25.3` upmerge (2 commits) is unaffected — normalizing 25.3 directly does not depend on it. +- Re-probe the proxy for all 171 tarballs before the first PR ([research OQ6](./research.md)): the ticket's + 166 × 200 / 5 × 302 result was taken on faith, and a curated-repo or Xray policy change would invalidate the + approach. + +--- + +### Dimension 7 — Canary validation + +**Chosen Approach:** For each of the three lockfile states (`25.2` | `25.4` | `26.x`), open a PR in a consumer FE repo +(e.g. `cplace-remote-filesystem-fe`) against the branch pinned to the matching `github-actions` branch, with the +`uses:` ref temporarily pointed at the fix branch. Confirm the composite's internal `npm ci` resolves. + +**Rationale:** A consumer pins the shared workflow by branch, which resolves to that branch's tip — so a canary using +the normal pin could only run *after* merging. Temporarily re-pinning the consumer PR's `uses:` ref validates before +anything lands in `github-actions`. This creates no tags and no release. The canary must use a `use-npmrc` path, +because the five workflows that run `npm ci` with no `~/.npmrc` resolve npmjs URLs normally today and therefore cannot +demonstrate the fix. + +**Alternatives Considered:** + +- **Tag/release pipeline canary**: rejected by explicit preference — no tags are to be created for validation. +- **Post-merge canary on the normal pin**: rejected; it inverts prove-then-merge into merge-then-hope. + +**Implications:** + +- `fe-pr-snapshot` publishes a `latest-pr-snapshot` package to JFrog (cleaned up by `fe-pr-close`). Not a tag or + release, but a real publish — called out so it is not a surprise. +- The specific consumer repo and branches are an implementation-planning detail. + +--- + +### Dimension 8 — Consumer-repo lockfiles + +**Chosen Approach:** Out of scope. No change to `fe-install-deps.yml` or any reusable workflow. + +**Rationale:** A failing check in a reusable workflow fires in **every** consumer repo at once, across all seven pinned +branches, breaking pipelines that are green today with no migration window and no consumer-side normalizer available. +It would also invert the dependency with PFM-ISSUE-34454, which already owns consumer-side `DOT_NPMRC` standardization. + +**Alternatives Considered:** + +- **Warning-only reconnaissance step**: zero risk of breakage and would feed 34454 real exposure data, but it modifies + a workflow every FE repo depends on, on seven branches, and unowned warnings get ignored. The same data is available + out-of-band. +- **Failing check in `fe-install-deps.yml`**: would genuinely enforce the broader criterion, at the cost of breaking + currently-green consumer pipelines immediately. + +**Implications:** + +- An **out-of-band consumer lockfile survey** (clone the FE repos, `grep -c registry.npmjs.org` per repo and branch) is + noted as prep work for PFM-ISSUE-34454, so it begins with a known blast radius. +- After normalization, the five `npm ci`-without-`~/.npmrc` workflows resolve 100 % of packages anonymously from JFrog + instead of ~69 %. No new dependency, larger exposure — 34454's concern, recorded here as understood and accepted. + +--- + +### Dimension 9 — Interim mitigation (added 2026-08-11, after implementation) + +**Chosen Approach:** `.github/actions/use-npmrc` appends `replace-registry-host=never` to the `~/.npmrc` it writes, and +additionally runs an advisory `warn-foreign-registry.sh` against the **consumer's** lockfile. + +**Rationale:** Dimension 8 declared consumer-repo lockfiles out of scope on the reasoning that a failing check in a +reusable workflow would break currently-green pipelines. Implementation found that reasoning rested on a false premise: +**`cplace-paw-fe` `release/25.2` and `release/25.3` are not green — they are broken today**, each carrying 14 +`registry.npmjs.org` entries in their own lockfile. Verified with a cold cache and the real secret: +`E404 GET https://cplace.jfrog.io/readdirp/-/readdirp-3.6.0.tgz`. Nothing masks it: that repo has zero caches on those +branches, and the cache key is `hashFiles('**/package-lock.json')`, so a cross-branch hit is impossible by +construction. + +That splits the problem into **two failure surfaces**, which this design had treated as one: + +| surface | where `npm ci` runs | whose lockfile | fixed by | +| --- | --- | --- | --- | +| composite | the action's own checkout, outside the workspace | *this* repo's | normalization (Dimensions 1–3) | +| consumer | the workspace | the *consumer's* | **only** the mitigation, or normalizing that consumer | + +`replace-registry-host=never` makes npm fetch each `resolved` URL verbatim rather than rewriting its host, which fixes +both surfaces at once and needs no lockfile change anywhere. Measured against real lockfiles with the real secret: +github-actions un-normalized → `added 542 packages`; `cplace-paw-fe release/25.2` → `added 2576 packages`. + +**Alternatives Considered:** + +- **Revert `use-npmrc` to a workspace-level `.npmrc`** (the pre-Node-24 behaviour). Rejected on measurement: it fixes + only the composite surface, because a consumer's own `npm ci` runs *in* the workspace where that file lives — so + `cplace-paw-fe` stays broken. It also makes the composite's install issue **730 anonymous JFrog requests per run**, + creating exactly the dependency PFM-ISSUE-34454 exists to remove. +- **`--replace-registry-host=never` on the four composites' `npm ci` only.** Smallest blast radius and no + consumer-visible change, but likewise leaves every consumer lockfile broken. +- **Conditional application** — set the flag only when a scan finds foreign URLs. Rejected: it makes behaviour + branch-dependent and harder to reason about, for no gain. The flag is a **no-op** on a clean lockfile, so applying it + unconditionally is deterministic; the *warning* carries the signal instead. + +**Implications:** + +- **This is a mitigation, not the fix.** Under it, entries still on npmjs are fetched directly from npmjs, bypassing + Xray and curation. Removal is owned by PFM-ISSUE-34454. +- **The warnings are the removal criteria.** `warn-foreign-registry.sh` emits a `::warning` annotation plus a job + summary naming offending package paths; when no pipeline reports one, the line comes out. The mitigation thus + inventories its own obsolescence. +- **The two compose safely in either order** — on a normalized lockfile the flag is a no-op — so removal is lazy and + per-branch rather than a coordinated switchover. +- **The check must never fail a build.** It runs in every consumer's pipeline; missing lockfile, missing `jq` and + invalid JSON all exit 0 silently, and local-path `resolved` values (present in 4 of 41 FE repos) are ignored rather + than reported as false positives. +- **A clean JFrog access log no longer proves everything resolves through the proxy** while this is in place, because + npmjs-direct requests never reach JFrog at all. Recorded on PFM-ISSUE-34454, whose shutdown criteria depend on it. + +--- + +## Overall Architecture + +### Key Components + +1. **`tools/scripts/lockfile/lib.sh`** — shared constants (the one proxy prefix) and helpers. Sourced by both scripts. +2. **`tools/scripts/lockfile/normalize-lockfile.sh`** — rewrites `.packages[].resolved` prefixes via jq; self-asserts + before/after; idempotent; prints the count rewritten and the byte delta. +3. **`tools/scripts/lockfile/fingerprint.jq`** — reduces a lockfile to a registry-independent, comparable form + (every `resolved` → its tarball path). +4. **`tools/scripts/lockfile/check-lockfile.sh`** — runs both assertions (graph invariance vs. baseline; prefix + exactness) and reports drift by package path. Exit 0 / 1. +5. **`tools/scripts/lockfile/warn-foreign-registry.sh`** — advisory scan of the **consumer's** lockfile, run by + `use-npmrc`. Emits a `::warning` plus job summary; never fails a build. Added by Dimension 9. +6. **`tools/scripts/lockfile/*.bats`** — behavioural tests over small JSON fixtures, including the six injected-drift + cases (t1–t6) that motivated the two-assertion design. +7. **`tools/scripts/lockfile/README.md`** — the documented entry point: rollout flow, conflict-resolution runbook, + guard-failure interpretation. +8. **`.github/workflows/pr-checks.yml`** — `lockfile` and `scripts` jobs. +9. **`package-lock.json`** — the artifact under change: 171 `resolved` prefixes per branch. + +### Data Flow + +**Rollout (per branch):** developer runs `normalize-lockfile.sh` → 171 prefixes rewritten, +4788 bytes → commit the +lockfile alone → `check-lockfile.sh --baseline HEAD~1` → graph identical, one distinct prefix, zero npmjs → push → +`pr-checks.yml` re-runs the same check on the PR → canary PR in a consumer repo proves `npm ci` resolves → merge. + +**Conflict resolution (future upmerge):** conflict on `package-lock.json` → `git checkout --ours -- package-lock.json` +→ re-run the idempotent normalizer → `check-lockfile.sh --baseline :2:` (or `:3:`) → proceed only on exit 0. Never +`--theirs`, never a hand edit. + +**Guard (every PR):** `pr-checks.yml` → `lockfile` job runs `check-lockfile.sh --prefix-only` → on failure, prints +offending package paths plus the remediation command; `scripts` job runs shellcheck + bats. **It asserts prefix +exactness only.** Graph invariance forbids any dependency change, so gating pull requests on it would fail every +legitimate `npm install`; it is run by hand with `--baseline` to verify a normalization commit. A green PR is therefore +not evidence that a lockfile diff changed only prefixes. + +### Integration Points + +- `.github/workflows/pr-checks.yml` is self-contained — it calls scripts in its own checkout, with no composite action + and no branch-pinned reference. +- The four affected composites (`artifacts`, `snapshots`, `upmerge`, `run-many`) are **not modified**; they simply stop + failing once the lockfile they `npm ci` is internally consistent. +- ~~`.github/actions/use-npmrc/action.yml` is **not modified** — that is PFM-ISSUE-34454's territory.~~ + **Superseded by [Dimension 9](#dimension-9--interim-mitigation-added-2026-08-11-after-implementation).** It *is* + modified: it appends `replace-registry-host=never` and runs the advisory `warn-foreign-registry.sh`. The reasoning + that put it out of scope assumed consumer pipelines were green; two are not. **Removal** remains + PFM-ISSUE-34454's territory, and that issue now carries the removal criteria. + +## Technology Choices + +**Normalizer / check language:** + +- Choice: bash (Google Shell Style Guide) + jq +- Why: the only option runnable when `npm ci` is broken; jq output measured byte-identical to a textual rewrite; jq + pre-installed on CI runners. + +**Test framework:** + +- Choice: bats, installed in the workflow via action or `apt-get` +- Why: the only realistic test framework for shell here. Explicitly **not** an npm devDependency, because that would + mutate `package-lock.json` on all seven branches and contradict the invariant this ticket establishes. + +**Static analysis:** + +- Choice: shellcheck in the `scripts` job +- Why: recovers part of the type-checking safety lost by leaving TypeScript; already available locally (0.11.0). + +**Guard trigger:** + +- Choice: `on: pull_request: branches: ['**']`, no `paths:` filter +- Why: mirrors `.github/workflow-templates/fe/fe-pr.yml:2-5`; a path filter would report pending and permanently block + merges once the check becomes required. + +## Trade-offs & Risks + +### Accepted Trade-offs + +1. **Bash + bats over TypeScript + jest**: accepting weaker static guarantees and a convention deviation to gain + independence from the broken `npm ci`. +2. **A guard that reports but cannot block**: accepting a visibility-only guard now to keep enforcement (Rule Sets) as + a separately owned, correctly scoped change. +3. **Seven PRs**: accepting review overhead to gain deterministic, upmerge-independent delivery. +4. **100 % anonymous JFrog resolution in five workflows**: accepting a larger blast radius for the anonymous-access + shutdown, explicitly handed to PFM-ISSUE-34454. +5. **Symptom over structure**: accepting that the runtime `npm ci` inside a composite remains the root fault. + +### Known Risks + +1. **Proxy availability was never re-probed.** If a curated-repo or Xray policy blocks any of the 171 tarballs through + `cplace-npm`, the entire approach fails. — *Mitigation:* re-probe all 171 URLs before the first PR; treat a + non-200/302 as a stop-the-line finding. +2. **Tooling drift across the seven branches** would reintroduce upmerge conflicts on files meant to be identical. — + *Mitigation:* `sha256sum` comparison of `tools/scripts/lockfile/*` and `pr-checks.yml` across branches before each + PR. +3. **The guard is unenforced until the Rule Sets follow-up lands**, so a determined merge can still reintroduce npmjs + URLs on six unprotected branches. — *Mitigation:* file the follow-up issue immediately, in parallel with this + rollout, not after it. +4. **jq absent on a developer's macOS machine** blocks local use. — *Mitigation:* the README states the one-line + install; CI runners have it pre-installed. +5. **A future lockfile with `link:`/`file:`/`git+ssh:` entries** would fail the "every entry has `resolved`" assertion. — + *Mitigation:* intentional. The failure names the package path, so loosening is a deliberate, reviewed edit. +6. **A bad merge could alter the scripts themselves**, and the lockfile invariant does not cover them. — *Mitigation:* + bats + shellcheck run on every PR; risk 2's checksum comparison catches divergence. + +## Out of Scope + +- Consumer-repo lockfile guarding (→ PFM-ISSUE-34454; out-of-band survey noted as prep). +- Branch protection / required status checks (→ own follow-up issue, GitHub Rule Sets). +- Enabling `jest` in CI (unmeasured), and `check-prettier` in CI (20 pre-existing failures). +- `DOT_NPMRC` standardization and the JFrog anonymous-access shutdown (PFM-ISSUE-34454). +- Branches ≤ 24.2 (latent; do not backport `use-npmrc` before normalizing their lockfiles). +- Eliminating the runtime `npm ci` via pre-bundling or publishing `tools/scripts/*`. +- The stale `--release 5.17` in `tools/scripts/upmerge/upmerge.ts:22`. +- Modifying `use-npmrc` or any of the four affected composite actions. + +## Success Criteria + +- `grep -c 'registry.npmjs.org' package-lock.json` returns **0** on all seven branches. +- On each branch, `check-lockfile.sh --baseline HEAD~1` exits 0: dependency graph identical, exactly one distinct + `resolved` prefix, zero entries missing `resolved` outside root. +- Each lockfile commit shows exactly **342 changed lines** and **+4788 bytes**, with no non-`resolved` line changed. +- The normalizer is idempotent: a second run rewrites 0 entries and changes 0 bytes. +- The invariant check **fails** on injected drift — poisoned `version`, poisoned `integrity`, changed tarball filename, + typo'd proxy prefix, an entry left on npmjs — naming the offending package path in each case. +- A consumer-repo canary PR per lockfile state completes the composite's internal `npm ci` successfully on a + `use-npmrc` path. +- `pr-checks.yml` fails a PR that reintroduces `registry.npmjs.org`, and its message names the remediation command. +- bats and shellcheck pass in CI on all seven branches. +- `tools/scripts/lockfile/*` and `pr-checks.yml` are byte-identical across all seven branches. + +## Next Steps + +1. Review this design document. +2. Refine if needed based on feedback. +3. Proceed to implementation planning: + `/spec-driven-development:create_plan specs/2026-08-10_normalize-package-lock-resolved-urls/design.md` + +Also to be filed alongside the plan: + +- Follow-up PFM issue: **enforcement via GitHub Rule Sets** for `release/*` + `master` (and, in the same family, + enabling `jest` / `check-prettier` once the 20 pre-existing prettier failures are cleaned up). +- Prep note on PFM-ISSUE-34454: out-of-band consumer lockfile survey. + +## References + +- Original ticket: [PFM-ISSUE-34453](https://base.cplace.io/pages/6lxohwjr2a51h39y5idjx6qj2/PFM-ISSUE-34453-github-actions-Normalize-package-lock.json-resolved-URLs-onto-the-JFrog-npm-proxy-release-25.2-master) +- Related research: [research.md](./research.md) +- Upstream cause: [`specs/2026-06-05_node24-workflow-migration/design.md`](../2026-06-05_node24-workflow-migration/design.md) + (replaced `bduff9/use-npmrc` with the in-repo composite writing `~/.npmrc`) +- SHA-pinning convention: [`specs/2026-06-05_node24-workflow-migration/sha-pins.md`](../2026-06-05_node24-workflow-migration/sha-pins.md) +- Blocked ticket: PFM-ISSUE-34454 (`DOT_NPMRC` standardization; JFrog anonymous-access shutdown) +- Prior "check does not block" precedent: PFM-ISSUE-33179 (`5c4ba52`) +- JSON read/mutate/write pattern: `tools/scripts/artifacts/nx-project.ts:235-271` +- Fail-loudly validator pattern: `tools/scripts/artifacts/utils.ts:309-328` +- Publish target vs. install proxy: `tools/scripts/artifacts/configuration.ts:2` +- The regression's mechanism: `.github/actions/use-npmrc/action.yml:10-14` +- Affected composites: `.github/actions/{artifacts,snapshots,upmerge,run-many}/action.yml` +- Non-executing PR template: `.github/workflow-templates/fe/fe-pr.yml:2-5` diff --git a/specs/2026-08-10_normalize-package-lock-resolved-urls/overview.html b/specs/2026-08-10_normalize-package-lock-resolved-urls/overview.html new file mode 100644 index 0000000..cb4b954 --- /dev/null +++ b/specs/2026-08-10_normalize-package-lock-resolved-urls/overview.html @@ -0,0 +1,1140 @@ +Lockfile Registry Invariant + + + +
+ +
+

tools/scripts/lockfile

+

Lockfile Registry Invariant

+

+ Three bash scripts, one jq program and a composite action that together keep every + resolved URL in package-lock.json pointing at the cplace JFrog npm proxy — + and prove that a normalization commit changed nothing else. +

+

+ The invariant: every non-root entry's resolved URL consists of exactly one + registry prefix — https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/ — followed by that + package's tarball path. Nothing else in the file may move when the prefixes are rewritten. +

+
+ +
+

The fix

+
+

+ A lockfile entry that resolves via registry.npmjs.org is not fetched from where it says. npm's + default replace-registry-host=npmjs rewrites the host onto whatever registry + ~/.npmrc configures — and pacote builds the new URL by joining the old pathname onto the + new base, so the registry's own path prefix is discarded. The request 404s, and because that prefix is the + JFROG_URL secret, CI prints the whole thing as ***. That is why + every message these scripts print names a package path and never a URL. +

+

+ The fix has two parts, and they attack the same rewrite from opposite ends. +

+
+ +
+
+ The fix — permanent +

Normalize the lockfile

+

+ Rewrite every resolved prefix onto the proxy, so the URL in the lockfile already agrees with the + registry. There is then nothing for npm to rewrite, whatever any ~/.npmrc says. This is what + normalize-lockfile.sh does and what check-lockfile.sh guards. +

+
+
+ The mitigation — interim +

replace-registry-host=never

+

+ One line appended to ~/.npmrc by + use-npmrc:25, + switching the rewrite off entirely. It fixes lockfiles nobody has normalized yet — including every + consumer's — which is why it ships to all ~41 downstream repositories rather than just this one. +

+
+
+ +
+
+ + + + + + + + 1 — UNFIXED, DEFAULT NPM + 2 — MITIGATION APPLIED + 3 — LOCKFILE NORMALIZED + + + entry on npmjs + registry.npmjs.org/ + foo/-/foo-1.0.0.tgz + + + replace-registry-host + = npmjs (the default) + + + host rewritten + new URL(oldPathname, registry) + — proxy path prefix dropped + + + + + GET cplace.jfrog.io/ + foo/-/foo-1.0.0.tgz + no /artifactory/api/npm/… + + + + + 404 + masked as *** in CI, + because the prefix is the secret + + + entry on npmjs + unchanged — nobody has + normalized this lockfile yet + + + use-npmrc:25 appends + replace-registry-host=never + + + no rewrite at all + npm fetches each resolved URL + exactly as written + + + + + GET registry.npmjs.org/ + foo/-/foo-1.0.0.tgz + straight from public npm + + + + + 200 + — but it bypassed the proxy: + no Xray, no curation + + + entry on the proxy + cplace.jfrog.io/…/cplace-npm/ + foo/-/foo-1.0.0.tgz + + + host already matches — + the flag is a no-op here + + + nothing to rewrite + true with the flag and + without it — hence removable + + + + + GET cplace.jfrog.io/ + artifactory/api/npm/cplace-npm/ + foo/-/foo-1.0.0.tgz + + + + + 200 + — through the proxy, + with its policy applied + +
+
+ Lane 2 removes the rewrite; lane 3 removes the need for it. The pairing is what makes the rollout + lazy rather than a coordinated switchover: on a normalized lockfile the flag changes nothing, so the two + compose in either order and the flag can come out per branch — once the advisory stops reporting anywhere. +
+
+ +
+

+ The mitigation is not free, and lane 2 is why: an entry still on npmjs is fetched from npmjs, + bypassing whatever curation the proxy enforces. It is a deliberate, documented trade — and the advisory's + warnings are the inventory that decides when it can be withdrawn. +

+
+
+ +
+

What the pull request contains

+
+

+ 19 files, +5596/−173 against release/25.2. Four + distinct things, deliberately separable — and the lockfile is committed on its own, so its + parent is the baseline the check verifies it against. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PartFilesWhat it is
The toolchaintools/scripts/lockfile/ (10)Normalizer, check, advisory, shared lib.sh, fingerprint.jq, three bats suites and their fixtures, plus the README runbook.
The guard.github/workflows/pr-checks.ymlThis repository's first on: pull_request workflow — everything else here is workflow_call-only. Two jobs: the invariant, and shellcheck + bats.
The mitigation.github/actions/use-npmrc/action.ymlreplace-registry-host=never, plus the advisory step that inventories which consumer lockfiles still need normalizing.
The lockfilepackage-lock.json342 changed lines, +4788 bytes, 171 npmjs URLs → 0. Not one changed line outside a "resolved".
The recordspecs/2026-08-10_… (5)Research, design, plan, the consumer survey across 41 repositories, and the review with all 15 findings resolved.
+
+
+

+ .prettierignore gains package-lock.json: prettier would otherwise reformat the file + the invariant is asserted over, and every branch would drift on the first save. +

+
+
+ +
+

The pieces

+
+

+ Bash and jq, in a repository that is otherwise TypeScript — deliberately. + npx ts-node needs node_modules, which needs npm ci, which is exactly + what is broken when the lockfile carries npmjs URLs. A TypeScript normalizer could not repair the lockfile it + exists to repair. These need only bash, jq and git. +

+
+ +
+
+ + + + + + + + CALLERS + SCRIPTS + SHARED + + + pr-checks.yml + every pull request + + + developer shell + flows 1 and 2 + + + use-npmrc action + writes ~/.npmrc + the flag + + + check-lockfile.sh + asserts — exit 0 or 1 + + + normalize-lockfile.sh + rewrites — idempotent + + + warn-foreign-registry.sh + advisory — always exit 0 + + + fingerprint.jq + registry-blind form + + + lib.sh + constants + helpers + + + --prefix-only + + + + --baseline + + + || true + + + + + sources + + + jq -f + +
+
+ lib.sh is sourced, never executed. It holds the proxy constant, both regexes and the one shared + jq predicate — defined once so the normalizer, the guard and the advisory cannot drift apart. They already had: + three hand-written copies of that predicate disagreed, and one of them passed an entry the guard rejected. +
+
+
+ +
+

What a resolved URL is made of

+
+

+ Both assertions read the same string and cut it in the same place. Everything downstream follows from that + one split. +

+
+ +
+
+ + + + + + + + https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/ + @scope/pkg/-/pkg-1.2.3.tgz + + + + + registry prefix + tarball path — captured by TARBALL_PATH_RE + + + + + + Assertion 2 — prefix exactness + strip the path, require what remains to equal the constant + + + Assertion 1 — graph invariance + keep only this, compare the whole document + +
+
+ The split is why neither assertion subsumes the other. Graph invariance is blind to which host an + entry sits on; prefix exactness is blind to everything except the host. A poisoned integrity + passes the second; an entry left on npmjs passes the first. +
+
+ +
+
+ Assertion 1 +

Graph invariance

+

Reduce both documents to their registry-blind form and require them byte-identical.

+
    +
  • Catches a changed version, a poisoned integrity, a renamed tarball, a moved dependency edge, an added or dropped entry.
  • +
  • Runs only with --baseline.
  • +
  • Needs assert_resolvable first — every entry must have a tarball path to compare on.
  • +
+
+
+ Assertion 2 +

Prefix exactness

+

Every registry entry's prefix must equal the one constant — exactly, not startswith.

+
    +
  • Catches an entry on npmjs, a typo'd cplace-nmp, a stray path segment, an http:// downgrade, an uppercase HTTPS://.
  • +
  • Runs in both modes. It is the whole of the PR guard.
  • +
  • Refuses to report compliance when it examined zero entries.
  • +
+
+
+
+ +
+

The two modes

+
+

+ Getting this distinction wrong blocks every dependency update — and it did, in review. Graph invariance + forbids any change to the dependency graph, which is exactly right when proving a normalization + commit touched nothing but prefixes, and exactly wrong on an everyday pull request where adding a dependency + is the point. +

+
+ +
+
+ + + + + + + + + main() + + + + + assert_supported_lockfile + lockfileVersion 2 or 3, else die + + + + + --prefix-only ? + + + yes + + no + + THE PR GUARD + + assert_prefix_exactness + no git, no baseline, no network + + + + + 0 entries examined? + refuse — rc 2, no normalizer advice + + + + + OK — resolves via the proxy + n entries examined, exit 0 + + VERIFYING A NORMALIZATION + + resolve_baseline + ref, :2: merge stage, or file + + + + + assert_resolvable × 2 + baseline and candidate + + + + + both assertions run + graph invariance, then prefix + exactness — one run, all faults + + + + normalized and graph-identical + + + + FAIL — names every package path + exit 1, never a URL + +
+
+ assert_resolvable sits on the baseline path only. It is a precondition for + fingerprinting — not for prefix exactness — and in front of --prefix-only it hard-failed + on exactly the npm-workspace and link: entries that mode exists to allow. +
+
+
+ +
+

Where it runs

+ +
+ CI +
+

The pull-request guard

+

+ + pr-checks.yml runs one line on every pull request, on any branch. No + setup-node, no npm ci, no baseline to fetch — the guard has to be trustworthy + precisely when the lockfile is broken. +

+
- name: Check package-lock.json resolved URLs
+  run: ./tools/scripts/lockfile/check-lockfile.sh --prefix-only
+

+ A second job runs shellcheck and the 74 bats cases. Neither job may add npm + devDependencies — that would mutate package-lock.json on all seven branches and break the very + invariant this workflow guards. +

+
+ + FLOW 1 +
+

Normalizing a branch

+
./tools/scripts/lockfile/normalize-lockfile.sh
+git add package-lock.json
+git commit -m 'PFM-ISSUE-34453 - github-actions: normalize …'
+./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1
+

+ Commit the lockfile on its own. Then the commit's parent is the baseline and + verification needs no ref to remember. Expect 342 changed lines, + +4788 bytes, and not one changed line outside a "resolved". +

+
+ + FLOW 2 +
+

Resolving an upmerge conflict

+
git checkout --ours -- package-lock.json
+./tools/scripts/lockfile/normalize-lockfile.sh
+./tools/scripts/lockfile/check-lockfile.sh --baseline :2:   # :2: ours, :3: theirs
+

+ Proceed only on exit 0. Never --theirs, never a hand edit: the point is that correctness does + not depend on anyone reading a 262 KB diff. +

+
+ + CONSUMERS +
+

The advisory, on every consumer's runner

+

+ + use-npmrc/action.yml:27 — the step you highlighted — runs + warn-foreign-registry.sh against the consumer's lockfile, through seven reusable + workflows across roughly 41 repositories. +

+
run: bash "$GITHUB_ACTION_PATH/../../../tools/scripts/lockfile/warn-foreign-registry.sh" || true
+

+ Two details carry weight. The || true is not belt-and-braces: the script's own "every + precondition is a silent success" contract cannot cover not being reachable — a missing exec bit, a + path with spaces — and continue-on-error is not honoured on composite steps. And the same + action appends + replace-registry-host=never to ~/.npmrc, which switches off the rewrite in the + first diagram. That line is a mitigation, not the fix — under it, an entry still on npmjs is fetched + straight from npmjs, bypassing the proxy's curation. The warnings are the inventory that decides + when it can be removed. +

+
+
+
+ +
+

Reading a failure

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
What you seeWhat it meansWhat to do
the dependency graph differs
from the baseline
Something other than a registry prefix changed — the bad-merge case.Do not re-run the normalizer; it rewrites prefixes and would not touch this. Work out why that entry moved.
every 'resolved' must carry
exactly the … proxy prefix
An entry is on npmjs, or on a typo'd proxy path.Run normalize-lockfile.sh.
… differ from the proxy ONLY
in scheme: they use plain http
Right host, wrong scheme. It fails the exact comparison like any other mismatch.Run the normalizer — it rewrites onto https.
no usable tarball URLA non-root entry has no resolved, or resolves over link:/file:/git+ssh:.A deliberate, reviewed loosening of assert_resolvable — not a workaround. Fires on the baseline path and in the normalizer, never in the PR guard.
has no registry entries
to check
Zero entries examined. Reporting compliance from an empty set is the vacuous pass this refuses to give.Expected only for an all-workspace lockfile; that needs a reviewed loosening too.
n entries in package-lock.json
do not resolve via the … proxy
The advisory, on a consumer runner. Never fails a build.Normalize that lockfile. When no pipeline reports this any more, replace-registry-host=never comes out.
+
+
+ +
+

The bats suite

+
+

+ 74 cases across three files — 42 for the check, + 19 for the normalizer, 13 for the advisory. They run on every + pull request beside shellcheck, and neither job may install an npm devDependency: that would + mutate package-lock.json on all seven branches and break the invariant under test. +

+
bats tools/scripts/lockfile/
+shellcheck tools/scripts/lockfile/*.sh tools/scripts/lockfile/test-helper.bash
+

+ Fixtures are tiny hand-written lockfiles from + test-helper.bash + — write_mixed_lockfile (one npmjs entry, one proxy entry, a root entry with no + resolved), plus builders for the unresolvable and git+ssh: shapes. Not copies of the + real 262 KB file: the tests assert behaviour, never byte counts. +

+
+ +
+

The six drift cases

+

+ t1t6 are the measured evidence behind the two-assertion design. Each one injects a + single fault and asserts not merely that the check fails, but that it fails via the right + assertion — without that, the two could silently collapse into one and nobody would notice until a + bad merge slipped through. +

+
+ +
+ + + + + + + + + + + + +
CaseInjected faultMust fail via
t1Changed tarball filenameGraph invariance
t2Typo'd proxy repo name — cplace-nmpPrefix exactness
t3Changed dependency edgeGraph invariance
t4An entry left on npmjsPrefix exactness
t5Poisoned integrity hashGraph invariance
t6Poisoned versionGraph invariance
+
+ +
+
+ Fails closed +

The paths that must not pass quietly

+
    +
  • When fingerprint_of cannot run, the normalizer leaves the lockfile untouched and exits non-zero — the case that once passed by comparing two empty outputs.
  • +
  • A lockfileVersion 1 file, and one with no lockfileVersion at all, each fail with their own message rather than a jq trace.
  • +
  • A baseline predating the v2/v3 upgrade fails readably instead of null (null) has no keys.
  • +
  • A lockfile with zero registry entries is refused, not reported compliant.
  • +
+
+
+ Contracts +

Properties, not just outcomes

+
    +
  • No failure message contains the JFrog host — asserted directly, because CI masks it as ***.
  • +
  • The advisory and the guard agree on what an offender is; they once disagreed, and the advisory under-reported.
  • +
  • The advisory exits 0 on a missing file, missing jq, invalid JSON, no packages section, and an unreachable lib.sh.
  • +
  • Everything RESOLVED_URL_RE accepts is capturable by TARBALL_PATH_RE — widening one without the other deletes resolved keys instead of raising.
  • +
  • The reported rewrite count matches what the rewrite actually changed.
  • +
+
+
+ +
+

+ Two cases exist because a canary caught what the suite had not. A tarball path containing slashes — + …/-/@scope/pkg-1.2.3.tgz, which JFrog really serves — was reported as foreign while sitting on the + proxy; and the root entry, which assert_resolvable deliberately skips, could lose its + resolved key silently. Both are now pinned by tests, in the suite that had passed + 65/65 the moment before. +

+
+
+ +
+

Seven branches, byte-identical

+
+

+ The same toolchain has to exist on every long-lived branch, and it has to be the same bytes on + each. Not for tidiness: an upmerge that finds an identical file on both sides resolves as a conflict-free + add/add. One stray character and every future upmerge stops on tools/scripts/lockfile/ — the + directory whose whole purpose is to make upmerges mechanical. +

+
+ +
+
+ + + + + + + + THE UPMERGE CHAIN — EACH INHERITS FROM THE LEFT + + + release/25.2 + PR #163 — the seed + + + + release/25.3 + + + + release/25.4 + + + + release/26.1 + + + + release/26.2 + + NOT DOWNSTREAM — THE FIX CANNOT RIDE THE UPMERGE + + + master + own pull request + + + release/26.3 + own pull request + + + + no inherited path + + + + 13 files · digest f0a7638dcfd16ba8 · identical on all seven + tools/scripts/lockfile/* · pr-checks.yml · use-npmrc/action.yml · .prettierignore + +
+
+ master and release/26.3 are not downstream of 26.2, so no amount of + upmerging carries the fix to them — each needs its own pull request. That is why the rollout is seven PRs and + not one plus a wait. +
+
+ +
+

How the rule is kept

+

+ The digest is the gate: hash those 13 files on each branch and require one value. It is computed after every + change to the toolchain, and the six downstream branches are rebuilt from the seed rather than patched — the + tooling commit is copied verbatim, then the normalizer runs and the lockfile is committed alone. Both commits, + then check-lockfile.sh --baseline HEAD~1 to prove the second one moved nothing but prefixes. +

+

+ The consequence worth internalising: a fix to any of these files is seven commits, not one. + Three times during review the toolchain changed and all six staged branches were discarded and rebuilt. That is + cheap when the branches are held back and expensive once they are pushed — which is exactly why they are held + back until the seed is approved. +

+
+ +
+ + + + + + + + + + + + + +
BranchEntriesLockfile commitRoute
release/25.2542342 lines · +4788 BPR #163 — the seed
release/25.3542342 lines · +4788 Bown PR, then upmerge
release/25.4542342 lines · +4788 Bown PR, then upmerge
release/26.1558342 lines · +4788 Bown PR, then upmerge
release/26.2558342 lines · +4788 Bown PR, then upmerge
release/26.3558342 lines · +4788 Bown PR — off-chain
master558342 lines · +4788 Bown PR — off-chain
+
+
+

+ Identical line and byte counts across branches with different entry counts is not a coincidence to gloss over: + the 171 npmjs entries are the same 171 packages everywhere, so the same rewrite + applies. It is also a cheap tripwire — a branch reporting different numbers has something else going on. +

+
+
+ +
+

Source map

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileRoleKey lines
lib.shShared constants and helpers. Sourced, never executed. + L20 proxy · + L48 tarball re · + L82 predicate · + L151 resolvable +
check-lockfile.shThe invariant check. Both assertions, both modes. + L44 baseline · + L73 graph · + L115 prefix · + L187 main +
normalize-lockfile.shThe normalizer. Idempotent, and fails closed on its own fingerprint self-check before writing.L47 rewrite
warn-foreign-registry.shAdvisory. Emits a ::warning plus a job summary; every exit is 0.L47 main
fingerprint.jqReduces a lockfile to its registry-independent comparable form.
use-npmrc/action.ymlWrites ~/.npmrc, appends the mitigation, runs the advisory. + L25 mitigation · + L27 advisory +
pr-checks.ymlThe guard job and the shellcheck/bats job.L39
README.mdThe runbook these sections condense.
+
+
+

+ Two properties worth keeping in mind when changing any of this. The proxy URL is a hard-coded constant + on purpose — it is the invariant being asserted, and a caller-supplied prefix would validate + itself. And these files are meant to be byte-identical across all seven long-lived branches, + so a future upmerge sees a conflict-free add/add: change them on one branch and the change has to reach the + others. +

+
+
+ +
+ Links point at commit 9b1d3bf on + fix/PFM-ISSUE-34453-normalize-package-lock-json/25.2 — PR #163. Counts as measured there: + 542 entries on the 25.x branches, 558 on 26.x, 74 bats cases. +
+ +
diff --git a/specs/2026-08-10_normalize-package-lock-resolved-urls/overview.md b/specs/2026-08-10_normalize-package-lock-resolved-urls/overview.md new file mode 100644 index 0000000..759e87f --- /dev/null +++ b/specs/2026-08-10_normalize-package-lock-resolved-urls/overview.md @@ -0,0 +1,378 @@ +# Lockfile registry invariant — a visual overview of PFM-ISSUE-34453 + +Companion to [`research.md`](./research.md), [`design.md`](./design.md), [`plan.md`](./plan.md) and +[`review.md`](./review.md). Those record *how the work was decided*; this one explains *how the result works*, for a +reader meeting the toolchain for the first time. + +> **The invariant.** Every non-root entry's `resolved` URL consists of exactly one registry prefix — +> `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/` — followed by that package's tarball path. Nothing else in +> the file may move when the prefixes are rewritten. + +An HTML edition of this document, with hand-drawn diagrams, sits beside it as +[`overview.html`](./overview.html). + +--- + +## 1. The fix + +A lockfile entry that resolves via `registry.npmjs.org` is not fetched from where it says. npm's default +`replace-registry-host=npmjs` rewrites the host onto whatever registry `~/.npmrc` configures — and pacote builds the +new URL by joining the *old pathname* onto the new base, so the registry's own path prefix is discarded. The request +404s, and because that prefix is the `JFROG_URL` secret, CI prints the whole thing as `***`. That is why **every +message these scripts print names a package path and never a URL.** + +The fix has two parts, attacking the same rewrite from opposite ends: + +| | What it does | Where | +| --- | --- | --- | +| **The fix** — permanent | Rewrite every `resolved` prefix onto the proxy, so the lockfile already agrees with the registry and there is nothing to rewrite | `normalize-lockfile.sh`, guarded by `check-lockfile.sh` | +| **The mitigation** — interim | `replace-registry-host=never` switches the rewrite off entirely, rescuing lockfiles nobody has normalized yet — including every consumer's | [`use-npmrc/action.yml:25`](../../.github/actions/use-npmrc/action.yml) | + +```mermaid +flowchart TB + subgraph L1["1 · unfixed, default npm"] + A1["entry on npmjs
registry.npmjs.org/foo/-/foo-1.0.0.tgz"] + A2["host rewritten
new URL(oldPathname, registry)"] + A3["GET cplace.jfrog.io/foo/-/foo-1.0.0.tgz
no /artifactory/api/npm/cplace-npm"] + A4(["404 — masked as *** in CI"]) + A1 -->|"replace-registry-host = npmjs"| A2 + A2 -->|"proxy path prefix dropped"| A3 + A3 --> A4 + end + + subgraph L2["2 · mitigation applied"] + B1["entry still on npmjs
nobody has normalized this lockfile"] + B2["no rewrite at all
each resolved URL fetched as written"] + B3["GET registry.npmjs.org/foo/-/foo-1.0.0.tgz"] + B4(["200 — but it bypassed the proxy:
no Xray, no curation"]) + B1 -->|"use-npmrc:25 appends
replace-registry-host=never"| B2 + B2 --> B3 + B3 --> B4 + end + + subgraph L3["3 · lockfile normalized"] + C1["entry on the proxy
cplace.jfrog.io/…/cplace-npm/foo/-/foo-1.0.0.tgz"] + C2["nothing to rewrite
true with the flag and without it"] + C3["GET cplace.jfrog.io/artifactory/api/npm/cplace-npm/foo/-/foo-1.0.0.tgz"] + C4(["200 — through the proxy, policy applied"]) + C1 -->|"host already matches —
the flag is a no-op here"| C2 + C2 --> C3 + C3 --> C4 + end + + classDef bad stroke:#b3261e,stroke-width:2px; + classDef ok stroke:#1f7a5a,stroke-width:2px; + class A2,A3,A4 bad; + class C2,C3,C4 ok; +``` + +Lane 2 removes the rewrite; lane 3 removes the *need* for it. Because the flag is a no-op on a normalized lockfile, +the two compose in either order and the flag can be dropped **per branch** rather than in a coordinated switchover — +once the advisory stops reporting anywhere. + +The mitigation is not free, and lane 2 is why: an entry still on npmjs is fetched *from* npmjs, bypassing whatever +curation the proxy enforces. A deliberate, documented trade, owned by PFM-ISSUE-34454. + +--- + +## 2. What the pull request contains + +19 files, +5596/−173 against `release/25.2`. Four separable things — and the lockfile is committed **on its own**, so +its parent is the baseline the check verifies it against. + +| Part | Files | What it is | +| --- | --- | --- | +| The toolchain | `tools/scripts/lockfile/` (10) | Normalizer, check, advisory, shared `lib.sh`, `fingerprint.jq`, three bats suites and their fixtures, plus the README runbook | +| The guard | `.github/workflows/pr-checks.yml` | The repository's first `on: pull_request` workflow — everything else is `workflow_call`-only. Two jobs: the invariant, and shellcheck + bats | +| The mitigation | `.github/actions/use-npmrc/action.yml` | `replace-registry-host=never`, plus the advisory step that inventories which consumer lockfiles still need normalizing | +| The lockfile | `package-lock.json` | 342 changed lines, +4788 bytes, 171 npmjs URLs → 0. Not one changed line outside a `"resolved"` | +| The record | `specs/2026-08-10_…` (5) | Research, design, plan, the consumer survey across 41 repositories, and the review with all 15 findings resolved | + +`.prettierignore` gains `package-lock.json`: prettier would otherwise reformat the file the invariant is asserted +over, and every branch would drift on the first save. + +--- + +## 3. The pieces + +Bash and jq, in a repository that is otherwise TypeScript — deliberately. `npx ts-node` needs `node_modules`, which +needs `npm ci`, which is exactly what is broken when the lockfile carries npmjs URLs. A TypeScript normalizer could +not repair the lockfile it exists to repair. + +```mermaid +flowchart LR + PR["pr-checks.yml
every pull request"] + DEV["developer shell
flows 1 and 2"] + NPMRC["use-npmrc action
writes ~/.npmrc + the flag"] + + CHECK["check-lockfile.sh
asserts — exit 0 or 1"] + NORM["normalize-lockfile.sh
rewrites — idempotent"] + WARN["warn-foreign-registry.sh
advisory — always exit 0"] + + FP["fingerprint.jq
registry-blind form"] + LIB["lib.sh
constants + helpers"] + + PR -->|"--prefix-only"| CHECK + DEV -->|"--baseline"| CHECK + DEV --> NORM + NPMRC -->|"|| true"| WARN + + CHECK -->|"jq -f"| FP + CHECK -.->|sources| LIB + NORM -.->|sources| LIB + WARN -.->|sources| LIB +``` + +`lib.sh` is sourced, never executed. It holds the proxy constant, both regexes and the one shared jq predicate — +defined once so the normalizer, the guard and the advisory cannot drift apart. They already had: three hand-written +copies of that predicate disagreed, and one of them passed an entry the guard rejected. + +--- + +## 4. What a `resolved` URL is made of + +Both assertions read the same string and cut it in the same place. + +``` +https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/ @scope/pkg/-/pkg-1.2.3.tgz +└──────────────────────┬───────────────────────────────┘ └────────────┬────────────┘ + registry prefix tarball path + Assertion 2 — strip the path, require Assertion 1 — keep only this, + what remains to equal the constant compare the whole document +``` + +| | Assertion 1 — graph invariance | Assertion 2 — prefix exactness | +| --- | --- | --- | +| Asks | Did anything but the registry change? | Does every entry sit on the one proxy? | +| Catches | Changed version, poisoned `integrity`, renamed tarball, moved dependency edge, added or dropped entry | Entry on npmjs, typo'd `cplace-nmp`, stray path segment, `http://` downgrade, uppercase `HTTPS://` | +| Blind to | *Which host* an entry sits on | Everything except the host | +| Runs in | `--baseline` only | Both modes — it is the whole of the PR guard | + +Neither subsumes the other, which is why both exist and why `check-lockfile.bats` asserts that each drift class fails +via the *correct* one. + +--- + +## 5. The two modes + +Getting this distinction wrong blocks every dependency update — and it did, in review. Graph invariance forbids *any* +change to the dependency graph: exactly right when proving a normalization commit touched nothing but prefixes, +exactly wrong on an everyday pull request where adding a dependency is the point. + +```mermaid +flowchart TB + M["main()"] --> SUP["assert_supported_lockfile
lockfileVersion 2 or 3, else die"] + SUP --> Q{"--prefix-only ?"} + + Q -->|yes| PE["assert_prefix_exactness
no git, no baseline, no network"] + PE --> ZERO{"0 entries examined?"} + ZERO -->|yes| REFUSE(["refuse — rc 2
no normalizer advice"]) + ZERO -->|no| OFF{"offenders?"} + OFF -->|yes| FAIL(["FAIL — names every package path
exit 1, never a URL"]) + OFF -->|no| OK(["OK — n entries examined, exit 0"]) + + Q -->|no| RB["resolve_baseline
ref, :2: merge stage, or file"] + RB --> SUP2["assert_supported_lockfile on the baseline"] + SUP2 --> RES["assert_resolvable × 2
baseline and candidate"] + RES --> BOTH["both assertions run
one run reports every fault"] + BOTH --> DONE(["normalized and graph-identical"]) + + classDef bad stroke:#b3261e,stroke-width:2px; + classDef ok stroke:#1f7a5a,stroke-width:2px; + class FAIL,REFUSE bad; + class OK,DONE ok; +``` + +`assert_resolvable` sits on the baseline path only. It is a precondition for *fingerprinting* — not for prefix +exactness — and in front of `--prefix-only` it hard-failed on exactly the npm-workspace and `link:` entries that mode +exists to allow. + +--- + +## 6. Where it runs + +### The pull-request guard + +```yaml +- name: Check package-lock.json resolved URLs + run: ./tools/scripts/lockfile/check-lockfile.sh --prefix-only +``` + +No `setup-node`, no `npm ci`, no baseline to fetch — the guard has to be trustworthy precisely when the lockfile is +broken. A second job runs `shellcheck` and the bats suites. + +### Flow 1 — normalizing a branch + +```bash +./tools/scripts/lockfile/normalize-lockfile.sh +git add package-lock.json +git commit -m 'PFM-ISSUE-34453 - github-actions: normalize package-lock.json resolved URLs onto the JFrog npm proxy' +./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1 +``` + +Commit the lockfile **on its own**. Then the commit's parent *is* the baseline and verification needs no ref to +remember. Expect 342 changed lines, +4788 bytes, and not one changed line outside a `"resolved"`. + +### Flow 2 — resolving an upmerge conflict + +```bash +git checkout --ours -- package-lock.json # keep this branch's lockfile +./tools/scripts/lockfile/normalize-lockfile.sh +./tools/scripts/lockfile/check-lockfile.sh --baseline :2: # :2: = ours, :3: = theirs +``` + +Proceed **only** on exit 0. Never `--theirs`, never a hand edit: the point is that correctness does not depend on +anyone reading a 262 KB diff. + +### The advisory, on every consumer's runner + +[`use-npmrc/action.yml:27`](../../.github/actions/use-npmrc/action.yml) runs the advisory against the *consumer's* +lockfile, through seven reusable workflows across roughly 41 repositories: + +```yaml +run: bash "$GITHUB_ACTION_PATH/../../../tools/scripts/lockfile/warn-foreign-registry.sh" || true +``` + +The `|| true` is not belt-and-braces: the script's own "every precondition is a silent success" contract cannot cover +*not being reachable* — a missing exec bit, a path with spaces — and `continue-on-error` is not honoured on composite +steps. **The warnings are the inventory that decides when `replace-registry-host=never` can be removed.** + +--- + +## 7. Reading a failure + +| What you see | What it means | What to do | +| --- | --- | --- | +| `the dependency graph differs from the baseline` | Something other than a registry prefix changed — the bad-merge case | Do **not** re-run the normalizer; it rewrites prefixes and would not touch this. Work out why that entry moved | +| `every 'resolved' must carry exactly the … proxy prefix` | An entry is on npmjs, or on a typo'd proxy path | Run `normalize-lockfile.sh` | +| `… differ from the proxy ONLY in scheme: they use plain http` | Right host, wrong scheme | Run the normalizer — it rewrites onto `https` | +| `no usable tarball URL` | A non-root entry has no `resolved`, or resolves over `link:`/`file:`/`git+ssh:` | A deliberate, reviewed loosening of `assert_resolvable` — not a workaround. Fires on the baseline path and in the normalizer, never in the PR guard | +| `has no registry entries to check` | Zero entries examined; reporting compliance from an empty set is the vacuous pass this refuses to give | Expected only for an all-workspace lockfile, which needs a reviewed loosening too | +| `n entries in package-lock.json do not resolve via the … proxy` | The advisory, on a consumer runner. Never fails a build | Normalize that lockfile. When no pipeline reports this any more, the mitigation comes out | + +--- + +## 8. The bats suite + +74 cases across three files — 42 for the check, 19 for the normalizer, 13 for the advisory. They run on every pull +request beside `shellcheck`, and neither job may install an npm devDependency: that would mutate `package-lock.json` +on all seven branches and break the invariant under test. + +```bash +bats tools/scripts/lockfile/ +shellcheck tools/scripts/lockfile/*.sh tools/scripts/lockfile/test-helper.bash +``` + +Fixtures are tiny hand-written lockfiles from `test-helper.bash` — not copies of the real 262 KB file: the tests +assert behaviour, never byte counts. + +### The six drift cases + +`t1`–`t6` are the measured evidence behind the two-assertion design. Each injects a single fault and asserts not +merely that the check fails, but that it fails **via the right assertion** — without that, the two could silently +collapse into one. + +| Case | Injected fault | Must fail via | +| --- | --- | --- | +| `t1` | Changed tarball filename | Graph invariance | +| `t2` | Typo'd proxy repo name — `cplace-nmp` | Prefix exactness | +| `t3` | Changed dependency edge | Graph invariance | +| `t4` | An entry left on npmjs | Prefix exactness | +| `t5` | Poisoned `integrity` hash | Graph invariance | +| `t6` | Poisoned `version` | Graph invariance | + +### What else the suite pins + +**Fails closed.** When `fingerprint_of` cannot run, the normalizer leaves the lockfile *untouched* and exits non-zero +— the case that once passed by comparing two empty outputs. A lockfileVersion 1 file, one with no `lockfileVersion` at +all, and a baseline predating the v2/v3 upgrade each fail with their own message rather than a jq trace. + +**Contracts, not just outcomes.** No failure message contains the JFrog host, asserted directly, because CI masks it +as `***`. The advisory and the guard agree on what an offender is — they once disagreed, and the advisory +under-reported. The advisory exits 0 on a missing file, missing `jq`, invalid JSON, no `packages` section, and an +unreachable `lib.sh`. Everything `RESOLVED_URL_RE` accepts is capturable by `TARBALL_PATH_RE`; widening one without +the other deletes `resolved` keys instead of raising. + +Two cases exist because a canary caught what the suite had not: a tarball path containing slashes — +`…/-/@scope/pkg-1.2.3.tgz`, which JFrog really serves — was reported as foreign while sitting on the proxy, and the +root entry could lose its `resolved` key silently. Both are pinned now, in a suite that had passed 65/65 the moment +before. + +--- + +## 9. Seven branches, byte-identical + +The same toolchain has to exist on every long-lived branch, and it has to be **the same bytes** on each. Not for +tidiness: an upmerge that finds an identical file on both sides resolves as a conflict-free add/add. One stray +character and every future upmerge stops on `tools/scripts/lockfile/` — the directory whose whole purpose is to make +upmerges mechanical. + +```mermaid +flowchart LR + A["release/25.2
PR #163 — the seed"] --> B["release/25.3"] + B --> C["release/25.4"] + C --> D["release/26.1"] + D --> E["release/26.2"] + + M["master
own pull request"] + N["release/26.3
own pull request"] + + E -.->|"not downstream —
no inherited path"| M + E -.->|"not downstream"| N + + classDef seed stroke-width:3px; + class A seed; +``` + +`master` and `release/26.3` are not downstream of `26.2`, so no amount of upmerging carries the fix to them — each +needs its own pull request. That is why the rollout is seven PRs and not one plus a wait. + +### How the rule is kept + +The digest is the gate: hash these 13 files on each branch and require one value. + +``` +tools/scripts/lockfile/* · .github/workflows/pr-checks.yml +.github/actions/use-npmrc/action.yml · .prettierignore +``` + +It is recomputed after every change to the toolchain, and the six downstream branches are **rebuilt from the seed +rather than patched** — the tooling is copied verbatim, then the normalizer runs and the lockfile is committed alone, +then `check-lockfile.sh --baseline HEAD~1` proves the second commit moved nothing but prefixes. + +The consequence worth internalising: **a fix to any of these files is seven commits, not one.** Three times during +review the toolchain changed and all six staged branches were discarded and rebuilt. That is cheap while the branches +are held back and expensive once they are pushed — which is why they are held back until the seed is approved. + +| Branch | Entries | Lockfile commit | Route | +| --- | --- | --- | --- | +| `release/25.2` | 542 | 342 lines · +4788 B | PR #163 — the seed | +| `release/25.3` | 542 | 342 lines · +4788 B | own PR, then upmerge | +| `release/25.4` | 542 | 342 lines · +4788 B | own PR, then upmerge | +| `release/26.1` | 558 | 342 lines · +4788 B | own PR, then upmerge | +| `release/26.2` | 558 | 342 lines · +4788 B | own PR, then upmerge | +| `release/26.3` | 558 | 342 lines · +4788 B | own PR — off-chain | +| `master` | 558 | 342 lines · +4788 B | own PR — off-chain | + +Identical line and byte counts across branches with different entry counts is not a coincidence to gloss over: the +171 npmjs entries are the same 171 packages everywhere, so the same rewrite applies. It is also a cheap tripwire — a +branch reporting different numbers has something else going on. + +--- + +## 10. Source map + +| File | Role | +| --- | --- | +| [`lib.sh`](../../tools/scripts/lockfile/lib.sh) | Shared constants and helpers. Sourced, never executed | +| [`check-lockfile.sh`](../../tools/scripts/lockfile/check-lockfile.sh) | The invariant check. Both assertions, both modes | +| [`normalize-lockfile.sh`](../../tools/scripts/lockfile/normalize-lockfile.sh) | The normalizer. Idempotent, and fails closed on its own fingerprint self-check before writing | +| [`warn-foreign-registry.sh`](../../tools/scripts/lockfile/warn-foreign-registry.sh) | Advisory. Emits a `::warning` plus a job summary; every exit is 0 | +| [`fingerprint.jq`](../../tools/scripts/lockfile/fingerprint.jq) | Reduces a lockfile to its registry-independent comparable form | +| [`use-npmrc/action.yml`](../../.github/actions/use-npmrc/action.yml) | Writes `~/.npmrc`, appends the mitigation, runs the advisory | +| [`pr-checks.yml`](../../.github/workflows/pr-checks.yml) | The guard job and the shellcheck/bats job | +| [`README.md`](../../tools/scripts/lockfile/README.md) | The runbook these sections condense | + +Two properties to keep in mind when changing any of this. The proxy URL is a hard-coded constant **on purpose** — it +*is* the invariant being asserted, and a caller-supplied prefix would validate itself. And these files are meant to be +byte-identical across all seven long-lived branches, so change them on one and the change has to reach the others. diff --git a/specs/2026-08-10_normalize-package-lock-resolved-urls/plan.md b/specs/2026-08-10_normalize-package-lock-resolved-urls/plan.md new file mode 100644 index 0000000..28b8133 --- /dev/null +++ b/specs/2026-08-10_normalize-package-lock-resolved-urls/plan.md @@ -0,0 +1,1812 @@ +--- +date: 2026-08-10 +git_commit: 7661e9b +branch: fix/PFM-ISSUE-34453-normalize-package-lock-json/25.2 +baseline_branch: release/25.2 +topic: 'PFM-ISSUE-34453: Normalize package-lock.json resolved URLs onto the JFrog npm proxy' +tags: [plan, package-lock, npm, jfrog, bash, jq, bats, shellcheck, ci, rollout] +status: ready +last_updated: 2026-08-10 +--- + +# PFM-ISSUE-34453 — Normalize `package-lock.json` resolved URLs onto the JFrog npm proxy — Implementation Plan + +## Overview + +Deliver, to all seven long-lived branches, a bash + jq **normalizer** that rewrites the 171 `registry.npmjs.org` +`resolved` prefixes in `package-lock.json` onto the JFrog npm proxy, a two-assertion **invariant check** that proves +nothing but those prefixes changed, and this repository's **first `on: pull_request` workflow** to guard against +reintroduction — then normalize the lockfile on each branch. + +Architecture is fixed by [design.md](./design.md); this plan is about *how* to build it. + +## Current State Analysis + +Measured directly against the working tree at `7661e9b` (`release/25.2` lineage), jq 1.8.1: + +| fact | value | +| --- | --- | +| `.packages` entries | 543 (1 root + 542 non-root) | +| non-root entries with a string `resolved` | 542 (**zero** missing) | +| `registry.npmjs.org` entries | **171** | +| `cplace.jfrog.io/artifactory/api/npm/cplace-npm/` entries | 371 | +| distinct `resolved` prefixes | exactly 2 | +| entries whose `resolved` is not a standard `/-/.tgz` URL | **0** | +| occurrences of `/-/` per `resolved` | exactly 1, on all 542 | +| scoped (`@scope/name`) entries | 155 | +| lockfile size | 262 063 bytes | + +**What exists:** nothing. `tools/scripts/` contains `artifacts/`, `run-many/`, `upmerge/` — all TypeScript. There is no +`lockfile/` directory, no `.sh` file anywhere under `tools/`, and no `on: pull_request` workflow in +`.github/workflows/` (all 13 are `workflow_call`-only). `.prettierignore` contains only `node_modules` and `.idea`. + +**Local toolchain:** jq 1.8.1, bats 1.13.0, shellcheck 0.11.0 all present. `shfmt` is **absent** — so no shfmt step +anywhere. + +**Repository is public** (`collaborationFactory/github-actions`, `isPrivate: false`), so CI needs no extra token. + +## Desired End State + +On each of the seven branches: + +- `tools/scripts/lockfile/{lib.sh,fingerprint.jq,normalize-lockfile.sh,check-lockfile.sh,warn-foreign-registry.sh,test-helper.bash,*.bats,README.md}` + exist and are **byte-identical across all seven branches**. +- `.github/workflows/pr-checks.yml` and `.github/actions/use-npmrc/action.yml` are byte-identical across all seven + branches; `pr-checks.yml` runs two node-free jobs on every PR. (`use-npmrc` joined the set in Phase 4b.) +- `package-lock.json` contains **zero** `registry.npmjs.org` occurrences and exactly one distinct `resolved` prefix. +- `./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1` exits 0. + +Verified by the automated criteria in each phase plus the cross-branch `sha256sum` comparison in Phase 7. + +### Key Discoveries + +These were measured during planning and change what gets written: + +1. **The jq rewrite is byte-identical to a raw `sed` prefix rewrite.** Verified on the real lockfile: + 262 063 → 266 851 (**+4788**), **342 changed diff lines, 0 of them non-`resolved`**, trailing newline preserved, + and a second run is a no-op. So the structured form costs nothing in byte-safety. (`design.md` claimed this; it now + holds for the exact jq program in this plan, not just for a prototype.) +2. **Both assertions are load-bearing, confirmed by injected drift:** + + | injected drift | graph invariance | prefix exactness | + | --- | --- | --- | + | t1 changed tarball filename | **catches** | misses | + | t3 changed dependency edge | **catches** | misses | + | t5 poisoned `integrity` | **catches** | misses | + | t2 typo'd proxy repo (`cplace-nmp`) | misses | **catches** | + | t4 entry left on npmjs | misses | **catches** | + +3. **`jq`'s `capture` raises an error on a non-tarball URL** — it does not return null. So both scripts need a + **pre-validation pass** that names offending package paths *before* any `capture` runs, or a future `link:`/`git+ssh:` + entry produces an unreadable jq stack trace instead of `design.md`'s promised "names the package path". Verified: the + pre-validation filter catches an injected `link: true` entry and returns zero offenders on the real lockfile. +4. **The tarball-path regex is unambiguous here.** Every `resolved` contains exactly one `/-/`, so + ~~`(?:@[^/]+/)?[^/]+/-/[^/]+$`~~ extracts `@scope/name/-/file.tgz` or `name/-/file.tgz` correctly for all 542 + entries, scoped and unscoped alike. It is defined **once**, in `lib.sh`, and passed to jq via `--arg` so the + normalizer and the fingerprint cannot drift apart. + **Amended:** the shipped constant is `(?:@[^/]+/)?[^/]+/-/.+\.tgz$` — `\.tgz` required in review, the tail widened + on 2026-08-13 (`8cb5d67`). "Exactly one `/-/`, and nothing after it but a filename" holds for this repository's own + lockfiles but **not** for consumer ones: JFrog serves `…/-/@scope/name-1.2.3.tgz` and `…/-//name.tgz`, + measured on cplace-paw-fe `release/25.2`. The 542-entry measurement is still correct; it was simply not a sample of + what the tooling would meet downstream. +5. **The introducing PR guards itself.** For `pull_request` (unlike `pull_request_target`), the workflow runs from the + merge commit, which contains both the base branch's tree and the PR's changes. Because `design.md` puts the tooling + commit and the lockfile commit in the *same* PR, the new `lockfile` job runs on the PR that introduces it and passes + — the candidate is already normalized, and the fingerprint strips the registry so it compares equal to the + un-normalized base. A tooling-only PR with an un-normalized lockfile would fail its own guard; the two-commit / + one-PR structure is therefore mandatory, not cosmetic. +6. ~~**The guard baseline must be `github.event.pull_request.base.sha`, not `origin/${{ github.base_ref }}`.** + `base.sha` is a parent of the checked-out merge commit and is guaranteed present with `fetch-depth: 0`, with no + dependence on which remote refs `actions/checkout` happened to fetch.~~ + **Superseded 2026-08-11 (`604f95e`): the guard takes no baseline at all.** It runs `--prefix-only`, so there is no + base commit to reach, no `fetch-depth: 0`, and this choice no longer arises — see the note under Phase 4's workflow + block. The finding itself still holds for any *future* baseline comparison run inside a `pull_request` workflow, and + is kept for that reason. +7. **`shellcheck` is preinstalled on GitHub's `ubuntu-24.04` image but `bats` is not.** Both are installed via + `apt-get` anyway, so the job does not silently depend on image contents. + +## What We're NOT Doing + +Carried verbatim from [design.md](./design.md) — out of scope, and not to be added during implementation: + +- **A git submodule for the tooling.** Considered and rejected during planning: `.github/workflows/pr-checks.yml` + cannot live in one (GitHub only executes workflows physically present in the repo's own tree), so the duplication it + would remove is only partial, while it adds a new repo, a pinned gitlink to bump on seven branches, and an untested + interaction between submodules and `cplace-cli flow --upmerge`. The `sha256sum` comparison in Phase 7 gives the same + guarantee at near-zero cost. +- Consumer-repo lockfile guarding — no change to `fe-install-deps.yml` or any reusable workflow (→ PFM-ISSUE-34454). +- Branch protection / required status checks (→ own follow-up, GitHub Rule Sets). The guard **reports**, it does not + **block**. +- Enabling `jest` in CI (unmeasured) or `check-prettier` in CI (20 pre-existing failures). +- `DOT_NPMRC` standardization / JFrog anonymous-access shutdown (→ PFM-ISSUE-34454). +- Branches ≤ 24.2. +- Eliminating the runtime `npm ci` (pre-bundling via esbuild/ncc, or publishing `tools/scripts/*`). +- Fixing the stale `--release 5.17` in `tools/scripts/upmerge/upmerge.ts:22`. +- ~~Modifying `use-npmrc`~~ — **superseded by Phase 4b (2026-08-11).** `use-npmrc` *is* now modified: it appends + `replace-registry-host=never` and runs the advisory `warn-foreign-registry.sh`. The original exclusion assumed + consumer pipelines were green and would only be endangered by a change here; the consumer survey found + `cplace-paw-fe` `release/25.2`/`25.3` **already broken**, which only a change here fixes. See Phase 4b and + [design.md](./design.md) Dimension 9. The **four affected composite actions** (`artifacts`, `snapshots`, `upmerge`, + `run-many`) remain unmodified. +- Any npm devDependency for bats or shellcheck — that would mutate `package-lock.json` on all seven branches and break + the very invariant this ticket establishes. + +## Implementation Approach + +Phases 1–6 execute end-to-end on the current branch +(`fix/PFM-ISSUE-34453-normalize-package-lock-json/25.2` → `release/25.2`). Phase 7 is a single parameterised runbook +applied six more times. Phase 8 files the follow-ups. + +**Commit structure on every branch (design decision 7 — mandatory, see Key Discovery 5):** one PR, two commits. + +- Commit 1 — tooling only (`tools/scripts/lockfile/*`, `.github/workflows/pr-checks.yml`, `.prettierignore`). +- Commit 2 — the normalized `package-lock.json` **alone**. + +so that verification is exactly `check-lockfile.sh --baseline HEAD~1` with no remembered ref to get wrong. PRs are +squash-merged; the two commits exist for review. + +**Established patterns.** These are the repository's first `.sh` files, so the pattern is *created* in Phase 2 and +approved before the rest is written: + +- Google Shell Style Guide; `#!/usr/bin/env bash`; `set -euo pipefail`; all logic in functions; `main "$@"` last. +- `readonly` for constants, `local` for everything else; `${var}` bracing throughout. +- Errors to **stderr** via `err`/`die`; every failure message names the offending **package path**, the README, and the + remediation command — and **never** echoes the JFrog host, which CI masks as `***`. +- shellcheck-clean at default severity; every `disable` carries a reason comment. +- Tests co-located as `.bats`, mirroring the repo's existing co-located `*.test.ts` convention. + +--- + +## Phase 1: Pre-flight — re-probe the proxy + +### Overview + +`design.md` Known Risk 1: the ticket's "166 × 200, 5 × 302" probe of the 171 tarballs through `cplace-npm` was taken on +faith and never re-verified. A curated-repo or Xray policy change would invalidate the entire approach. A non-200/302 +is **stop-the-line** — do not proceed to Phase 2. + +### Changes Required + +None in the repository. This is a throwaway script in the scratchpad, deliberately not committed. + +**File**: `/probe-proxy.sh` + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Probe every npmjs-hosted tarball through the cplace JFrog npm proxy, +# anonymously (as the five ~/.npmrc-less workflows do). +jq -r ' + .packages + | to_entries[] + | select(.key != "") + | .value.resolved + | select(type == "string") + | select(test("registry\\.npmjs\\.org")) +' package-lock.json \ + | sed 's#^https://registry\.npmjs\.org/#https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/#' \ + | while read -r url; do + printf '%s %s\n' "$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "${url}")" "${url}" + done \ + | tee probe-results.txt \ + | awk '{print $1}' | sort | uniq -c +``` + +### Success Criteria + +#### Automated Verification + +- [x] The probe covers exactly 171 URLs: `wc -l < probe-results.txt` is `171` +- [x] Every status code is `200` or `302`: `awk '$1 !~ /^(200|302)$/' probe-results.txt` prints nothing +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 1 complete. Summary: re-probed all 171 npmjs tarballs through the cplace-npm proxy; status-code histogram is . Please review and reply 'yes' to continue to Phase 2." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [x] The status-code histogram is consistent with the ticket's original probe (predominantly 200, a small number of 302) + — **166 × 200, 5 × 302**, probed anonymously 2026-08-11, exactly reproducing the ticket's original probe +- [x] If any URL returns 403/404, **stop** and report — the approach itself is invalidated, not just this phase + — none did. The five 302s (`@babel/core`, `caniuse-lite`, `object.assign`, `prettier`, `typescript`) are JFrog + remote-repo redirects; following one yields 200 with a real 232 744-byte payload. + +--- + +## Phase 2: Establish the bash pattern + +### Overview + +Create `lib.sh`, `fingerprint.jq`, and **one** sample bats test. Nothing else is written until this pattern is +approved — it is the template every subsequent file follows, and the repo has no precedent to copy. + +### Changes Required + +#### 1. Shared constants and helpers + +**File**: `tools/scripts/lockfile/lib.sh` +**Changes**: New file. Sourced by both scripts; never executed directly. + +```bash +#!/usr/bin/env bash +# +# Shared constants and helpers for the package-lock.json normalizer and checker. +# +# This file is sourced, never executed. It deliberately depends on nothing but +# bash and jq: the scripts that source it exist to repair the lockfile that +# makes `npm ci` fail, so they cannot require `npm ci` to have succeeded. +# +# See tools/scripts/lockfile/README.md + +# The one npm proxy every `resolved` URL in this repository's package-lock.json +# must point at. +# +# Hard-coded on purpose. This constant *is* the invariant that check-lockfile.sh +# asserts; if it were caller-supplied, a typo'd prefix handed to both scripts +# would validate itself. It is not a secret - it is already committed in +# plaintext in every JFrog-hosted lockfile entry - and it is NOT the JFROG_URL +# publish target (`.../artifactory/cplace-npm-local`, see +# tools/scripts/artifacts/configuration.ts:2), which points somewhere else. +readonly JFROG_NPM_PROXY='https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/' + +# Extracts the registry-independent tarball path from a `resolved` URL: +# https:////@scope/name/-/name-1.2.3.tgz -> @scope/name/-/name-1.2.3.tgz +# https:////name/-/name-1.2.3.tgz -> name/-/name-1.2.3.tgz +# Anchored at the end and safe because every `resolved` in this lockfile +# contains exactly one `/-/` (verified: 542/542). +# +# Defined once, here, and passed to jq via --arg, so that the normalizer and +# fingerprint.jq can never drift apart. +readonly TARBALL_PATH_RE='(?(?:@[^/]+/)?[^/]+/-/[^/]+)$' # superseded, see below +``` + +~~The regex above is what this phase specified.~~ **Superseded — the shipped constant is** +`'(?(?:@[^/]+/)?[^/]+/-/.+\.tgz)$'`. Two changes, in order: `\.tgz` was **required** in response to review of +PR #163, so the regex matches what the documentation and the error messages promise (0 of 542 entries affected, +fingerprint unchanged, `\.tgz` deliberately case-sensitive while the scheme is not); then the tail was **widened** +from `[^/]+` to `.+` on 2026-08-13 (`8cb5d67`), because JFrog serves tarball paths containing slashes — a repeated +scope and an interposed version segment — which the narrower form rejected on entries that were already on the proxy. +See Phase 9. The fingerprint is byte-identical on all seven branches under both changes. + +```bash + +# Shape every non-root entry's `resolved` must have. Checked BEFORE any +# `capture`, because jq's capture raises an error rather than returning null +# when it does not match - which would replace a readable "package X has no +# usable tarball URL" with a jq stack trace. +readonly RESOLVED_URL_RE='^https?://[^/]+/.*(?:@[^/]+/)?[^/]+/-/[^/]+$' + +readonly README_PATH='tools/scripts/lockfile/README.md' +readonly NORMALIZE_CMD='./tools/scripts/lockfile/normalize-lockfile.sh' + +LOCKFILE_TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly LOCKFILE_TOOLS_DIR +readonly FINGERPRINT_JQ="${LOCKFILE_TOOLS_DIR}/fingerprint.jq" + +info() { + printf '%s\n' "$*" +} + +err() { + printf '%s\n' "$*" >&2 +} + +die() { + err "ERROR: $*" + exit 1 +} + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + die "jq is required but not installed (macOS: brew install jq). See ${README_PATH}" + fi +} + +byte_size() { + wc -c <"$1" | tr -d '[:space:]' +} + +# Fails, naming every offending package path, if any non-root entry lacks a +# usable tarball URL. Never echoes a URL: CI masks the JFrog host as ***, so a +# message built from one is unreadable exactly when it matters most. +assert_resolvable() { + local lockfile="$1" + local offenders + offenders="$(jq -r --arg url_re "${RESOLVED_URL_RE}" ' + .packages + | to_entries[] + | select(.key != "") + | select(((.value.resolved | type) != "string") + or ((.value.resolved | test($url_re)) | not)) + | .key + ' "${lockfile}")" + + if [[ -n "${offenders}" ]]; then + err "ERROR: these entries in ${lockfile} have no usable tarball URL:" + while IFS= read -r package_path; do + err " ${package_path}" + done <<<"${offenders}" + err "" + err "Every non-root entry must carry a standard /-/.tgz `resolved` URL." + err "A link:/file:/git+ssh: dependency is a deliberate, reviewed loosening of this" + err "assertion - see ${README_PATH}." + exit 1 + fi +} + +# Reduces a lockfile to its registry-independent comparable form. Sorted keys so +# the output is diffable and order-insensitive. +fingerprint_of() { + jq -S --arg tarball_re "${TARBALL_PATH_RE}" -f "${FINGERPRINT_JQ}" "$1" +} + +# Number of entries not already on the proxy - i.e. how much work there is to do. +count_foreign_entries() { + jq --arg proxy "${JFROG_NPM_PROXY}" ' + [ .packages[] + | select((type == "object") and ((.resolved | type) == "string")) + | select((.resolved | startswith($proxy)) | not) + ] | length + ' "$1" +} +``` + +#### 2. The registry-independent fingerprint + +**File**: `tools/scripts/lockfile/fingerprint.jq` +**Changes**: New file. + +```jq +# Reduces a package-lock.json to a registry-independent, comparable form: every +# `resolved` URL is replaced by its bare tarball path. A legitimately rehosted +# entry therefore compares EQUAL, while a changed version, integrity, tarball +# filename or dependency edge does not. +# +# Requires: --arg tarball_re '' (see lib.sh) +# +# This transform is deliberately BLIND to which host an entry was rehosted onto +# - a typo'd proxy repo name and an entry left on npmjs both survive it. That +# blindness is exactly why check-lockfile.sh runs a second, independent prefix +# assertion; the two together are what `design.md` Dimension 2 requires. +# +# The whole entry is compared, not a version/integrity/tarball subset, because +# the subset misses dependency-edge drift (drift case t3). + +.packages |= with_entries( + if (.value | type) == "object" and (.value.resolved | type) == "string" then + .value.resolved |= (capture($tarball_re) | .t) + else + . + end +) +``` + +#### 3. The sample test — the bats pattern + +**File**: `tools/scripts/lockfile/test-helper.bash` +**Changes**: New file. Builds the minimal fixtures both suites share. + +```bash +#!/usr/bin/env bash +# +# Shared bats fixture builders. Fixtures are tiny hand-written lockfiles, not +# copies of the real 262 KB one: the tests assert behaviour, not byte counts. + +readonly PROXY='https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/' +readonly NPMJS='https://registry.npmjs.org/' + +# A two-entry mixed lockfile: one npmjs entry (scoped), one already on the proxy. +write_mixed_lockfile() { + cat >"$1" <<'JSON' +{ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "@scope/alpha": "^1.0.0" + } + }, + "node_modules/@scope/alpha": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@scope/alpha/-/alpha-1.0.0.tgz", + "integrity": "sha512-AAAA==", + "dependencies": { + "beta": "^2.0.0" + } + }, + "node_modules/beta": { + "version": "2.0.0", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/beta/-/beta-2.0.0.tgz", + "integrity": "sha512-BBBB==" + } + } +} +JSON +} +``` + +**File**: `tools/scripts/lockfile/normalize-lockfile.bats` +**Changes**: New file containing **one** test — the sample that establishes the pattern. The remaining tests arrive in +Phase 3. + +```bash +#!/usr/bin/env bats + +setup() { + LOCKFILE_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)" + load "${LOCKFILE_DIR}/test-helper.bash" + NORMALIZE="${LOCKFILE_DIR}/normalize-lockfile.sh" + TMP="${BATS_TEST_TMPDIR}/package-lock.json" +} + +@test "rewrites npmjs entries onto the proxy and leaves proxy entries alone" { + write_mixed_lockfile "${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [ "$(grep -c 'registry.npmjs.org' "${TMP}" || true)" -eq 0 ] + [ "$(jq -r '.packages["node_modules/@scope/alpha"].resolved' "${TMP}")" \ + = "${PROXY}@scope/alpha/-/alpha-1.0.0.tgz" ] + [ "$(jq -r '.packages["node_modules/beta"].resolved' "${TMP}")" \ + = "${PROXY}beta/-/beta-2.0.0.tgz" ] + [ "$(jq -r '.packages["node_modules/@scope/alpha"].integrity' "${TMP}")" = 'sha512-AAAA==' ] +} +``` + +> This test cannot pass until `normalize-lockfile.sh` exists (Phase 3). Phase 2 delivers it as the **reviewed pattern**; +> Phase 3 makes it green. Verify the pattern in Phase 2 with the standalone commands below, which exercise `lib.sh` and +> `fingerprint.jq` directly. + +### Success Criteria + +#### Automated Verification + +- [x] Files exist: `ls tools/scripts/lockfile/{lib.sh,fingerprint.jq,test-helper.bash,normalize-lockfile.bats}` +- [x] shellcheck is clean: `shellcheck tools/scripts/lockfile/lib.sh tools/scripts/lockfile/test-helper.bash` + — clean after one `# shellcheck disable=SC2034` on `NORMALIZE_CMD` (consumed by `check-lockfile.sh`, invisible to + shellcheck when analysing `lib.sh` standalone) and a file-level SC2034 disable for the fixture constants +- [x] `lib.sh` sources without error: `bash -c 'source tools/scripts/lockfile/lib.sh && echo "${TARBALL_PATH_RE}"'` +- [x] The fingerprint is stable on the real lockfile — it must equal itself after normalization. Run: + `bash -c 'source tools/scripts/lockfile/lib.sh; fingerprint_of package-lock.json | sha256sum'` and confirm it + matches the fingerprint of a jq-normalized copy of the same file — both `223e40e6ff642ff3…` +- [x] `assert_resolvable` returns 0 on the real lockfile and exits 1 naming `node_modules/foo` on a copy with + `.packages["node_modules/foo"] = {"link": true}` injected +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 2 complete. Summary: created lib.sh (hard-coded proxy constant, single tarball-path regex, assert_resolvable/fingerprint_of/count_foreign_entries helpers), fingerprint.jq, test-helper.bash and one sample bats test — this is the bash + bats pattern every remaining file will follow. Please review the pattern and reply 'yes' to continue to Phase 3." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [ ] The pattern reads like something this repo would accept as its first shell code *(reviewer judgement)* +- [ ] Comments explain *why* (hard-coded constant, deliberate blindness of the fingerprint), not *what* *(reviewer judgement)* +- [x] No failure message anywhere contains the JFrog host — `assert_resolvable`'s failure output greps + `cplace.jfrog.io` zero times + +--- + +## Phase 3: Normalizer, check, and the full bats suite + +### Overview + +Write the two executables and complete the test suite, including all five injected-drift cases that motivated the +two-assertion design. + +### Changes Required + +#### 1. The normalizer + +**File**: `tools/scripts/lockfile/normalize-lockfile.sh` +**Changes**: New file, `chmod +x`. + +```bash +#!/usr/bin/env bash +# +# Rewrites every `resolved` URL in a package-lock.json onto the cplace JFrog npm +# proxy, changing nothing else. Idempotent. +# +# Usage: +# ./tools/scripts/lockfile/normalize-lockfile.sh [] +# +# Defaults to ./package-lock.json. +# +# Requires only bash and jq - no node, no `npm ci`. That is the point: this +# script repairs the lockfile whose npmjs URLs make `npm ci` fail against a +# JFrog ~/.npmrc, so it cannot depend on `npm ci` having worked. +# +# See tools/scripts/lockfile/README.md + +set -euo pipefail + +# shellcheck source=tools/scripts/lockfile/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +main() { + local lockfile="${1:-package-lock.json}" + + require_jq + [[ -f "${lockfile}" ]] || die "no such lockfile: ${lockfile}" + + assert_resolvable "${lockfile}" + + local before_bytes rewritten + before_bytes="$(byte_size "${lockfile}")" + rewritten="$(count_foreign_entries "${lockfile}")" + + local tmp + tmp="$(mktemp)" + # shellcheck disable=SC2064 # expand ${tmp} now, not when the trap fires + trap "rm -f '${tmp}'" EXIT + + jq --arg proxy "${JFROG_NPM_PROXY}" --arg tarball_re "${TARBALL_PATH_RE}" ' + .packages |= with_entries( + if (.value | type) == "object" and (.value.resolved | type) == "string" then + .value.resolved |= ($proxy + (capture($tarball_re) | .t)) + else + . + end + ) + ' "${lockfile}" >"${tmp}" + + # Self-assertion. Secondary by design: it cannot see drift that arrived BEFORE + # this run (a bad merge), which is why check-lockfile.sh compares against a git + # baseline instead. It does make this script safe to run standalone. + if ! diff -q <(fingerprint_of "${lockfile}") <(fingerprint_of "${tmp}") >/dev/null; then + die "internal error: normalization altered the dependency graph; ${lockfile} left untouched" + fi + + # `cat >` rather than `mv`, to preserve the file's existing permissions. + cat "${tmp}" >"${lockfile}" + + local after_bytes + after_bytes="$(byte_size "${lockfile}")" + + info "normalized ${lockfile}" + info " entries rewritten: ${rewritten}" + info " byte delta: $((after_bytes - before_bytes)) (${before_bytes} -> ${after_bytes})" + if ((rewritten == 0)); then + info " already normalized - nothing to do" + fi +} + +main "$@" +``` + +#### 2. The invariant check + +**File**: `tools/scripts/lockfile/check-lockfile.sh` +**Changes**: New file, `chmod +x`. + +```bash +#!/usr/bin/env bash +# +# Proves that a package-lock.json differs from its baseline ONLY in registry +# prefixes, and that every `resolved` URL points at the one correct proxy. +# +# Usage: +# ./tools/scripts/lockfile/check-lockfile.sh [--baseline ] [] +# +# --baseline HEAD~1 the commit before the lockfile commit (default: HEAD) +# --baseline :2: "ours" during a merge conflict +# --baseline :3: "theirs" during a merge conflict +# --baseline path/to/other.json an explicit file +# +# Two independent assertions, BOTH required to pass: +# 1. graph invariance - the whole document, with every `resolved` reduced to a +# registry-independent tarball path, must equal the baseline; +# 2. prefix exactness - every `resolved` must carry exactly the one proxy prefix. +# +# Neither alone is sufficient: (1) is blind to WHICH host an entry moved to, and +# (2) is blind to everything except the host. See design.md Dimension 2. +# +# See tools/scripts/lockfile/README.md + +set -euo pipefail + +# shellcheck source=tools/scripts/lockfile/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +usage() { + err "usage: ${0##*/} [--baseline ] []" + exit 2 +} + +# Writes the baseline lockfile to ${2}, resolving ${1} as a file path if one +# exists, otherwise as a git ref. Always prints what it resolved, so a wrong +# baseline is visible rather than silent. +resolve_baseline() { + local ref="$1" out="$2" + + if [[ -f "${ref}" ]]; then + cat "${ref}" >"${out}" + info "baseline: file ${ref}" + return + fi + + local spec="${ref}" + if [[ "${spec}" == *: ]]; then + spec="${spec}package-lock.json" # `:2:` -> `:2:package-lock.json` + elif [[ "${spec}" != *:* ]]; then + spec="${spec}:package-lock.json" # `HEAD~1` -> `HEAD~1:package-lock.json` + fi + + if ! git show "${spec}" >"${out}" 2>/dev/null; then + die "cannot read baseline '${ref}' (tried '${spec}'); pass a git ref or an existing file" + fi + info "baseline: ${spec}" +} + +# Assertion 1. Prints every drifted package path; returns 1 if any. +assert_graph_invariant() { + local baseline="$1" candidate="$2" + local fp_base fp_cand drift + + fp_base="$(mktemp)" + fp_cand="$(mktemp)" + + fingerprint_of "${baseline}" >"${fp_base}" + fingerprint_of "${candidate}" >"${fp_cand}" + + drift="$(jq -n -r --slurpfile a "${fp_base}" --slurpfile b "${fp_cand}" ' + ($a[0]) as $A | ($b[0]) as $B + | [ ((($A | keys) + ($B | keys)) | unique)[] + | select(. != "packages") + | select(($A[.] | tojson) != ($B[.] | tojson)) + | "top-level key: " + . ] + + [ (((($A.packages // {}) | keys) + (($B.packages // {}) | keys)) | unique)[] + | select((($A.packages[.]) | tojson) != (($B.packages[.]) | tojson)) ] + | .[] + ')" + + # Cleaned up explicitly rather than via `trap ... RETURN`: a RETURN trap set + # inside a function is global unless `functrace` is set, so it would also fire + # on unrelated function returns later in the run. + rm -f "${fp_base}" "${fp_cand}" + + if [[ -n "${drift}" ]]; then + err "FAIL: the dependency graph differs from the baseline. Drifted entries:" + while IFS= read -r package_path; do + err " ${package_path}" + done <<<"${drift}" + return 1 + fi + + info "PASS: dependency graph identical to baseline" +} + +# Assertion 2. Prints every package path not on the one correct proxy. +assert_prefix_exactness() { + local candidate="$1" + local offenders distinct + + offenders="$(jq -r --arg proxy "${JFROG_NPM_PROXY}" --arg tarball_re "${TARBALL_PATH_RE}" ' + .packages + | to_entries[] + | select(.key != "") + | select((.value.resolved | sub($tarball_re; "")) != $proxy) + | .key + ' "${candidate}")" + + distinct="$(jq -r --arg tarball_re "${TARBALL_PATH_RE}" ' + [ .packages | to_entries[] | select(.key != "") + | (.value.resolved | sub($tarball_re; "")) ] | unique | length + ' "${candidate}")" + + if [[ -n "${offenders}" ]]; then + err "FAIL: ${distinct} distinct registry prefixes found (expected exactly 1)." + err "These entries do not resolve via the cplace npm proxy:" + while IFS= read -r package_path; do + err " ${package_path}" + done <<<"${offenders}" + return 1 + fi + + info "PASS: exactly ${distinct} registry prefix, matching the expected proxy" +} + +main() { + local baseline='HEAD' candidate='package-lock.json' + + while (($# > 0)); do + case "$1" in + --baseline) + [[ $# -ge 2 ]] || usage + baseline="$2" + shift 2 + ;; + -h | --help) usage ;; + -*) usage ;; + *) + candidate="$1" + shift + ;; + esac + done + + require_jq + command -v git >/dev/null 2>&1 || die "git is required but not installed" + [[ -f "${candidate}" ]] || die "no such lockfile: ${candidate}" + + local baseline_file + baseline_file="$(mktemp)" + # shellcheck disable=SC2064 # expand path now, not when the trap fires + trap "rm -f '${baseline_file}'" EXIT + + resolve_baseline "${baseline}" "${baseline_file}" + info "candidate: ${candidate}" + + assert_resolvable "${baseline_file}" + assert_resolvable "${candidate}" + + # Run BOTH assertions before failing, so one run reports every problem. + local failed=0 + assert_graph_invariant "${baseline_file}" "${candidate}" || failed=1 + assert_prefix_exactness "${candidate}" || failed=1 + + if ((failed != 0)); then + err "" + err "To fix: run ${NORMALIZE_CMD}" + err "then re-run: ${0} --baseline ${baseline}" + err "See ${README_PATH}" + exit 1 + fi + + info "OK: ${candidate} is normalized and graph-identical to its baseline" +} + +main "$@" +``` + +#### 3. The full test suite + +**File**: `tools/scripts/lockfile/normalize-lockfile.bats` +**Changes**: Extend the Phase 2 sample with: + +- idempotence — a second run reports `entries rewritten: 0` and a byte delta of `0` +- `version`, `integrity` and `dependencies` are untouched +- a non-root entry with no `resolved` fails, naming the package path +- a `git+ssh:` `resolved` fails, naming the package path (rather than raising a jq error) +- a missing file argument fails cleanly +- **no error message contains `cplace.jfrog.io`** (masking safety) + +**File**: `tools/scripts/lockfile/check-lockfile.bats` +**Changes**: New file. `test-helper.bash` gains one builder per drift case. + +| test | drift injected into the candidate | must fail via | +| --- | --- | --- | +| passes on a clean normalization | none | — (exit 0) | +| t1 | tarball filename `alpha-1.0.0.tgz` → `alpha-9.9.9.tgz` | graph invariance | +| t2 | proxy repo `cplace-npm` → `cplace-nmp` | prefix exactness | +| t3 | `dependencies.beta` `^2.0.0` → `^9.0.0` | graph invariance | +| t4 | one entry left on `registry.npmjs.org` | prefix exactness | +| t5 | `integrity` → `sha512-POISONED==` | graph invariance | +| t6 | `version` `1.0.0` → `9.9.9` | graph invariance | + +Plus: every failure names the offending package path; a git-ref baseline resolves (`--baseline HEAD`); two explicit +file paths are accepted; a bad baseline ref exits 1 with a readable message; no failure message contains +`cplace.jfrog.io`. + +### Success Criteria + +#### Automated Verification + +- [x] Both scripts are executable: `test -x tools/scripts/lockfile/normalize-lockfile.sh -a -x tools/scripts/lockfile/check-lockfile.sh` +- [x] shellcheck is clean: `shellcheck tools/scripts/lockfile/*.sh tools/scripts/lockfile/test-helper.bash` +- [x] bats passes: `bats tools/scripts/lockfile/` — 24/24 at the time of this phase; **52/52** after the two review + rounds added `warn-foreign-registry.bats` and the regression tests +- [x] All seven drift cases (t1–t6 plus the clean case) are present and asserted: `grep -c '^@test' tools/scripts/lockfile/check-lockfile.bats` is at least `11` +- [x] On a **copy** of the real lockfile, the normalizer reports `entries rewritten: 171` and `byte delta: 4788` + — exact: `262063 -> 266851` +- [x] The result is byte-identical to a raw prefix rewrite: + `sed 's#"resolved": "https://registry.npmjs.org/#"resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/#' package-lock.json | cmp - ` +- [x] `diff | grep '^[<>]' | wc -l` is `342`, and `... | grep -vc '"resolved"'` is `0` +- [x] A second normalizer run on the normalized copy reports `entries rewritten: 0` and `byte delta: 0` +- [x] No error output contains the proxy host: inject each drift case and confirm `grep -c 'cplace.jfrog.io'` on stderr is `0` + — all four real-scale drift cases: `host-leaks=0`, each naming `node_modules/jest` +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 3 complete. Summary: normalize-lockfile.sh and check-lockfile.sh written, shellcheck-clean; bats suite green with all six injected-drift cases (t1–t6); on a copy of the real lockfile the normalizer rewrites 171 entries for +4788 bytes / 342 changed lines / 0 non-resolved lines, byte-identical to a raw sed rewrite, and is idempotent. Please review and reply 'yes' to continue to Phase 4." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [ ] A failure message read cold tells you which package is wrong and exactly what to run next *(reviewer judgement)* +- [x] The two assertions genuinely fail independently — t2 and t4 fail *only* prefix exactness, t1/t3/t5/t6 *only* graph + invariance. `check-lockfile.bats` asserts the *identity* of the failing assertion, not merely that a failure occurred. +- [x] Nothing in either script would break if `${JFROG_URL}` changed — the proxy constant is unrelated to the publish + target. Neither script reads any environment variable. + +--- + +## Phase 4: The PR guard, `.prettierignore`, and the README + +### Overview + +Add the repository's first `on: pull_request` workflow, plus documentation. Together with Phases 2–3 this is **commit 1** +of the branch's PR. + +### Changes Required + +#### 1. The guard workflow + +**File**: `.github/workflows/pr-checks.yml` +**Changes**: New file. Deliberately **not** `fe-`-prefixed — in this repo that prefix means "reusable workflow consumed +by other repositories". + +```yaml +name: PR Checks + +# This repository's first `on: pull_request` workflow. Everything under +# .github/workflows/ is otherwise `workflow_call`-only, and the `pull_request` +# trigger lives in .github/workflow-templates/fe/fe-pr.yml, which GitHub never +# executes. +# +# No `paths:` filter on purpose: a path-filtered workflow reports as pending +# rather than success, and would permanently block merges once it becomes a +# required check. +on: + pull_request: + branches: + - '**' + +permissions: + contents: read + +jobs: + lockfile: + name: Lockfile registry invariant + runs-on: ${{ vars.SMALL_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # --prefix-only, NOT a baseline comparison. With no baseline there is + # nothing to fetch, so the default shallow checkout is enough. jq is + # pre-installed on GitHub-hosted ubuntu runners, and this job runs no node + # and no `npm ci` - the guard has to be trustworthy precisely when the + # lockfile is broken. + - name: Check package-lock.json resolved URLs + run: ./tools/scripts/lockfile/check-lockfile.sh --prefix-only + + scripts: + name: Shell scripts + runs-on: ${{ vars.SMALL_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # Never npm devDependencies: adding them would mutate package-lock.json on + # all seven branches and break the invariant this workflow exists to guard. + - name: Install bats and shellcheck + run: | + sudo apt-get update + sudo apt-get install -y bats shellcheck + + - name: shellcheck + run: shellcheck tools/scripts/lockfile/*.sh tools/scripts/lockfile/test-helper.bash + + - name: bats + run: bats tools/scripts/lockfile/ +``` + +**Changed during execution (2026-08-11, commit `604f95e`):** the block above is the *shipped* workflow. As originally +planned, the guard ran `check-lockfile.sh --baseline "${{ github.event.pull_request.base.sha }}"` on a +`fetch-depth: 0` checkout. Review of PR #163 showed that gate fails **every legitimate dependency change** — graph +invariance forbids any change to the graph, which is exactly what an ordinary `npm install` PR does — and then advises +running the normalizer, which cannot fix a graph difference. Graph invariance verifies a *normalization commit*; the +ticket's ongoing criterion is prefix exactness alone. Consequences, all recorded where they arise: + +- `--prefix-only` needs no baseline, so `fetch-depth: 0` is gone and the default shallow checkout is used. +- Key Discovery 6 (`base.sha` vs `origin/${{ github.base_ref }}`) is thereby superseded — see the strikethrough there. +- `--baseline` is not lost, only relocated: it is run by hand to verify each normalization commit (Phase 5, Phase 7) + and during conflict resolution, which is what README Flows 1 and 2 document. +- A green PR is therefore **not** evidence that a lockfile diff changed only prefixes. `design.md:528` states this. + +#### 2. Prettier insurance + +**File**: `.prettierignore` +**Changes**: Add `package-lock.json`. Forward-looking only — prettier currently leaves the lockfile byte-unchanged both +before and after normalization. This protects against a future npm writing differently-formatted JSON once +`check-prettier` eventually runs in CI. + +``` +node_modules +.idea +package-lock.json +``` + +#### 3. The documented entry point + +**File**: `tools/scripts/lockfile/README.md` +**Changes**: New file. `design.md` Dimension 4 makes discoverability a documentation responsibility rather than an npm +alias, so this must be written, not stubbed. Three flows: + +1. **Per-branch rollout** — `normalize-lockfile.sh`, commit the lockfile alone, `check-lockfile.sh --baseline HEAD~1`. +2. **Conflict resolution during an upmerge** — `git checkout --ours -- package-lock.json`, re-run the normalizer, + `check-lockfile.sh --baseline :2:` (or `:3:`), proceed only on exit 0. Never `--theirs`, never a hand edit. +3. **Interpreting a guard failure** — what each assertion means, why messages name package paths rather than URLs + (`JFROG_URL` masking), and what to do about a legitimate future `link:`/`file:`/`git+ssh:` entry. + +Plus: the `brew install jq` prerequisite, and why this is bash rather than TypeScript (the bootstrap argument). + +### Success Criteria + +#### Automated Verification + +- [x] The workflow is valid YAML — **PyYAML is not installed here**; validated with `ruby -ryaml` and additionally + with **`actionlint`, which is clean** +- [x] The checkout SHA matches the pinned convention in `specs/2026-06-05_node24-workflow-migration/sha-pins.md`: + `grep -c 'df4cb1c069e1874edd31b4311f1884172cec0e10' .github/workflows/pr-checks.yml` is `2` +- [x] No third-party action is used: `grep -E '^\s+uses:' .github/workflows/pr-checks.yml | grep -vc 'actions/checkout'` is `0` +- [x] The workflow is node-free — **the plan's grep is too naive**: it matches the explanatory comment "this job runs + no node and no `npm ci`". Correct check, on non-comment lines only: + `grep -vE '^\s*#' .github/workflows/pr-checks.yml | grep -E 'setup-node|npm ci'` → no matches +- [x] `.prettierignore` contains `package-lock.json` +- [x] `tools/scripts/lockfile/README.md` documents all three flows: `grep -c 'checkout --ours' tools/scripts/lockfile/README.md` is at least `1` +- [x] The whole tooling set is committed as commit 1 with no lockfile change — **the plan's command is wrong**: + `git show --stat` includes the commit *message*, which legitimately mentions `package-lock.json` in prose. + Correct check: `git show --name-only --format= HEAD | grep -c '^package-lock.json$'` → `0` +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 4 complete. Summary: added .github/workflows/pr-checks.yml (first on: pull_request workflow here; lockfile + scripts jobs, node-free, apt-get for bats/shellcheck, only actions/checkout pinned by SHA), package-lock.json added to .prettierignore, and tools/scripts/lockfile/README.md covering rollout, conflict resolution and guard-failure interpretation. This is commit 1 of the PR. Please review and reply 'yes' to continue to Phase 5." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [ ] The README is genuinely usable by someone whose `npm ci` is broken right now *(reviewer judgement)* +- [ ] The workflow name and job names read well in the PR checks list *(reviewer judgement)* +- [x] Nothing in the workflow depends on the runner image happening to ship a tool — `bats` and `shellcheck` are + installed explicitly; `jq` is the one image assumption, and `require_jq` fails loudly naming the README if absent + +### Blocker discovered at first run — repository Actions policy + +The first PR run (`31471849858`) ended in **`startup_failure` at 0s, with no jobs and no annotations**. Diagnosis: + +``` +$ gh api repos/collaborationFactory/github-actions/actions/permissions +{"enabled":true,"allowed_actions":"local_only","sha_pinning_required":false} + +$ gh api repos/collaborationFactory/cplace-e2e/actions/permissions # a repo whose workflows do run +{"enabled":true,"allowed_actions":"all","sha_pinning_required":false} +``` + +`local_only` is the UI radio *"Allow `collaborationFactory` actions and reusable workflows"*. It permits actions owned +by the repo's **owner org**, so `actions/checkout` — owned by the `actions` organization — is rejected before any job +starts. The `409 Conflict` from the `selected-actions` endpoint confirms the repo is not in `selected` mode, which is +the only mode offering the *"Allow actions created by GitHub"* checkbox. + +**Neither `research.md` nor `design.md` caught this.** Both correctly established "this repo has no CI of its own"; +neither asked whether it *could*. `design.md` Dimension 5 treated adding a `pull_request` workflow as purely a +file-authoring problem. It is also a repo-settings problem — and it applies to all seven branches at once, since +`allowed_actions` is repo-scoped, not branch-scoped. + +Ruled out during diagnosis, each with evidence: + +| suspect | verdict | +| --- | --- | +| `runs-on: ${{ vars.SMALL_RUNNER \|\| 'ubuntu-latest' }}` | **not the cause** — the org's documented pattern, running today in `cplace-e2e`, `cplace-staging-builds`, `cplace-gh-workflows` | +| Invalid workflow YAML | **not the cause** — `actionlint` clean, ruby/psych parses it | +| Bad pinned checkout SHA | **not the cause** — SHA exists upstream, used by 9 other workflows here | +| Unbuildable merge ref | **not the cause** — PR reports `MERGEABLE` / `CLEAN` | + +**Blast radius of raising the policy: exactly one workflow.** Measured across all seven branches — all 91 files under +`.github/workflows/` (13 × 7) are `workflow_call`-only and never self-execute. GitHub has registered 14 workflows here: +the 13 `fe-*.yml` plus `pr-checks.yml`. Nothing under `.github/workflow-templates/` is registered, despite those files +carrying `push` / `schedule` / `pull_request` triggers — confirming they are inert template content. Consumer repos are +unaffected either way: a `workflow_call` run executes in the **caller's** context under the caller's policy, which is +why every FE repo consumes these workflows today while this repo sits at `local_only`. + +**Resolved.** The repo owner raised the policy in the UI. Confirmed by API: + +``` +$ gh api repos/collaborationFactory/github-actions/actions/permissions +{"enabled":true,"allowed_actions":"selected", ...} + +$ gh api repos/collaborationFactory/github-actions/actions/permissions/selected-actions +{"github_owned_allowed":true,"patterns_allowed":[],"verified_allowed":false} +``` + +This permits `actions/*` and nothing else — no Marketplace, no arbitrary third parties — so it is strictly narrower +than `cplace-e2e`'s `all`. The workflow needed no change, and `local_only` was therefore repo-set rather than +org-enforced. The failed run could not be retried (`This workflow run cannot be retried`), so the guard was +re-triggered by closing and reopening the PR, which fires `pull_request: reopened` without adding a commit. + +**Consequence for Phase 7:** none. `allowed_actions` is repo-scoped, so this one change covers all seven branches. + +**Fallback, no longer needed but recorded:** had the policy been org-enforced, the guard would have replaced +`actions/checkout` with plain `git` in `run:` steps — dependency-free, and incidentally dropping the third-party SHA +pin this design flagged as a per-branch maintenance chore. + +--- + +## Phase 5: Normalize `release/25.2` + +### Overview + +**Commit 2** of the PR: the normalized `package-lock.json` alone. Its parent *is* the baseline, so the invariant is +provable by construction. + +### Changes Required + +**File**: `package-lock.json` +**Changes**: 171 `resolved` prefixes rewritten. No other line touched. + +```bash +./tools/scripts/lockfile/normalize-lockfile.sh +git add package-lock.json +git commit -m 'PFM-ISSUE-34453 - github-actions: normalize package-lock.json resolved URLs onto the JFrog npm proxy' +./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1 +``` + +### Success Criteria + +#### Automated Verification + +- [x] `grep -c 'registry.npmjs.org' package-lock.json` returns `0` +- [x] `./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1` exits 0 and prints both PASS lines +- [x] The commit touches exactly one file: `git show --stat HEAD --name-only --format= | wc -l` is `1`, and it is `package-lock.json` +- [x] Exactly 342 changed lines, none of them non-`resolved`: + `git show HEAD -- package-lock.json | grep -c '^[+-][^+-]'` is `342`, and + `git show HEAD -- package-lock.json | grep '^[+-][^+-]' | grep -vc '"resolved"'` is `0` +- [x] Byte delta is exactly +4788: `git show HEAD~1:package-lock.json | wc -c` vs `wc -c < package-lock.json` + — `262063 -> 266851` +- [x] The normalizer is idempotent: re-running it reports `entries rewritten: 0`, and `git diff --exit-code package-lock.json` is clean +- [x] bats and shellcheck still pass: `shellcheck tools/scripts/lockfile/*.sh && bats tools/scripts/lockfile/` + — 24/24 at the time of this phase; **52/52** now +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 5 complete. Summary: package-lock.json normalized on release/25.2 as its own commit — 171 entries rewritten, +4788 bytes, 342 changed lines, 0 non-resolved lines, 0 registry.npmjs.org remaining; check-lockfile.sh --baseline HEAD~1 exits 0. Ready to push and open the PR. Please review and reply 'yes' to continue to Phase 6." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [x] Spot-check three entries in the diff — a scoped package, an unscoped one, and one that was already on JFrog + (which must be unchanged). Scoped `@ampproject/remapping` and unscoped `update-browserslist-db` (the ticket's own + example URL) both rewritten with the tarball path preserved; `@actions/core`, already on JFrog, appears **0 times** + in the diff. +- [x] Push the branch and confirm **both** `pr-checks.yml` jobs run and pass on the PR itself (see Key Discovery 5) + — PR [#163](https://github.com/collaborationFactory/github-actions/pull/163) against `release/25.2`. After the + Actions policy was raised, run `31475431467` is **green on both jobs**, and the logs prove the assertions really + executed rather than passing vacuously: + + ``` + baseline: 967168ed1c896821e28a3ad343ddfcc6b07a4bcb:package-lock.json # = release/25.2 tip = base.sha + candidate: package-lock.json + PASS: dependency graph identical to baseline + PASS: exactly 1 registry prefix, matching the expected proxy + OK: package-lock.json is normalized and graph-identical to its baseline + ``` + + The `scripts` job ran **24/24** bats tests plus shellcheck (the suite is **52** after two review rounds). Key Discovery 5 is confirmed empirically: the + workflow ran on the very PR that introduced it, and passed. + + **That output predates `604f95e`.** The guard ran `--baseline` at the time of this run, which is why the log + quotes a baseline and a graph-invariance line. Since the switch to `--prefix-only` the same job prints only + `PASS: exactly 1 registry prefix, matching the expected proxy` / `OK: package-lock.json resolves entirely via the + cplace npm proxy`. The evidence is kept verbatim as the historical record of the run; do not treat it as the + expected output of the current workflow. + + Runner resolved to `ubicloud-standard-2`, so `vars.SMALL_RUNNER` **is** defined for this repo, and both `jq` + (preinstalled) and `sudo apt-get` work there. Job times: `lockfile` **8s**, `scripts` 23s — the guard is the + fastest job in the repo precisely because it runs no `setup-node` and no `npm ci`. +- [x] The PR description states plainly that the guard reports but cannot block until the Rule Sets follow-up lands + +**Change requested mid-phase:** both jobs in `pr-checks.yml` now use +`runs-on: ${{ vars.SMALL_RUNNER || 'ubuntu-latest' }}`. Folded into commit 1 by amend (both commits were still +unpushed) rather than added as a third commit, so the two-commit structure `--baseline HEAD~1` depends on is preserved. +actionlint clean. Commit 1 is now `fd63b83`. + +`SMALL_RUNNER` is new to this repository — it appears nowhere on this branch, `master`, `26.2` or `26.3`. The nearest +precedent is `runs-on: ${{ inputs.GITHUB_RUNNER }}` in two reusable workflows. + +**Runner confirmed:** `SMALL_RUNNER` is hosted by Ubicloud, whose images track the GitHub-hosted runner spec — so the +`lockfile` job's preinstalled `jq` and the `scripts` job's `sudo apt-get` both hold. The first PR run is the empirical +proof of that, and is exactly what the Phase 5 manual-verification item below checks. + +--- + +## Phase 6: Consumer-repo canary + +### Overview + +Prove the fix before anything merges. `design.md` Dimension 7: the canary must exercise a `use-npmrc` path — the five +workflows that run `npm ci` with no `~/.npmrc` resolve npmjs URLs fine today and cannot demonstrate anything. + +### Changes Required + +**Repository**: ~~`cplace-remote-filesystem-fe`~~ → **`cplace-paw-fe`** (temporary PR — **not** merged) + +**Changed during execution:** `cplace-remote-filesystem-fe`'s release branches do not go back to `25.2`, so it cannot +exercise the `25.2` lockfile state at all. `cplace-paw-fe` has a `release/25.2` that pins +`github-actions@release/25.2`, so it was used instead. + +1. Branch from the branch whose workflows pin `github-actions@release/25.2`. +2. Temporarily re-point the `uses:` refs to the branch under test, in a workflow that goes through `use-npmrc`. +3. Open the PR, let the pipeline run, confirm the composite's internal `npm ci` resolves every package. +4. Close the PR and discard the branch. Do not merge; create no tag and no release. + +> Note, so it is not a surprise: `fe-pr-snapshot` publishes a `latest-pr-snapshot` package to JFrog (cleaned up by +> `fe-pr-close`). That is a real publish, though not a tag or a release. + +### Two execution findings that would have invalidated a naive canary + +**1. Re-pinning the consumer's `uses:` alone tests the wrong lockfile.** The `artifacts` composite runs +`cd "$GITHUB_ACTION_PATH/../../.." && npm ci`, so it installs *its own* checkout — meaning the ref on the **composite**, +not on the reusable workflow, decides which `package-lock.json` is under test. `fe-pr-snapshot.yml` pins `use-npmrc` +and `artifacts` internally at `@release/25.2`. A canary that re-pinned only the consumer's `uses:` would have installed +`release/25.2`'s **un-normalized** lockfile and passed for the wrong reason. + +Resolved with a throwaway `canary/PFM-ISSUE-34453-lockfile/25.2` branch in `github-actions` = the fix branch plus +internal ref re-pins, so nothing temporary could reach the real PR. Deleted after validation. The runner log proves the +right tree was used: + +``` +GITHUB_ACTION_PATH=/home/runner/work/_actions/collaborationFactory/github-actions/canary/PFM-ISSUE-34453-lockfile/25.2/.github/actions/artifacts +``` + +**2. The canary job is label-gated.** `fe-pr-snapshot.yml` carries +`if: contains(github.event.pull_request.labels.*.name, 'snapshot')`, and the consumer's trigger is +`types: [labeled, synchronize, reopened]` — **not `opened`**. Opening the PR fires nothing; adding the `snapshot` +label both fires the event and satisfies the `if:`. + +Also worth knowing for the remaining states: only `fe-pr-snapshot` and `fe-pr-close` are valid canary paths. +`fe-licenses` and `fe-install-deps` call `use-npmrc` but then run a workflow-level `npm ci` against the *consumer's* +own lockfile — they never invoke a composite, so they cannot demonstrate anything. `fe-cleanup-snapshots` is a cron in +`cplace-paw-fe` with no `workflow_dispatch`, so it is not PR-triggerable. + +### Success Criteria + +#### Automated Verification + +- [x] The canary pipeline run completes green — `cplace-paw-fe` PR #184, run `31487742492`, + `publish-pr-snapshot` **success**. paw-fe's normal PR CI (still pinned at `@release/25.2`) was also green on all + 15 jobs beforehand, as a control. +- [x] The composite's `npm ci` step log shows no `E404` and no `***`-masked resolution failure — **zero `E404` in the + entire run**. The full chain, in log order: + + ``` + 435 Run cd "$GITHUB_ACTION_PATH/../../.." && pwd && npm ci + 448 added 542 packages, and audited 543 packages in 14s + 460 Run npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/artifacts/main.ts" + ``` + + 542 packages / 543 audited is the **github-actions** lockfile's exact entry count, matching the local run + precisely — so this installed the tree under test, not the consumer's. `main.ts` then ran successfully on top, + proving `node_modules` was functional and not merely populated. +- [x] The re-pinned `uses:` refs point at the branch under test, verified in the PR diff before the run + +**Additional local proof (design.md Manual testing steps 1–2), a controlled experiment — same machine, same +`~/.npmrc`, lockfile the only variable:** + +| lockfile | `npm ci` | +| --- | --- | +| pre-fix (171 npmjs URLs) | `npm error code E404` — `GET https://cplace.jfrog.io/unicode-property-aliases-ecmascript/-/…tgz`, i.e. the `/artifactory/api/npm/cplace-npm` prefix dropped | +| normalized (0 npmjs URLs) | `added 542 packages, and audited 543 packages in 5s`, exit 0 | + +**No artifact was published, and the canary does not claim one.** `main.ts` correctly reported *"No snapshots of +projects have been published (probably no project is affected)"* — the canary PR changes only a workflow file, so nx +found no affected project. Confirmed with `jf rt search`: no `cf-training-extended` artifact exists for PR 184. This is +unrelated to the fix; `npm publish` never depended on lockfile resolution, and the step that *did* fail before the fix +(`npm ci`) is proven working. +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 6 complete. Summary: canary PR in cplace-paw-fe (cplace-remote-filesystem-fe has no release/25.2) with uses: temporarily re-pinned to the fix branch, exercising a use-npmrc path — the composite's internal npm ci resolved with no E404. Canary PR closed, no tag or release created. release/25.2 is ready to merge. Please review and reply 'yes' to continue to Phase 7 (the six remaining branches)." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [x] The chosen workflow demonstrably ran `use-npmrc` before the composite (check step ordering in the run log) + — step 5 `Use .npmrc` → success, step 7 `Build and Push to Jfrog NPM Registry` → success. (Step 6 + `Install modules` was **skipped** on a cache hit; that is the *consumer's* own install and is irrelevant. The + composite's `npm ci` inside step 7 runs unconditionally, which is exactly why it is the failing step today.) +- [x] The canary PR is closed and its branch deleted; no `uses:` re-pin is left behind anywhere — PR #184 closed with + `--delete-branch`; `canary/PFM-ISSUE-34453-lockfile/25.2` deleted local and remote. `git ls-remote origin 'canary/*'` + returns 0. Both repos verified clean, and the fix branch's internal refs are back at `@release/25.2`. +- [ ] `release/25.2` PR merged after the canary is green — prove-then-merge, in that order + +**Canary coverage decision:** this validated the `25.2` lockfile state. The other two states (`25.3 = 25.4` and +`26.1 = 26.2 = 26.3 = master`) get their own canary run during Phase 7, as `design.md` Dimension 7 requires. + +--- + +## Phase 7: Replicate to the six remaining branches + +### Overview + +One parameterised runbook, executed six times in order: `25.3 → 25.4 → 26.1 → 26.2 → master → 26.3`. `master` and +`26.3` are **not** downstream of `26.2` (research §8), so none of this can ride the upmerge. + +Tooling files must be **byte-identical** across branches, so a future upmerge sees a conflict-free add/add. + +### PR #163 is the seed; `master` is explicitly in scope + +**`master` receives this fix like every other branch** — it is in the ordered list above, and because it is not +downstream of `26.2` it can only get there through its own PR. It is not a leftover to be handled later. + +**#163 is the seed, not merely the first instance.** Everything the six inherit — the tooling set, the guard, the +mitigation, and the two rounds of review fixes folded into it — is settled here. That is why this PR carries the whole +substantive review and the others do not, and why the six are held unpushed until #163 is approved: a change here has +to be replicated seven times. + +**Each PR still carries the differences its own branch requires.** Byte-identity is a property of the *tooling set* +(`tools/scripts/lockfile/*`, `pr-checks.yml`, `use-npmrc/action.yml`, `.prettierignore`) — **not** of the whole diff. +Per branch, legitimately different: + +- the `package-lock.json` content and its byte totals (three distinct lockfile states — `25.2` | `25.3 = 25.4` | + `26.1 = 26.2 = 26.3 = master`), though every branch shows the same `171` / `+4788` / `342` / `0` signature; +- the PR's base branch, and on `master` the base is `master` rather than a `release/*`; +- anything a branch already differs in that the tooling touches — verified 2026-08-11 to be nothing: + `.prettierignore` is byte-identical on all seven, and no branch already contains `tools/scripts/lockfile/` or + `pr-checks.yml`. + +The byte-identity gate in step 3 below is what keeps that distinction honest: it compares only the tooling paths, so a +legitimate per-branch difference elsewhere cannot mask tooling drift. + +### Gate: PR #163 must be APPROVED before this phase starts + +Not *merged* — approved. The six branches are technically independent of #163's merge: the tooling is copied from commit +`fd63b83`, which is already pushed, and each branch normalizes its **own** pre-fix lockfile, so no branch can inherit +another's dependency versions. Each PR's guard also runs on itself, proven on #163. + +The binding constraint is different: because the tooling files must stay byte-identical across all seven branches, **any +change arising from #163's review would have to be force-pushed to all seven PRs.** Approval is therefore the event that +makes the tooling content safe to replicate — merge is irrelevant to it. + +Review context measured 2026-08-11: `release/25.2` has **no branch protection at all**; only `master` requires an +approval (1), with zero required status checks; no rulesets, no CODEOWNERS. So review is a team practice here rather +than something the repo enforces — which is precisely what the Phase 8 Rule Sets follow-up addresses. + +When reviewing the six, note that #163 is the only PR with novel content; the others are the same tooling plus a +machine-checked lockfile transformation. State that in each PR description, with the `sha256sum` proof, so the six do +not consume six substantive reviews. + +### Changes Required — per branch `` + +```bash +# 1. Branch from the release branch +git fetch origin +git checkout -b "fix/PFM-ISSUE-34453-normalize-package-lock-json/" "origin/release/" # or origin/master + +# 2. Commit 1 - tooling, copied verbatim from the merged 25.2 commit +git checkout -- \ + tools/scripts/lockfile .github/workflows/pr-checks.yml .prettierignore +git commit -m 'PFM-ISSUE-34453 - github-actions: add package-lock.json normalizer, invariant check and PR guard' + +# 3. Byte-identity gate - MUST be empty before continuing +git diff --stat HEAD -- \ + tools/scripts/lockfile .github/workflows/pr-checks.yml + +# 4. Commit 2 - the lockfile alone +./tools/scripts/lockfile/normalize-lockfile.sh +git add package-lock.json +git commit -m 'PFM-ISSUE-34453 - github-actions: normalize package-lock.json resolved URLs onto the JFrog npm proxy' + +# 5. Prove it +./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1 +``` + +### Pre-flight measurements for this phase (2026-08-11, while #163 is in review) + +**No drift since research.** All seven branches still show exactly the state the plan assumes, and the last commit +touching any lockfile is from 2025 — so no Dependabot churn to account for: + +| branch | entries | npmjs | jfrog | lockfile sha256[:10] | +| --- | --- | --- | --- | --- | +| `release/25.2` | 543 | 171 | 371 | `21aead4730` | +| `release/25.3`, `release/25.4` | 543 | 171 | 371 | `012c07592f` | +| `release/26.1`, `26.2`, `26.3`, `master` | 559 | 171 | 387 | `d06efe2cba` | + +**The normalizer was dry-run against all six remaining branches.** Every one succeeds, and the `+4788` delta holds +across all three lockfile states (it is `171 × 28`, independent of entry count): + +| branch | before → after | delta | changed lines | non-`resolved` | idempotent | +| --- | --- | --- | --- | --- | --- | +| `25.3`, `25.4` | 262 061 → 266 849 | +4788 | 342 | 0 | yes | +| `26.1`, `26.2`, `26.3`, `master` | 270 244 → 275 032 | +4788 | 342 | 0 | yes | + +**Two conflict risks retired:** + +- `.prettierignore` is **byte-identical on all seven branches** (`node_modules` / `.idea`), so the conflict this plan + warned about cannot occur — the same one-line addition applies cleanly everywhere. +- **No branch already contains `tools/scripts/lockfile/` or `pr-checks.yml`** (0 files on each), so every copy is a + clean add. + +**The canary trap recurs per state, and is not a 25.2 quirk:** each branch pins its composites to its own release +(`release/25.4` → `@release/25.4`, `release/26.2` → `@release/26.2`). So the `25.4` and `26.x` canaries each need +their own throwaway branch with the internal `use-npmrc`/`artifacts` refs re-pinned, exactly as Phase 6 did. + +**Canary coverage.** There are three distinct lockfile states — `25.2` | `25.3 = 25.4` | `26.1 = 26.2 = 26.3 = master`. +Phase 6 covered the first. Run one further canary per remaining state: one on `25.4`, one on `26.2`. The other three +26.x branches carry a byte-identical lockfile and need no separate canary. + +### Canary runbook (per remaining state: `25.4`, then `26.2`) + +Derived from Phase 6, where both of the traps below were hit for real. `` is `release/25.4` or `release/26.2`. + +```bash +# 1. Throwaway branch in github-actions: the fix branch for , plus internal ref re-pins. +# MANDATORY. The artifacts composite runs `cd "$GITHUB_ACTION_PATH/../../.." && npm ci`, +# so the ref on the COMPOSITE decides which lockfile is installed. Re-pinning only the +# consumer's `uses:` would install 's UN-normalized lockfile and pass for the wrong reason. +# Verified 2026-08-11: every branch pins its composites to its own release, so this recurs per state. +git checkout -b "canary/PFM-ISSUE-34453-lockfile/" "fix/PFM-ISSUE-34453-normalize-package-lock-json/" +# edit .github/workflows/fe-pr-snapshot.yml: use-npmrc@ and artifacts@ +# -> @canary/PFM-ISSUE-34453-lockfile/ (use an editor: BSD sed has no \| alternation) +git commit -am 'PFM-ISSUE-34453 - TEMPORARY canary pin - DO NOT MERGE' && git push -u origin HEAD + +# 2. Consumer branch in cplace-paw-fe, from the branch pinning github-actions@ +git checkout -b "test/PFM-ISSUE-34453-lockfile-canary/" "origin/" +# edit .github/workflows/fe-pr-snapshot.yml: the single `uses:` -> @canary/PFM-ISSUE-34453-lockfile/ +git commit -am 'PFM-ISSUE-34453 - paw-fe TEMPORARY canary pin - DO NOT MERGE' && git push -u origin HEAD +gh pr create --draft --base "" --title 'PFM-ISSUE-34453 - CANARY (do not merge)' --body '…' + +# 3. Fire it. The job is gated on `if: contains(…labels.*.name, 'snapshot')` and the consumer +# trigger is `types: [labeled, synchronize, reopened]` - NOT `opened`. Opening the PR fires nothing. +gh pr edit --add-label snapshot + +# 4. Verify - the composite path must contain the canary branch, or you tested the wrong tree +gh run view --log | grep -E 'GITHUB_ACTION_PATH=|added [0-9]+ packages|E404' +``` + +Pass criteria: the log shows `GITHUB_ACTION_PATH=…/github-actions/canary/PFM-ISSUE-34453-lockfile//…`, then +`added 542 packages` (25.x) or `added 558 packages` (26.x), and **zero `E404`** in the whole run. + +Cleanup: `gh pr close --delete-branch`, then delete the github-actions canary branch local and remote. Confirm with +`git ls-remote --heads origin 'canary/*'` returning nothing. + +Note `cplace-paw-fe` has `release/25.2` and `release/25.3` but **check `release/25.4` and `release/26.2` exist there** +before starting; if not, pick another consumer pinning that branch. Also note paw-fe's own `release/25.2` and `25.3` +lockfiles each carry 14 npmjs entries ([consumer-survey.md](./consumer-survey.md)) — unrelated to this fix, but it +means a *workflow-level* `npm ci` on those branches may fail for the same underlying reason. Do not mistake that for a +canary failure: the canary's subject is the **composite's** install, not the consumer's. + +### Reusable PR description for the six replication PRs + +The point is to make these cheap to review, since only #163 carries novel content. + +```markdown +## What this is + +Branch of 7 for PFM-ISSUE-34453. Normalizes this branch's `package-lock.json` `resolved` +URLs onto the JFrog npm proxy, and adds the same lockfile tooling and PR guard. + +**The tooling is byte-identical to #163** — verified, not asserted: + + sha256(tools/scripts/lockfile/* + pr-checks.yml) = # same on all seven branches + +So the only thing needing review here is the lockfile commit, and that is machine-checked. +The substantive review is on #163. + +## Two commits + +1. Tooling — copied verbatim from #163's tooling commit. +2. The normalized lockfile alone. Its parent *is* the baseline, so the invariant is provable + by construction: `./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1`. + +## Evidence + +- 171 entries rewritten, +4788 bytes (`` → ``), 342 changed lines, **0** outside a `"resolved"` line +- `grep -c registry.npmjs.org package-lock.json` → **0** +- `check-lockfile.sh --baseline HEAD~1` exits 0: graph identical, exactly one registry prefix +- bats 52/52 and shellcheck clean, both in CI via `pr-checks.yml` + +Known limitation, same as #163: the guard **reports but cannot block** until PFM-ISSUE-34465 +(Rule Sets enforcement) lands. +``` + +**Cross-branch consistency check**, after all seven PRs are merged: + +```bash +for b in release/25.2 release/25.3 release/25.4 release/26.1 release/26.2 release/26.3 master; do + printf '%s ' "${b}" + git show "origin/${b}:.github/workflows/pr-checks.yml" | sha256sum | cut -c1-16 +done +for b in release/25.2 release/25.3 release/25.4 release/26.1 release/26.2 release/26.3 master; do + printf '%s ' "${b}" + for f in lib.sh fingerprint.jq normalize-lockfile.sh check-lockfile.sh test-helper.bash README.md; do + git show "origin/${b}:tools/scripts/lockfile/${f}" + done | sha256sum | cut -c1-16 +done +``` + +### Success Criteria + +#### Automated Verification + +- [ ] On each branch: `grep -c 'registry.npmjs.org' package-lock.json` returns `0` +- [ ] On each branch: `./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1` exits 0 +- [ ] On each branch: the lockfile commit shows 342 changed lines, 0 non-`resolved`, +4788 bytes +- [ ] On each branch: `shellcheck tools/scripts/lockfile/*.sh && bats tools/scripts/lockfile/` passes +- [ ] The byte-identity gate (step 3) produces empty output on all six branches +- [ ] Both `sha256sum` loops print the **same** digest on all seven rows +- [ ] `pr-checks.yml` runs and passes on all seven PRs +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 7 complete. Summary: all seven branches normalized (25.2, 25.3, 25.4, 26.1, 26.2, master, 26.3); every branch has 0 registry.npmjs.org entries and passes check-lockfile.sh --baseline HEAD~1; tooling files and pr-checks.yml are byte-identical across all seven (sha256 ); canaries run for all three lockfile states. Please review and reply 'yes' to continue to Phase 8." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [ ] Canaries green for the `25.4` and `26.x` lockfile states, both on a `use-npmrc` path +- [ ] The pending `25.2 → 25.3` upmerge (2 commits) is unaffected — normalizing `25.3` directly did not depend on it +- [ ] The two tag/release pipelines originally reported in the ticket now succeed +- [ ] No branch was normalized before its tooling commit landed on that same branch + +--- + +## Phase 4b: Interim mitigation in `use-npmrc` (added 2026-08-11, folded into PR #163) + +### Overview + +Normalization fixes this repo's lockfile on seven branches, but not consumer lockfiles — and the consumer survey found +`cplace-paw-fe` `release/25.2`/`25.3` **already broken** for the same reason. This phase stops the bleeding everywhere +without touching a single lockfile. + +### Changes Required + +**File**: `.github/actions/use-npmrc/action.yml` — append one line, and run the advisory check: + +```yaml +run: | + echo "$DOT_NPMRC" > ~/.npmrc + echo 'replace-registry-host=never' >> ~/.npmrc +``` + +**File**: `tools/scripts/lockfile/warn-foreign-registry.sh` — advisory; warns when the **consumer's** lockfile has +entries outside the proxy, via a `::warning` annotation plus a job summary. Never fails a build. + +`use-npmrc` now joins the byte-identical tooling set, so the Phase 7 digest covers it. + +### Success Criteria + +#### Automated Verification + +- [x] `replace-registry-host=never` present in `use-npmrc` +- [x] Mitigation verified against **real** lockfiles with the real secret: + github-actions un-normalized → `added 542 packages`; `cplace-paw-fe release/25.2` → `added 2576 packages` +- [x] The advisory check is silent on a normalized lockfile, warns with a count on a mixed one, ignores local-path + `resolved` values, and exits 0 on missing file / missing `jq` / invalid JSON / no `packages` section +- [x] No URL in the warning, so it survives `***` masking +- [x] bats **34/34** at the time of this phase (**52/52** now), shellcheck clean, `pr-checks.yml` green +- [x] Tooling digest identical on all seven branches including `use-npmrc`: `4131452a775f78ab` + +#### Manual Verification + +- [ ] Consumer canary proves the mitigation on a real runner *(Phase 6b below)* +- [ ] Removal is tracked on PFM-ISSUE-34454 *(done — a dedicated section was appended)* + +**Rejected alternative, recorded because it was the first instinct:** reverting `use-npmrc` to a workspace-level +`.npmrc`. It fixes only the composite surface — a consumer's own `npm ci` runs *in* the workspace where that file +lives, so `cplace-paw-fe` would stay broken — and it makes the composite's install issue **730 anonymous JFrog +requests per run**, creating exactly the dependency PFM-ISSUE-34454 exists to remove. + +--- + +## Phase 6b: Mitigation canary (consumer surface) + +### Overview + +Phase 6 proved the *composite* surface — its subject was the **github-actions** lockfile installed by the composite, +not paw-fe's own, which that run never installed (`Install modules` was skipped on a cache hit). This proves the +*consumer* surface: paw-fe's own lockfile on `release/25.2`, the branch that is actually broken. Both canary PRs were +opened against `release/25.2`. + +### Changes Required + +Canary branch `canary/PFM-ISSUE-34453-mitigation/25.2` in `github-actions` (fix branch + internal refs re-pinned to +itself), and a draft PR in `cplace-paw-fe` against **`release/25.2`** — the branch carrying 14 npmjs entries — with +`fe-pr.yml` re-pinned at that canary. + +> **The internal re-pin is what makes this test valid.** The mitigation *lives in* `use-npmrc`, and +> `fe-install-deps.yml` pins `use-npmrc@release/25.2` internally. Re-pinning only the consumer's `uses:` would load the +> old `use-npmrc` and test nothing — the same trap as Phase 6, one level deeper. + +### Success Criteria + +#### Automated Verification + +- [x] `install-deps` **succeeds** on `cplace-paw-fe` `release/25.2`, where it fails without the mitigation + — [paw-fe#185](https://github.com/collaborationFactory/cplace-paw-fe/pull/185), run `31509557594`, job + `93839623043`. Every link in the chain, from the runner log: + + ``` + echo 'replace-registry-host=never' >> ~/.npmrc + ##[warning]14 entries in package-lock.json do not resolve via the cplace npm proxy. … + added 2439 packages, and audited 2440 packages in 8m + Install modules -> success + ``` + + The **8m** install time confirms a genuinely cold cache — nothing was restored, which is precisely the condition + under which this branch fails today. `Install modules` is the step that returns `E404` without the mitigation. +- [x] The `::warning` annotation and job summary appear, naming the 14 offending package paths — the annotation + independently counted **14**, matching the local survey, and surfaces in the run UI without failing the job +- [x] Canary PR closed, both throwaway branches deleted — `git ls-remote --heads origin 'canary/*'` returns 0, both + repos clean, and the fix branch's internal refs are back at `@release/25.2` (7 occurrences, none on a canary) +- [x] **The full pipeline went green, not just `install-deps`** — all 15 jobs (4 e2e shards, 4 code-quality shards, + 2 builds, 2 storybooks, SonarCloud, install-deps) succeeded on a branch that cannot install without the + mitigation. Stronger than this criterion asked for. + +> **Process note, recorded because it nearly lost work.** The canary branch was created with `git checkout -b` and +> never switched away from, so the two documentation commits (`design.md` Dimension 9, `plan.md` Phase 4b/6b) landed on +> the *canary* branch and were pushed to its remote. Deleting the throwaway branch with `git branch -D` plus +> `git push --delete` then orphaned both. Recovered by cherry-picking the SHAs out of the reflog. **When working on a +> throwaway branch, commit documentation on the target branch first, or verify `git branch --show-current` before every +> commit.** + +--- + +## Phase 8: File the follow-ups + +### Overview + +`design.md` explicitly defers two pieces of work and asks that they be filed **alongside** the plan, not after the +rollout — the guard is unenforced until the first one lands. + +### Changes Required + +1. **New PFM issue — enforcement via GitHub Rule Sets.** Measured today: six release branches unprotected; `master` + protected with `required_status_checks.contexts = []`; zero rulesets. One ruleset targeting `release/*` + `master` + replaces seven per-branch configurations and makes `pr-checks.yml` a required check. Reference PFM-ISSUE-34453 and + the PFM-ISSUE-33179 precedent ("check does not block"). In the same family: enabling `jest` and `check-prettier` in + CI once the 20 pre-existing prettier failures are cleaned up. +2. **Prep note on PFM-ISSUE-34454** — out-of-band consumer lockfile survey (clone the FE repos, + `grep -c registry.npmjs.org` per repo and branch) so 34454 starts knowing its blast radius. Also record that after + this fix the five `npm ci`-without-`~/.npmrc` workflows resolve 100 % of packages anonymously from JFrog rather + than ~69 %. + +### Success Criteria + +#### Automated Verification + +- [ ] Both follow-ups exist and are linked to PFM-ISSUE-34453 +- [ ] PFM-ISSUE-34453 can be closed: every item in **Success Criteria** below is checked +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 8 complete. Summary: filed the Rule Sets enforcement follow-up (with jest/check-prettier CI noted in the same family) and the PFM-ISSUE-34454 consumer-lockfile-survey prep note, both linked to 34453. This completes the plan. Please review and reply 'yes' to close out." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification + +- [ ] The Rule Sets issue states plainly that until it lands, `pr-checks.yml` reports but cannot block +- [ ] PFM-ISSUE-34454 is unblocked and its description reflects the increased anonymous-access exposure + +--- + +## Phase 9: Review iteration 1 — triage fixes and jq audit (added 2026-08-13) + +### Overview + +Discharges the seven iteration-1 review findings that require a change, plus four defects found by an +argument-by-argument audit of every `jq` invocation in the toolkit, run during triage at the reviewer's request. + +**This phase must complete before Phase 7.** The tooling files are meant to be byte-identical across all seven +branches, so a fix made after replication has to be made seven times instead of once. + +The audit's headline: `normalize-lockfile.sh` and `fingerprint.jq` do **not** use the shared `JQ_REGISTRY_ENTRIES` +predicate — they transform every entry with a string `resolved`, including the root `""` key that the predicate +excludes. Reproduced on jq 1.8.1: + +| input | observed | +| --- | --- | +| root entry resolving to `https://registry.npmjs.org/x/-/x-1.0.0.tgz` | file rewritten, +90 bytes, run printed `entries rewritten: 0` and `already normalized - nothing to do` | +| root entry resolving to `packages/root` | `resolved` **silently deleted**, exit 0, `already normalized` | + +The cause is a false premise recorded in `lib.sh:37-40`: jq's `capture` does **not** raise on a non-match, it produces +`empty` — and `.x |= empty` *deletes* the key (`{"a":1,"b":2}` → `{"b":2}`). `fingerprint.jq` performs the identical +deletion on both sides of the comparison, so the fail-closed self-assertion and graph invariance are both blind to it. +Non-root entries are protected, because `assert_resolvable` runs first and every string matching `RESOLVED_URL_RE` +also matches `TARBALL_PATH_RE`; real lockfiles do not put `resolved` on the root entry, so this is latent rather than +firing today. + +**Not a defect, recorded because it was raised and checked:** the `https?` scheme test is not a permission. The proxy +constant is `https://`, and `assert_prefix_exactness` demands exact equality, so `http://cplace.jfrog.io/…` fails the +guard (verified, exit 1), the advisory reports it, and the normalizer rewrites it to `https`. Narrowing the selector to +`^https://` would make plaintext-http entries *invisible* to the predicate and therefore silently compliant — bug +[1.3] again in a new scheme. The selector stays `https?`; only the failure message changes. + +### Changes Required + +**File**: `tools/scripts/lockfile/normalize-lockfile.sh` + +**Changes**: gate the rewrite on the shared predicate's own terms — skip the root `""` key and require +`(.value.resolved | test("^https?://"; "i"))` — so `capture` is never handed a value `JQ_REGISTRY_ENTRIES` would have +passed over, and so `count_foreign_entries` and the rewrite genuinely agree, as `lib.sh:161-165` already claims they +do. + +**File**: `tools/scripts/lockfile/fingerprint.jq` + +**Changes**: apply the same two guards, so a root `resolved` is compared verbatim instead of being captured away — +which is what makes the graph comparison able to see it at all. Extend the header comment: it currently documents only +`--arg tarball_re`, while the transform additionally requires that the caller has already run `assert_resolvable`. + +**File**: `tools/scripts/lockfile/lib.sh` — the tarball-path widening is **already applied** (see below); the rest of +this entry remains to be done. + +**Applied 2026-08-13, ahead of the phase, because the rollout depends on it.** The 2026-08-13 canary +(cplace-paw-fe #186) reported **18** foreign entries where only 14 were foreign. The four extras sit on the correct +proxy host but carry a tarball path the regex rejected — a repeated scope +(`…/@cplace-next/cf-frontend-sdk/-/@cplace-next/cf-frontend-sdk-25.2.30.tgz`) or an interposed version segment +(`…/@fortawesome/fontawesome-pro/-/5.15.4/fontawesome-pro-5.15.4.tgz`), both served by JFrog and both installed by +npm without complaint. Two consequences: the advisory inventory that governs removal of `replace-registry-host=never` +could never reach zero on that consumer, and `normalize-lockfile.sh` refused to touch the lockfile at all +(`assert_resolvable`: "no usable tarball URL"). Both constants were widened from `[^/]+\.tgz` to `.+\.tgz`, in +lockstep — everything `RESOLVED_URL_RE` accepts must stay capturable by `TARBALL_PATH_RE`, or validation passes an +entry to a `capture` that yields `empty` and deletes its `resolved` key. Measured after the change: paw-fe reports +14, its normalizer run rewrites 14 (+392 bytes) and then passes the guard with the four odd-shaped entries untouched; +this repo's guard still passes and its normalizer is still idempotent (0 entries, 0 bytes); and the fingerprint is +**byte-identical on all seven branches** (`44464a7b05bbaf6a`, `e011920349fb2ecc` ×2, `93e0b3cd361bc1cc` ×4), so +Phase 5's recorded evidence stands. Five bats cases pin it: the two real shapes pass the guard, the same shape on a +foreign host is still rejected, the `RESOLVED_URL_RE` ⊆ `TARBALL_PATH_RE` invariant is asserted directly, and the +one genuine ambiguity — `.+` may cross a `/-/`, so a prefix containing one anchors on the first — is pinned with its +fail-loud direction recorded. + +**Changes**: correct the `RESOLVED_URL_RE` rationale at lines 37-40. A non-matching `capture` yields `empty`, and +`|= empty` deletes the key — pre-validation exists to stop a *silent deletion*, not a jq stack trace. The conclusion +stands; only the stated mechanism was wrong, and the wrong one is what made the root-entry path read as safe. Give +`assert_supported_lockfile` an optional display-name argument, so the baseline can be checked without the error +message naming a `mktemp` path. + +**File**: `tools/scripts/lockfile/check-lockfile.sh` + +**Changes**: +- Call `assert_supported_lockfile` on the resolved baseline before fingerprinting it, passing the baseline spec as the + display name. Today a lockfileVersion 1 baseline — `--baseline ` predating the v2/v3 upgrade is the realistic + way in — produces `jq: error … null (null) has no keys` followed by `internal error: cannot fingerprint the baseline + (is fingerprint.jq present?)`: a raw jq trace plus a wrong diagnosis, the exact failure class this toolkit exists to + eliminate. +- Report the number of registry entries examined on the `PASS:` line, and refuse to report compliance from an empty + set. A lockfile whose entries are all `link:`, and one carrying only a root entry, both currently print + `PASS: exactly 0 registry prefix, matching the expected proxy` and `OK: … resolves entirely via the cplace npm + proxy` — asserting a fact from nothing, which is the vacuity `assert_supported_lockfile` was written to prevent. + Trade-off, stated deliberately: a genuinely dependency-free lockfile now fails rather than passing silently; + measured 542 entries on `release/25.2` and non-zero on all seven branches. +- When an offender's stripped prefix differs from the proxy **only** in scheme, add a line naming the plaintext-`http` + downgrade as the reason. The entry already fails; the message says only "does not resolve via the cplace npm proxy", + which is true but sends the reader looking for a path problem. +- Rebuild both jq programs in `assert_prefix_exactness` from single-quoted fragments concatenated around + `JQ_REGISTRY_ENTRIES` — `'[ '"${JQ_REGISTRY_ENTRIES}"' | select(…) ]'` — so no jq `$var` sits inside a + double-quoted bash string needing `\$tarball_re` / `\"\"` escaping. Drop the `--arg proxy` passed to the `distinct` + program, which never references it. *(discharges [1.13])* + +**File**: `tools/scripts/lockfile/check-lockfile.bats` + +**Changes**: add cases for +- a `:2:` merge-stage baseline: build a real conflict on `package-lock.json`, then assert + `check-lockfile.sh --baseline :2:` resolves and prints `baseline: :2:package-lock.json`. This is the + `spec="${spec}${lockfile_path}"` arm of `resolve_baseline` and the path the Flow 2 runbook depends on *(discharges + [1.5])* +- a lockfile omitting `lockfileVersion` entirely: exit 1 and `is it a package-lock.json?`, distinguishing the + `missing` arm from its unsupported-version sibling *(discharges [1.11])* +- a lockfileVersion 1 baseline: fails with the readable message, no jq trace +- a lockfile with zero registry entries: does not claim proxy compliance +- an `http://` entry on the proxy host: fails, and the message names the scheme + +**File**: `tools/scripts/lockfile/normalize-lockfile.bats` + +**Changes**: add cases for +- a lockfile omitting `lockfileVersion` *(discharges [1.11])* +- a root entry carrying a foreign tarball URL: whatever the run reports must match what the file did — no + `entries rewritten: 0` on a file that changed +- a root entry whose `resolved` is not a tarball URL: the key survives the run + +**File**: `.github/actions/use-npmrc/action.yml` + +**Changes**: line 41 uses the bash variable `$GITHUB_ACTION_PATH`, as `artifacts`, `snapshots`, `upmerge` and +`run-many` all do, instead of the `${{ github.action_path }}` expression — one spelling of the value per directory, +and the form that is quoted at runtime rather than interpolated into the command line, which is what the step's own +comment worries about. *(discharges [1.9])* + +**File**: `tools/scripts/lockfile/README.md` + +**Changes**: retitle "The two scripts" to "The scripts" (the table lists three, in four rows); change "Both default to +`./package-lock.json`" to "All three default"; split the invocation claim so "no npm script and no composite action +wrapper" applies to the two lockfile scripts it is true of, while `warn-foreign-registry.sh` is named as invoked by +`use-npmrc`. *(discharges [1.10])* In "no usable tarball URL", state that the hard failure fires on the `--baseline` +path and not on the `--prefix-only` PR guard. *(discharges [1.12])* + +**File**: `specs/2026-08-10_normalize-package-lock-resolved-urls/design.md` + +**Changes**: strike through Dimension 2's `link:`/`file:`/`git+ssh:` implication and annotate it +"**Superseded 2026-08-11 (`604f95e`)**" — matching the treatment its neighbouring `fetch-depth` implication already +carries — recording that the hard failure holds on the `--baseline` path while `--prefix-only` admits non-http entries +by design, so the guard would stop blocking npm workspaces. *(discharges [1.12])* + +**File**: `specs/2026-08-10_normalize-package-lock-resolved-urls/plan.md` + +**Changes**: strike through the `TARBALL_PATH_RE` line in the Phase 1 code block and the inline regex in Key +Discovery 4, annotating both with the shipped form — `'(?(?:@[^/]+/)?[^/]+/-/.+\.tgz)$'`, i.e. tightened to +require `\.tgz` in response to review and then widened after the tail must be allowed to contain slashes (above) — +that 0 of 542 entries are affected and the fingerprint is unchanged on all seven branches, and that `\.tgz` is +deliberately case-sensitive while the scheme is not. *(discharges [1.14])* + +### Success Criteria + +#### Automated Verification + +- [x] `bats tools/scripts/lockfile/` passes, including every case added above — **74/74** (57 before triage, 65 after + the regex widening, 74 now) +- [x] `shellcheck tools/scripts/lockfile/*.sh` is clean +- [x] On the real `package-lock.json`: `check-lockfile.sh --prefix-only` exits 0 and now reports the number of entries + examined (`542 entries examined`); `normalize-lockfile.sh` reports `entries rewritten: 0` with byte delta 0 +- [x] `check-lockfile.sh --baseline` still exits 0 across the Phase 5 normalization commit (`f08ac98`): `PASS: + dependency graph identical to baseline`, `542 entries examined` +- [x] A root entry carrying `"resolved": "packages/root"` survives a normalizer run with its key intact — verified + directly and pinned by a bats case +- [x] Review findings [1.5], [1.9], [1.10], [1.11], [1.12], [1.13] and [1.14] marked resolved in `review.md` — all 15 + findings are now ticked, 0 open +- [x] `pr-checks.yml` green on PR #163 — run `31710289086` at `5b7a73b`: `Lockfile registry invariant` pass (8s), + `Shell scripts` pass (27s) + +#### Manual Verification + +- [x] The four audit fixes are re-read against the reproductions in the Overview table — each one no longer occurs: + the root entry keeps `resolved` and the count no longer says 0 about a changed file (N1); the `capture` + rationale states what jq actually does (N2); a zero-entry lockfile is refused rather than reported compliant, + with status 2 so the normalizer is not offered as the remedy (N3); a lockfileVersion 1 baseline fails with + `baseline v1.json is lockfileVersion 1`, no jq trace and no "is fingerprint.jq present?" (N4) +- [x] The fingerprint is byte-identical on all seven branches **after** the `fingerprint.jq` guards — the change most + able to move the comparison basis, re-measured against the pre-phase transform +- [ ] The tooling digest is recomputed **after** this phase, and it is that digest Phase 7 replicates + +--- + +## Testing Strategy + +### Unit / behavioural tests (bats) + +- `normalize-lockfile.bats` — rewriting (scoped and unscoped), idempotence, `version`/`integrity`/`dependencies` + untouched, missing-`resolved` failure, non-tarball-protocol failure, missing-file failure, masking safety. +- `check-lockfile.bats` — the clean case plus all six injected-drift cases (t1–t6), each asserted to fail via the + *correct* assertion; baseline resolution from a git ref, from `:2:`-style merge stages, and from two explicit file + paths; a bad baseline ref; masking safety. + +Fixtures are tiny hand-written lockfiles from `test-helper.bash` — the tests assert behaviour, not the real file's byte +counts. Byte-level facts are asserted separately against a copy of the real lockfile in Phase 3's success criteria. + +### Integration tests + +- Phase 3's real-lockfile assertions: 171 rewritten, +4788 bytes, 342 lines, 0 non-`resolved`, byte-identical to a raw + `sed` rewrite, idempotent. +- Phase 5's `check-lockfile.sh --baseline HEAD~1` against a real two-commit history. +- `pr-checks.yml` running on the introducing PR itself. + +### Manual testing steps + +1. Reproduce the bug first: with a JFrog `~/.npmrc` active, run `npm ci` in a fresh clone at the pre-fix commit and + observe the `E404`. +2. Normalize, then re-run `npm ci` in the same environment — it must resolve every package. +3. Simulate a bad merge: hand-edit one `version` in the normalized lockfile, run `check-lockfile.sh --baseline HEAD~1`, + confirm it exits 1 and names that package path. +4. Simulate the conflict runbook: create a conflict on `package-lock.json`, `git checkout --ours`, re-run the + normalizer, `check-lockfile.sh --baseline :2:`. +5. Confirm no failure output anywhere contains `cplace.jfrog.io`. + +## Performance Considerations + +Negligible and worth stating only to rule it out: `jq` processes the 262 KB lockfile in well under a second, and the +`lockfile` guard job runs no `setup-node` and no `npm ci` at all — it is the fastest job in the repo by construction, +which is the point. The `scripts` job pays ~20 s for `apt-get update && apt-get install`. + +## Migration Notes + +- **Nothing to migrate for consumers.** The four affected composites are unmodified; they simply stop failing once the + lockfile they `npm ci` is internally consistent. +- **Rollback** is `git revert` of the lockfile commit on the affected branch. The tooling commit is inert on its own — + reverting it alone would leave a normalized lockfile with no guard, which is safe but pointless. +- **Future upmerge conflicts** on `package-lock.json` have a mechanical resolution, documented in the README: + `git checkout --ours -- package-lock.json`, re-run the idempotent normalizer, verify with + `check-lockfile.sh --baseline :2:` (or `:3:`). Never `--theirs`, never a hand edit. +- **A future legitimate `link:`/`file:`/`git+ssh:` dependency** will fail `assert_resolvable`, by design. The failure + names the package path; loosening the assertion is then a deliberate, reviewed edit rather than a silent pass. + +## Success Criteria (ticket-level) + +From [design.md](./design.md), verifiable when all seven PRs are merged: + +- [ ] `grep -c 'registry.npmjs.org' package-lock.json` returns **0** on all seven branches +- [ ] `check-lockfile.sh --baseline HEAD~1` exits 0 on each branch +- [ ] Each lockfile commit shows exactly **342 changed lines** and **+4788 bytes**, with no non-`resolved` line changed +- [ ] The normalizer is idempotent: a second run rewrites 0 entries and changes 0 bytes +- [ ] The check **fails** on injected drift — poisoned `version`, poisoned `integrity`, changed tarball filename, + typo'd proxy prefix, an entry left on npmjs — naming the offending package path in each case +- [ ] A consumer-repo canary PR per lockfile state completes the composite's internal `npm ci` on a `use-npmrc` path +- [ ] `pr-checks.yml` fails a PR that reintroduces `registry.npmjs.org`, and its message names the remediation command +- [ ] bats and shellcheck pass in CI on all seven branches +- [ ] `tools/scripts/lockfile/*` and `pr-checks.yml` are byte-identical across all seven branches + +## References + +- Research: [research.md](./research.md) +- Design: [design.md](./design.md) +- Original ticket: [PFM-ISSUE-34453](https://base.cplace.io/pages/6lxohwjr2a51h39y5idjx6qj2/PFM-ISSUE-34453-github-actions-Normalize-package-lock.json-resolved-URLs-onto-the-JFrog-npm-proxy-release-25.2-master) +- Upstream cause: [`specs/2026-06-05_node24-workflow-migration/design.md`](../2026-06-05_node24-workflow-migration/design.md) +- SHA-pinning convention: [`specs/2026-06-05_node24-workflow-migration/sha-pins.md`](../2026-06-05_node24-workflow-migration/sha-pins.md) +- Non-executing PR template (the `on: pull_request` shape to mirror): `.github/workflow-templates/fe/fe-pr.yml:2-5` +- ~~`fetch-depth: 0` precedent: `.github/workflows/fe-check-upmerge.yml:21`~~ — no longer used; the guard takes no + baseline (see Key Discovery 6) +- Fail-loudly validator pattern: `tools/scripts/artifacts/utils.ts:309-328` +- Publish target vs. install proxy — must not be confused: `tools/scripts/artifacts/configuration.ts:2` +- The regression's mechanism: `.github/actions/use-npmrc/action.yml:10-14` +- Blocked ticket: PFM-ISSUE-34454 +- "Check does not block" precedent: PFM-ISSUE-33179 (`5c4ba52`) diff --git a/specs/2026-08-10_normalize-package-lock-resolved-urls/research.md b/specs/2026-08-10_normalize-package-lock-resolved-urls/research.md new file mode 100644 index 0000000..ce8a951 --- /dev/null +++ b/specs/2026-08-10_normalize-package-lock-resolved-urls/research.md @@ -0,0 +1,499 @@ +--- +date: 2026-08-10T15:26:55+02:00 +git_commit: 967168ed1c896821e28a3ad343ddfcc6b07a4bcb +branch: fix/PFM-ISSUE-34453-normalize-package-lock-json/25.2 +baseline_branch: release/25.2 +baseline_commit: 967168ed1c896821e28a3ad343ddfcc6b07a4bcb +researched_from_working_tree: release/26.2 @ f79120d47a50d66d5960d94dd70c9237ac3f04c9 +topic: 'PFM-ISSUE-34453: Normalize package-lock.json resolved URLs onto the JFrog npm proxy' +tags: + [ + research, + codebase, + package-lock, + npm, + jfrog, + artifactory, + use-npmrc, + composite-actions, + upmerge, + ci, + ] +status: complete +last_updated: 2026-08-10 +--- + +# Research: PFM-ISSUE-34453 — Normalize `package-lock.json` resolved URLs onto the JFrog npm proxy + +**Date**: 2026-08-10T15:26:55+02:00 +**Branch**: `fix/PFM-ISSUE-34453-normalize-package-lock-json/25.2` +**Git Commit**: `967168e` (`967168ed1c896821e28a3ad343ddfcc6b07a4bcb`) — branched off `origin/release/25.2`, the branch the fix must land on first +**Researched from**: the `release/26.2` working tree @ `f79120d`, analysing `origin/release/25.2` via a read-only worktree + +## Research Question + +Research the codebase for PFM-ISSUE-34453: the repo ships a mixed `package-lock.json` in which 171 entries still +resolve via `registry.npmjs.org`, which npm rewrites onto the JFrog host while dropping the registry's path prefix, +producing `E404` in consumer pipelines. Work must start on `release/25.2`, and branches follow the pattern +`fix/PFM-ISSUE-34453-normalize-package-lock-json/`. + +## PFM Ticket Context + +- **Ticket**: [PFM-ISSUE-34453](https://base.cplace.io/pages/6lxohwjr2a51h39y5idjx6qj2/PFM-ISSUE-34453-github-actions-Normalize-package-lock.json-resolved-URLs-onto-the-JFrog-npm-proxy-release-25.2-master) +- **Title**: github-actions: Normalize package-lock.json resolved URLs onto the JFrog npm proxy (release/25.2 - master) +- **Type**: PFM-ISSUE, subtype `10.bug` +- **Target Release**: 25.2 (reported in 26.2) +- **Status / Priority**: `10.unqualified` / `20.high` +- **Assignee**: not set on the ticket (creator / requested by: Christian Kaltenbach) — *inferred, field empty* +- **Squad**: 01 - Build & Connect / Stratum · **Topic**: GitHub · **Reported By**: extern (Service Desk INC-019045) +- **Relations**: blocks PFM-ISSUE-34454 (Standardize `DOT_NPMRC` across FE consumer repos and pre-flight the JFrog anonymous-access shutdown) +- **Branch pattern (user-specified, overrides the ticket's `branchName` field)**: + `fix/PFM-ISSUE-34453-normalize-package-lock-json/` + → `…/25.2`, `…/25.3`, `…/25.4`, `…/26.1`, `…/26.2`, `…/26.3`, `…/master` + (the ticket's own field reads `fix/PFM-ISSUE-34453-github-actions-Normalize-package-lock-json`) + +### Description (condensed; full text in the ticket) + +`collaborationFactory/github-actions` ships a **mixed** `package-lock.json`: 171 of its non-root entries resolve via +`https://registry.npmjs.org/` while the rest resolve via `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/`. +The shared composite actions run `npm ci` inside the action directory while the consumer's user-level `~/.npmrc` is +active, so npm rewrites those 171 URLs onto the configured registry and **drops the registry's path prefix**, +producing a 404. The failure is hard to spot because the broken URL prefix is the `JFROG_URL` secret and therefore +masked as `***`. + +npm root cause: `node_modules/pacote/lib/remote.js:16` — `this.resolved = new URL(resolvedURL.pathname, this.registry).href`. +Because `resolvedURL.pathname` starts with `/`, the registry's path is discarded under npm's default +`replace-registry-host=npmjs`: + +| | | +| --- | --- | +| lockfile | `https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz` | +| requested | `https://cplace.jfrog.io/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz` → **404** | +| correct | `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz` → 200 | + +**Regression trigger**: the Node 24 migration replaced `bduff9/use-npmrc@v1.1` (wrote `/.npmrc`, invisible +to the action's own install) with the in-repo `.github/actions/use-npmrc` (writes `~/.npmrc`, active for every npm +invocation on the runner). + +**The fix**: a pure rewrite of the `resolved` field prefix (171 occurrences per branch) — no `version`, no `integrity`, +no graph change. Delivered as (1) a normalizer, (2) an invariant check, (3) an ongoing PR-pipeline guard. Rollout is +one PR per branch, oldest → newest; the lockfile change must not ride the upmerge. Normalizer + check land at +`release/25.2` so the upmerge carries them upward. + +**Out of scope**: `DOT_NPMRC` standardization / JFrog anonymous-access shutdown (PFM-ISSUE-34454); branches ≤ 24.2 +(latent — do not backport `use-npmrc` there before their lockfile is normalized); eliminating the runtime `npm ci` +(pre-bundling via esbuild/ncc — strategic follow-up). + +## Summary + +Every factual claim in the ticket that could be checked locally **was checked and holds**. Beyond confirming the +ticket, the research surfaced **four findings that materially change the implementation plan**: + +1. **The transformation is provably safe, and both plausible implementations are byte-safe.** A prototype run against + all three lockfile states rehosts exactly 171 entries, leaves the dependency graph identical, is idempotent, and + changes only `"resolved"` lines (342 changed diff lines = 171 × 2, zero non-`resolved` lines). Critically, + `JSON.parse` → `JSON.stringify(obj, null, 2) + '\n'` round-trips the real lockfiles **byte-identically** + (262 063 → 262 063 bytes on 25.2; 270 244 → 270 244 on master), so a parse/mutate/serialize normalizer is just as + safe as a textual regex — the formatting-drift risk usually associated with rewriting lockfiles does not exist here. +2. **There is no `pull_request`-triggered workflow in this repository at all.** All 13 `.github/workflows/fe-*.yml` + files are `on: workflow_call:` only; the `pull_request` trigger lives in `.github/workflow-templates/fe/fe-pr.yml`, + which GitHub *never executes* — it is template content offered to consumer repos. The repo's own `jest` tests and + `prettier --check` are likewise **not run by any workflow**. The acceptance criterion "the PR pipeline rejects any + `package-lock.json` containing `registry.npmjs.org`" therefore cannot be satisfied by editing an existing workflow; + it requires creating the repo's **first** `on: pull_request` workflow. +3. **The upmerge is a Slack notifier, not an upmerge.** `tools/scripts/upmerge/upmerge.ts:22` runs + `cplace-cli flow --upmerge --release 5.17 --no-push --show-files` — a dry run that only detects a pending upmerge + and posts to `#frontend-upmerge`. No merge is performed or pushed by CI, and no branch chain is encoded in this + repo. The "regular upmerge" that the ticket relies on to carry the normalizer upward is a **human-driven** process. +4. **`master` and `release/26.3` are NOT downstream of `release/26.2` in git ancestry**, so a commit made at + `release/25.2` will *not* reach them by upmerging along the release chain. `master` is 17 commits behind `26.2` and + received the Node 24 migration through a **separate** PR (`914a755 up master: PFM-TASK-7777 … (#132)`); + `release/26.3` was branched off `master` (`b5869c7`, parent `53ea313`), not off `26.2`. The chain is + `25.2 → 25.3 → 25.4 → 26.1 → 26.2` **plus two independent tips** (`master`, and `26.3` off `master`). + +A fifth, softer finding: five reusable workflows invoke a composite whose internal `npm ci` runs with **no `~/.npmrc` +at all**, which is why they do not currently fail — and which ties this ticket to the linked PFM-ISSUE-34454. + +## Detailed Findings + +### 1. Lockfile state — measured, all seven branches + +Measured with `jq` over `git show origin/:package-lock.json` (`lockfileVersion: 3` on every branch; no legacy +`dependencies` mirror section — top-level keys are exactly `lockfileVersion, name, packages, requires, version`): + +| Branch | lockfile sha256[:10] | non-root entries | `registry.npmjs.org` | `cplace.jfrog.io` | no `resolved` | other protocol | +| --- | --- | --- | --- | --- | --- | --- | +| `release/25.2` | `21aead4730` | 542 | **171** | 371 | 0 | 0 | +| `release/25.3` | `012c07592f` | 542 | **171** | 371 | 0 | 0 | +| `release/25.4` | `012c07592f` | 542 | **171** | 371 | 0 | 0 | +| `release/26.1` | `d06efe2cba` | 558 | **171** | 387 | 0 | 0 | +| `release/26.2` | `d06efe2cba` | 558 | **171** | 387 | 0 | 0 | +| `release/26.3` | `d06efe2cba` | 558 | **171** | 387 | 0 | 0 | +| `master` | `d06efe2cba` | 558 | **171** | 387 | 0 | 0 | + +Confirms the ticket exactly: + +- **Three distinct lockfile states**, grouped `25.2` | `25.3 = 25.4` | `26.1 = 26.2 = 26.3 = master`. +- `371 + 171 + 1 = 543` and `387 + 171 + 1 = 559` entries including root — **no `git+ssh:`, `file:` or `link: true` + entries need special handling** (`other protocol = 0`, `no resolved = 0` on every branch). +- The **171 npmjs entries are byte-identical across all three states**: extracting + `path \t version \t resolved \t integrity` for the npmjs subset and diffing gives `IDENTICAL` for 25.2 vs 25.4 and + 25.4 vs master, at exactly 171 lines. The inter-branch difference lies exclusively in the untouched JFrog entries + (16 extra packages in 26.x). +- All 171 are standard `//-/.tgz` tarball URLs (a regex filter for non-standard shapes returns nothing), + so a single prefix rewrite is sufficient — there is no scoped/aliased/edge-case URL form to special-case. +- `grep -c 'registry.npmjs.org' package-lock.json` returns **171** on each state, and total occurrences also equal 171 + — i.e. one occurrence per line, no second occurrence hiding elsewhere in the file. The acceptance criterion's `grep -c` + is therefore a faithful measure. + +The ticket's own sha values (`d6898012d0` / `18277f9f84` / `499e0da7b9`) differ from the sha256 prefixes above because +a different hashing method was used; **the grouping is what matters and it matches exactly**. + +### 2. The transformation and the invariant check — prototyped and proven + +A research spike (`proto-normalize.mjs`, scratchpad only — not a deliverable) applied +`"resolved": "https://registry.npmjs.org/` → `…"https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/` and then +compared `path → (version, integrity, tarball path, link)` before/after. Results on all three states: + +| state | entries rehosted | npmjs left | graph invariant | idempotent | byte delta | trailing newline | +| --- | --- | --- | --- | --- | --- | --- | +| 25.2 | 171 | 0 | **IDENTICAL** | yes | +4788 | preserved | +| 25.4 | 171 | 0 | **IDENTICAL** | yes | +4788 | preserved | +| master | 171 | 0 | **IDENTICAL** | yes | +4788 | preserved | + +`+4788 = 171 × 28`, where 28 is the length difference between the two registry prefixes — an exact arithmetic check +that nothing else changed. Structural equality of the whole document with all `resolved` values stripped is `true`. +A line-level diff confirms **342 changed lines, of which 0 are non-`resolved` lines**. + +**The invariant check has teeth.** Simulating the bad merge the ticket warns about — poisoning one entry's `version` +(`2.2.0` → `9.9.9`) and another's `integrity` (→ `sha512-POISONED==`), then normalizing and comparing against the +clean pre-fix baseline — the check **fails with exit 1** and names both drifts: + +``` +node_modules/@ampproject/remapping: version "2.2.0" -> "9.9.9" +node_modules/@ampproject/remapping/node_modules/@jridgewell/gen-mapping: integrity "sha512-sQXCas…" -> "sha512-POISONED==" +``` + +while the clean normalized file against the same baseline reports `PASS - graph identical` (exit 0). Note the check +must normalize the *tarball path* when comparing (strip the `/artifactory/api/npm/cplace-npm` prefix from +`URL.pathname`) so that a legitimately rehosted entry compares equal while a changed tarball filename does not. + +**Implementation freedom (new finding).** The agent research recommended a parse → mutate → `JSON.stringify(obj, null, 2)` +round-trip but flagged formatting drift as a risk. Measured: the round-trip is **byte-identical** on the real +lockfiles (`identical=true`, `delta=0` on both 25.2 and master). npm's lockfile writer and `JSON.stringify(…, null, 2) + '\n'` +agree exactly. So either implementation is safe, and the choice can be made on readability/testability grounds rather +than on byte-safety. + +### 3. Where the failure happens — the four composite actions + +All four affected composites use the identical idiom, with no `working-directory:` key — the `cd` inside `run:` does +the work, walking out of `.github/actions//` to the repo root: + +- [`.github/actions/artifacts/action.yml:8`](.github/actions/artifacts/action.yml) — `cd "$GITHUB_ACTION_PATH/../../.." && pwd && npm ci`, then `:10` `npx ts-node .../tools/scripts/artifacts/main.ts` +- [`.github/actions/snapshots/action.yml:8`](.github/actions/snapshots/action.yml) — same `npm ci`, then `:10` `.../tools/scripts/artifacts/main-snapshots.ts` +- [`.github/actions/upmerge/action.yml:8`](.github/actions/upmerge/action.yml) — same `npm ci`, then `:10` `npm install -g @cplace/cli`, then `:12` `.../tools/scripts/upmerge/main.ts` +- [`.github/actions/run-many/action.yml:22`](.github/actions/run-many/action.yml) — same `npm ci`, then `:24` `.../tools/scripts/run-many/run-many.ts` + +All four `npm ci` steps run **unconditionally** — unlike the top-level `npm ci` steps in the calling workflows, they +carry no `if: steps.npm-cache.outputs.cache-hit != 'true'` guard, so they execute on every run regardless of cache. + +The `npm ci` is needed only to make `npx ts-node` and the `tools/scripts/**` sources runnable — which is exactly why +the ticket lists pre-bundling as the strategic follow-up. + +### 4. `use-npmrc` — the whole file, and what it does not do + +[`.github/actions/use-npmrc/action.yml`](.github/actions/use-npmrc/action.yml), lines 10-14: + +```yaml +- name: Write ~/.npmrc + shell: bash + env: + DOT_NPMRC: ${{ inputs.dot-npmrc }} + run: echo "$DOT_NPMRC" > ~/.npmrc +``` + +- Writes **`~/.npmrc`** (runner home), not `$GITHUB_WORKSPACE/.npmrc` — this is precisely the regression the ticket + identifies, since `~/.npmrc` is active for *every* npm invocation on the runner, including the composites' own `npm ci`. +- Fed by input `dot-npmrc` ← `secrets.DOT_NPMRC` at all seven call sites (`fe-cleanup-snapshots.yml:39`, + `fe-install-deps.yml:38`, `fe-licenses.yml:41`, `fe-pr-close.yml:41`, `fe-pr-snapshot.yml:43`, `fe-release.yml:42`, + `fe-sonar.yml:61`). +- Sets **no** npm config of its own: no `npm config set`, no `registry=`, and **no `replace-registry-host`** anywhere + in the repo (verified by `git grep` on `origin/release/25.2` — the only non-lockfile hits for registry-ish terms are + `DOT_NPMRC` plumbing and `JFROG_URL` in `tools/scripts/artifacts/`). Registry and auth configuration is entirely + opaque, embedded in the secret's text. +- **No cleanup**: no `post:` step, nothing removes `~/.npmrc`. It persists for the rest of the job. +- No root `.npmrc` exists in the repo. + +Consequently, the fix cannot be achieved by tuning npm config in this repo — there is nothing to tune here. The +lockfile is the only lever inside `github-actions`, which supports the ticket's chosen approach. + +### 5. Why only some pipelines fail — and the link to PFM-ISSUE-34454 + +Ordering in the workflows that *do* call `use-npmrc`: it always precedes both the workflow's own `npm ci` and any +composite invoked later in the same job. So `fe-pr-snapshot`, `fe-release`, `fe-pr-close` (→ `artifacts`) and +`fe-cleanup-snapshots` (→ `snapshots`) run the composite's internal `npm ci` **with `~/.npmrc` active** → these are +the failing paths, matching the ticket's two reported failures (a tag pipeline and Cleanup Snapshots). + +But five workflows invoke a composite containing an internal `npm ci` with **no `use-npmrc` step anywhere in that +job**: `fe-check-upmerge.yml` (→ `upmerge`), `fe-snapshot.yml` (→ `artifacts`), and `fe-build.yml` / +`fe-code-quality.yml` / `fe-e2e.yml` (→ `run-many`). With no `~/.npmrc`, npm uses its default registry, the 171 npmjs +URLs resolve normally, and the ~69 % absolute JFrog URLs are fetched **anonymously** — which works only because JFrog +anonymous read is currently open (the ticket's own probe: 166 × 200, 5 × 302). + +Two consequences for the plan: + +- These five workflows are *not* currently broken, so they are poor canaries for this fix. The canary runs must use a + `use-npmrc` path (tag/release/cleanup-snapshots), as the ticket's testing section already specifies. +- After normalization these jobs resolve **100 %** of packages from JFrog anonymously instead of ~69 %. This does not + create a new dependency — the ~69 % already existed — but it does increase the blast radius of the anonymous-access + shutdown tracked in **PFM-ISSUE-34454**, which is the ticket this one blocks. Worth stating explicitly in the design + as an accepted, understood consequence, and it also *helps* the acceptance criterion "no consumer pipeline resolves + npm packages outside the `cplace-npm` proxy." + +### 6. The PR-guard acceptance criterion has no place to live yet + +Every one of the 13 files in `.github/workflows/` declares `on: workflow_call:` and nothing else — verified +individually: `fe-build.yml:3-11`, `fe-check-upmerge.yml:3-11`, `fe-cleanup-snapshots.yml:3-13`, +`fe-code-quality.yml:3-28`, `fe-e2e.yml:3-14`, `fe-install-deps.yml:3-11`, `fe-licenses.yml:3-14`, +`fe-pr-close.yml:3-17`, `fe-pr-snapshot.yml:3-17`, `fe-release.yml:3-13`, `fe-snapshot.yml:3-20`, +`fe-sonar.yml:4-28`, `fe-tag.yml:3-24`. **No `pull_request`, `push`, `schedule` or `workflow_dispatch` trigger exists +in `.github/workflows/`.** + +The real triggers live in `.github/workflow-templates/fe/` — e.g. `fe-pr.yml:2-5` (`on: pull_request: branches: ['**']`), +`fe-main.yml:2-6` (`on: push`), `fe-check-upmerge.yml:2-5` (`on: schedule: cron '0 18 * * *'`). Files under +`workflow-templates/` are **never executed by GitHub Actions**; they are template content surfaced in the "New +workflow" UI for consumer repos to copy. Editing a template affects neither this repo's CI nor any consumer that has +already copied it. + +Corroborating this: `git grep` for `npm run test`, `npm test`, `check-prettier` or `jest` under `.github/` on +`release/25.2` returns **nothing** — the repo's own jest suite (`package.json` `"test": "jest --config=./jest.config.js"`) +and `prettier --check .` are not run by any workflow. This repo has no self-CI. + +So the guard needs a **new** file directly under `.github/workflows/` with an explicit `on: pull_request` trigger — the +first of its kind here. Two distinct scopes should not be conflated in the design: + +- **Guarding this repo's own lockfile** (what the acceptance criterion asks for) → new `on: pull_request` workflow in + `.github/workflows/`. This is also the natural home for finally running `npm test` / `prettier --check`. +- **Guarding consumer repos' lockfiles** (not asked for by this ticket) → would mean a step inside a reusable workflow + such as `fe-install-deps.yml`, and/or a new template. Out of scope unless explicitly widened. + +### 7. The upmerge is a notifier — the "carry it upward" assumption needs care + +`.github/workflows/fe-check-upmerge.yml` (job steps 14-40): checkout `fetch-depth: 0` → `setup-node` (18.19.1) → +`actions/cache` for `node_modules` keyed on `hashFiles('**/package-lock.json')` (line 32 — the **only** +`package-lock.json` reference in the entire upmerge path, and purely a cache key) → the `upmerge` composite (34-39) +with `SLACK_TOKEN_UPMERGE`, `GIT_USER_EMAIL`, `GIT_USER`. + +`tools/scripts/upmerge/upmerge.ts`: + +- `checkUpmergeAndNotifiy()` (12-17): sets git identity from env, calls `isUpmergeNeeded()`, then `postToSlack()`. +- `isUpmergeNeeded()` (19-38) line 22: `execSync('cplace-cli flow --upmerge --release 5.17 --no-push --show-files')` + — **`--no-push`, i.e. a dry run**. It greps stdout for `"have been merged"`, derives the source release from the + preceding `Merging … release X into …` line, and returns + `Please upmerge from release ${releaseThatNeedsUpmerge} in repo ${repo}`. +- On a thrown `execSync` (merge conflict or CLI error) it catches and posts an error plus a link to the failed run as a + Slack thread reply (25-30). No file is inspected or resolved by this repo's code. +- `postToSlack()` (40-61) posts to `#frontend-upmerge` via `@slack/web-api`. + +So: **no branch chain is encoded in this repo**, no merge is ever pushed by CI, and there is **no per-file conflict +strategy and no `.gitattributes`** (confirmed absent, repo-wide) — hence no merge driver for `package-lock.json`. +The hard-coded `--release 5.17` looks stale and is worth a glance during implementation. The chain-like branch +sequence in `tools/scripts/upmerge/upmerge.test.ts:7-18` is mocked `cplace-cli` output, not configuration. + +The ticket's mechanical conflict rule (`git checkout --ours -- package-lock.json`, then re-run the idempotent +normalizer, never `--theirs`, never hand-edit) is therefore a **human runbook instruction**, not something CI can +enforce. Since the invariant check is proven to catch exactly the drift a bad merge introduces (§2), the design should +make that check runnable on demand so a human resolving a conflict can verify their resolution. + +### 8. Branch topology — the rollout assumption that does not hold + +Measured with `git merge-base --is-ancestor` on freshly fetched refs: + +| edge | state | +| --- | --- | +| `25.2` → `25.3` | NOT merged — 2 commits of 25.2 not in 25.3 | +| `25.3` → `25.4` | MERGED | +| `25.4` → `26.1` | MERGED | +| `26.1` → `26.2` | MERGED | +| `26.2` → `26.3` | **NOT merged — 17 commits of 26.2 not in 26.3** | +| `26.3` → `master` | NOT merged — 1 commit of 26.3 not in master | + +Containment in `master`: `25.2` +5, `25.3` +8, `25.4` +12, `26.1` +16, `26.2` +17 commits ahead — **none of the release +branches is contained in `master`**. + +The cause is visible in the history: `master` received the Node 24 migration via its **own** PR, +`914a755 up master: PFM-TASK-7777 - github-actions: Upgrade GitHub Actions to Support Node.js 24 (#132)`, separate +from the `#130` PR that went into the release chain. And `release/26.3` (`b5869c7 Branch off: Release 26.3`) has parent +`53ea313` = `master`'s tip, with `merge-base(26.2, 26.3) = e164c022 "Branch off: Release 26.2"`. `master` **is** an +ancestor of `26.3`. + +So the effective topology is: + +``` +25.2 → 25.3 → 25.4 → 26.1 → 26.2 (linear upmerge chain) +master ─────────────────────→ 26.3 (26.3 branched off master; master fed by separate PRs) +``` + +Despite the divergent history, all of `26.1`, `26.2`, `26.3`, `master` carry the **same lockfile** (`d06efe2cba`) and +all seven branches have `.github/actions/use-npmrc/action.yml` — so the affected-branch matrix in the ticket is +correct even though the ancestry is not linear. + +**Implication for the plan**: the ticket states "add the normalizer plus the check at `release/25.2` so the upmerge +carries them upward to every branch." That holds for `25.3 → 25.4 → 26.1 → 26.2`. It does **not** hold for `master` or +`release/26.3`, which are not downstream of `26.2`. Those two need the normalizer and check delivered by their own PRs +— which the per-branch PR plan already provides, but the design must say so explicitly rather than relying on the +upmerge. This also means "once all seven branches are normalized the 171 lines are identical across branches again" +is true, while "the next scheduled upmerge sees no one-sided diff" only describes the 25.2→26.2 chain. + +### 9. Repo conventions for the new scripts + +- **Layout**: `tools/scripts//` with a thin `main.ts` entrypoint, a logic module, a co-located + `.test.ts`, and optionally a shared `test-data.ts`. Existing features: `artifacts/`, `run-many/`, `upmerge/`. + All TypeScript; no `.js` under `tools/`. +- **Invocation**: never via an npm script — always `npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts//main.ts"` + from a composite action. `package.json` exposes only `test`, `check-prettier`, `write-prettier`. +- **Module system**: CommonJS (no `"type"` field; `tsconfig.json` `module: commonjs`, `target: es5`, `types: [node, jest]`, + **no `strict`**). Node `18.19.1` per `.nvmrc` and `.tool-versions` — note this is the *repo tooling* version and is + unrelated to the Node 24 *runner* migration. +- **Tests**: `jest.config.js` uses `ts-jest`; files are co-located and named `*.test.ts` (never `.spec.ts`). + `babel.config.js` exists but is vestigial — the jest transform is `ts-jest`. Existing file tests **mock `fs` with + `jest.spyOn(fs, 'readFileSync' | 'writeFileSync' | 'existsSync')`** and feed inline JSON *string* fixtures from + `test-data.ts`; no tmp dirs, no real disk I/O anywhere in `tools/`. +- **Model to copy for JSON read/mutate/write**: `tools/scripts/artifacts/nx-project.ts:235-271` + (`setVersionOrGeneratePackageJsonInDist`) reads a `package.json`, mutates fields, and writes it back via + `getPrettyPackageJson()` = `JSON.stringify(this.packageJsonContent, null, 2)`. For a fail-loudly validator, follow + `Utils.parseScopeFromPackageJson` (`tools/scripts/artifacts/utils.ts:309-328`), which `process.exit(1)`s on invalid + state. +- **Formatting**: `.prettierrc.json` is `{ "singleQuote": true }`; `.prettierignore` contains only `node_modules` and + `.idea` — it does **not** exclude `package-lock.json`, and `check-prettier` runs `prettier --check .` over + everything. No ESLint config exists anywhere. Since prettier is not run in CI (§6), this is latent rather than + active, but adding `package-lock.json` to `.prettierignore` is cheap insurance — especially if the new + `on: pull_request` workflow starts running `check-prettier`. +- **Two distinct JFrog paths are in play** and must not be confused: the install **proxy** + `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/` (what this fix writes into `resolved`) versus the publish + **target** `https://cplace.jfrog.io/artifactory/cplace-npm-local` (the `JFROG_URL` default in + `tools/scripts/artifacts/configuration.ts:2`, also asserted in `nx-project.test.ts:31,34`). + +### 10. Reintroduction risk + +`.github/dependabot.yml` on `release/25.2` declares **only** `package-ecosystem: "github-actions"` — six entries: the +default branch plus `target-branch:` for `release/25.2`, `25.3`, `25.4`, `26.1`, `26.2` (comment: "Active release +branches (release/25.2 → release/26.2)"). **Zero `npm` ecosystem entries**, confirming the ticket's statement that +npm Dependabot is not enabled and the realistic reintroduction path is a developer running `npm install` with an +npmjs-default `.npmrc`. Note `release/26.3` is not covered by any dependabot `target-branch` either — a small +pre-existing gap, out of scope here but worth mentioning to whoever owns that file. + +## Code References + +- `.github/actions/artifacts/action.yml:8` — `cd "$GITHUB_ACTION_PATH/../../.." && pwd && npm ci` (unconditional) +- `.github/actions/artifacts/action.yml:10` — `npx ts-node .../tools/scripts/artifacts/main.ts` +- `.github/actions/snapshots/action.yml:8,10` — same `npm ci`, then `main-snapshots.ts` +- `.github/actions/upmerge/action.yml:8,10,12` — `npm ci`, `npm install -g @cplace/cli`, `upmerge/main.ts` +- `.github/actions/run-many/action.yml:22,24` — `npm ci`, then `run-many.ts` with argv +- `.github/actions/use-npmrc/action.yml:10-14` — `echo "$DOT_NPMRC" > ~/.npmrc`; the regression's mechanism +- `.github/workflows/fe-install-deps.yml:38`, `fe-cleanup-snapshots.yml:39`, `fe-licenses.yml:41`, `fe-pr-close.yml:41`, `fe-pr-snapshot.yml:43`, `fe-release.yml:42`, `fe-sonar.yml:61` — the seven `use-npmrc` call sites +- `.github/workflows/fe-check-upmerge.yml:32` — `hashFiles('**/package-lock.json')`, the only lockfile reference in the upmerge path +- `.github/workflows/fe-check-upmerge.yml:34-39` — invokes the `upmerge` composite +- `.github/workflow-templates/fe/fe-pr.yml:2-5` — the only `pull_request` trigger in the repo, in a **non-executing** template +- `tools/scripts/upmerge/upmerge.ts:19-38` — `isUpmergeNeeded()`; line 22 is the `--no-push` dry run with hard-coded `--release 5.17` +- `tools/scripts/upmerge/upmerge.ts:40-61` — `postToSlack()` → `#frontend-upmerge` +- `tools/scripts/upmerge/upmerge.test.ts:7-18` — mocked chain output (not configuration) +- `tools/scripts/artifacts/nx-project.ts:235-271` — the JSON read/mutate/write pattern to model the normalizer on +- `tools/scripts/artifacts/nx-project.ts:42-61` — paired `existsSync` + `readFileSync` + `JSON.parse` idiom +- `tools/scripts/artifacts/utils.ts:309-328` — fail-loudly validator pattern (`process.exit(1)`) +- `tools/scripts/artifacts/nx-project.test.ts:1-36` — `jest.spyOn(fs, …)` + inline fixture test pattern +- `tools/scripts/artifacts/configuration.ts:2` — `JFROG_URL` default `…/artifactory/cplace-npm-local` (publish target, *not* the proxy) +- `tools/scripts/artifacts/jfrog-credentials.ts:6-13` — `JFROG_URL`/`JFROG_USER`/`JFROG_BASE64_TOKEN` from `process.env`, not `.npmrc` +- `.github/dependabot.yml:4,18-81` — `github-actions` ecosystem only; release branches 25.2 → 26.2 +- `package.json` — `test`, `check-prettier`, `write-prettier`; CommonJS; no `engines` +- `jest.config.js`, `tsconfig.json`, `.prettierrc.json`, `.prettierignore` — tooling baseline (§9) +- `package-lock.json` — the artifact under change; 171 `resolved` prefixes per branch + +## Architecture Insights + +- **This repo is a library of reusable workflows and composite actions, with no CI of its own.** Everything under + `.github/workflows/` is `workflow_call`-only; triggers live in consumer repos (seeded from + `.github/workflow-templates/fe/`). Any self-validation — this ticket's PR guard, and arguably the existing jest and + prettier scripts — is greenfield here. +- **Branch-pinned self-reference.** Composites and workflows reference each other by branch + (`collaborationFactory/github-actions/.github/actions/upmerge@release/25.2`), which is why release branches are + long-lived and why a fix must be applied per branch rather than once at a tip. The Node 24 design doc already + recorded the need to update these internal refs after each upmerge. +- **The runtime `npm ci` inside a composite action is the structural fault.** Because the action installs its own + dependencies on the consumer's runner, it inherits the consumer's npm environment (`~/.npmrc`) — coupling this + repo's lockfile to every consumer's registry configuration. The lockfile normalization removes the symptom by + making all URLs absolute-and-correct; the architectural fix (pre-bundling `tools/scripts/*`, or shipping them as a + published package / container action) is the tracked follow-up. +- **Secret masking as a diagnosability hazard.** The 404 URL's prefix is `JFROG_URL`, so logs show `***` and the error + is far harder to read than it should be. Any error surfaced by the new check or guard should print the *offending + lockfile path and package name* rather than a reconstructed URL, so the message survives masking. +- **Invariants over review discipline.** The ticket's framing — guarantee correctness with a machine-checked + invariant rather than careful reviewing — is well matched to a lockfile, where the risk is a silent version or + integrity change buried in a 262 KB diff. The prototype confirms the invariant is both satisfiable and violation-detecting. + +## Historical Context (from specs/) + +`specs/` contains exactly one topic, identical on `release/25.2` and `release/26.2`: +`specs/2026-06-05_node24-workflow-migration/` — `research.md`, `design.md`, `plan.md`, `sha-pins.md`, `validation-log.md` +(topic: "Node 24 migration of all 13 reusable FE workflows (PFM-TASK-7777)", dated 2026-06-05). + +- `specs/2026-06-05_node24-workflow-migration/design.md` — the migration that **caused** this bug: replaces four + unmaintained third-party actions, including `bduff9/use-npmrc` → the in-repo `.github/actions/use-npmrc` composite. + Documents the upmerge chain as `25.3 → 25.4 → 26.1 → 26.2 → master` and the need to update internal composite refs + after each upmerge. Note this recorded chain **includes `master` as downstream of `26.2` and omits `26.3`**, which + §8 shows does not match the actual git ancestry — `master` was fed by a separate PR (`#132`) and `26.3` branched off + `master`. `design.md:139` explicitly reasoned that consumer impact was unchanged because "`DOT_NPMRC` flows into the + new composite exactly as it did into `bduff9/use-npmrc`" — true of the *input*, but not of the *write target* + (`~/.npmrc` vs `/.npmrc`), which is the gap this ticket closes. +- `specs/2026-06-05_node24-workflow-migration/plan.md:220-221` — the verbatim `echo "$DOT_NPMRC" > ~/.npmrc` step as + planned. Scoped the five upmerge PRs (25.3 → master) as follow-on work in "Migration Notes", not executed in that plan. +- `specs/2026-06-05_node24-workflow-migration/research.md:92,198-202` — every call site passes exactly one input + (`dot-npmrc`); `fe-snapshot.yml` does **not** use it (consistent with §5, where `fe-snapshot` is one of the five + workflows running `npm ci` without `~/.npmrc`). +- `specs/2026-06-05_node24-workflow-migration/validation-log.md:78-94` — validation was thorough on the Node-24 runtime + question (actionlint + 7/13 live runs) but scoped to GHA-runtime deprecations, so an npm *resolution* regression was + outside what it looked for. Its one "side finding" (line 78) concerns `kentaro-m/auto-assign-action` in cplace-fe and + is **unrelated** to this bug — checked explicitly. Verdict line 94 confirms that migration's PR went + `feature → release/25.2`, the same entry point this ticket prescribes. + +**No prior spec covers lockfile / registry-URL normalization** — a targeted grep for `package-lock`, `registry.npmjs`, +`resolved.*npmjs`, `npm ci` inside the Node 24 topic returns zero matches. This research document is the first on the topic. + +## Related Research + +- `specs/2026-06-05_node24-workflow-migration/` (research / design / plan / sha-pins / validation-log) — the direct + upstream cause; see Historical Context. +- PFM-ISSUE-34454 (linked, blocked by this ticket) — `DOT_NPMRC` standardization across FE consumer repos and the + JFrog anonymous-access shutdown. §5 shows this fix increases those five workflows' reliance on anonymous JFrog reads + from ~69 % to 100 % of packages, so the two tickets should be sequenced with that in mind. +- PFM-ISSUE-33179 (`83ea625`) and PFM-TASK-7528 (`916fe05`) — recent workflow fixes in the same release chain; no + spec documents exist for either. + +## Open Questions + +1. **Where does the new `on: pull_request` workflow belong, and how much should it do?** The guard needs the repo's + first self-CI workflow (§6). Decide whether it only greps the lockfile, or also finally runs `npm test` and + `check-prettier` — and whether it must be added to all seven branches (a `pull_request` workflow only runs from the + version on the PR's *base* branch, so per-branch delivery is required for the guard to protect every branch). +2. **Should the guard also protect consumer repos?** Adding the check to a reusable workflow such as + `fe-install-deps.yml` would catch npmjs URLs in *consumers'* lockfiles. Not requested by this ticket; needs an + explicit decision, since it could break consumer pipelines that are currently green. +3. **`master` and `release/26.3` delivery.** Confirmed not downstream of `26.2` (§8), so they need their own + normalizer + check commits rather than inheriting them via upmerge. Confirm the intended PR order for these two + relative to the linear chain — and whether `26.3` should be treated as branching from `master` (matching git) or + from `26.2` (matching the ticket's table). +4. **Does `prettier --check .` currently pass on `package-lock.json`?** Unverified — `node_modules` is absent from the + working tree, so prettier could not be run. Low risk today because prettier is not in CI (§6), but it becomes + relevant the moment the new PR workflow runs `check-prettier`. Cheap mitigation: add `package-lock.json` to + `.prettierignore`. +5. **Is the hard-coded `--release 5.17` in `tools/scripts/upmerge/upmerge.ts:22` still correct?** It looks stale next + to a 25.x/26.x branch set. Out of scope for this ticket, but it is in the file family being touched and affects + whether the upmerge notification the ticket relies on actually fires. +6. **Proxy availability was not re-probed here.** The ticket's probe (166 × 200, 5 × 302 for the 171 tarballs through + `cplace-npm`) is taken as given; no network requests were made during this research. Worth one re-probe at + implementation time, since a curated/Xray policy change would invalidate the whole approach. +7. **Post-fix canary selection.** Per §5 the five `npm ci`-without-`~/.npmrc` workflows cannot demonstrate the fix. + Confirm the three canary runs (one per lockfile state: 25.2 | 25.4 | 26.2) all use a `use-npmrc` path such as + tag/release/cleanup-snapshots. +8. **Assignee is unset on the ticket.** Recorded above as Christian Kaltenbach (creator), marked inferred — worth + filling in on the cplace page. diff --git a/specs/2026-08-10_normalize-package-lock-resolved-urls/review.md b/specs/2026-08-10_normalize-package-lock-resolved-urls/review.md new file mode 100644 index 0000000..cf20acc --- /dev/null +++ b/specs/2026-08-10_normalize-package-lock-resolved-urls/review.md @@ -0,0 +1,216 @@ +# Code Review: PFM-ISSUE-34453 — Normalize `package-lock.json` resolved URLs onto the JFrog npm proxy + +## Iteration 1 — 2026-08-12 + +*Reviewed `origin/release/25.2...HEAD` (18 files, +4644/−173) against `plan.md`. Base is `release/25.2`, the PR's +actual base — the default `master` would have carried 23 unrelated files, since `master` is not downstream of the +release chain (research §8). Agents: plan-conformance, correctness, craft, test, security. `best-practices` did not +run (no `CLAUDE.md`, `.claude/rules/` or `best-practices/MAP.md` in the repo). 15 findings kept, 0 dropped in +verification, 0 merged. Four were fixed during the review and are marked resolved. The security finding arrived +`inferred` and was **upgraded** rather than downgraded, because it reproduced.* + +### [1.1] 🚨 The advisory scan reproduces the `startswith($proxy)` bug already fixed elsewhere in this PR + +- [x] Resolved — `4a160a7` + +**File(s):** +- [warn-foreign-registry.sh:49](/tools/scripts/lockfile/warn-foreign-registry.sh) +- [lib.sh:44](/tools/scripts/lockfile/lib.sh#L44) + +`warn-foreign-registry.sh` carried its own `startswith($proxy)` predicate — the exact bug `lib.sh:44-49` documents as +already found and fixed once in `count_foreign_entries`. An entry on the correct host with a stray path segment +(`.../cplace-npm/extra/beta/-/beta-2.0.0.tgz`) satisfies `startswith`, so the advisory stayed **silent** while +`check-lockfile.sh --prefix-only` correctly failed on the same input. This mattered more here than anywhere else: +these warnings are the inventory that decides when the `replace-registry-host=never` mitigation may be removed, so the +false negative could green-light removing it while lockfiles are still broken. Now sources `lib.sh` and uses the shared +`JQ_REGISTRY_ENTRIES` predicate, with three tests including one asserting the advisory and the guard agree. + +### [1.2] 🚨 Fingerprint temp files leak on the fail-closed paths in `assert_graph_invariant` + +- [x] Resolved — moved both `mktemp` calls into `main()` beside `baseline_file` and widened the existing EXIT trap to cover all three, so the `|| die` paths inside `assert_graph_invariant` no longer leak; the function now takes the two paths as arguments + +**File(s):** +- [check-lockfile.sh:82](/tools/scripts/lockfile/check-lockfile.sh#L82) + +`fp_base`/`fp_cand` are created with `mktemp` and removed only by the explicit `rm -f` at the end of the function, but +`die()` calls `exit 1`, so all three `|| die` guards between them bypass that cleanup — including the `fingerprint_of` +failure path the function's own comment calls out as the one that "must fail CLOSED", which a bats test exercises on +every run. `main()` registers an `EXIT` trap for `baseline_file` only, while `normalize-lockfile.sh:64` does trap its +equivalent `fp_before`/`fp_after`, so this is also an inconsistency with the pattern used elsewhere in the same change. + +### [1.3] 🙏 An uppercase URL scheme bypasses the PR guard entirely + +- [x] Resolved — made the scheme case-insensitive in both spellings (`test("^https?://"; "i")` in `JQ_REGISTRY_ENTRIES`, `^(?i:https?)://` in `RESOLVED_URL_RE`), keeping `\.tgz` case-sensitive; two bats cases now assert the guard rejects a `HTTPS://` entry and the normalizer rewrites it onto the proxy + +**File(s):** +- [lib.sh:59](/tools/scripts/lockfile/lib.sh#L59) +- [check-lockfile.sh:131](/tools/scripts/lockfile/check-lockfile.sh#L131) +- [pr-checks.yml:40](/.github/workflows/pr-checks.yml#L40) + +`JQ_REGISTRY_ENTRIES` selects entries with `test("^https?://")`, and jq's `test()` is case-sensitive without an `"i"` +flag, while npm treats the URI scheme case-insensitively. Reproduced — a lockfile whose only entry resolves to +`HTTPS://attacker.example.com/evil/-/evil-1.0.0.tgz` yields `OK: … resolves entirely via the cplace npm proxy`, +exit 0. `--prefix-only` is the sole automated guard on every pull request and deliberately skips `assert_resolvable`, +so nothing else in that path validates the scheme either; under the new `replace-registry-host=never` mitigation npm +would fetch that URL verbatim. The repository is public and the workflow runs on `pull_request` for any branch. + +### [1.4] 🙏 The guard's invocation changed from `--baseline base.sha` to `--prefix-only`, unrecorded in the plan + +- [x] Resolved — `plan.md`'s Phase 4 block now shows the shipped `--prefix-only` workflow with a note naming `604f95e` and its four consequences; Key Discovery 6, `plan.md`'s fetch-depth reference and `design.md:258` are struck through and annotated as superseded; Phase 5's CI evidence is kept verbatim but labelled as predating the switch + +**File(s):** +- [pr-checks.yml:40](/.github/workflows/pr-checks.yml#L40) +- [pr-checks.yml:24](/.github/workflows/pr-checks.yml#L24) + +`plan.md:846` still specifies `--baseline "${{ github.event.pull_request.base.sha }}"` with `fetch-depth: 0` at +`plan.md:840`, and Key Discovery 6 makes `base.sha` a load-bearing decision — yet Phase 4 is checked off throughout. +Phase 5's recorded CI evidence quotes `baseline: 967168…` and `PASS: dependency graph identical to baseline`, output +the current workflow cannot produce. `design.md:528` was updated to `--prefix-only`, but `design.md:259` still requires +`fetch-depth: 0` and `plan.md` was not updated at all. This should be reconciled **before** the tooling is replicated +to six more branches. + +### [1.5] 🙏 The `:2:`/`:3:` merge-stage baseline the runbook depends on has no test + +- [x] Resolved — added a bats case that drives a real merge conflict on `package-lock.json`, resolves it the way Flow 2 documents (`--ours`, re-normalize) and asserts `--baseline :2:` resolves to `:2:package-lock.json` and passes graph invariance + +**File(s):** +- [check-lockfile.bats](/tools/scripts/lockfile/check-lockfile.bats) + +`plan.md:1524` commits to bats coverage of "baseline resolution from a git ref, from `:2:`-style merge stages, and from +two explicit file paths". The suite has the git-ref and two-file cases; a grep for `:2:`/`:3:` returns **0**. The +untested branch is `resolve_baseline`'s `spec="${spec}${lockfile_path}"` arm — the specific code path the documented +Flow 2 conflict-resolution runbook depends on, and the case that runs when someone is mid-upmerge and least able to +debug it. + +### [1.6] 🙏 The advisory script re-declared the proxy constant instead of sourcing `lib.sh` + +- [x] Resolved — `4a160a7` + +**File(s):** +- [warn-foreign-registry.sh:28](/tools/scripts/lockfile/warn-foreign-registry.sh) +- [lib.sh:20](/tools/scripts/lockfile/lib.sh#L20) + +`warn-foreign-registry.sh` did not source `lib.sh` — unlike both sibling scripts — and re-declared the identical proxy +URL as `PROXY_PREFIX`, directly against `lib.sh`'s own comment that the constant is defined once so the tools "can +never drift apart". It is invoked from the same checked-out tree, so sourcing was available. Fixed together with +[1.1]; sourcing is guarded on both sides so an unreachable `lib.sh` still exits 0 silently, preserving the advisory +contract. + +### [1.7] 🙏 Both spec documents forbade the `use-npmrc` change that Phase 4b made + +- [x] Resolved — `2be5ef6` + +**File(s):** +- [plan.md:116](/specs/2026-08-10_normalize-package-lock-resolved-urls/plan.md#L116) +- [design.md:540](/specs/2026-08-10_normalize-package-lock-resolved-urls/design.md#L540) + +`plan.md`'s *What We're NOT Doing* listed "Modifying `use-npmrc`" and `design.md`'s Integration Points stated +"`use-npmrc/action.yml` is **not modified** — that is PFM-ISSUE-34454's territory", while Phase 4b / Dimension 9 in +the same two documents modify exactly that. A reader hitting the out-of-scope list first would conclude the change was +unauthorized. Both are now struck through and annotated with what superseded them and why; the still-true half — the +four composite actions really are unmodified — is preserved rather than deleted. + +### [1.8] 🙏 The plan's record of Phases 1–4 verification was lost + +- [x] Resolved — `2be5ef6` + +**File(s):** +- [plan.md](/specs/2026-08-10_normalize-package-lock-resolved-urls/plan.md) + +Phases 1–3 carried **zero** ticked success criteria and Phase 4 carried one, while Phases 5, 6, 4b and 6b were fully +ticked — despite all of them having been executed and checkpointed. `git log -S` on Phase 1's probe criterion returns +no commit, confirming the marks were never persisted rather than later removed; the most plausible cause is the +mid-session reformat that rewrote `plan.md`. Since the plan is the audit trail for what was actually verified, this +made four completed phases indistinguishable from skipped ones. Restored with the evidence each was verified against; +the four criteria that are reviewer judgement rather than machine-checkable are now labelled as such instead of ticked. + +### [1.9] 💡 The new composite step spells `$GITHUB_ACTION_PATH` differently from all four siblings + +- [x] Resolved — the step now uses the bash variable `$GITHUB_ACTION_PATH`, matching all four siblings; it is also the form quoted at runtime rather than interpolated into the command line, which is what the step's own comment worries about + +**File(s):** +- [use-npmrc/action.yml:41](/.github/actions/use-npmrc/action.yml#L41) + +`artifacts`, `snapshots`, `upmerge` and `run-many` all build the same "escape the action directory to repo root" path +with the bash variable `$GITHUB_ACTION_PATH`. The new step uses the Actions expression `${{ github.action_path }}` +instead, introducing a second spelling of the identical value in the same directory for no stated reason. + +### [1.10] 💡 The README's "The two scripts" section contradicts itself + +- [x] Resolved — section retitled "The scripts", "Both default" corrected to "All three default", and the invocation claim split: the normalizer and the check have no npm script and no wrapper, while the advisory is named as wrapped and run by `use-npmrc` + +**File(s):** +- [README.md:31](/tools/scripts/lockfile/README.md#L31) +- [README.md:40](/tools/scripts/lockfile/README.md#L40) + +The heading says "two scripts" over a table that now lists four rows, and the sentence below states "there is no npm +script and no composite action wrapper" — one line under a row whose own text reads "Run by `use-npmrc`", which is +exactly a composite action wrapper. A reader consulting the README to learn how these scripts are invoked is told two +incompatible things in the same paragraph. + +### [1.11] 💡 The `missing lockfileVersion` arm is untested + +- [x] Resolved — both suites now feed a lockfile omitting the key and assert exit 1 plus the arm's own "is it a package-lock.json?" message, which distinguishes it from the unsupported-version sibling + +**File(s):** +- [lib.sh:111](/tools/scripts/lockfile/lib.sh#L111) + +`assert_supported_lockfile` has a three-way `case`; both suites exercise only `{"lockfileVersion":1}`, the catch-all +arm. No test constructs a lockfile omitting the key entirely, which is what the `missing` arm and its distinct message +("is it a package-lock.json?") exist for. Low risk — a `die` either way — but it is added branching with no assertion +distinguishing it from its sibling. + +### [1.12] ❓ `--prefix-only` skips `assert_resolvable`, so a `link:`/`git+ssh:` entry now passes CI silently + +- [x] Resolved — the loosening was sanctioned in `604f95e`, which moved `assert_resolvable` behind the baseline path so the guard would stop hard-failing on the npm-workspace and `link:` entries `--prefix-only` exists to allow; `design.md`'s Dimension 2 implication is now struck through and annotated to say the hard failure holds on the baseline path and in the normalizer but not in CI, and the README's "no usable tarball URL" section carries the same caveat + +**File(s):** +- [check-lockfile.sh:212](/tools/scripts/lockfile/check-lockfile.sh#L212) +- [lib.sh:59](/tools/scripts/lockfile/lib.sh#L59) + +`design.md:261` states that a future `link:`/`file:`/`git+ssh:` dependency "requires a deliberate, visible loosening +rather than a silent pass". In `--prefix-only` mode `assert_resolvable` is deliberately not run and +`JQ_REGISTRY_ENTRIES` skips non-http values, so precisely that class now passes the only assertion CI makes. This was +a deliberate change — made so the guard would stop blocking npm workspaces — and it is covered by a test and code +comments, but neither `design.md` nor `plan.md` records it. Was the loosening sanctioned, or should Dimension 2's +implication be amended to match? + +### [1.13] ℹ️ A third jq-embedding style now sits alongside the established one + +- [x] Resolved — both programs in `assert_prefix_exactness` are now single-quoted fragments concatenated around `JQ_REGISTRY_ENTRIES`, so no jq `$var` sits inside a double-quoted bash string and the `\$tarball_re` / `\"\"` escaping is gone; the `--arg proxy` the `distinct` program never referenced was dropped at the same time + +**File(s):** +- [check-lockfile.sh:131](/tools/scripts/lockfile/check-lockfile.sh#L131) +- [lib.sh:54](/tools/scripts/lockfile/lib.sh#L54) + +To share the `JQ_REGISTRY_ENTRIES` predicate, `assert_prefix_exactness` and `count_foreign_entries` build their jq +program as a double-quoted bash string, which forces every jq `$var` in the spliced text to be backslash-escaped +(`\$tarball_re`, `\"\"`). Every other jq call in these files — and `fingerprint.jq` via `-f` — uses a single-quoted +literal with `--arg`. The sharing is what fixed [1.1]'s bug class, so the trade may well be worth it; noting only that +the more fragile style now lives next to the plainer one. + +### [1.14] ℹ️ The tarball regex was tightened to require `.tgz`, diverging from Key Discovery 4 + +- [x] Resolved — `plan.md`'s Phase 1 constant and Key Discovery 4 are struck through and annotated with the shipped form `'(?(?:@[^/]+/)?[^/]+/-/.+\.tgz)$'` and both changes that produced it: `\.tgz` required in review, then the tail widened on 2026-08-13 after the canary showed JFrog serves tarball paths containing slashes. Key Discovery 4's premise is corrected rather than deleted — the 542-entry measurement was right, but it was not a sample of what the tooling meets on consumer lockfiles + +**File(s):** +- [lib.sh:35](/tools/scripts/lockfile/lib.sh#L35) + +`plan.md:249` specifies `'(?(?:@[^/]+/)?[^/]+/-/[^/]+)$'` and Key Discovery 4 states that regex is the one measured +against all 542 entries. The shipped `TARBALL_PATH_RE` and `RESOLVED_URL_RE` additionally require `\.tgz`, turning a +non-`.tgz` tarball URL into a hard `assert_resolvable` failure. The change was made in response to review, is +behaviour-neutral on real data (0 of 542 entries fail it, fingerprint unchanged) and is covered by a test — but +`plan.md` mentions `.tgz` nowhere, so the phase spec and the code disagree. + +### [1.15] ℹ️ The mitigation widens the trust boundary for every consumer until 34454 lands + +- [x] Resolved — no change: the widened boundary is accepted and documented in `design.md:484-486`, the README and the action's own comment, its removal is owned by PFM-ISSUE-34454, and the advisory warnings are the criteria that trigger it + +**File(s):** +- [use-npmrc/action.yml:25](/.github/actions/use-npmrc/action.yml#L25) + +`replace-registry-host=never` means any `resolved` entry still pointing at `registry.npmjs.org` — in this repo or any +consumer's — is fetched directly from public npm rather than through the proxy, bypassing whatever Xray/curation +policy the proxy enforces. This is consumed by seven reusable workflows across ~41 downstream repositories. Recorded +as information rather than an issue because it is explicitly named and accepted in `design.md:485-486` and paired with +the advisory inventory that governs its removal — but it is the single largest change in blast radius in this PR. diff --git a/tools/scripts/lockfile/README.md b/tools/scripts/lockfile/README.md new file mode 100644 index 0000000..02661f1 --- /dev/null +++ b/tools/scripts/lockfile/README.md @@ -0,0 +1,206 @@ +# `package-lock.json` registry normalization + +Every `resolved` URL in this repository's `package-lock.json` must point at the cplace JFrog npm **proxy**. This +directory contains the tooling that enforces that. + +## Why this exists + +The composite actions in `.github/actions/` run `npm ci` **inside the action directory, on the consumer's runner**, +while that consumer's `~/.npmrc` is active. When a lockfile entry resolves via `https://registry.npmjs.org/`, npm +rewrites the host onto the configured registry and **discards the registry's path prefix** +(`pacote/lib/remote.js`: `new URL(resolvedURL.pathname, this.registry)`), producing a 404: + +| | | +| --- | --- | +| lockfile | `https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz` | +| requested | `https://cplace.jfrog.io/update-browserslist-db/-/…` → **404** | +| correct | `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/update-browserslist-db/-/…` → 200 | + +The failure is hard to read because the broken URL's prefix is the `JFROG_URL` secret, so CI masks it as `***`. That +is why **every message these scripts print names a package path, never a URL.** + +## Why bash and jq, in a repository that is otherwise TypeScript + +Bootstrap independence. `npx ts-node` needs `node_modules`, which needs `npm ci` — which is exactly what is broken when +the lockfile carries npmjs URLs and your `~/.npmrc` points at JFrog. A TypeScript normalizer could not repair the +lockfile it exists to repair. These scripts need only `bash`, `jq` and `git`, all of which work on a fresh clone with +nothing installed. + +**Prerequisite:** `jq`. macOS: `brew install jq`. It is pre-installed on GitHub-hosted ubuntu runners. + +## The scripts + +| script | what it does | +| --- | --- | +| `normalize-lockfile.sh []` | Rewrites every `resolved` prefix onto the proxy. Idempotent. Changes nothing else. | +| `check-lockfile.sh [--baseline ] []` | Proves a lockfile differs from its baseline **only** in registry prefixes. Exit 0 or 1. | +| `check-lockfile.sh --prefix-only []` | Asserts only that every entry resolves via the proxy. **This is the PR guard.** | +| `warn-foreign-registry.sh []` | Advisory. Warns when a lockfile has entries outside the proxy. Never fails. Run by `use-npmrc`. | + +All three default to `./package-lock.json`. Call the normalizer and the check by path: they have no npm script and no +composite action wrapper, so that they keep working when `npm ci` does not. The advisory is the exception — it is +wrapped, run by `use-npmrc` on every consumer's runner, and can also be called by hand the same way. + +--- + +## The interim mitigation in `use-npmrc` + +`.github/actions/use-npmrc` appends one line to the `~/.npmrc` it writes: + +``` +replace-registry-host=never +``` + +This tells npm to fetch each `resolved` URL **verbatim** rather than rewriting its host onto the configured registry — +which is exactly the bug. It makes an un-normalized lockfile install successfully, so it protects **consumer** +repositories too, not just this one. JFrog URLs stay authenticated by the secret, so it introduces no dependency on +anonymous JFrog access. + +**It is a mitigation, not the fix.** Under it, any entry still pointing at `registry.npmjs.org` is fetched *directly +from npmjs*, bypassing the proxy — no Xray, no curation. That is precisely what this ticket exists to eliminate. + +Because of that, `use-npmrc` also runs `warn-foreign-registry.sh` against the consumer's own `package-lock.json` and +emits a `::warning` annotation plus a job summary listing the offending package paths. **Those warnings are the +inventory of lockfiles still to normalize.** When no pipeline reports one any more, the `replace-registry-host=never` +line can be deleted. + +The two mechanisms compose safely and in either order: on a normalized lockfile the flag is a no-op, because there are +no foreign URLs left to rewrite. So the mitigation can be removed lazily, per branch, rather than in a coordinated +switchover. + +The check is **advisory and must stay that way** — it runs in every consumer's pipeline, so a missing lockfile, +missing `jq`, or malformed JSON all exit 0 silently. It never becomes a new way for someone else's build to fail. + +--- + +## Flow 1 — normalizing a branch + +```bash +./tools/scripts/lockfile/normalize-lockfile.sh +git add package-lock.json +git commit -m 'PFM-ISSUE-34453 - github-actions: normalize package-lock.json resolved URLs onto the JFrog npm proxy' +./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1 +``` + +Commit the lockfile **on its own**, separate from any tooling change. Then the commit's parent *is* the baseline, and +verification is exactly `--baseline HEAD~1` with no ref to remember. + +Expect: `entries rewritten: 171`, `byte delta: 4788`, 342 changed lines, none of them outside a `"resolved"` line. + +## Flow 2 — resolving an upmerge conflict on `package-lock.json` + +```bash +git checkout --ours -- package-lock.json # keep this branch's lockfile +./tools/scripts/lockfile/normalize-lockfile.sh +./tools/scripts/lockfile/check-lockfile.sh --baseline :2: # :2: = ours, :3: = theirs +``` + +Proceed **only** on exit 0. Never `--theirs`, never a hand edit: the point of the check is that correctness does not +depend on anyone reading a 262 KB diff. + +`--baseline` accepts a git ref (`HEAD~1`, `origin/release/25.2`), a merge stage (`:2:`, `:3:`), or a plain file path. +Whatever it resolves to is printed on every run, so a wrong baseline is visible rather than silent. + +## Two modes, and when each applies + +This distinction matters, and getting it wrong blocks every dependency update. + +| mode | asserts | use for | +| --- | --- | --- | +| `--prefix-only` | every entry resolves via the proxy | **the PR guard** — runs on every pull request | +| `--baseline ` | the above **plus** the dependency graph is unchanged | **verifying a normalization commit** | + +Graph invariance deliberately forbids *any* change to the dependency graph. That is exactly what you want when proving +a normalization commit touched nothing but registry prefixes — and exactly what you do **not** want on an everyday pull +request, where adding, updating or removing a dependency is the whole point. `pr-checks.yml` therefore uses +`--prefix-only`, and the baseline form is a manual/verification tool. + +A graph failure is also **not** fixed by running the normalizer, so the message for it deliberately does not suggest +that. If you see one on a pull request that legitimately changes dependencies, you are using the wrong mode. + +--- + +## Flow 3 — interpreting a guard failure + +`.github/workflows/pr-checks.yml` runs `check-lockfile.sh --prefix-only` on every pull request, so **the PR guard +makes assertion 2 only**. Assertion 1 runs when you pass `--baseline ` by hand, to verify a normalization +commit — see "Two modes" above. + +> **A pull request passing CI is therefore not evidence that its lockfile changed only prefixes.** Where that matters +> — a normalization commit, or an upmerge conflict resolution — run the baseline form locally. CI deliberately does +> not assert it, because doing so would fail every legitimate dependency change. + +**1. Graph invariance** — `--baseline` only; **not** run on pull requests. The whole document, with every `resolved` +reduced to its bare tarball path, must equal the baseline's. This catches a changed version, a poisoned `integrity`, a +changed tarball filename, a changed dependency edge, an added or dropped entry. + +``` +FAIL: the dependency graph differs from the baseline. Drifted entries: + node_modules/@ampproject/remapping +``` + +→ Something other than a registry prefix changed. This is the bad-merge case. Do not "fix" it by re-running the +normalizer; work out why that entry moved. + +**2. Prefix exactness.** Every `resolved` must carry exactly the one proxy prefix. + +``` +FAIL: 2 distinct registry prefixes found (expected exactly 1). +These entries do not resolve via the cplace npm proxy: + node_modules/jest +``` + +→ An entry is on npmjs, or on a typo'd proxy path. Run `normalize-lockfile.sh`. + +Neither assertion subsumes the other. Graph invariance deliberately strips the host, so it cannot see an entry left on +npmjs or a typo'd `cplace-nmp`; prefix exactness sees nothing *but* the host. Both are required, and +`check-lockfile.bats` asserts that each drift class fails via the correct one. + +### "no usable tarball URL" + +``` +ERROR: these entries in package-lock.json have no usable tarball URL: + node_modules/foo +``` + +A non-root entry has no `resolved`, or resolves over `link:` / `file:` / `git+ssh:`. There are none today (measured: 0 +on all seven branches), and this is a **deliberate** hard failure rather than a silent skip. Introducing such a +dependency legitimately means loosening `assert_resolvable` in `lib.sh` as a reviewed edit — not working around it. + +**This fires on the `--baseline` path and on the normalizer, not on the PR guard.** `assert_resolvable` is a +precondition for *fingerprinting* — every entry needs a tarball path to compare on — so `--prefix-only` deliberately +does not run it, and passes over non-http values instead. That is what lets an npm workspace through the guard; it +also means CI is not the thing that would catch such an entry. + +--- + +## Files + +| file | role | +| --- | --- | +| `lib.sh` | Shared constants and helpers. Sourced, never executed. | +| `fingerprint.jq` | Reduces a lockfile to its registry-independent comparable form. | +| `normalize-lockfile.sh` | The normalizer. | +| `check-lockfile.sh` | The invariant check. | +| `test-helper.bash` | bats fixture builders. | +| `*.bats` | Behavioural tests, including the six injected-drift cases. | + +Run the tests with `bats tools/scripts/lockfile/` and the linter with `shellcheck tools/scripts/lockfile/*.sh`. Both +also run in CI on every pull request. + +**The proxy URL is a hard-coded constant in `lib.sh`, on purpose.** It *is* the invariant being asserted; if it were +caller-supplied, a typo'd prefix passed to both scripts would validate itself. It is not a secret — it is already +committed in plaintext in hundreds of lockfile entries — and it is **not** `JFROG_URL`, which is the *publish* target +(`…/artifactory/cplace-npm-local`), a different path entirely. + +These files are intended to be **byte-identical across all seven long-lived branches**, so that a future upmerge sees a +conflict-free add/add. Change them on one branch and the change has to reach the others. + +## Related + +- `specs/2026-08-10_normalize-package-lock-resolved-urls/` — research, design and implementation plan +- `specs/2026-08-10_normalize-package-lock-resolved-urls/overview.md` — **start here for the whole picture**: the + rewrite that breaks `npm ci`, the two-part fix, the two assertions, the two modes and the seven-branch rollout, with + diagrams. `overview.html` beside it is the same document with hand-drawn figures +- PFM-ISSUE-34453 — the ticket +- PFM-ISSUE-34454 — `DOT_NPMRC` standardization and the JFrog anonymous-access shutdown diff --git a/tools/scripts/lockfile/check-lockfile.bats b/tools/scripts/lockfile/check-lockfile.bats new file mode 100644 index 0000000..a697f48 --- /dev/null +++ b/tools/scripts/lockfile/check-lockfile.bats @@ -0,0 +1,556 @@ +#!/usr/bin/env bats +# +# Behavioural tests for check-lockfile.sh. +# +# The drift cases t1-t6 are the measured evidence behind the two-assertion +# design (design.md Dimension 2): graph invariance is blind to WHICH host an +# entry moved to, prefix exactness is blind to everything else. Each test below +# asserts not merely that the check fails, but that it fails via the RIGHT +# assertion - otherwise the two could silently collapse into one. +# +# Run with: bats tools/scripts/lockfile/ + +setup() { + load 'test-helper' + CHECK="${BATS_TEST_DIRNAME}/check-lockfile.sh" + NORMALIZE="${BATS_TEST_DIRNAME}/normalize-lockfile.sh" + + # Mirrors reality: the baseline is the un-normalized base branch, the + # candidate is the normalized PR. + BASE="${BATS_TEST_TMPDIR}/base.json" + CAND="${BATS_TEST_TMPDIR}/candidate.json" + write_mixed_lockfile "${BASE}" + write_mixed_lockfile "${CAND}" + "${NORMALIZE}" "${CAND}" >/dev/null +} + +@test "passes on a clean normalization" { + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'PASS: dependency graph identical to baseline'* ]] + [[ "${output}" == *'PASS: exactly 1 registry prefix'* ]] +} + +@test "t1: a changed tarball filename fails GRAPH INVARIANCE" { + mutate "${CAND}" '.packages["node_modules/@scope/alpha"].resolved |= + sub("alpha-1\\.0\\.0"; "alpha-9.9.9")' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'FAIL: the dependency graph differs'* ]] + [[ "${output}" == *'node_modules/@scope/alpha'* ]] + [[ "${output}" != *'distinct registry prefixes'* ]] +} + +@test "t2: a typo'd proxy repo name fails PREFIX EXACTNESS" { + mutate "${CAND}" '.packages["node_modules/beta"].resolved |= + sub("cplace-npm"; "cplace-nmp")' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'must carry exactly the cplace npm proxy prefix'* ]] + [[ "${output}" == *'node_modules/beta'* ]] + [[ "${output}" == *'PASS: dependency graph identical to baseline'* ]] +} + +@test "t3: a changed dependency edge fails GRAPH INVARIANCE" { + mutate "${CAND}" '.packages["node_modules/@scope/alpha"].dependencies.beta = "^9.0.0"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'FAIL: the dependency graph differs'* ]] + [[ "${output}" == *'node_modules/@scope/alpha'* ]] +} + +@test "t4: an entry left on npmjs fails PREFIX EXACTNESS" { + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "https://registry.npmjs.org/beta/-/beta-2.0.0.tgz"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'must carry exactly the cplace npm proxy prefix'* ]] + [[ "${output}" == *'node_modules/beta'* ]] + [[ "${output}" == *'PASS: dependency graph identical to baseline'* ]] +} + +@test "t5: a poisoned integrity fails GRAPH INVARIANCE" { + mutate "${CAND}" '.packages["node_modules/beta"].integrity = "sha512-POISONED=="' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'FAIL: the dependency graph differs'* ]] + [[ "${output}" == *'node_modules/beta'* ]] +} + +@test "t6: a poisoned version fails GRAPH INVARIANCE" { + mutate "${CAND}" '.packages["node_modules/beta"].version = "9.9.9"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'FAIL: the dependency graph differs'* ]] + [[ "${output}" == *'node_modules/beta'* ]] +} + +@test "a dropped entry fails GRAPH INVARIANCE, naming the missing path" { + mutate "${CAND}" 'del(.packages["node_modules/beta"])' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'node_modules/beta'* ]] +} + +@test "a PREFIX failure names the normalizer, which does fix it" { + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "https://registry.npmjs.org/beta/-/beta-2.0.0.tgz"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'normalize-lockfile.sh'* ]] + [[ "${output}" == *'tools/scripts/lockfile/README.md'* ]] +} + +@test "every failure names the README" { + mutate "${CAND}" '.packages["node_modules/beta"].version = "9.9.9"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'tools/scripts/lockfile/README.md'* ]] +} + +@test "resolves a baseline from a git ref" { + cd "${BATS_TEST_TMPDIR}" + git init -q -b main . + git config user.email 'test@example.com' + git config user.name 'test' + write_mixed_lockfile package-lock.json + git add package-lock.json + git commit -qm 'baseline' + "${NORMALIZE}" package-lock.json >/dev/null + + run "${CHECK}" --baseline HEAD + + [ "${status}" -eq 0 ] + [[ "${output}" == *'baseline: HEAD:package-lock.json'* ]] +} + +@test "accepts two explicit file paths and prints both" { + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"baseline: file ${BASE}"* ]] + [[ "${output}" == *"candidate: ${CAND}"* ]] +} + +@test "fails readably on a baseline ref that cannot be resolved" { + cd "${BATS_TEST_TMPDIR}" + + run "${CHECK}" --baseline no-such-ref "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'cannot read baseline'* ]] +} + +@test "fails cleanly when the candidate does not exist" { + run "${CHECK}" --baseline "${BASE}" "${BATS_TEST_TMPDIR}/absent.json" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'no such lockfile'* ]] +} + +# --- the ongoing PR guard: --prefix-only ------------------------------------ +# +# Raised in review of PR #163: comparing against the base branch fails every +# pull request that legitimately adds or updates a dependency. Graph invariance +# is for verifying a normalization commit, not for guarding everyday PRs. + +@test "prefix-only accepts a PR that legitimately ADDS a dependency" { + mutate "${CAND}" '.packages["node_modules/gamma"] = { + "version": "3.0.0", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/gamma/-/gamma-3.0.0.tgz", + "integrity": "sha512-CCCC==" + }' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] +} + +@test "prefix-only accepts a PR that UPDATES a dependency's version" { + mutate "${CAND}" '.packages["node_modules/beta"].version = "2.1.0" + | .packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/beta/-/beta-2.1.0.tgz"' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] +} + +@test "prefix-only accepts a PR that REMOVES a dependency" { + mutate "${CAND}" 'del(.packages["node_modules/beta"])' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] +} + +@test "prefix-only still rejects a newly added entry that is NOT on the proxy" { + mutate "${CAND}" '.packages["node_modules/gamma"] = { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/gamma/-/gamma-3.0.0.tgz", + "integrity": "sha512-CCCC==" + }' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'node_modules/gamma'* ]] +} + +@test "prefix-only needs no baseline, so it works outside a git repository" { + cd "${BATS_TEST_TMPDIR}" + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] + [[ "${output}" != *'baseline'* ]] +} + +# --- the root package entry -------------------------------------------------- +# +# The root package's key is the empty string. Emitted verbatim it produced a +# blank line that `[[ -n ... ]]` read as "no drift", so changes to the project's +# OWN declared dependencies passed silently. + +@test "a change to the root package's declared dependencies fails GRAPH INVARIANCE" { + mutate "${CAND}" '.packages[""].dependencies["@scope/alpha"] = "^9.0.0"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'FAIL: the dependency graph differs'* ]] + [[ "${output}" == *''* ]] +} + +@test "a graph failure does not advise running the normalizer, which cannot fix it" { + mutate "${CAND}" '.packages["node_modules/beta"].version = "9.9.9"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'NOT fixed by normalizing'* ]] + [[ "${output}" == *'--prefix-only'* ]] +} + +@test "no failure message leaks the JFrog host, which CI masks as ***" { + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "https://registry.npmjs.org/beta/-/beta-2.0.0.tgz"' + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" != *'cplace.jfrog.io'* ]] +} + +# --- review of PR #163, second round ----------------------------------------- +# +# Every test below pins a defect that reproduced. Several of these FAILED OPEN: +# the check reported success on a lockfile it had not actually verified, which +# is worse than any false positive. + +@test "graph invariance FAILS CLOSED when its own jq cannot run" { + # main() calls the assertion inside a `||` list, which disables `set -e` for + # the function body. An unchecked jq failure therefore left `drift` empty and + # fell through to PASS - on a poisoned lockfile. + mutate "${CAND}" '.packages["node_modules/beta"].integrity = "sha512-POISONED=="' + local hidden="${BATS_TEST_TMPDIR}/fingerprint.jq.hidden" + mv "${BATS_TEST_DIRNAME}/fingerprint.jq" "${hidden}" + + run "${CHECK}" --baseline "${BASE}" "${CAND}" + local rc="${status}" out="${output}" + + mv "${hidden}" "${BATS_TEST_DIRNAME}/fingerprint.jq" + + [ "${rc}" -ne 0 ] + [[ "${out}" != *'PASS: dependency graph identical'* ]] + [[ "${out}" == *'cannot fingerprint'* ]] +} + +@test "--prefix-only and --baseline are rejected together, not silently ranked" { + mutate "${CAND}" '.packages["node_modules/beta"].integrity = "sha512-POISONED=="' + + run "${CHECK}" --baseline "${BASE}" --prefix-only "${CAND}" + + [ "${status}" -eq 2 ] + [[ "${output}" == *'mutually exclusive'* ]] +} + +@test "a second positional is rejected rather than silently replacing the first" { + run "${CHECK}" "${BASE}" "${CAND}" + + [ "${status}" -eq 2 ] + [[ "${output}" == *'only one candidate lockfile'* ]] + [[ "${output}" == *'--baseline'* ]] +} + +@test "the baseline is read from the candidate's own path, not a hardcoded one" { + cd "${BATS_TEST_TMPDIR}" + git init -q -b main . + git config user.email 'test@example.com' + git config user.name 'test' + mkdir -p sub + write_mixed_lockfile sub/package-lock.json + git add -A + git commit -qm 'baseline' + "${NORMALIZE}" sub/package-lock.json >/dev/null + + run "${CHECK}" --baseline HEAD sub/package-lock.json + + [ "${status}" -eq 0 ] + [[ "${output}" == *'baseline: HEAD:sub/package-lock.json'* ]] +} + +@test "--prefix-only tolerates workspace and link: entries" { + # These are not registry references at all. Rejecting them blocked the first + # PR introducing an npm workspace, and contradicted warn-foreign-registry.sh, + # which passes over the same class. + mutate "${CAND}" '.packages["tools/eslint-rules"] = {"version":"1.0.0"} + | .packages["node_modules/eslint-rules"] = {"resolved":"tools/eslint-rules","link":true}' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] +} + +@test "an unsupported lockfileVersion fails readably instead of a jq trace" { + printf '{"name":"x","lockfileVersion":1}\n' >"${CAND}" + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'lockfileVersion 1'* ]] + [[ "${output}" != *'jq: error'* ]] + # Must not pass vacuously by treating a missing .packages as "nothing wrong". + [[ "${output}" != *'resolves entirely via'* ]] +} + +@test "an uppercase URL scheme does not slip past the PR guard" { + # npm reads the scheme case-insensitively, so a case-sensitive predicate made + # such an entry invisible to --prefix-only - the sole automated guard on every + # pull request - and reported OK on a lockfile pointing at an arbitrary host. + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "HTTPS://attacker.example.com/evil/-/evil-1.0.0.tgz"' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'node_modules/beta'* ]] + [[ "${output}" != *'resolves entirely via'* ]] +} + +@test "the normalizer rewrites an uppercase-scheme entry onto the proxy" { + # The guard's remediation advice is "run the normalizer", so the normalizer + # has to recognize the same entries the guard rejects. + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "HTTPS://attacker.example.com/beta/-/beta-2.0.0.tgz"' + + run "${NORMALIZE}" "${CAND}" + + [ "${status}" -eq 0 ] + [ "$(jq -r '.packages["node_modules/beta"].resolved' "${CAND}")" = "${PROXY}beta/-/beta-2.0.0.tgz" ] +} + +@test "the prefix failure message does not contradict itself" { + # `distinct` counts prefixes present, not wrong ones, so a lockfile uniformly + # on npmjs used to fail with "1 distinct registry prefixes found (expected + # exactly 1)" - the rollout case, reading as though the check were broken. + mutate "${CAND}" '.packages["node_modules/@scope/alpha"].resolved = + "https://registry.npmjs.org/@scope/alpha/-/alpha-1.0.0.tgz" + | .packages["node_modules/beta"].resolved = + "https://registry.npmjs.org/beta/-/beta-2.0.0.tgz"' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'must carry exactly the cplace npm proxy prefix'* ]] + [[ "${output}" != *'distinct registry prefixes found (expected exactly 1)'* ]] +} + +@test "an entry whose tarball path repeats the scope passes the guard" { + # Measured on cplace-paw-fe release/25.2, where npm installs it without + # complaint: JFrog serves some privately published scoped packages with the + # scope repeated after `/-/`. Requiring `[^/]+` there reported an entry that + # was already on the proxy as foreign. + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@cplace-next/beta/-/@cplace-next/beta-2.0.0.tgz"' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'PASS: exactly 1 registry prefix'* ]] +} + +@test "an entry with a version segment in its tarball path passes the guard" { + # The other real shape from the same lockfile: `/-//.tgz`. + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@fortawesome/beta/-/2.0.0/beta-2.0.0.tgz"' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'PASS: exactly 1 registry prefix'* ]] +} + +@test "the same entry on a foreign host is still rejected" { + # The widening must not turn into "anything ending in .tgz is fine". + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "https://registry.npmjs.org/@cplace-next/beta/-/@cplace-next/beta-2.0.0.tgz"' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'node_modules/beta'* ]] +} + +@test "a prefix containing its own /-/ keeps the debris in the compared path" { + # `.+` may cross a `/-/`, so the match anchors on the first one. No registry + # in use has `/-/` in its prefix; this pins which way the ambiguity resolves, + # and the direction is fail-loud: the retained path carries prefix debris, so + # the entry cannot silently compare equal to a proxy-hosted one. + source "${BATS_TEST_DIRNAME}/lib.sh" + + run jq -rn --arg re "${TARBALL_PATH_RE}" \ + --arg u 'https://host/a/-/b/beta/-/beta-2.0.0.tgz' '$u | capture($re) | .t' + + [ "${status}" -eq 0 ] + [ "${output}" = 'a/-/b/beta/-/beta-2.0.0.tgz' ] +} + +@test "every URL assert_resolvable accepts can be captured by the tarball regex" { + # The lockstep invariant between RESOLVED_URL_RE and TARBALL_PATH_RE. If a URL + # passes validation but the capture misses it, jq's capture yields empty and + # `|= empty` DELETES the resolved key instead of raising - a silent loss, not + # a loud failure. Widening one regex without the other reintroduces exactly + # that, so assert it directly rather than trusting the two to stay aligned. + source "${BATS_TEST_DIRNAME}/lib.sh" + + local urls=( + "${PROXY}beta/-/beta-2.0.0.tgz" + "${PROXY}@scope/beta/-/beta-2.0.0.tgz" + "${PROXY}@cplace-next/beta/-/@cplace-next/beta-2.0.0.tgz" + "${PROXY}@fortawesome/beta/-/2.0.0/beta-2.0.0.tgz" + "HTTPS://attacker.example.com/beta/-/beta-2.0.0.tgz" + ) + + local url + for url in "${urls[@]}"; do + echo "validating: ${url}" + jq -en --arg u "${url}" --arg url_re "${RESOLVED_URL_RE}" '$u | test($url_re)' >/dev/null + jq -en --arg u "${url}" --arg re "${TARBALL_PATH_RE}" '$u | test($re)' >/dev/null + done +} + +@test "resolves a baseline from a :2: merge stage" { + # The Flow 2 conflict runbook: `git checkout --ours`, re-normalize, verify + # against `:2:`. That is the arm of resolve_baseline that appends the + # candidate's path to a spec ending in a colon, and it runs when someone is + # mid-upmerge and least able to debug the guard. + cd "${BATS_TEST_TMPDIR}" + git init -q -b main . + git config user.email 'test@example.com' + git config user.name 'test' + write_mixed_lockfile package-lock.json + git add package-lock.json + git commit -qm 'base' + + git checkout -q -b theirs + mutate package-lock.json '.packages["node_modules/@scope/alpha"].resolved = + "https://registry.npmjs.org/@scope/alpha/-/alpha-1.0.1.tgz" + | .packages["node_modules/@scope/alpha"].version = "1.0.1"' + git commit -qam 'theirs' + + git checkout -q main + "${NORMALIZE}" package-lock.json >/dev/null + git commit -qam 'ours' + + run git merge theirs + [ "${status}" -ne 0 ] + + git checkout -q --ours -- package-lock.json + "${NORMALIZE}" package-lock.json >/dev/null + + run "${CHECK}" --baseline :2: + + [ "${status}" -eq 0 ] + [[ "${output}" == *'baseline: :2:package-lock.json'* ]] + [[ "${output}" == *'PASS: dependency graph identical to baseline'* ]] +} + +@test "a lockfile with no lockfileVersion at all fails readably" { + # The `missing` arm of assert_supported_lockfile, distinct from its + # unsupported-version sibling: this message asks whether the file is a + # package-lock.json in the first place. + printf '{"name":"x","packages":{}}\n' >"${CAND}" + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'is it a package-lock.json?'* ]] +} + +@test "a lockfileVersion 1 baseline fails readably instead of a jq trace" { + # The baseline used to reach fingerprint.jq unchecked, where `.packages` is + # absent in a v1 file: `null (null) has no keys`, then "cannot fingerprint the + # baseline (is fingerprint.jq present?)" - a jq trace and a wrong diagnosis. + printf '{"name":"x","lockfileVersion":1,"dependencies":{}}\n' >"${BATS_TEST_TMPDIR}/v1.json" + + run "${CHECK}" --baseline "${BATS_TEST_TMPDIR}/v1.json" "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'baseline'* ]] + [[ "${output}" == *'lockfileVersion 1'* ]] + [[ "${output}" != *'has no keys'* ]] + [[ "${output}" != *'fingerprint.jq present'* ]] +} + +@test "a lockfile with no registry entries does not claim proxy compliance" { + # Zero entries examined used to print "exactly 0 registry prefix, matching the + # expected proxy" and "resolves entirely via the cplace npm proxy" - a fact + # asserted from an empty set. + printf '%s\n' '{"name":"x","lockfileVersion":3,"packages":{"":{"name":"x"},"node_modules/a":{"resolved":"link:../a","link":true}}}' >"${CAND}" + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'no registry entries to check'* ]] + [[ "${output}" != *'resolves entirely via'* ]] +} + +@test "the pass line reports how many entries were examined" { + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'(2 entries examined)'* ]] +} + +@test "an http entry on the proxy host is named as a scheme downgrade" { + # It fails the exact-prefix comparison either way; without the extra line the + # message sends the reader looking for a path mistake that is not there. + mutate "${CAND}" '.packages["node_modules/beta"].resolved = + "http://cplace.jfrog.io/artifactory/api/npm/cplace-npm/beta/-/beta-2.0.0.tgz"' + + run "${CHECK}" --prefix-only "${CAND}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'ONLY in scheme'* ]] + [[ "${output}" == *'plain http'* ]] +} diff --git a/tools/scripts/lockfile/check-lockfile.sh b/tools/scripts/lockfile/check-lockfile.sh new file mode 100755 index 0000000..3ce80c7 --- /dev/null +++ b/tools/scripts/lockfile/check-lockfile.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# +# Proves that a package-lock.json differs from its baseline ONLY in registry +# prefixes, and that every `resolved` URL points at the one correct proxy. +# +# Usage: +# ./tools/scripts/lockfile/check-lockfile.sh [--baseline ] [] +# ./tools/scripts/lockfile/check-lockfile.sh --prefix-only [] +# +# --baseline HEAD~1 the commit before the lockfile commit (default: HEAD) +# --baseline :2: "ours" during a merge conflict +# --baseline :3: "theirs" during a merge conflict +# --baseline path/to/other.json an explicit file +# --prefix-only assertion 2 only; THE PR GUARD +# +# The two modes are mutually exclusive, and the difference matters: +# +# 1. graph invariance - the whole document, with every `resolved` reduced to a +# registry-independent tarball path, must equal the baseline. Forbids ANY +# dependency change, so it verifies a normalization commit and must never +# gate an everyday pull request. +# 2. prefix exactness - every `resolved` that is an http(s) URL must carry +# exactly the one proxy prefix. This is what CI asserts. +# +# Neither alone is sufficient for a normalization commit: (1) is blind to WHICH +# host an entry moved to, and (2) is blind to everything except the host. +# See design.md Dimension 2. +# +# See tools/scripts/lockfile/README.md + +set -euo pipefail + +# shellcheck source=tools/scripts/lockfile/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +usage() { + err "usage: ${0##*/} [--baseline ] [--prefix-only] []" + exit 2 +} + +# Writes the baseline lockfile to ${2}, resolving ${1} as a file path if one +# exists, otherwise as a git ref. Always prints what it resolved, so a wrong +# baseline is visible rather than silent. +resolve_baseline() { + local ref="$1" out="$2" + # The candidate's own path, so a nested lockfile compares against the same + # path in the baseline ref. Hardcoding `package-lock.json` made + # `--baseline HEAD sub/package-lock.json` either fail outright or, in a repo + # with both a root and a nested lockfile, silently compare two different files + # and report every entry as drifted. + local lockfile_path="${3:-package-lock.json}" + + if [[ -f "${ref}" ]]; then + cat "${ref}" >"${out}" + info "baseline: file ${ref}" + return + fi + + local spec="${ref}" + if [[ "${spec}" == *: ]]; then + spec="${spec}${lockfile_path}" # `:2:` -> `:2:` + elif [[ "${spec}" != *:* ]]; then + spec="${spec}:${lockfile_path}" # `HEAD~1` -> `HEAD~1:` + fi + + if ! git show "${spec}" >"${out}" 2>/dev/null; then + die "cannot read baseline '${ref}' (tried '${spec}'); pass a git ref or an existing file" + fi + info "baseline: ${spec}" +} + +# Assertion 1. Prints every drifted package path; returns 1 if any. +assert_graph_invariant() { + local baseline="$1" candidate="$2" fp_base="$3" fp_cand="$4" + local drift + + # Every step below is checked. main() calls this function inside a `||` list, + # which disables `set -e` for its whole body, so an unchecked failure here does + # not abort - it leaves `drift` empty and falls through to "PASS: dependency + # graph identical to baseline". This is the strongest assertion in the toolkit + # and the only one that ever looks at integrity hashes; it must fail CLOSED. + fingerprint_of "${baseline}" >"${fp_base}" \ + || die "internal error: cannot fingerprint the baseline (is ${FINGERPRINT_JQ} present?)" + fingerprint_of "${candidate}" >"${fp_cand}" \ + || die "internal error: cannot fingerprint ${candidate} (is ${FINGERPRINT_JQ} present?)" + + drift="$(jq -n -r --slurpfile a "${fp_base}" --slurpfile b "${fp_cand}" ' + ($a[0]) as $A | ($b[0]) as $B + | [ ((($A | keys) + ($B | keys)) | unique)[] + | select(. != "packages") + | select(($A[.] | tojson) != ($B[.] | tojson)) + | "top-level key: " + . ] + + [ (((($A.packages // {}) | keys) + (($B.packages // {}) | keys)) | unique)[] + | select((($A.packages[.]) | tojson) != (($B.packages[.]) | tojson)) + # The root package uses the empty string as its key. Emitted verbatim + # that is a blank line, which the caller test reads as "no drift", + # silently hiding any change to the declared dependencies of this + # project. Name it so it is both detectable and readable. + | if . == "" then "" else . end ] + | .[] + ')" || die "internal error: graph comparison failed" + + if [[ -n "${drift}" ]]; then + err "FAIL: the dependency graph differs from the baseline. Drifted entries:" + while IFS= read -r package_path; do + err " ${package_path}" + done <<<"${drift}" + return 1 + fi + + info "PASS: dependency graph identical to baseline" +} + +# Assertion 2. Prints every package path not on the one correct proxy. +assert_prefix_exactness() { + local candidate="$1" + local offenders distinct examined scheme_only + + # Single-quoted jq programs with the shared predicate spliced between them. + # Written as one double-quoted string, every jq `$var` in the literal text had + # to be backslash-escaped, which is the fragile spelling in a file where every + # other jq call is a plain single-quoted program. + # + # Considers only entries whose `resolved` is an http(s) URL. A `link:`/`file:`/ + # workspace entry is not a registry reference at all, so rejecting it here + # would block the first pull request introducing an npm workspace - and would + # contradict warn-foreign-registry.sh, which passes over the same class. + examined="$(jq -r '[ '"${JQ_REGISTRY_ENTRIES}"' ] | length' "${candidate}")" \ + || die "cannot read ${candidate} as a lockfile (is it valid JSON?)" + + # An empty set proves nothing, and saying "resolves entirely via the proxy" + # about it is the vacuous pass assert_supported_lockfile exists to prevent: + # every entry examined, zero examined, same message. + # + # Returns 2, not 1: the caller must not answer this with "run the normalizer", + # which would rewrite the same zero entries and change nothing. + if ((examined == 0)); then + err "FAIL: ${candidate} has no registry entries to check." + err " Refusing to report proxy compliance from an empty set - nothing was examined." + err "A lockfile that is genuinely all workspace/link: entries needs a deliberate," + err "reviewed loosening here rather than a silent pass." + return 2 + fi + + # shellcheck disable=SC2016 # $proxy/$tarball_re are jq variables, bound via --arg + offenders="$(jq -r --arg proxy "${JFROG_NPM_PROXY}" --arg tarball_re "${TARBALL_PATH_RE}" \ + '[ '"${JQ_REGISTRY_ENTRIES}"' | select((.value.resolved | sub($tarball_re; "")) != $proxy) | .key ] | .[]' \ + "${candidate}")" || die "cannot read ${candidate} as a lockfile (is it valid JSON?)" + + # shellcheck disable=SC2016 # $tarball_re is a jq variable, bound via --arg + distinct="$(jq -r --arg tarball_re "${TARBALL_PATH_RE}" \ + '[ '"${JQ_REGISTRY_ENTRIES}"' | (.value.resolved | sub($tarball_re; "")) ] | unique | length' \ + "${candidate}")" || die "cannot read ${candidate} as a lockfile (is it valid JSON?)" + + if [[ -n "${offenders}" ]]; then + # Name the expectation, not the cardinality. `distinct` counts how many + # prefixes exist, not how many are wrong, so a lockfile uniformly on npmjs + # used to fail with "1 distinct registry prefixes found (expected exactly 1)" + # - which reads as though the check itself is broken. That is the state of + # every branch before this tooling lands, i.e. the rollout case. + err "FAIL: every 'resolved' must carry exactly the cplace npm proxy prefix" + err " (${distinct} distinct prefix(es) present in ${candidate})." + err "These entries do not resolve via the cplace npm proxy:" + while IFS= read -r package_path; do + err " ${package_path}" + done <<<"${offenders}" + + # An entry on the right host over plain http fails the exact-prefix + # comparison like any other, but "does not resolve via the cplace npm proxy" + # sends the reader looking for a path problem that is not there. + # shellcheck disable=SC2016 # $proxy/$tarball_re are jq variables, bound via --arg + scheme_only="$(jq -r --arg proxy "${JFROG_NPM_PROXY}" --arg tarball_re "${TARBALL_PATH_RE}" \ + '[ '"${JQ_REGISTRY_ENTRIES}"' | (.value.resolved | sub($tarball_re; "")) + | select(. != $proxy) | select(sub("^http://"; "https://") == $proxy) ] | length' \ + "${candidate}")" || scheme_only=0 + if ((scheme_only > 0)); then + err "" + err "${scheme_only} of these differ from the proxy ONLY in scheme: they use plain http." + err "The proxy prefix is https; an http URL is a downgrade, not a path mistake." + fi + return 1 + fi + + info "PASS: exactly ${distinct} registry prefix, matching the expected proxy (${examined} entries examined)" +} + +main() { + local baseline='HEAD' candidate='package-lock.json' prefix_only=0 + local baseline_given=0 candidate_given=0 + + while (($# > 0)); do + case "$1" in + --baseline) + [[ $# -ge 2 ]] || usage + baseline="$2" + baseline_given=1 + shift 2 + ;; + --prefix-only) + prefix_only=1 + shift + ;; + -h | --help) usage ;; + -*) usage ;; + *) + # A second positional used to silently replace the first, so the natural + # two-file form `check base.json cand.json` dropped the baseline, fell + # back to HEAD, and printed a drift report naming every package in the + # repo - reading as catastrophic corruption of a file that is fine. + if ((candidate_given)); then + err "ERROR: only one candidate lockfile may be given (got '${candidate}' and '$1')." + err "To compare two files, pass the baseline explicitly: --baseline '${candidate}' '$1'" + usage + fi + candidate="$1" + candidate_given=1 + shift + ;; + esac + done + + # --baseline asks for a graph comparison; --prefix-only says not to make one. + # Silently honouring the second reported OK on a poisoned integrity hash, and + # `--baseline HEAD~1 --prefix-only` is the natural thing to type once the + # README calls --prefix-only "the PR guard". + if ((prefix_only)) && ((baseline_given)); then + err "ERROR: --prefix-only and --baseline are mutually exclusive." + err " --prefix-only asserts only that every entry resolves via the proxy (the PR guard)" + err " --baseline additionally asserts the dependency graph is unchanged" + usage + fi + + require_jq + [[ -f "${candidate}" ]] || die "no such lockfile: ${candidate}" + assert_supported_lockfile "${candidate}" + + # --prefix-only is the ONGOING guard: it answers "does every entry resolve via + # the proxy?" and nothing else, so a pull request may freely add, remove or + # update dependencies. Graph invariance deliberately forbids exactly that, which + # makes it the wrong gate for everyday pull requests - it belongs to verifying a + # normalization commit, where the graph genuinely must not move. + if ((prefix_only)); then + info "candidate: ${candidate} (prefix-exactness only; dependency changes allowed)" + local prefix_rc=0 + assert_prefix_exactness "${candidate}" || prefix_rc=$? + if ((prefix_rc != 0)); then + err "" + # Status 2 is the "nothing to examine" refusal, which the normalizer cannot + # fix - it would rewrite the same zero entries. Only offer the command when + # rewriting is actually the remedy. + if ((prefix_rc == 1)); then + err "To fix: run ${NORMALIZE_CMD}" + fi + err "See ${README_PATH}" + exit 1 + fi + info "OK: ${candidate} resolves entirely via the cplace npm proxy" + return 0 + fi + + command -v git >/dev/null 2>&1 || die "git is required but not installed" + + local baseline_file fp_base fp_cand + baseline_file="$(mktemp)" + fp_base="$(mktemp)" || die "internal error: mktemp failed" + fp_cand="$(mktemp)" || die "internal error: mktemp failed" + # The fingerprint scratch files belong to assert_graph_invariant but are made + # and trapped here: every `|| die` in that function exits, so a cleanup at its + # end is bypassed on exactly the fail-closed paths it exists to take. + # shellcheck disable=SC2064 # expand paths now, not when the trap fires + trap "rm -f '${baseline_file}' '${fp_base}' '${fp_cand}'" EXIT + + resolve_baseline "${baseline}" "${baseline_file}" "${candidate}" + # The baseline is a lockfile too, and fingerprint.jq reduces `.packages` - + # absent in lockfileVersion 1. Unchecked, a baseline ref predating the v2/v3 + # upgrade produced a raw `null (null) has no keys` jq trace followed by + # "cannot fingerprint the baseline (is fingerprint.jq present?)", which is the + # wrong diagnosis as well as the unreadable failure this toolkit replaces. + assert_supported_lockfile "${baseline_file}" "baseline ${baseline}" + info "candidate: ${candidate}" + + # assert_resolvable is a precondition for FINGERPRINTING - every entry must + # have a tarball path to compare on - not for prefix exactness. It therefore + # belongs here, on the baseline path, and not in front of --prefix-only, where + # it hard-failed on exactly the workspace and link: entries that mode promises + # to allow. + assert_resolvable "${baseline_file}" + assert_resolvable "${candidate}" + + # Run BOTH assertions before failing, so one run reports every problem. + local failed=0 graph_failed=0 + assert_graph_invariant "${baseline_file}" "${candidate}" "${fp_base}" "${fp_cand}" \ + || { failed=1; graph_failed=1; } + assert_prefix_exactness "${candidate}" || failed=1 + + if ((failed != 0)); then + err "" + if ((graph_failed)); then + # Do not tell people to run the normalizer here: it rewrites prefixes and + # would not touch a graph difference, so the advice would be misleading. + err "A graph difference is NOT fixed by normalizing. Either the baseline is" + err "wrong for what you are checking, or something other than a registry" + err "prefix really did change - work out which before proceeding." + err "For a pull request that legitimately changes dependencies, use --prefix-only." + else + err "To fix: run ${NORMALIZE_CMD}" + err "then re-run: ${0} --baseline ${baseline}" + fi + err "See ${README_PATH}" + exit 1 + fi + + info "OK: ${candidate} is normalized and graph-identical to its baseline" +} + +main "$@" diff --git a/tools/scripts/lockfile/fingerprint.jq b/tools/scripts/lockfile/fingerprint.jq new file mode 100644 index 0000000..c0c1911 --- /dev/null +++ b/tools/scripts/lockfile/fingerprint.jq @@ -0,0 +1,36 @@ +# Reduces a package-lock.json to a registry-independent, comparable form: every +# `resolved` URL is replaced by its bare tarball path. A legitimately rehosted +# entry therefore compares EQUAL, while a changed version, integrity, tarball +# filename or dependency edge does not. +# +# Requires: --arg tarball_re '' (see lib.sh) +# +# Requires also that the caller has already run `assert_resolvable`. A +# non-matching `capture` yields `empty`, and `|= empty` deletes the key, so an +# unvalidated entry would lose its `resolved` here rather than raising - on both +# sides of the comparison at once, which is precisely what this transform exists +# to detect. +# +# This transform is deliberately BLIND to which host an entry was rehosted onto +# - a typo'd proxy repo name and an entry left on npmjs both survive it. That +# blindness is exactly why check-lockfile.sh runs a second, independent prefix +# assertion; the two together are what design.md Dimension 2 requires. +# +# The whole entry is compared, not a version/integrity/tarball subset, because +# the subset misses dependency-edge drift (drift case t3). + +# The three guards are the shared predicate's own terms (lib.sh's +# JQ_REGISTRY_ENTRIES): the root entry is keyed by the empty string and +# `assert_resolvable` deliberately skips it, and a `link:`/workspace value is not +# a registry reference. Reducing either of those would compare it to nothing. +.packages |= with_entries( + if .key != "" + and (.value | type) == "object" + and (.value.resolved | type) == "string" + and (.value.resolved | test("^https?://"; "i")) + then + .value.resolved |= (capture($tarball_re) | .t) + else + . + end +) diff --git a/tools/scripts/lockfile/lib.sh b/tools/scripts/lockfile/lib.sh new file mode 100644 index 0000000..b96c1bc --- /dev/null +++ b/tools/scripts/lockfile/lib.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# +# Shared constants and helpers for the package-lock.json normalizer and checker. +# +# This file is sourced, never executed. It deliberately depends on nothing but +# bash and jq: the scripts that source it exist to repair the lockfile that +# makes `npm ci` fail, so they cannot require `npm ci` to have succeeded. +# +# See tools/scripts/lockfile/README.md + +# The one npm proxy every `resolved` URL in this repository's package-lock.json +# must point at. +# +# Hard-coded on purpose. This constant *is* the invariant that check-lockfile.sh +# asserts; if it were caller-supplied, a typo'd prefix handed to both scripts +# would validate itself. It is not a secret - it is already committed in +# plaintext in every JFrog-hosted lockfile entry - and it is NOT the JFROG_URL +# publish target (`.../artifactory/cplace-npm-local`, see +# tools/scripts/artifacts/configuration.ts:2), which points somewhere else. +readonly JFROG_NPM_PROXY='https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/' + +# Extracts the registry-independent tarball path from a `resolved` URL: +# https:////@scope/name/-/name-1.2.3.tgz -> @scope/name/-/name-1.2.3.tgz +# https:////name/-/name-1.2.3.tgz -> name/-/name-1.2.3.tgz +# +# The segment after `/-/` may contain slashes, because JFrog serves two real +# shapes this repository's own lockfiles happen not to contain: +# .../@cplace-next/cf-frontend-sdk/-/@cplace-next/cf-frontend-sdk-25.2.30.tgz +# .../@fortawesome/fontawesome-pro/-/5.15.4/fontawesome-pro-5.15.4.tgz +# Requiring `[^/]+` there rejected both: the advisory reported four entries that +# were already on the proxy, and the normalizer refused to touch the lockfile +# carrying them - measured on cplace-paw-fe release/25.2, where npm installs all +# four without complaint. +# +# Consequence, pinned by a test: `.+` may cross a `/-/`, so a prefix that itself +# contained one would anchor the match on the FIRST rather than the last. The +# retained path then keeps prefix debris, so such an entry fails graph +# invariance loudly instead of comparing equal by accident. No registry in use +# has `/-/` in its prefix. +# +# The `\.tgz` suffix is required rather than accepting any filename, so the +# regex matches what the documentation and error messages promise. Anything else +# is an unexpected shape that should fail loudly rather than be silently +# rehosted. +# +# Defined once, here, and passed to jq via --arg, so that the normalizer and +# fingerprint.jq can never drift apart. +readonly TARBALL_PATH_RE='(?(?:@[^/]+/)?[^/]+/-/.+\.tgz)$' + +# Shape every non-root entry's `resolved` must have. Checked BEFORE any +# `capture`, because a non-matching `capture` does not raise: it produces +# `empty`, and `.value.resolved |= empty` DELETES the key. Unvalidated, an +# unexpected shape would therefore lose its `resolved` silently - and +# fingerprint.jq makes the identical deletion on both sides of a comparison, so +# neither the self-assertion nor graph invariance can see it happen. +# +# The scheme is matched case-insensitively because npm treats it that way: a +# `HTTPS://` entry is a registry reference and must be normalized like any +# other. The `\.tgz` suffix stays case-sensitive - an unexpected shape should +# fail loudly rather than be silently rehosted. +# +# The tail must be widened in LOCKSTEP with TARBALL_PATH_RE. Everything this +# accepts has to be capturable by that one, or an entry passes validation and is +# then handed to `capture`, which yields empty on a non-match - and `|= empty` +# DELETES the key rather than raising. A test asserts the two agree. +readonly RESOLVED_URL_RE='^(?i:https?)://[^/]+/.*(?:@[^/]+/)?[^/]+/-/.+\.tgz$' + +# The single definition of "which entries this tooling has an opinion about", +# and of "what a normalized entry looks like". Three hand-written copies of this +# predicate had drifted apart: `count_foreign_entries` asked `startswith($proxy)` +# while the rewrite asked "does $proxy + differ from the +# current value?", so an entry already under the proxy host but with a stray path +# segment was counted as normalized and then silently rewritten anyway. +# +# Requires --arg proxy and --arg tarball_re. Selects only entries whose `resolved` +# is an http(s) URL: `link:`/`file:`/workspace values are not registry references +# and must be passed over rather than rejected. The scheme test carries "i" +# because npm reads it case-insensitively - without the flag a `HTTPS://` entry +# was not a registry entry at all, so --prefix-only reported OK on a lockfile +# pointing at an arbitrary host. +# shellcheck disable=SC2016 # $proxy/$tarball_re are jq variables, bound via --arg +readonly JQ_REGISTRY_ENTRIES=' + (.packages // {}) + | to_entries[] + | select(.key != "") + | select((.value.resolved | type) == "string") + | select(.value.resolved | test("^https?://"; "i")) +' + +# Given such an entry, true when the rewrite would change it. +# shellcheck disable=SC2016 # $proxy/$tarball_re are jq variables, bound via --arg +readonly JQ_NEEDS_REWRITE=' + select((.value.resolved | test($tarball_re)) + and (.value.resolved != ($proxy + (.value.resolved | capture($tarball_re) | .t)))) +' + +readonly README_PATH='tools/scripts/lockfile/README.md' +# shellcheck disable=SC2034 # consumed by check-lockfile.sh, which shellcheck cannot see from here +readonly NORMALIZE_CMD='./tools/scripts/lockfile/normalize-lockfile.sh' + +LOCKFILE_TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly LOCKFILE_TOOLS_DIR +readonly FINGERPRINT_JQ="${LOCKFILE_TOOLS_DIR}/fingerprint.jq" + +info() { + printf '%s\n' "$*" +} + +err() { + printf '%s\n' "$*" >&2 +} + +die() { + err "ERROR: $*" + exit 1 +} + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + die "jq is required but not installed (macOS: brew install jq). See ${README_PATH}" + fi +} + +byte_size() { + wc -c <"$1" | tr -d '[:space:]' +} + +# These scripts operate on `.packages`, which exists only from lockfileVersion 2. +# Guarding `.packages` alone would make a v1 file pass vacuously - reporting +# "resolves entirely via the proxy" having examined nothing at all, which is a +# worse outcome than the raw jq trace it replaced. Fail loudly instead. +# +# The optional second argument names the file in the message. The baseline is +# checked through a `mktemp` copy, and "/var/folders/.../tmp.4Xh2 has no +# lockfileVersion" tells the reader nothing about which ref they passed. +assert_supported_lockfile() { + local lockfile="$1" label="${2:-$1}" version + version="$(jq -r '.lockfileVersion // "missing"' "${lockfile}" 2>/dev/null)" \ + || die "cannot read ${label} as JSON" + + case "${version}" in + 2 | 3) return 0 ;; + missing) die "${label} has no lockfileVersion - is it a package-lock.json? See ${README_PATH}" ;; + *) die "${label} is lockfileVersion ${version}; these scripts need 2 or 3 (they operate on '.packages'). See ${README_PATH}" ;; + esac +} + +# Fails, naming every offending package path, if any non-root entry lacks a +# usable tarball URL. Never echoes a URL: CI masks the JFrog host as ***, so a +# message built from one is unreadable exactly when it matters most. +assert_resolvable() { + local lockfile="$1" + local offenders + # `(.packages // {})`, not `.packages`: a lockfileVersion 1 or hand-truncated + # file has no such section, and dereferencing it raises a raw jq trace with + # exit 5 - the unreadable failure these scripts exist to eliminate. + offenders="$(jq -r --arg url_re "${RESOLVED_URL_RE}" ' + (.packages // {}) + | to_entries[] + | select(.key != "") + | select(((.value.resolved | type) != "string") + or ((.value.resolved | test($url_re)) | not)) + | .key + ' "${lockfile}")" || die "cannot read ${lockfile} as a lockfile (is it valid JSON?)" + + if [[ -n "${offenders}" ]]; then + err "ERROR: these entries in ${lockfile} have no usable tarball URL:" + while IFS= read -r package_path; do + err " ${package_path}" + done <<<"${offenders}" + err "" + err 'Every non-root entry must carry a standard /-/.tgz "resolved" URL.' + err "A link:/file:/git+ssh: dependency is a deliberate, reviewed loosening of this" + err "assertion - see ${README_PATH}." + exit 1 + fi +} + +# Reduces a lockfile to its registry-independent comparable form. Sorted keys so +# the output is diffable and order-insensitive. +fingerprint_of() { + jq -S --arg tarball_re "${TARBALL_PATH_RE}" -f "${FINGERPRINT_JQ}" "$1" +} + +# Number of entries the rewrite would actually change - i.e. how much work there +# is to do. Uses the rewrite's own predicate, so the reported count cannot +# disagree with what the normalizer does to the file. README Flow 1 makes this +# number the documented verification signal for a normalization commit, so it +# must not be able to say "untouched" about a file that was modified. +count_foreign_entries() { + jq --arg proxy "${JFROG_NPM_PROXY}" --arg tarball_re "${TARBALL_PATH_RE}" \ + "[ ${JQ_REGISTRY_ENTRIES} | ${JQ_NEEDS_REWRITE} ] | length" "$1" \ + || die "cannot read $1 as a lockfile (is it valid JSON?)" +} diff --git a/tools/scripts/lockfile/normalize-lockfile.bats b/tools/scripts/lockfile/normalize-lockfile.bats new file mode 100644 index 0000000..b7a986b --- /dev/null +++ b/tools/scripts/lockfile/normalize-lockfile.bats @@ -0,0 +1,247 @@ +#!/usr/bin/env bats +# +# Behavioural tests for normalize-lockfile.sh. +# +# Run with: bats tools/scripts/lockfile/ + +setup() { + load 'test-helper' + NORMALIZE="${BATS_TEST_DIRNAME}/normalize-lockfile.sh" + TMP="${BATS_TEST_TMPDIR}/package-lock.json" +} + +@test "rewrites npmjs entries onto the proxy and leaves proxy entries alone" { + write_mixed_lockfile "${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [ "$(grep -c 'registry.npmjs.org' "${TMP}" || true)" -eq 0 ] + [ "$(jq -r '.packages["node_modules/@scope/alpha"].resolved' "${TMP}")" \ + = "${PROXY}@scope/alpha/-/alpha-1.0.0.tgz" ] + [ "$(jq -r '.packages["node_modules/beta"].resolved' "${TMP}")" \ + = "${PROXY}beta/-/beta-2.0.0.tgz" ] + [ "$(jq -r '.packages["node_modules/@scope/alpha"].integrity' "${TMP}")" = 'sha512-AAAA==' ] +} + +@test "reports how many entries it rewrote" { + write_mixed_lockfile "${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'entries rewritten: 1'* ]] +} + +@test "is idempotent - a second run rewrites nothing and changes no bytes" { + write_mixed_lockfile "${TMP}" + "${NORMALIZE}" "${TMP}" >/dev/null + local before + before="$(wc -c <"${TMP}")" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'entries rewritten: 0'* ]] + [[ "${output}" == *'already normalized'* ]] + [ "$(wc -c <"${TMP}")" -eq "${before}" ] +} + +@test "leaves version, integrity and dependency edges untouched" { + write_mixed_lockfile "${TMP}" + local before + before="$(jq -S 'del(.packages[].resolved)' "${TMP}")" + + "${NORMALIZE}" "${TMP}" >/dev/null + + [ "$(jq -S 'del(.packages[].resolved)' "${TMP}")" = "${before}" ] +} + +@test "preserves the trailing newline" { + write_mixed_lockfile "${TMP}" + + "${NORMALIZE}" "${TMP}" >/dev/null + + # od, not xxd: the workflow installs only bats and shellcheck, so anything + # outside coreutils is an undeclared dependency on the runner image. + [ "$(tail -c 1 "${TMP}" | od -An -tx1 | tr -d '[:space:]')" = '0a' ] +} + +@test "fails, naming the package path, when an entry has no resolved URL" { + write_unresolvable_lockfile "${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'node_modules/beta'* ]] + [[ "${output}" == *'no usable tarball URL'* ]] +} + +@test "fails, naming the package path, on a git+ssh resolved URL" { + write_git_protocol_lockfile "${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'node_modules/beta'* ]] + # A jq capture stack trace would leak through here instead of a named path. + [[ "${output}" != *'jq: error'* ]] +} + +@test "fails, naming the package path, on a tarball URL that is not .tgz" { + write_mixed_lockfile "${TMP}" + mutate "${TMP}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/beta/-/beta-2.0.0.zip"' + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'node_modules/beta'* ]] + [[ "${output}" != *'jq: error'* ]] +} + +@test "leaves the lockfile untouched when it refuses to normalize" { + write_git_protocol_lockfile "${TMP}" + local before + before="$(cat "${TMP}")" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 1 ] + [ "$(cat "${TMP}")" = "${before}" ] +} + +@test "fails cleanly when the lockfile does not exist" { + run "${NORMALIZE}" "${BATS_TEST_TMPDIR}/absent.json" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'no such lockfile'* ]] +} + +@test "no failure message leaks the JFrog host, which CI masks as ***" { + write_unresolvable_lockfile "${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 1 ] + [[ "${output}" != *'cplace.jfrog.io'* ]] +} + +@test "the reported count matches what the rewrite actually changes" { + # count_foreign_entries used `startswith($proxy)` while the rewrite asked + # whether $proxy + differed. An entry already under + # the proxy host but with a stray path segment satisfied the first and not the + # second, so the normalizer reported "0 rewritten / already normalized" while + # silently rewriting the file - and README Flow 1 makes that count the + # documented verification signal. + write_mixed_lockfile "${TMP}" + mutate "${TMP}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/extra/beta/-/beta-2.0.0.tgz"' + local before + before="$(wc -c <"${TMP}")" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'entries rewritten: 2'* ]] + [[ "${output}" != *'already normalized'* ]] + [ "$(wc -c <"${TMP}")" -ne "${before}" ] +} + +@test "an unsupported lockfileVersion fails readably instead of a jq trace" { + printf '{"name":"x","lockfileVersion":1}\n' >"${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'lockfileVersion 1'* ]] + [[ "${output}" != *'jq: error'* ]] +} + +@test "the self-assertion FAILS CLOSED and leaves the lockfile untouched when it cannot run" { + # `if ! diff -q <(fingerprint_of A) <(fingerprint_of B)` disabled `set -e` for + # the condition, so a failing fingerprint_of produced two empty streams, diff + # called them identical, and the file was overwritten unverified with exit 0. + write_mixed_lockfile "${TMP}" + local before + before="$(cat "${TMP}")" + local hidden="${BATS_TEST_TMPDIR}/fingerprint.jq.hidden" + mv "${BATS_TEST_DIRNAME}/fingerprint.jq" "${hidden}" + + run "${NORMALIZE}" "${TMP}" + local rc="${status}" out="${output}" + + mv "${hidden}" "${BATS_TEST_DIRNAME}/fingerprint.jq" + + [ "${rc}" -ne 0 ] + [[ "${out}" == *'left untouched'* ]] + [ "$(cat "${TMP}")" = "${before}" ] +} + +@test "rewrites a scope-repeating tarball path onto the proxy" { + # The npmjs form of the shape JFrog serves for privately published scoped + # packages. The captured path must keep the repeated scope verbatim. + write_mixed_lockfile "${TMP}" + mutate "${TMP}" '.packages["node_modules/beta"].resolved = + "https://registry.npmjs.org/@cplace-next/beta/-/@cplace-next/beta-2.0.0.tgz"' + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [ "$(jq -r '.packages["node_modules/beta"].resolved' "${TMP}")" \ + = "${PROXY}@cplace-next/beta/-/@cplace-next/beta-2.0.0.tgz" ] +} + +@test "a lockfile with no lockfileVersion at all fails readably" { + printf '{"name":"x","packages":{}}\n' >"${TMP}" + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 1 ] + [[ "${output}" == *'is it a package-lock.json?'* ]] +} + +@test "the root entry is left alone, and the reported count says so" { + # The rewrite used to run on every entry with a string `resolved` while + # count_foreign_entries excluded the root key, so a run that rewrote the root + # still printed "entries rewritten: 0" and "already normalized" about a file + # it had changed - and README Flow 1 makes that count the verification signal. + write_mixed_lockfile "${TMP}" + mutate "${TMP}" '.packages[""].resolved = "https://registry.npmjs.org/x/-/x-1.0.0.tgz"' + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [ "$(jq -r '.packages[""].resolved' "${TMP}")" = 'https://registry.npmjs.org/x/-/x-1.0.0.tgz' ] + [[ "${output}" == *'entries rewritten: 1'* ]] +} + +@test "a root entry whose resolved is not a tarball URL keeps its key" { + # assert_resolvable deliberately skips the root entry, so this value reached + # `capture` unvalidated - which yields empty on a non-match, and `|= empty` + # deletes the key. The loss was silent: fingerprint.jq dropped it on both + # sides, so the self-assertion compared equal and the run reported success. + write_mixed_lockfile "${TMP}" + mutate "${TMP}" '.packages[""].resolved = "packages/root"' + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [ "$(jq -r '.packages[""].resolved' "${TMP}")" = 'packages/root' ] +} + +@test "leaves an odd-shaped entry already on the proxy untouched" { + # This is what the pre-widening regex got wrong: the entry is already correct, + # so the normalizer must neither rewrite it nor - via a capture that matches + # nothing - drop its resolved key. + write_mixed_lockfile "${TMP}" + mutate "${TMP}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@fortawesome/beta/-/2.0.0/beta-2.0.0.tgz"' + + run "${NORMALIZE}" "${TMP}" + + [ "${status}" -eq 0 ] + [ "$(jq -r '.packages["node_modules/beta"].resolved' "${TMP}")" \ + = "${PROXY}@fortawesome/beta/-/2.0.0/beta-2.0.0.tgz" ] + [[ "${output}" == *'entries rewritten: 1'* ]] +} diff --git a/tools/scripts/lockfile/normalize-lockfile.sh b/tools/scripts/lockfile/normalize-lockfile.sh new file mode 100755 index 0000000..81e6495 --- /dev/null +++ b/tools/scripts/lockfile/normalize-lockfile.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Rewrites every `resolved` URL in a package-lock.json onto the cplace JFrog npm +# proxy, changing nothing else. Idempotent. +# +# Usage: +# ./tools/scripts/lockfile/normalize-lockfile.sh [] +# +# Defaults to ./package-lock.json. +# +# Requires only bash and jq - no node, no `npm ci`. That is the point: this +# script repairs the lockfile whose npmjs URLs make `npm ci` fail against a +# JFrog ~/.npmrc, so it cannot depend on `npm ci` having worked. +# +# See tools/scripts/lockfile/README.md + +set -euo pipefail + +# shellcheck source=tools/scripts/lockfile/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +main() { + local lockfile="${1:-package-lock.json}" + + require_jq + [[ -f "${lockfile}" ]] || die "no such lockfile: ${lockfile}" + assert_supported_lockfile "${lockfile}" + + assert_resolvable "${lockfile}" + + local before_bytes rewritten + before_bytes="$(byte_size "${lockfile}")" + rewritten="$(count_foreign_entries "${lockfile}")" + + local tmp + tmp="$(mktemp)" + # shellcheck disable=SC2064 # expand ${tmp} now, not when the trap fires + trap "rm -f '${tmp}'" EXIT + + # The guards are the shared predicate's own terms (lib.sh's + # JQ_REGISTRY_ENTRIES), so that what this rewrites and what + # count_foreign_entries reports cannot disagree. Without the `.key != ""` arm + # the root entry was rewritten while the count - which excludes it - still + # said "entries rewritten: 0, already normalized" about a changed file; and a + # root `resolved` that is not a tarball URL lost its key entirely, because a + # non-matching capture yields empty and `|= empty` deletes. + jq --arg proxy "${JFROG_NPM_PROXY}" --arg tarball_re "${TARBALL_PATH_RE}" ' + .packages |= with_entries( + if .key != "" + and (.value | type) == "object" + and (.value.resolved | type) == "string" + and (.value.resolved | test("^https?://"; "i")) + then + .value.resolved |= ($proxy + (capture($tarball_re) | .t)) + else + . + end + ) + ' "${lockfile}" >"${tmp}" || die "internal error: the rewrite failed; ${lockfile} left untouched" + + # Self-assertion. Secondary by design: it cannot see drift that arrived BEFORE + # this run (a bad merge), which is why check-lockfile.sh compares against a git + # baseline instead. It does make this script safe to run standalone. + # + # Both fingerprints are materialised and CHECKED first, rather than compared + # through process substitution inside `if !`. That construct disables `set -e` + # for the condition, so if fingerprint_of failed, both substitutions produced + # empty output, `diff` called them identical, the assertion "passed" - and the + # lockfile was overwritten unverified, exit 0. It has to fail CLOSED: this is + # the step that decides whether to write the file at all. + local fp_before fp_after + fp_before="$(mktemp)" || die "internal error: mktemp failed" + fp_after="$(mktemp)" || die "internal error: mktemp failed" + # shellcheck disable=SC2064 # expand paths now, not when the trap fires + trap "rm -f '${tmp}' '${fp_before}' '${fp_after}'" EXIT + + fingerprint_of "${lockfile}" >"${fp_before}" \ + || die "internal error: cannot fingerprint ${lockfile} (is ${FINGERPRINT_JQ} present?); left untouched" + fingerprint_of "${tmp}" >"${fp_after}" \ + || die "internal error: cannot fingerprint the rewritten lockfile (is ${FINGERPRINT_JQ} present?); ${lockfile} left untouched" + + if ! diff -q "${fp_before}" "${fp_after}" >/dev/null; then + die "internal error: normalization altered the dependency graph; ${lockfile} left untouched" + fi + + # `cat >` rather than `mv`, to preserve the file's existing permissions. + cat "${tmp}" >"${lockfile}" + + local after_bytes + after_bytes="$(byte_size "${lockfile}")" + + info "normalized ${lockfile}" + info " entries rewritten: ${rewritten}" + info " byte delta: $((after_bytes - before_bytes)) (${before_bytes} -> ${after_bytes})" + if ((rewritten == 0)); then + info " already normalized - nothing to do" + fi +} + +main "$@" diff --git a/tools/scripts/lockfile/test-helper.bash b/tools/scripts/lockfile/test-helper.bash new file mode 100644 index 0000000..8704f94 --- /dev/null +++ b/tools/scripts/lockfile/test-helper.bash @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Shared bats fixture builders. Fixtures are tiny hand-written lockfiles, not +# copies of the real 262 KB one: the tests assert behaviour, not byte counts. +# +# shellcheck disable=SC2034 # PROXY/NPMJS are consumed by the .bats files that load this + +readonly PROXY='https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/' +readonly NPMJS='https://registry.npmjs.org/' + +# A three-entry mixed lockfile: one npmjs entry (scoped, with a dependency edge), +# one already on the proxy, plus the root entry which carries no `resolved`. +write_mixed_lockfile() { + cat >"$1" <<'JSON' +{ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "@scope/alpha": "^1.0.0" + } + }, + "node_modules/@scope/alpha": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@scope/alpha/-/alpha-1.0.0.tgz", + "integrity": "sha512-AAAA==", + "dependencies": { + "beta": "^2.0.0" + } + }, + "node_modules/beta": { + "version": "2.0.0", + "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/beta/-/beta-2.0.0.tgz", + "integrity": "sha512-BBBB==" + } + } +} +JSON +} + +# Applies a jq filter to a lockfile in place. Used to inject drift. +mutate() { + local file="$1" filter="$2" tmp + tmp="$(mktemp)" + jq "${filter}" "${file}" >"${tmp}" + mv "${tmp}" "${file}" +} + +# A lockfile whose non-root entry carries no `resolved` at all. +write_unresolvable_lockfile() { + write_mixed_lockfile "$1" + mutate "$1" 'del(.packages["node_modules/beta"].resolved)' +} + +# A lockfile whose non-root entry resolves over a protocol this tool does not +# handle. Must fail by NAME, not with a jq capture stack trace. +write_git_protocol_lockfile() { + write_mixed_lockfile "$1" + mutate "$1" '.packages["node_modules/beta"].resolved = + "git+ssh://git@github.com/example/beta.git#0123456789abcdef"' +} diff --git a/tools/scripts/lockfile/warn-foreign-registry.bats b/tools/scripts/lockfile/warn-foreign-registry.bats new file mode 100644 index 0000000..034baee --- /dev/null +++ b/tools/scripts/lockfile/warn-foreign-registry.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats +# +# Behavioural tests for warn-foreign-registry.sh. +# +# The overriding property is that this check is ADVISORY: it runs inside +# use-npmrc in every consumer's pipeline, so it must never fail a build no +# matter what it is pointed at. +# +# Run with: bats tools/scripts/lockfile/ + +setup() { + load 'test-helper' + WARN="${BATS_TEST_DIRNAME}/warn-foreign-registry.sh" + TMP="${BATS_TEST_TMPDIR}/package-lock.json" + SUMMARY="${BATS_TEST_TMPDIR}/summary.md" +} + +@test "is silent on a lockfile that resolves entirely through the proxy" { + write_mixed_lockfile "${TMP}" + "${BATS_TEST_DIRNAME}/normalize-lockfile.sh" "${TMP}" >/dev/null + + run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "warns, with a count, when entries resolve elsewhere" { + write_mixed_lockfile "${TMP}" + + run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'::warning'* ]] + [[ "${output}" == *'1 entries'* ]] +} + +@test "ignores local-path resolved values, which are not registry references" { + write_mixed_lockfile "${TMP}" + "${BATS_TEST_DIRNAME}/normalize-lockfile.sh" "${TMP}" >/dev/null + mutate "${TMP}" '.packages["node_modules/local-plugin"] = + {"version":"1.0.0","resolved":"tools/eslint-rules","link":true}' + + run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "writes a job summary when GITHUB_STEP_SUMMARY is set" { + write_mixed_lockfile "${TMP}" + + GITHUB_STEP_SUMMARY="${SUMMARY}" run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] + [ -f "${SUMMARY}" ] + grep -q 'outside the cplace npm proxy' "${SUMMARY}" + grep -q 'node_modules/@scope/alpha' "${SUMMARY}" +} + +@test "the warning names no URL, so it survives *** masking" { + write_mixed_lockfile "${TMP}" + + GITHUB_STEP_SUMMARY="${SUMMARY}" run "${WARN}" "${TMP}" + + [[ "${output}" != *'cplace.jfrog.io'* ]] + [[ "${output}" != *'registry.npmjs.org'* ]] +} + +# --- advisory guarantee: none of these may fail a build ----------------------- + +@test "exits 0 and says nothing when the lockfile does not exist" { + run "${WARN}" "${BATS_TEST_TMPDIR}/absent.json" + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "exits 0 on a lockfile that is not valid JSON" { + printf 'not json at all\n' >"${TMP}" + + run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] +} + +@test "exits 0 on a lockfile with no packages section" { + printf '{"name":"x","lockfileVersion":3}\n' >"${TMP}" + + run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "falls back to GITHUB_WORKSPACE when given no argument" { + write_mixed_lockfile "${BATS_TEST_TMPDIR}/package-lock.json" + + GITHUB_WORKSPACE="${BATS_TEST_TMPDIR}" run "${WARN}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'::warning'* ]] +} + +@test "reports an entry on the right host but with a stray path segment" { + # `startswith($proxy)` passed this and reported nothing, while + # check-lockfile.sh correctly rejected it. The advisory inventory decides when + # the replace-registry-host mitigation can be removed, so a false negative + # here could green-light removing it while lockfiles are still broken. + write_mixed_lockfile "${TMP}" + "${BATS_TEST_DIRNAME}/normalize-lockfile.sh" "${TMP}" >/dev/null + mutate "${TMP}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/extra/beta/-/beta-2.0.0.tgz"' + + run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *'::warning'* ]] + [[ "${output}" == *'1 entries'* ]] +} + +@test "agrees with check-lockfile.sh about what is an offender" { + write_mixed_lockfile "${TMP}" + "${BATS_TEST_DIRNAME}/normalize-lockfile.sh" "${TMP}" >/dev/null + mutate "${TMP}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/extra/beta/-/beta-2.0.0.tgz"' + + run "${BATS_TEST_DIRNAME}/check-lockfile.sh" --prefix-only "${TMP}" + local check_rc="${status}" + run "${WARN}" "${TMP}" + + # check-lockfile fails (1) exactly when the advisory warns (non-empty output). + [ "${check_rc}" -eq 1 ] + [[ "${output}" == *'::warning'* ]] +} + +@test "exits 0 silently when lib.sh cannot be sourced" { + write_mixed_lockfile "${TMP}" + local hidden="${BATS_TEST_TMPDIR}/lib.sh.hidden" + mv "${BATS_TEST_DIRNAME}/lib.sh" "${hidden}" + + run "${WARN}" "${TMP}" + local rc="${status}" out="${output}" + + mv "${hidden}" "${BATS_TEST_DIRNAME}/lib.sh" + + [ "${rc}" -eq 0 ] + [ -z "${out}" ] +} + +@test "does not report an odd-shaped entry that is already on the proxy" { + # The inventory these warnings produce is what decides when the + # replace-registry-host=never mitigation can be removed, so a false positive + # is not cosmetic: it keeps the count above zero forever. Measured on + # cplace-paw-fe release/25.2, which reported 18 entries where only 14 were + # actually foreign. + write_mixed_lockfile "${TMP}" + "${BATS_TEST_DIRNAME}/normalize-lockfile.sh" "${TMP}" >/dev/null + mutate "${TMP}" '.packages["node_modules/beta"].resolved = + "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@cplace-next/beta/-/@cplace-next/beta-2.0.0.tgz"' + + run "${WARN}" "${TMP}" + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} diff --git a/tools/scripts/lockfile/warn-foreign-registry.sh b/tools/scripts/lockfile/warn-foreign-registry.sh new file mode 100755 index 0000000..968adb1 --- /dev/null +++ b/tools/scripts/lockfile/warn-foreign-registry.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# +# Advisory check: warn when a lockfile has `resolved` URLs that do NOT go +# through the cplace JFrog npm proxy. +# +# Usage: +# ./tools/scripts/lockfile/warn-foreign-registry.sh [] +# +# Runs inside the `use-npmrc` composite, so it inspects the CONSUMER's lockfile +# on the runner. Those entries resolve today only because `use-npmrc` sets +# `replace-registry-host=never`; without it npm rewrites their host onto the +# configured registry, drops that registry's path prefix, and fails with an +# E404 masked as *** (PFM-ISSUE-34453). +# +# ADVISORY ONLY - this must never fail a consumer's build. Every exit is 0. +# It is a discovery mechanism: the warnings are the inventory of lockfiles that +# still need normalizing, and the moment that inventory is empty the +# `replace-registry-host=never` mitigation can be dropped. +# +# Emits a `::warning` annotation (surfaces at the top of the run and against the +# file) plus a job-summary table. Names package paths only, never URLs, so the +# message survives *** masking of the JFrog host. +# +# See tools/scripts/lockfile/README.md + +set -uo pipefail + +# Sourced, not re-implemented. This script previously carried its own copy of the +# proxy URL and its own `startswith($proxy)` predicate - which is the exact bug +# lib.sh documents as already found and fixed once: an entry on the right host +# but with a stray path segment satisfies `startswith` and was silently reported +# as compliant. That mattered here more than anywhere else, because these +# warnings are the inventory that decides when the mitigation can be removed. +# +# Sourcing is guarded on both sides: an unreachable or unloadable lib.sh exits 0 +# silently rather than failing a consumer's build, which the advisory contract +# below requires and which the script's own care cannot cover once it has to +# reference an undefined constant under `set -u`. +LIB="$(dirname "${BASH_SOURCE[0]}")/lib.sh" +readonly LIB +[[ -r "${LIB}" ]] || exit 0 +# shellcheck source=tools/scripts/lockfile/lib.sh +source "${LIB}" || exit 0 + +readonly MAX_LISTED=10 + +main() { + local lockfile="${1:-${GITHUB_WORKSPACE:-.}/package-lock.json}" + + # Every precondition is a silent success: an advisory check must not become a + # new way for consumer pipelines to fail. + [[ -f "${lockfile}" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + local offenders total + # The SAME predicate check-lockfile.sh asserts on, via lib.sh: strip the + # tarball path and require the remaining prefix to equal the proxy exactly. + # `startswith` was wrong here - it passes an entry on the right host with a + # stray path segment, e.g. `.../cplace-npm/extra/beta/-/beta-2.0.0.tgz`, which + # check-lockfile.sh correctly rejects. The two must agree, or this inventory + # under-reports the very lockfiles it exists to find. + # + # JQ_REGISTRY_ENTRIES considers only http(s) `resolved` values: local-path + # values (e.g. "tools/eslint-rules" for a workspace-local plugin) are not + # hosted anywhere and must not be reported - 4 of 41 FE repos have them. + offenders="$(jq -r --arg proxy "${JFROG_NPM_PROXY}" --arg tarball_re "${TARBALL_PATH_RE}" \ + "[ ${JQ_REGISTRY_ENTRIES} | select((.value.resolved | sub(\$tarball_re; \"\")) != \$proxy) | .key ] | .[]" \ + "${lockfile}" 2>/dev/null)" || return 0 + + [[ -n "${offenders}" ]] || return 0 + + total="$(printf '%s\n' "${offenders}" | wc -l | tr -d '[:space:]')" + + printf '::warning file=%s::%s entries in package-lock.json do not resolve via the cplace npm proxy. They install today only because use-npmrc sets replace-registry-host=never. Normalize this lockfile - see PFM-ISSUE-34453.\n' \ + "$(basename "${lockfile}")" "${total}" + + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + # shellcheck disable=SC2016 # backticks here are markdown for the job summary, not command substitution + { + printf '### Lockfile entries outside the cplace npm proxy\n\n' + printf '**%s** `resolved` entries in `%s` point somewhere other than the cplace npm proxy.\n\n' \ + "${total}" "${lockfile#"${GITHUB_WORKSPACE:-}/"}" + printf 'They install successfully only because `use-npmrc` sets `replace-registry-host=never`. ' + printf 'Without it npm rewrites their host onto the configured registry, drops its path prefix, ' + printf 'and fails with an `E404` masked as `***`.\n\n' + printf 'Fix by normalizing the lockfile onto the proxy (PFM-ISSUE-34453); the mitigation can be ' + printf 'removed once no lockfile reports this.\n\n' + printf '
First %s affected packages\n\n' "${MAX_LISTED}" + printf '```\n' + printf '%s\n' "${offenders}" | head -n "${MAX_LISTED}" + (( total > MAX_LISTED )) && printf '… and %s more\n' "$((total - MAX_LISTED))" + printf '```\n\n
\n\n' + } >>"${GITHUB_STEP_SUMMARY}" + fi + + return 0 +} + +main "$@"