From 083108d9f7da6ac79cc34d67a7bcabba8b41fcd3 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 22 Apr 2026 14:49:19 -0700 Subject: [PATCH 01/19] ci(client): scope PR tests to changed packages via pnpm filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `detect_changes` job to the client build pipeline that computes the merge-base vs the PR's target branch and emits a pnpm `--filter "...[]"` expression as an output variable. Downstream test jobs consume it via `npm_config_filter`, so pnpm natively scopes recursive runs to packages changed in the PR (plus their transitive dependents) — no wrapper scripts, no package.json changes. Activates when one of: * `enableChangedPackageTestScoping` pipeline parameter is true, OR * the PR source branch name contains `test/filtered-ci/` — a branch-name convention that lets contributors exercise the feature from an auto-triggered PR build without flipping parameters. When the detect job is skipped (non-PR builds, or PR builds that haven't opted in) its output variables resolve to empty strings, which downstream jobs interpret as "no filter — run every package" — so pipeline behavior is effectively byte-identical to today for non-opt-in runs. The merge-base SHA is passed to pnpm's selector instead of a branch ref because pnpm's `[ref]` uses a two-dot diff (pnpm/pnpm#9907), which would pick up unrelated commits from the target branch as "changed". 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- tools/pipelines/build-client.yml | 10 ++ .../templates/build-npm-client-package.yml | 85 +++++++++- .../include-detect-changed-packages.yml | 149 ++++++++++++++++++ .../pipelines/templates/include-test-task.yml | 10 ++ 4 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 tools/pipelines/templates/include-detect-changed-packages.yml diff --git a/tools/pipelines/build-client.yml b/tools/pipelines/build-client.yml index b6c184953b4f..2e9bc6d424e2 100644 --- a/tools/pipelines/build-client.yml +++ b/tools/pipelines/build-client.yml @@ -50,6 +50,15 @@ parameters: displayName: Fluid build tools version (default = installs version in repo) type: string default: repo +# Rollout switch for scoping PR test execution to packages changed by the PR +# (plus their transitive dependents). When false, scoping still activates +# automatically for PR builds whose source branch contains 'test/filtered-ci/' +# — a magic substring used to verify the feature end-to-end before general +# rollout. See build-npm-client-package.yml for the full rules. +- name: enableChangedPackageTestScoping + displayName: Scope tests to changed packages (PR builds only) + type: boolean + default: false trigger: branches: @@ -199,6 +208,7 @@ extends: releaseBuildOverride: ${{ parameters.releaseBuildOverride }} packageTypesOverride: ${{ parameters.packageTypesOverride }} buildToolsVersionToInstall: ${{ parameters.buildToolsVersionToInstall }} + enableChangedPackageTestScoping: ${{ parameters.enableChangedPackageTestScoping }} interdependencyRange: ${{ parameters.interdependencyRange }} packageManagerInstallCommand: 'pnpm i --frozen-lockfile' packageManager: pnpm diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 70472d4a4763..6edc5dde08ae 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -129,6 +129,28 @@ parameters: type: boolean default: false +# Feature flag: when true, PR builds run a `detect_changes` job up front +# and scope subsequent test execution to packages affected by the PR diff +# (plus their transitive dependents). Scoping is applied natively by pnpm +# via `npm_config_filter="...[]"`, so no wrapper scripts or +# package.json changes are needed. +# +# The detect_changes job is always compiled into the pipeline but runs +# conditionally at build time. It executes when all of these are true: +# (1) Build.Reason == 'PullRequest' (scoping only makes sense for PRs) +# (2) this parameter is true, +# OR the PR source branch name contains 'test/filtered-ci/' — a magic +# branch-name prefix that lets contributors exercise the scoped +# behavior from an auto-triggered PR build without flipping a +# parameter at queue time. +# When the condition is false the job is skipped; its output variables +# resolve to empty strings, and downstream test jobs interpret that as +# "no filter — run every package" (the historical behavior). So the +# pipeline remains effectively byte-identical for non-opt-in runs. +- name: enableChangedPackageTestScoping + type: boolean + default: false + # The `resources` specify the location and version of the 1ES Pipeline Template. resources: repositories: @@ -206,6 +228,33 @@ extends: displayName: Build Stage dependsOn: [] # this stage doesn't depend on preceding stage jobs: + # Detect packages changed by this PR so downstream test jobs can + # scope via pnpm's `--filter "...[]"` selector (set as + # `npm_config_filter`). Always compiled in, but skipped at runtime + # for non-PR builds and for PR builds that haven't opted into + # scoping (neither the parameter set nor the magic branch name). + # When skipped, the output variables resolve to empty strings, + # which downstream jobs interpret as "run every package" — the + # historical behavior. + - job: detect_changes + displayName: Detect changed packages + condition: >- + and( + eq(variables['Build.Reason'], 'PullRequest'), + or( + eq('${{ parameters.enableChangedPackageTestScoping }}', 'True'), + contains(variables['System.PullRequest.SourceBranch'], 'test/filtered-ci/') + ) + ) + variables: + - name: targetBranchName + value: $(System.PullRequest.TargetBranch) + steps: + - template: /tools/pipelines/templates/include-detect-changed-packages.yml@self + parameters: + buildDirectory: ${{ parameters.buildDirectory }} + targetBranchName: $(targetBranchName) + # Job - Build - job: build displayName: Build @@ -561,7 +610,9 @@ extends: - job: Coverage_tests displayName: "Coverage tests" - dependsOn: build + dependsOn: + - build + - detect_changes variables: - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: - name: targetBranchName @@ -577,6 +628,14 @@ extends: value: 'false' - name: COMMIT_SHA value: $[ dependencies.build.outputs['setCommitSHA.COMMIT_SHA'] ] + # Outputs from the detect_changes job. Empty when the job was + # skipped (non-PR builds, or PR builds that didn't opt into + # scoping) — downstream steps interpret that as "run every + # package", matching the historical behavior. + - name: shouldRunTests + value: $[ dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'] ] + - name: scopedPnpmFilter + value: $[ dependencies.detect_changes.outputs['setChangedPackages.scopedPnpmFilter'] ] steps: # Setup @@ -619,9 +678,14 @@ extends: # Set variable startTest if everything is good so far and we'll start running tests, # so that the steps to process/upload test coverage results only run if we got to the point of actually running tests. + # The extra `ne(shouldRunTests, 'false')` guard skips test execution + # when the detect job explicitly concluded no packages were affected. + # An empty `shouldRunTests` (the job was skipped entirely) still lets + # tests run — matching the historical "no scoping" behavior. - script: | echo "##vso[task.setvariable variable=startTest]true" displayName: Start Test + condition: and(succeeded(), ne(variables['shouldRunTests'], 'false')) - ${{ each test in parameters.coverageTests }}: - template: /tools/pipelines/templates/include-test-task.yml@self @@ -629,6 +693,7 @@ extends: taskTestStep: '${{ test.name }}' buildDirectory: '${{ parameters.buildDirectory }}' testCoverage: '${{ parameters.testCoverage }}' + pnpmFilter: $(scopedPnpmFilter) - task: Npm@1 displayName: 'npm run test:copyresults' @@ -722,7 +787,9 @@ extends: - ${{ each test in parameters.taskTest }}: - job: Test_${{ test.jobName }} displayName: "Run Task Test ${{ test.jobName }}" - dependsOn: build + dependsOn: + - build + - detect_changes variables: - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: - name: targetBranchName @@ -738,6 +805,14 @@ extends: value: 'false' - name: COMMIT_SHA value: $[ dependencies.build.outputs['setCommitSHA.COMMIT_SHA'] ] + # Outputs from the detect_changes job. Empty when the job was + # skipped (non-PR builds, or PR builds that didn't opt into + # scoping) — downstream steps interpret that as "run every + # package", matching the historical behavior. + - name: shouldRunTests + value: $[ dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'] ] + - name: scopedPnpmFilter + value: $[ dependencies.detect_changes.outputs['setChangedPackages.scopedPnpmFilter'] ] steps: # Setup - checkout: self @@ -774,15 +849,21 @@ extends: # Set variable startTest if everything is good so far and we'll start running tests, # so that the steps to process/upload test coverage results only run if we got to the point of actually running tests. + # The extra `ne(shouldRunTests, 'false')` guard skips test execution + # when the detect job explicitly concluded no packages were affected. + # An empty `shouldRunTests` (the job was skipped entirely) still lets + # tests run — matching the historical "no scoping" behavior. - script: | echo "##vso[task.setvariable variable=startTest]true" displayName: Start Test + condition: and(succeeded(), ne(variables['shouldRunTests'], 'false')) - template: /tools/pipelines/templates/include-test-task.yml@self parameters: taskTestStep: '${{ test.name }}' buildDirectory: '${{ parameters.buildDirectory }}' testCoverage: 'false' + pnpmFilter: $(scopedPnpmFilter) - task: Npm@1 displayName: 'npm run test:copyresults' diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml new file mode 100644 index 000000000000..ae67e234509a --- /dev/null +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft Corporation and contributors. All rights reserved. +# Licensed under the MIT License. + +# include-detect-changed-packages +# +# Detects whether a PR's diff warrants scoping downstream test execution to +# a subset of workspace packages and publishes output variables that test +# jobs read to make that decision. +# +# The scoping itself is handled natively by pnpm via the +# `--filter "...[]"` selector — this template just computes the right +# git ref (the merge-base between HEAD and the target branch) and emits it +# as a ready-to-use filter string that downstream jobs set as +# `npm_config_filter`. pnpm then picks up the env var automatically and +# applies it to any `pnpm -r run ` invocation. That keeps the root +# package.json scripts unchanged and removes the need for a custom wrapper. +# +# Output variables (from the `setChangedPackages` step): +# - shouldRunTests "true" | "false" — whether any test work is needed at all +# - scopedPnpmFilter The pnpm filter string ("...[]") when scoping is +# active, or an empty string when a full test run is +# required. Downstream jobs pass this into +# `npm_config_filter` verbatim; pnpm treats an empty +# value as "no filter applied", so recursive `-r` runs +# fall back to the historical every-package behavior. +# +# On any error path (missing merge-base, unsupported ref format) this +# template degrades safely to a full-run outcome, never to a silent skip. +# +# Why merge-base (and not just `origin/` directly): pnpm's +# `--filter "[ref]"` uses a two-dot diff internally (see pnpm/pnpm#9907), so +# commits that landed on `origin/` after this PR diverged would show +# up as "changed." Computing the merge-base SHA ourselves and feeding that +# SHA into the selector gives three-dot (merge-base) semantics. + +parameters: +- name: buildDirectory + type: string + +- name: targetBranchName + type: string + +steps: + - checkout: self + path: $(FluidFrameworkDirectory) + clean: true + # Full history is required for `git merge-base HEAD origin/` to + # produce a meaningful ancestor on a PR branch. + fetchDepth: 0 + + - task: Bash@3 + name: setChangedPackages + displayName: Detect changed packages + inputs: + targetType: inline + workingDirectory: '$(Pipeline.Workspace)/${{ parameters.buildDirectory }}' + script: | + set -eu -o pipefail + + # Normalize: ADO returns "refs/heads/main" on Azure Repos, just "main" on GitHub. + TARGET_BRANCH="${{ parameters.targetBranchName }}" + TARGET_BRANCH="${TARGET_BRANCH#refs/heads/}" + echo "Target branch: ${TARGET_BRANCH}" + + # Safe fallback is always a full run — non-empty filter would be a skip. + SHOULD_RUN="true" + FILTER="" + + emit_and_exit() { + echo "shouldRunTests=${SHOULD_RUN}" + echo "scopedPnpmFilter=${FILTER}" + echo "##vso[task.setvariable variable=shouldRunTests;isOutput=true]${SHOULD_RUN}" + echo "##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${FILTER}" + exit 0 + } + + if ! git fetch origin "${TARGET_BRANCH}"; then + echo "##vso[task.logissue type=warning]Could not fetch origin/${TARGET_BRANCH}; falling back to full test run." + emit_and_exit + fi + + MERGE_BASE="$(git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || true)" + if [ -z "${MERGE_BASE}" ]; then + echo "##vso[task.logissue type=warning]No merge-base with origin/${TARGET_BRANCH}; falling back to full test run." + emit_and_exit + fi + echo "Merge base: ${MERGE_BASE}" + + CHANGED_FILES="$(git diff --name-only "${MERGE_BASE}" || true)" + FILE_COUNT="$(printf '%s\n' "${CHANGED_FILES}" | grep -c . || true)" + echo "Changed files (${FILE_COUNT}):" + printf '%s\n' "${CHANGED_FILES}" | head -30 + if [ "${FILE_COUNT}" -gt 30 ]; then + echo "... and $((FILE_COUNT - 30)) more" + fi + + # Full-run trigger patterns. A diff touching any of these paths forces + # running every package's tests (FILTER stays empty → pnpm -r runs + # across the whole workspace). + # Keep this list conservative — it's the safety net for changes that + # could plausibly invalidate assumptions across the entire workspace. + FULL_RUN_PATTERNS=( + '^package\.json$' + '^pnpm-lock\.yaml$' + '^pnpm-workspace\.yaml$' + '^fluidBuild\.config\.cjs$' + '^tsconfig[^/]*\.json$' + '^biome\.' + '^tools/' + '^common/' + '^scripts/' + '^\.changeset/config\.json$' + ) + for pattern in "${FULL_RUN_PATTERNS[@]}"; do + if printf '%s\n' "${CHANGED_FILES}" | grep -Eq "${pattern}"; then + echo "Match for full-run pattern '${pattern}' — forcing full test run." + emit_and_exit + fi + done + + # Quick sanity check: did any changed file land under a package dir + # (anything with a package.json below the repo root)? If not, there's + # no test work to do at all and we can short-circuit. + HAS_PACKAGE_CHANGE="false" + while IFS= read -r file; do + [ -z "${file}" ] && continue + d="$(dirname "${file}")" + while [ "${d}" != "." ] && [ "${d}" != "/" ]; do + if [ -f "${d}/package.json" ]; then + HAS_PACKAGE_CHANGE="true" + break 2 + fi + d="$(dirname "${d}")" + done + done <<< "${CHANGED_FILES}" + + if [ "${HAS_PACKAGE_CHANGE}" = "false" ]; then + echo "No changed files mapped to a workspace package — skipping test execution." + SHOULD_RUN="false" + FILTER="" + emit_and_exit + fi + + # Hand the merge-base SHA to pnpm's native selector. The leading `...` + # pulls in transitive dependents so consumers of a changed package + # also get re-tested. + FILTER="...[${MERGE_BASE}]" + echo "Computed pnpm filter: ${FILTER}" + emit_and_exit diff --git a/tools/pipelines/templates/include-test-task.yml b/tools/pipelines/templates/include-test-task.yml index 6901553a5d7b..cd6efd88d18a 100644 --- a/tools/pipelines/templates/include-test-task.yml +++ b/tools/pipelines/templates/include-test-task.yml @@ -14,6 +14,14 @@ parameters: type: boolean default: false +# Optional pnpm filter expression (e.g. "...[]"). When non-empty, it is +# set as `npm_config_filter` so pnpm scopes the recursive run to the listed +# packages plus their dependents. Empty means "no filter" — pnpm falls back +# to running across every workspace package (the historical behavior). +- name: pnpmFilter + type: string + default: '' + steps: # Test - With coverage - ${{ if and(parameters.testCoverage, startsWith(parameters.taskTestStep, 'ci:test')) }}: @@ -25,6 +33,7 @@ steps: customCommand: 'run ${{ parameters.taskTestStep }}:coverage' condition: and(succeededOrFailed(), eq(variables['startTest'], 'true')) env: + npm_config_filter: ${{ parameters.pnpmFilter }} # Tests can use this environment variable to behave differently when running from a test branch ${{ if contains(parameters.taskTestStep, 'tinylicious') }}: # Disable colorization for tinylicious logs (not useful when printing to a file) @@ -41,6 +50,7 @@ steps: customCommand: 'run ${{ parameters.taskTestStep }}' condition: and(succeededOrFailed(), eq(variables['startTest'], 'true')) env: + npm_config_filter: ${{ parameters.pnpmFilter }} # Tests can use this environment variable to behave differently when running from a test branch ${{ if contains(parameters.taskTestStep, 'tinylicious') }}: # Disable colorization for tinylicious logs (not useful when printing to a file) From 42db3328d5f4c624ddfe35ad08cf4147fa92fb2f Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 22 Apr 2026 15:38:23 -0700 Subject: [PATCH 02/19] ci(client): move test-skip guard to job level and tighten detect_changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the scoping PR: - Skip Coverage_tests and Test_* jobs entirely (not just their test steps) when detect_changes reports shouldRunTests=false. Empty (detect_changes skipped) still runs, preserving the historical non-opt-in behavior. Saves agent allocation + checkout + install for no-package-change PRs. - Remove the now-redundant step-level Start Test condition and orphaned `shouldRunTests` job variable; `scopedPnpmFilter` is still exposed as a job variable because test-task steps consume it. - Shallow-clone the detect_changes checkout (fetchDepth: 200) with a runtime unshallow fallback guarded by .git/shallow presence, so full clones don't error on --unshallow. - Cross-reference `pr: paths: include:` in build-client.yml from FULL_RUN_PATTERNS to flag the convention-based overlap. - Condense duplicated "historical behavior" narration across the diff. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- tools/pipelines/build-client.yml | 7 +- .../templates/build-npm-client-package.yml | 76 +++++++------------ .../include-detect-changed-packages.yml | 25 +++++- 3 files changed, 52 insertions(+), 56 deletions(-) diff --git a/tools/pipelines/build-client.yml b/tools/pipelines/build-client.yml index 2e9bc6d424e2..9fd054ed145d 100644 --- a/tools/pipelines/build-client.yml +++ b/tools/pipelines/build-client.yml @@ -50,11 +50,8 @@ parameters: displayName: Fluid build tools version (default = installs version in repo) type: string default: repo -# Rollout switch for scoping PR test execution to packages changed by the PR -# (plus their transitive dependents). When false, scoping still activates -# automatically for PR builds whose source branch contains 'test/filtered-ci/' -# — a magic substring used to verify the feature end-to-end before general -# rollout. See build-npm-client-package.yml for the full rules. +# Rollout switch for scoping PR test execution to changed packages. +# See build-npm-client-package.yml for the full activation rules. - name: enableChangedPackageTestScoping displayName: Scope tests to changed packages (PR builds only) type: boolean diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 6edc5dde08ae..59af0d523ef7 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -129,24 +129,13 @@ parameters: type: boolean default: false -# Feature flag: when true, PR builds run a `detect_changes` job up front -# and scope subsequent test execution to packages affected by the PR diff -# (plus their transitive dependents). Scoping is applied natively by pnpm -# via `npm_config_filter="...[]"`, so no wrapper scripts or -# package.json changes are needed. -# -# The detect_changes job is always compiled into the pipeline but runs -# conditionally at build time. It executes when all of these are true: -# (1) Build.Reason == 'PullRequest' (scoping only makes sense for PRs) -# (2) this parameter is true, -# OR the PR source branch name contains 'test/filtered-ci/' — a magic -# branch-name prefix that lets contributors exercise the scoped -# behavior from an auto-triggered PR build without flipping a -# parameter at queue time. -# When the condition is false the job is skipped; its output variables -# resolve to empty strings, and downstream test jobs interpret that as -# "no filter — run every package" (the historical behavior). So the -# pipeline remains effectively byte-identical for non-opt-in runs. +# Feature flag: when true, PR builds scope test execution to packages +# affected by the PR diff (plus transitive dependents) via pnpm's native +# `--filter "...[]"`. Also activates when the PR source branch +# name contains 'test/filtered-ci/' — a rollout opt-in for exercising the +# behavior from an auto-triggered PR build without flipping a parameter at +# queue time. Non-opt-in runs remain effectively byte-identical to today. +# See include-detect-changed-packages.yml for the full filter semantics. - name: enableChangedPackageTestScoping type: boolean default: false @@ -228,14 +217,9 @@ extends: displayName: Build Stage dependsOn: [] # this stage doesn't depend on preceding stage jobs: - # Detect packages changed by this PR so downstream test jobs can - # scope via pnpm's `--filter "...[]"` selector (set as - # `npm_config_filter`). Always compiled in, but skipped at runtime - # for non-PR builds and for PR builds that haven't opted into - # scoping (neither the parameter set nor the magic branch name). - # When skipped, the output variables resolve to empty strings, - # which downstream jobs interpret as "run every package" — the - # historical behavior. + # Detect packages changed by this PR; publishes output variables that + # scope downstream test jobs. Skipped for non-PR builds and non-opt-in + # PRs; see include-detect-changed-packages.yml for the full semantics. - job: detect_changes displayName: Detect changed packages condition: >- @@ -613,6 +597,11 @@ extends: dependsOn: - build - detect_changes + # Skip the job entirely when detect_changes explicitly concluded no + # packages were affected — saves agent allocation, checkout, and + # install on scoped PRs. Empty (detect_changes was skipped) still + # runs. See include-detect-changed-packages.yml for the invariants. + condition: and(succeeded(), ne(dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'], 'false')) variables: - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: - name: targetBranchName @@ -628,12 +617,10 @@ extends: value: 'false' - name: COMMIT_SHA value: $[ dependencies.build.outputs['setCommitSHA.COMMIT_SHA'] ] - # Outputs from the detect_changes job. Empty when the job was - # skipped (non-PR builds, or PR builds that didn't opt into - # scoping) — downstream steps interpret that as "run every - # package", matching the historical behavior. - - name: shouldRunTests - value: $[ dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'] ] + # Output from detect_changes; empty when that job was skipped + # (interpreted downstream as "full run" — the historical behavior). + # `shouldRunTests` is consumed via the job-level condition above, + # so only `scopedPnpmFilter` needs a job-scoped variable here. - name: scopedPnpmFilter value: $[ dependencies.detect_changes.outputs['setChangedPackages.scopedPnpmFilter'] ] @@ -678,14 +665,9 @@ extends: # Set variable startTest if everything is good so far and we'll start running tests, # so that the steps to process/upload test coverage results only run if we got to the point of actually running tests. - # The extra `ne(shouldRunTests, 'false')` guard skips test execution - # when the detect job explicitly concluded no packages were affected. - # An empty `shouldRunTests` (the job was skipped entirely) still lets - # tests run — matching the historical "no scoping" behavior. - script: | echo "##vso[task.setvariable variable=startTest]true" displayName: Start Test - condition: and(succeeded(), ne(variables['shouldRunTests'], 'false')) - ${{ each test in parameters.coverageTests }}: - template: /tools/pipelines/templates/include-test-task.yml@self @@ -790,6 +772,11 @@ extends: dependsOn: - build - detect_changes + # Skip the job entirely when detect_changes explicitly concluded no + # packages were affected — saves agent allocation, checkout, and + # install on scoped PRs. Empty (detect_changes was skipped) still + # runs. See include-detect-changed-packages.yml for the invariants. + condition: and(succeeded(), ne(dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'], 'false')) variables: - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: - name: targetBranchName @@ -805,12 +792,10 @@ extends: value: 'false' - name: COMMIT_SHA value: $[ dependencies.build.outputs['setCommitSHA.COMMIT_SHA'] ] - # Outputs from the detect_changes job. Empty when the job was - # skipped (non-PR builds, or PR builds that didn't opt into - # scoping) — downstream steps interpret that as "run every - # package", matching the historical behavior. - - name: shouldRunTests - value: $[ dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'] ] + # Output from detect_changes; empty when that job was skipped + # (interpreted downstream as "full run" — the historical behavior). + # `shouldRunTests` is consumed via the job-level condition above, + # so only `scopedPnpmFilter` needs a job-scoped variable here. - name: scopedPnpmFilter value: $[ dependencies.detect_changes.outputs['setChangedPackages.scopedPnpmFilter'] ] steps: @@ -849,14 +834,9 @@ extends: # Set variable startTest if everything is good so far and we'll start running tests, # so that the steps to process/upload test coverage results only run if we got to the point of actually running tests. - # The extra `ne(shouldRunTests, 'false')` guard skips test execution - # when the detect job explicitly concluded no packages were affected. - # An empty `shouldRunTests` (the job was skipped entirely) still lets - # tests run — matching the historical "no scoping" behavior. - script: | echo "##vso[task.setvariable variable=startTest]true" displayName: Start Test - condition: and(succeeded(), ne(variables['shouldRunTests'], 'false')) - template: /tools/pipelines/templates/include-test-task.yml@self parameters: diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index ae67e234509a..633917c8271d 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -44,9 +44,10 @@ steps: - checkout: self path: $(FluidFrameworkDirectory) clean: true - # Full history is required for `git merge-base HEAD origin/` to - # produce a meaningful ancestor on a PR branch. - fetchDepth: 0 + # Shallow clone; the bash step below unshallows on demand if the + # merge-base can't be resolved within this depth. Most PRs merge-base + # within a few hundred commits, so this is cheap in the common case. + fetchDepth: 200 - task: Bash@3 name: setChangedPackages @@ -79,7 +80,17 @@ steps: emit_and_exit fi + # Try to resolve the merge-base in the shallow clone first. If the + # PR diverged further back than the shallow boundary, unshallow and + # retry once. The presence of a `shallow` file under `.git` is how + # git marks a shallow repo; skip the unshallow on a full clone + # (which would error with "--unshallow on a complete repository"). MERGE_BASE="$(git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || true)" + if [ -z "${MERGE_BASE}" ] && [ -f "$(git rev-parse --git-dir)/shallow" ]; then + echo "Merge-base not found in shallow clone; unshallowing and retrying." + git fetch --unshallow origin "${TARGET_BRANCH}" 2>/dev/null || true + MERGE_BASE="$(git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || true)" + fi if [ -z "${MERGE_BASE}" ]; then echo "##vso[task.logissue type=warning]No merge-base with origin/${TARGET_BRANCH}; falling back to full test run." emit_and_exit @@ -99,6 +110,14 @@ steps: # across the whole workspace). # Keep this list conservative — it's the safety net for changes that # could plausibly invalidate assumptions across the entire workspace. + # + # This list partially overlaps with `pr: paths: include:` in + # tools/pipelines/build-client.yml (which decides whether the pipeline + # runs at all). The concepts differ — one gates the pipeline, the + # other gates scoping within a pipeline that's already running — but + # adding a new cross-cutting root-level file generally warrants + # updating both. There's no programmatic link, so keep them in sync + # by convention. FULL_RUN_PATTERNS=( '^package\.json$' '^pnpm-lock\.yaml$' From c6d35a0af261d48539bfa98441b3c12f89a56241 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 22 Apr 2026 16:08:02 -0700 Subject: [PATCH 03/19] ci(client): replace succeeded() with explicit build.result check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #27134 revealed that `succeeded()` on `Coverage_tests` and `Test_*` false-skips the job when `detect_changes` is Skipped, contrary to the documented "skipped deps count as successful" behavior. Check `dependencies.build.result` directly so the test jobs run for non-opt-in PRs (where `detect_changes` is intentionally skipped) while still respecting a 'false' output signal when scoping is active. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- .../pipelines/templates/build-npm-client-package.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 59af0d523ef7..4bef922e447f 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -601,7 +601,11 @@ extends: # packages were affected — saves agent allocation, checkout, and # install on scoped PRs. Empty (detect_changes was skipped) still # runs. See include-detect-changed-packages.yml for the invariants. - condition: and(succeeded(), ne(dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'], 'false')) + # + # We check `build.result` directly instead of using `succeeded()` + # because the latter treats a Skipped `detect_changes` dependency + # as non-success and false-skips this job on non-opt-in PR builds. + condition: and(in(dependencies.build.result, 'Succeeded', 'SucceededWithIssues'), ne(dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'], 'false')) variables: - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: - name: targetBranchName @@ -776,7 +780,11 @@ extends: # packages were affected — saves agent allocation, checkout, and # install on scoped PRs. Empty (detect_changes was skipped) still # runs. See include-detect-changed-packages.yml for the invariants. - condition: and(succeeded(), ne(dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'], 'false')) + # + # We check `build.result` directly instead of using `succeeded()` + # because the latter treats a Skipped `detect_changes` dependency + # as non-success and false-skips this job on non-opt-in PR builds. + condition: and(in(dependencies.build.result, 'Succeeded', 'SucceededWithIssues'), ne(dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'], 'false')) variables: - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: - name: targetBranchName From ae2edf410b9e8104bbab16d477fad9ca7b963fad Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 22 Apr 2026 16:21:07 -0700 Subject: [PATCH 04/19] ci(client): drop unused enableChangedPackageTestScoping parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter only takes effect on manually-queued builds, where an operator can tick it in the ADO UI. Auto-triggered PR builds always run with the `false` default, so the only practical opt-in is the 'test/filtered-ci/' branch-name substring. Removing the parameter eliminates a lever that can't be pulled from a developer's normal workflow and simplifies the detect_changes activation condition. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- tools/pipelines/build-client.yml | 7 ------ .../templates/build-npm-client-package.yml | 22 +++++-------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/tools/pipelines/build-client.yml b/tools/pipelines/build-client.yml index 9fd054ed145d..b6c184953b4f 100644 --- a/tools/pipelines/build-client.yml +++ b/tools/pipelines/build-client.yml @@ -50,12 +50,6 @@ parameters: displayName: Fluid build tools version (default = installs version in repo) type: string default: repo -# Rollout switch for scoping PR test execution to changed packages. -# See build-npm-client-package.yml for the full activation rules. -- name: enableChangedPackageTestScoping - displayName: Scope tests to changed packages (PR builds only) - type: boolean - default: false trigger: branches: @@ -205,7 +199,6 @@ extends: releaseBuildOverride: ${{ parameters.releaseBuildOverride }} packageTypesOverride: ${{ parameters.packageTypesOverride }} buildToolsVersionToInstall: ${{ parameters.buildToolsVersionToInstall }} - enableChangedPackageTestScoping: ${{ parameters.enableChangedPackageTestScoping }} interdependencyRange: ${{ parameters.interdependencyRange }} packageManagerInstallCommand: 'pnpm i --frozen-lockfile' packageManager: pnpm diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 4bef922e447f..b16d3f3b0e48 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -129,17 +129,6 @@ parameters: type: boolean default: false -# Feature flag: when true, PR builds scope test execution to packages -# affected by the PR diff (plus transitive dependents) via pnpm's native -# `--filter "...[]"`. Also activates when the PR source branch -# name contains 'test/filtered-ci/' — a rollout opt-in for exercising the -# behavior from an auto-triggered PR build without flipping a parameter at -# queue time. Non-opt-in runs remain effectively byte-identical to today. -# See include-detect-changed-packages.yml for the full filter semantics. -- name: enableChangedPackageTestScoping - type: boolean - default: false - # The `resources` specify the location and version of the 1ES Pipeline Template. resources: repositories: @@ -218,17 +207,16 @@ extends: dependsOn: [] # this stage doesn't depend on preceding stage jobs: # Detect packages changed by this PR; publishes output variables that - # scope downstream test jobs. Skipped for non-PR builds and non-opt-in - # PRs; see include-detect-changed-packages.yml for the full semantics. + # scope downstream test jobs. Activated only for PR builds whose + # source branch name contains 'test/filtered-ci/' — a rollout opt-in + # to be broadened once the feature has soaked. See + # include-detect-changed-packages.yml for the full semantics. - job: detect_changes displayName: Detect changed packages condition: >- and( eq(variables['Build.Reason'], 'PullRequest'), - or( - eq('${{ parameters.enableChangedPackageTestScoping }}', 'True'), - contains(variables['System.PullRequest.SourceBranch'], 'test/filtered-ci/') - ) + contains(variables['System.PullRequest.SourceBranch'], 'test/filtered-ci/') ) variables: - name: targetBranchName From 35b71138659bb8ac3c77f15f1b2c7a1d71ea6176 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Thu, 23 Apr 2026 11:51:05 -0700 Subject: [PATCH 05/19] ci(client): fall back to full run on git diff / rev-parse failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review feedback on #27133: - git diff failures after merge-base resolution now emit a pipeline warning and call emit_and_exit (full run) instead of swallowing the error into an empty CHANGED_FILES, which would bypass full-run patterns and suppress all tests. - git rev-parse --git-dir is captured into GIT_DIR with a 2>/dev/null || true fallback and the shallow-file check gates on [ -n "${GIT_DIR}" ], so a rev-parse failure skips the unshallow branch instead of aborting under set -eu. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- .../templates/include-detect-changed-packages.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index 633917c8271d..239a4e7a41a3 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -85,8 +85,12 @@ steps: # retry once. The presence of a `shallow` file under `.git` is how # git marks a shallow repo; skip the unshallow on a full clone # (which would error with "--unshallow on a complete repository"). + # Capture `git rev-parse --git-dir` into a variable with a fallback + # so a rev-parse failure degrades to "skip the unshallow check" under + # `set -eu` instead of aborting the whole script. MERGE_BASE="$(git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || true)" - if [ -z "${MERGE_BASE}" ] && [ -f "$(git rev-parse --git-dir)/shallow" ]; then + GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)" + if [ -z "${MERGE_BASE}" ] && [ -n "${GIT_DIR}" ] && [ -f "${GIT_DIR}/shallow" ]; then echo "Merge-base not found in shallow clone; unshallowing and retrying." git fetch --unshallow origin "${TARGET_BRANCH}" 2>/dev/null || true MERGE_BASE="$(git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || true)" @@ -97,7 +101,13 @@ steps: fi echo "Merge base: ${MERGE_BASE}" - CHANGED_FILES="$(git diff --name-only "${MERGE_BASE}" || true)" + # On diff failure, fall back to a full run rather than swallowing the + # error — an empty CHANGED_FILES would bypass the full-run patterns + # and the package-change check, silently suppressing all test jobs. + if ! CHANGED_FILES="$(git diff --name-only "${MERGE_BASE}")"; then + echo "##vso[task.logissue type=warning]git diff against merge-base ${MERGE_BASE} failed; falling back to full test run." + emit_and_exit + fi FILE_COUNT="$(printf '%s\n' "${CHANGED_FILES}" | grep -c . || true)" echo "Changed files (${FILE_COUNT}):" printf '%s\n' "${CHANGED_FILES}" | head -30 From 5392eb95626d5f9203966bcc43af8d5fb2a3ce78 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Thu, 23 Apr 2026 12:16:07 -0700 Subject: [PATCH 06/19] ci(client): only emit npm_config_filter when filter is non-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review H1: wrapping the env var in `${{ if ne(parameters.pnpmFilter, '') }}` keeps the contract explicit — we only ask pnpm to filter when a filter is actually set. Empirically pnpm 10 treats an empty value the same as unset, but the conditional avoids relying on that unverified behavior. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- tools/pipelines/templates/include-test-task.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/pipelines/templates/include-test-task.yml b/tools/pipelines/templates/include-test-task.yml index cd6efd88d18a..eed51173e210 100644 --- a/tools/pipelines/templates/include-test-task.yml +++ b/tools/pipelines/templates/include-test-task.yml @@ -33,7 +33,11 @@ steps: customCommand: 'run ${{ parameters.taskTestStep }}:coverage' condition: and(succeededOrFailed(), eq(variables['startTest'], 'true')) env: - npm_config_filter: ${{ parameters.pnpmFilter }} + # Only emit npm_config_filter when a filter is actually set. pnpm 10 + # happens to treat an empty value the same as unset, but the conditional + # keeps the contract explicit and avoids relying on that behavior. + ${{ if ne(parameters.pnpmFilter, '') }}: + npm_config_filter: ${{ parameters.pnpmFilter }} # Tests can use this environment variable to behave differently when running from a test branch ${{ if contains(parameters.taskTestStep, 'tinylicious') }}: # Disable colorization for tinylicious logs (not useful when printing to a file) @@ -50,7 +54,11 @@ steps: customCommand: 'run ${{ parameters.taskTestStep }}' condition: and(succeededOrFailed(), eq(variables['startTest'], 'true')) env: - npm_config_filter: ${{ parameters.pnpmFilter }} + # Only emit npm_config_filter when a filter is actually set. pnpm 10 + # happens to treat an empty value the same as unset, but the conditional + # keeps the contract explicit and avoids relying on that behavior. + ${{ if ne(parameters.pnpmFilter, '') }}: + npm_config_filter: ${{ parameters.pnpmFilter }} # Tests can use this environment variable to behave differently when running from a test branch ${{ if contains(parameters.taskTestStep, 'tinylicious') }}: # Disable colorization for tinylicious logs (not useful when printing to a file) From 0e41263d2aeef7c13f4103261ea1996991074fe7 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Thu, 23 Apr 2026 12:17:48 -0700 Subject: [PATCH 07/19] ci(client): surface full test-skip as pipeline warning with file dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review M1 without introducing a bash unit test framework. The HAS_PACKAGE_CHANGE=false path is the most aggressive skip in the template — it causes every test job to be bypassed. Previously an accidental misclassification (bug in the directory walk, unexpected file layout) would be invisible in the pipeline summary: the skip was logged with a plain echo and the specific files considered were not dumped. Now that branch emits an ADO warning (`##vso[task.logissue type=warning]`) and prints the full list of files that went into the decision, so the suppression is auditable from the pipeline run without a re-run. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- .../templates/include-detect-changed-packages.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index 239a4e7a41a3..c4ae5be419d3 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -164,7 +164,12 @@ steps: done <<< "${CHANGED_FILES}" if [ "${HAS_PACKAGE_CHANGE}" = "false" ]; then - echo "No changed files mapped to a workspace package — skipping test execution." + # This is the most aggressive skip path: no test jobs run at all. + # Surface it as a pipeline warning (not a plain echo) and dump the + # file list so an accidental silent-suppression bug is auditable + # from the pipeline summary without needing to re-run the build. + echo "##vso[task.logissue type=warning]No changed files mapped to a workspace package — skipping all test execution. Files considered (${FILE_COUNT}):" + printf '%s\n' "${CHANGED_FILES}" | sed 's/^/ /' SHOULD_RUN="false" FILTER="" emit_and_exit From 89524e0c6e835b6420a13941ee8db8515da14ad9 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Fri, 24 Apr 2026 11:11:17 -0700 Subject: [PATCH 08/19] ci(client): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the review threads on #27133: - Extract the inline bash from include-detect-changed-packages.yml into scripts/detect-changed-packages.ts — runnable/debuggable locally and covered by unit tests (jason-ha, alexvy86). - Add node:test unit tests under scripts/test/ and wire them into the build job (runs early, fails fast, no new test framework). - Add .pnpmfile.cjs, .npmrc, .nvmrc to FULL_RUN_PATTERNS — root-level install/runtime config that affects every package (Copilot, alexvy86). - Fix deleted-package detection: union the merge-base tree's package.json list with the working tree so a package removed on this branch still maps to a known package dir (Copilot). - Unfilter the non-recursive `pnpm puppeteer` call in test:jest* via `cross-env npm_config_filter=`; confirmed empirically that npm_config_filter propagates to every pnpm invocation, not just recursive ones (Copilot). 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- package.json | 6 +- scripts/detect-changed-packages.ts | 275 ++++++++++++++++++ scripts/test/detect-changed-packages.test.ts | 175 +++++++++++ .../templates/build-npm-client-package.yml | 15 + .../include-detect-changed-packages.yml | 168 ++--------- .../pipelines/templates/include-test-task.yml | 9 + 6 files changed, 498 insertions(+), 150 deletions(-) create mode 100644 scripts/detect-changed-packages.ts create mode 100644 scripts/test/detect-changed-packages.test.ts diff --git a/package.json b/package.json index 1019e264d90b..043bbb91778b 100644 --- a/package.json +++ b/package.json @@ -106,9 +106,9 @@ "test:copyresults": "copyfiles --exclude \"**/node_modules/**\" \"**/nyc/**\" nyc", "test:coverage": "c8 npm test", "test:fromroot": "mocha \"packages/**/dist/test/**/*.spec.*js\" --exit", - "test:jest": "assign-test-ports && pnpm puppeteer browsers install chrome-headless-shell && pnpm -r --no-sort --stream --no-bail test:jest --color", - "test:jest:bail": "assign-test-ports && pnpm puppeteer browsers install chrome-headless-shell && pnpm -r --no-sort --stream test:jest", - "test:jest:report": "assign-test-ports && pnpm puppeteer browsers install chrome-headless-shell && pnpm -r --no-sort --stream --no-bail --workspace-concurrency=4 test:jest", + "test:jest": "assign-test-ports && cross-env npm_config_filter= pnpm puppeteer browsers install chrome-headless-shell && pnpm -r --no-sort --stream --no-bail test:jest --color", + "test:jest:bail": "assign-test-ports && cross-env npm_config_filter= pnpm puppeteer browsers install chrome-headless-shell && pnpm -r --no-sort --stream test:jest", + "test:jest:report": "assign-test-ports && cross-env npm_config_filter= pnpm puppeteer browsers install chrome-headless-shell && pnpm -r --no-sort --stream --no-bail --workspace-concurrency=4 test:jest", "test:mocha": "pnpm run -r --no-sort --stream --no-bail test:mocha --color", "test:mocha:bail": "pnpm run -r --no-sort --stream test:mocha", "test:realsvc": "pnpm run -r --no-sort --stream --no-bail test:realsvc", diff --git a/scripts/detect-changed-packages.ts b/scripts/detect-changed-packages.ts new file mode 100644 index 000000000000..faa52d93e3d0 --- /dev/null +++ b/scripts/detect-changed-packages.ts @@ -0,0 +1,275 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * detect-changed-packages + * + * Decides whether a PR's diff warrants scoping downstream test execution to a + * subset of workspace packages. Emits two ADO output variables: + * + * shouldRunTests "true" | "false" — whether any test work is needed + * scopedPnpmFilter pnpm filter string "...[]" when scoping is active, + * empty when a full test run is required. Downstream jobs + * pass this verbatim into `npm_config_filter`; pnpm treats + * an empty value as "no filter applied" so recursive `-r` + * runs fall back to the historical every-package behavior. + * + * Safe-fallback policy: any unexpected error (missing merge-base, git failure, + * unparseable ref) MUST result in a full run — never a silent skip. An + * accidental silent skip would suppress all tests and hide real regressions. + * + * Why merge-base (and not just `origin/` directly): pnpm's + * `--filter "[ref]"` uses a two-dot diff internally (see pnpm/pnpm#9907), so + * commits that landed on `origin/` after this PR diverged would show + * up as "changed." Computing the merge-base SHA ourselves and feeding that + * SHA into the selector gives three-dot (merge-base) semantics. + * + * This module exports pure helpers so the decision logic can be unit tested + * without an ADO pipeline context or a populated git repo. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, type Dirent } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Full-run trigger patterns. A diff touching any of these paths forces running + * every package's tests (filter stays empty → pnpm -r runs across the whole + * workspace). Keep this list conservative — it's the safety net for changes + * that could plausibly invalidate assumptions across the entire workspace. + * + * This list partially overlaps with `pr: paths: include:` in + * tools/pipelines/build-client.yml (which decides whether the pipeline runs + * at all). The concepts differ — one gates the pipeline, the other gates + * scoping within a pipeline that's already running — but adding a new + * cross-cutting root-level file generally warrants updating both. There's no + * programmatic link, so keep them in sync by convention. + */ +export const FULL_RUN_PATTERNS: readonly RegExp[] = [ + /^package\.json$/, + /^pnpm-lock\.yaml$/, + /^pnpm-workspace\.yaml$/, + /^\.pnpmfile\.cjs$/, + /^\.npmrc$/, + /^\.nvmrc$/, + /^fluidBuild\.config\.cjs$/, + /^tsconfig[^/]*\.json$/, + /^biome\./, + /^tools\//, + /^common\//, + /^scripts\//, + /^\.changeset\/config\.json$/, +]; + +/** Azure Repos emits "refs/heads/main"; GitHub emits just "main". Normalize. */ +export function normalizeTargetBranch(branch: string): string { + return branch.replace(/^refs\/heads\//, ""); +} + +/** + * Return the first pattern that any of the given files match, or `undefined` + * if none match. Used by callers to surface *why* a full run was forced. + */ +export function checkFullRunPatterns( + files: readonly string[], + patterns: readonly RegExp[] = FULL_RUN_PATTERNS, +): RegExp | undefined { + for (const pattern of patterns) { + if (files.some((f) => pattern.test(f))) { + return pattern; + } + } + return undefined; +} + +/** + * Build the set of directories that hold (or held, at `mergeBase`) a + * package.json. Unions the merge-base tree with the working tree so a + * package DELETED on this branch still maps correctly — the reviewer-flagged + * case the bash implementation missed. + * + * `listHistoricalPackages` and `listCurrentPackages` are injected so tests + * can drive this logic without spinning up a real git repo. + */ +export function buildPackageDirSet( + mergeBase: string, + listHistoricalPackages: (ref: string) => readonly string[], + listCurrentPackages: () => readonly string[], +): ReadonlySet { + const dirs = new Set(); + const record = (file: string): void => { + // file is like "packages/foo/package.json" or "package.json". + const dir = path.posix.dirname(file); + dirs.add(dir === "" ? "." : dir); + }; + for (const f of listHistoricalPackages(mergeBase)) record(f); + for (const f of listCurrentPackages()) record(f); + return dirs; +} + +/** + * Return `true` if any changed file lives under a known package directory. + * A file at `packages/foo/src/x.ts` matches if `packages/foo` (or any + * ancestor above it, stopping at the root) is in `packageDirs`. + * + * The root pseudo-dir `"."` is deliberately ignored here: root-level package + * changes are already caught by `FULL_RUN_PATTERNS` and should not double- + * count as a per-package signal. + */ +export function findChangedPackages( + changedFiles: readonly string[], + packageDirs: ReadonlySet, +): boolean { + for (const file of changedFiles) { + if (!file) continue; + let dir = path.posix.dirname(file); + while (dir !== "." && dir !== "/" && dir !== "") { + if (packageDirs.has(dir)) { + return true; + } + dir = path.posix.dirname(dir); + } + } + return false; +} + +/** Thin wrapper for `git` calls. Returns stdout or `undefined` on failure. */ +function git(args: string[]): string | undefined { + try { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + } catch { + return undefined; + } +} + +/** Git-backed implementation of `listHistoricalPackages`. */ +function gitHistoricalPackages(ref: string): string[] { + const out = git(["ls-tree", "-r", "--name-only", ref]); + if (out === undefined) return []; + return out.split("\n").filter((f) => /(^|\/)package\.json$/.test(f)); +} + +/** Walk the working tree for package.json files, skipping node_modules. */ +function currentPackages(cwd: string = process.cwd()): string[] { + const results: string[] = []; + const walk = (dir: string): void => { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name.startsWith(".git")) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name === "package.json") { + results.push(path.relative(cwd, full).split(path.sep).join("/")); + } + } + }; + walk(cwd); + return results; +} + +function emitVsoOutputs(shouldRunTests: boolean, scopedPnpmFilter: string): void { + const flag = shouldRunTests ? "true" : "false"; + console.log(`shouldRunTests=${flag}`); + console.log(`scopedPnpmFilter=${scopedPnpmFilter}`); + console.log(`##vso[task.setvariable variable=shouldRunTests;isOutput=true]${flag}`); + console.log(`##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`); +} + +function logWarning(message: string): void { + console.log(`##vso[task.logissue type=warning]${message}`); +} + +/** Pipeline entry point. Reads TARGET_BRANCH from env, writes vso outputs. */ +export function main(): void { + const raw = process.env.TARGET_BRANCH ?? ""; + const targetBranch = normalizeTargetBranch(raw); + if (!targetBranch) { + logWarning("TARGET_BRANCH not set; falling back to full test run."); + emitVsoOutputs(true, ""); + return; + } + console.log(`Target branch: ${targetBranch}`); + + if (git(["fetch", "origin", targetBranch]) === undefined) { + logWarning(`Could not fetch origin/${targetBranch}; falling back to full test run.`); + emitVsoOutputs(true, ""); + return; + } + + // Try to resolve the merge-base in the shallow clone first. If the PR + // diverged further back than the shallow boundary, unshallow and retry + // once. `.git/shallow` is how git marks a shallow repo; skip the + // unshallow on a full clone (which would error with "--unshallow on a + // complete repository"). + let mergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim(); + const gitDir = git(["rev-parse", "--git-dir"])?.trim(); + if (!mergeBase && gitDir && existsSync(path.join(gitDir, "shallow"))) { + console.log("Merge-base not found in shallow clone; unshallowing and retrying."); + git(["fetch", "--unshallow", "origin", targetBranch]); + mergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim(); + } + if (!mergeBase) { + logWarning(`No merge-base with origin/${targetBranch}; falling back to full test run.`); + emitVsoOutputs(true, ""); + return; + } + console.log(`Merge base: ${mergeBase}`); + + // On diff failure, fall back to a full run rather than swallowing the + // error — an empty changed-files list would bypass the full-run patterns + // and the package-change check, silently suppressing all test jobs. + const diffOut = git(["diff", "--name-only", mergeBase]); + if (diffOut === undefined) { + logWarning(`git diff against merge-base ${mergeBase} failed; falling back to full test run.`); + emitVsoOutputs(true, ""); + return; + } + const changedFiles = diffOut.split("\n").filter((f) => f.length > 0); + console.log(`Changed files (${changedFiles.length}):`); + for (const f of changedFiles.slice(0, 30)) console.log(f); + if (changedFiles.length > 30) console.log(`... and ${changedFiles.length - 30} more`); + + const match = checkFullRunPatterns(changedFiles); + if (match !== undefined) { + console.log(`Match for full-run pattern '${match.source}' — forcing full test run.`); + emitVsoOutputs(true, ""); + return; + } + + const packageDirs = buildPackageDirSet(mergeBase, gitHistoricalPackages, currentPackages); + if (!findChangedPackages(changedFiles, packageDirs)) { + // Most aggressive skip path: no test jobs run. Surface as a pipeline + // warning (not plain console output) and dump the file list so an + // accidental silent-suppression bug is auditable from the pipeline + // summary without needing to re-run the build. + logWarning( + `No changed files mapped to a workspace package — skipping all test execution. Files considered (${changedFiles.length}):`, + ); + for (const f of changedFiles) console.log(` ${f}`); + emitVsoOutputs(false, ""); + return; + } + + // Hand the merge-base SHA to pnpm's native selector. The leading `...` + // pulls in transitive dependents so consumers of a changed package also + // get re-tested. + const filter = `...[${mergeBase}]`; + console.log(`Computed pnpm filter: ${filter}`); + emitVsoOutputs(true, filter); +} + +// Run main only when invoked directly, not when imported by tests. +// fileURLToPath avoids Windows drive-letter mismatches that caught older +// `process.argv[1]`-based guards. +if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/test/detect-changed-packages.test.ts b/scripts/test/detect-changed-packages.test.ts new file mode 100644 index 000000000000..e9d04b3b34d9 --- /dev/null +++ b/scripts/test/detect-changed-packages.test.ts @@ -0,0 +1,175 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Tests for detect-changed-packages.ts. These exercise the exported pure + * helpers so regressions in the change-detection logic are caught before + * landing. Uses Node's built-in test runner (node:test) — no mocha/jest + * needed, keeping the pipeline cost of running these low. + * + * Pipeline invocation (from repo root, after `pnpm install`): + * pnpm run test:scripts + */ + +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + FULL_RUN_PATTERNS, + buildPackageDirSet, + checkFullRunPatterns, + findChangedPackages, + normalizeTargetBranch, +} from "../detect-changed-packages.ts"; + +describe("normalizeTargetBranch", () => { + it("strips refs/heads/ prefix", () => { + strictEqual(normalizeTargetBranch("refs/heads/main"), "main"); + }); + + it("passes plain branch names through", () => { + strictEqual(normalizeTargetBranch("next"), "next"); + }); + + it("preserves slashes after the prefix", () => { + strictEqual(normalizeTargetBranch("refs/heads/release/2.x"), "release/2.x"); + }); + + it("returns empty string for empty input", () => { + strictEqual(normalizeTargetBranch(""), ""); + }); +}); + +describe("checkFullRunPatterns", () => { + it("matches pnpm-lock.yaml", () => { + const match = checkFullRunPatterns(["pnpm-lock.yaml"]); + strictEqual(match?.source, "^pnpm-lock\\.yaml$"); + }); + + it("matches .pnpmfile.cjs (added in review)", () => { + const match = checkFullRunPatterns([".pnpmfile.cjs"]); + strictEqual(match?.source, "^\\.pnpmfile\\.cjs$"); + }); + + it("matches .npmrc (added in review)", () => { + const match = checkFullRunPatterns([".npmrc"]); + strictEqual(match?.source, "^\\.npmrc$"); + }); + + it("matches .nvmrc (added in review)", () => { + const match = checkFullRunPatterns([".nvmrc"]); + strictEqual(match?.source, "^\\.nvmrc$"); + }); + + it("matches tools/ prefix", () => { + const match = checkFullRunPatterns(["tools/pipelines/build-client.yml"]); + ok(match, "expected tools/ prefix to match"); + }); + + it("matches ROOT package.json, not a nested one", () => { + strictEqual(checkFullRunPatterns(["packages/foo/package.json"]), undefined); + strictEqual(checkFullRunPatterns(["package.json"])?.source, "^package\\.json$"); + }); + + it("matches root tsconfig (anchored), not nested", () => { + ok(checkFullRunPatterns(["tsconfig.base.json"])); + strictEqual(checkFullRunPatterns(["packages/foo/tsconfig.json"]), undefined); + }); + + it("returns undefined when nothing matches", () => { + strictEqual(checkFullRunPatterns(["packages/foo/src/x.ts"]), undefined); + }); + + it("returns the first pattern hit when several qualify", () => { + // Stability matters for readable pipeline logs. + const match = checkFullRunPatterns(["pnpm-lock.yaml", "biome.jsonc"]); + strictEqual(match?.source, "^pnpm-lock\\.yaml$"); + }); + + it("exposes the pattern list for external audits", () => { + ok(FULL_RUN_PATTERNS.length > 0); + // Ensure each of the three review-added patterns made it into the + // exported list (not just the checker). + const sources = FULL_RUN_PATTERNS.map((r) => r.source); + ok(sources.includes("^\\.pnpmfile\\.cjs$")); + ok(sources.includes("^\\.npmrc$")); + ok(sources.includes("^\\.nvmrc$")); + }); +}); + +describe("buildPackageDirSet", () => { + it("unions historical and current packages", () => { + const historical = ["packages/old/package.json", "packages/shared/package.json"]; + const current = ["packages/shared/package.json", "packages/new/package.json"]; + const dirs = buildPackageDirSet("sha", () => historical, () => current); + deepStrictEqual( + [...dirs].sort(), + ["packages/new", "packages/old", "packages/shared"], + ); + }); + + it("maps a root-level package.json to '.'", () => { + const dirs = buildPackageDirSet("sha", () => ["package.json"], () => []); + deepStrictEqual([...dirs], ["."]); + }); + + it("tolerates either list being empty", () => { + strictEqual(buildPackageDirSet("sha", () => [], () => []).size, 0); + strictEqual( + buildPackageDirSet("sha", () => ["packages/a/package.json"], () => []).size, + 1, + ); + }); +}); + +describe("findChangedPackages", () => { + const pkgDirs = new Set(["packages/alive", "packages/doomed"]); + + it("detects a file inside a known package dir", () => { + strictEqual(findChangedPackages(["packages/alive/src/x.ts"], pkgDirs), true); + }); + + it("detects a deleted package's file (regression — see review #3133324370)", () => { + // Working-tree check would MISS this because packages/doomed/package.json + // no longer exists on disk. The historical-set merge in + // buildPackageDirSet is what keeps this path live. + strictEqual(findChangedPackages(["packages/doomed/package.json"], pkgDirs), true); + }); + + it("detects a NEW package (added on this branch)", () => { + const withNew = new Set(["packages/new"]); + strictEqual( + findChangedPackages(["packages/new/package.json", "packages/new/src.ts"], withNew), + true, + ); + }); + + it("returns false for root-only changes", () => { + // Root-level file changes are handled by FULL_RUN_PATTERNS, not here. + strictEqual(findChangedPackages(["README.md"], pkgDirs), false); + }); + + it("returns false when file lives in an unrelated sibling dir", () => { + strictEqual(findChangedPackages(["packages/other/src.ts"], pkgDirs), false); + }); + + it("ignores empty file entries", () => { + strictEqual(findChangedPackages(["", "packages/alive/src.ts"], pkgDirs), true); + }); + + it("walks up from nested paths to find an ancestor package dir", () => { + strictEqual( + findChangedPackages(["packages/alive/src/deeply/nested/x.ts"], pkgDirs), + true, + ); + }); + + it("does not treat the root pseudo-dir '.' as a per-package hit", () => { + // Even if '.' is in packageDirs (root package.json case), we should + // not declare per-package changes for a random root file. + const dirsWithRoot = new Set([".", "packages/alive"]); + strictEqual(findChangedPackages(["some-root-file.md"], dirsWithRoot), false); + }); +}); diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index b423c4ec2d35..357808c7dc48 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -358,6 +358,21 @@ extends: - template: /tools/pipelines/templates/include-use-node-version.yml@self + # Unit tests for helper scripts under scripts/. detect-changed-packages.ts + # gates whether the Coverage_tests / Test_* jobs run at all (see + # include-detect-changed-packages.yml); a regression in the change + # detection logic could silently suppress every package test, so we + # run its tests early in the build job — before install — so they + # fail fast and independently of package dependencies. + - task: Bash@3 + displayName: Scripts unit tests + inputs: + targetType: 'inline' + workingDirectory: $(Pipeline.Workspace)/${{ parameters.buildDirectory }} + script: | + set -eu -o pipefail + npx --yes tsx@4.19.4 --test scripts/test/*.test.ts + - template: /tools/pipelines/templates/include-install.yml@self parameters: packageManager: '${{ parameters.packageManager }}' diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index c4ae5be419d3..caec28b0479e 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -3,35 +3,28 @@ # include-detect-changed-packages # -# Detects whether a PR's diff warrants scoping downstream test execution to -# a subset of workspace packages and publishes output variables that test -# jobs read to make that decision. -# -# The scoping itself is handled natively by pnpm via the -# `--filter "...[]"` selector — this template just computes the right -# git ref (the merge-base between HEAD and the target branch) and emits it -# as a ready-to-use filter string that downstream jobs set as -# `npm_config_filter`. pnpm then picks up the env var automatically and -# applies it to any `pnpm -r run ` invocation. That keeps the root -# package.json scripts unchanged and removes the need for a custom wrapper. +# Thin pipeline wrapper around scripts/detect-changed-packages.ts. The +# detection logic lives in the TypeScript module (extracted in response to +# PR review feedback so it can be run/debugged locally and covered by unit +# tests under scripts/test/). Keeping this template minimal ensures the +# template itself doesn't drift from the tested script. # # Output variables (from the `setChangedPackages` step): -# - shouldRunTests "true" | "false" — whether any test work is needed at all +# - shouldRunTests "true" | "false" — whether any test work is needed # - scopedPnpmFilter The pnpm filter string ("...[]") when scoping is # active, or an empty string when a full test run is # required. Downstream jobs pass this into # `npm_config_filter` verbatim; pnpm treats an empty -# value as "no filter applied", so recursive `-r` runs +# value as "no filter applied" so recursive `-r` runs # fall back to the historical every-package behavior. # -# On any error path (missing merge-base, unsupported ref format) this -# template degrades safely to a full-run outcome, never to a silent skip. +# On any error path (missing merge-base, unsupported ref format) the script +# degrades safely to a full-run outcome, never to a silent skip. # -# Why merge-base (and not just `origin/` directly): pnpm's -# `--filter "[ref]"` uses a two-dot diff internally (see pnpm/pnpm#9907), so -# commits that landed on `origin/` after this PR diverged would show -# up as "changed." Computing the merge-base SHA ourselves and feeding that -# SHA into the selector gives three-dot (merge-base) semantics. +# Why tsx (and not a pre-compiled .js or global install): tsx is invoked via +# `npx --yes tsx@` so this step needs zero prior install — no pnpm +# install, no root deps — keeping the gate as close to "git + node" fast as +# possible. npx caches the download per-agent across runs. parameters: - name: buildDirectory @@ -44,140 +37,21 @@ steps: - checkout: self path: $(FluidFrameworkDirectory) clean: true - # Shallow clone; the bash step below unshallows on demand if the - # merge-base can't be resolved within this depth. Most PRs merge-base - # within a few hundred commits, so this is cheap in the common case. + # Shallow clone; the script unshallows on demand if the merge-base + # can't be resolved within this depth. Most PRs merge-base within a + # few hundred commits, so this is cheap in the common case. fetchDepth: 200 + - template: /tools/pipelines/templates/include-use-node-version.yml@self + - task: Bash@3 name: setChangedPackages displayName: Detect changed packages + env: + TARGET_BRANCH: ${{ parameters.targetBranchName }} inputs: targetType: inline workingDirectory: '$(Pipeline.Workspace)/${{ parameters.buildDirectory }}' script: | set -eu -o pipefail - - # Normalize: ADO returns "refs/heads/main" on Azure Repos, just "main" on GitHub. - TARGET_BRANCH="${{ parameters.targetBranchName }}" - TARGET_BRANCH="${TARGET_BRANCH#refs/heads/}" - echo "Target branch: ${TARGET_BRANCH}" - - # Safe fallback is always a full run — non-empty filter would be a skip. - SHOULD_RUN="true" - FILTER="" - - emit_and_exit() { - echo "shouldRunTests=${SHOULD_RUN}" - echo "scopedPnpmFilter=${FILTER}" - echo "##vso[task.setvariable variable=shouldRunTests;isOutput=true]${SHOULD_RUN}" - echo "##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${FILTER}" - exit 0 - } - - if ! git fetch origin "${TARGET_BRANCH}"; then - echo "##vso[task.logissue type=warning]Could not fetch origin/${TARGET_BRANCH}; falling back to full test run." - emit_and_exit - fi - - # Try to resolve the merge-base in the shallow clone first. If the - # PR diverged further back than the shallow boundary, unshallow and - # retry once. The presence of a `shallow` file under `.git` is how - # git marks a shallow repo; skip the unshallow on a full clone - # (which would error with "--unshallow on a complete repository"). - # Capture `git rev-parse --git-dir` into a variable with a fallback - # so a rev-parse failure degrades to "skip the unshallow check" under - # `set -eu` instead of aborting the whole script. - MERGE_BASE="$(git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || true)" - GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)" - if [ -z "${MERGE_BASE}" ] && [ -n "${GIT_DIR}" ] && [ -f "${GIT_DIR}/shallow" ]; then - echo "Merge-base not found in shallow clone; unshallowing and retrying." - git fetch --unshallow origin "${TARGET_BRANCH}" 2>/dev/null || true - MERGE_BASE="$(git merge-base HEAD "origin/${TARGET_BRANCH}" 2>/dev/null || true)" - fi - if [ -z "${MERGE_BASE}" ]; then - echo "##vso[task.logissue type=warning]No merge-base with origin/${TARGET_BRANCH}; falling back to full test run." - emit_and_exit - fi - echo "Merge base: ${MERGE_BASE}" - - # On diff failure, fall back to a full run rather than swallowing the - # error — an empty CHANGED_FILES would bypass the full-run patterns - # and the package-change check, silently suppressing all test jobs. - if ! CHANGED_FILES="$(git diff --name-only "${MERGE_BASE}")"; then - echo "##vso[task.logissue type=warning]git diff against merge-base ${MERGE_BASE} failed; falling back to full test run." - emit_and_exit - fi - FILE_COUNT="$(printf '%s\n' "${CHANGED_FILES}" | grep -c . || true)" - echo "Changed files (${FILE_COUNT}):" - printf '%s\n' "${CHANGED_FILES}" | head -30 - if [ "${FILE_COUNT}" -gt 30 ]; then - echo "... and $((FILE_COUNT - 30)) more" - fi - - # Full-run trigger patterns. A diff touching any of these paths forces - # running every package's tests (FILTER stays empty → pnpm -r runs - # across the whole workspace). - # Keep this list conservative — it's the safety net for changes that - # could plausibly invalidate assumptions across the entire workspace. - # - # This list partially overlaps with `pr: paths: include:` in - # tools/pipelines/build-client.yml (which decides whether the pipeline - # runs at all). The concepts differ — one gates the pipeline, the - # other gates scoping within a pipeline that's already running — but - # adding a new cross-cutting root-level file generally warrants - # updating both. There's no programmatic link, so keep them in sync - # by convention. - FULL_RUN_PATTERNS=( - '^package\.json$' - '^pnpm-lock\.yaml$' - '^pnpm-workspace\.yaml$' - '^fluidBuild\.config\.cjs$' - '^tsconfig[^/]*\.json$' - '^biome\.' - '^tools/' - '^common/' - '^scripts/' - '^\.changeset/config\.json$' - ) - for pattern in "${FULL_RUN_PATTERNS[@]}"; do - if printf '%s\n' "${CHANGED_FILES}" | grep -Eq "${pattern}"; then - echo "Match for full-run pattern '${pattern}' — forcing full test run." - emit_and_exit - fi - done - - # Quick sanity check: did any changed file land under a package dir - # (anything with a package.json below the repo root)? If not, there's - # no test work to do at all and we can short-circuit. - HAS_PACKAGE_CHANGE="false" - while IFS= read -r file; do - [ -z "${file}" ] && continue - d="$(dirname "${file}")" - while [ "${d}" != "." ] && [ "${d}" != "/" ]; do - if [ -f "${d}/package.json" ]; then - HAS_PACKAGE_CHANGE="true" - break 2 - fi - d="$(dirname "${d}")" - done - done <<< "${CHANGED_FILES}" - - if [ "${HAS_PACKAGE_CHANGE}" = "false" ]; then - # This is the most aggressive skip path: no test jobs run at all. - # Surface it as a pipeline warning (not a plain echo) and dump the - # file list so an accidental silent-suppression bug is auditable - # from the pipeline summary without needing to re-run the build. - echo "##vso[task.logissue type=warning]No changed files mapped to a workspace package — skipping all test execution. Files considered (${FILE_COUNT}):" - printf '%s\n' "${CHANGED_FILES}" | sed 's/^/ /' - SHOULD_RUN="false" - FILTER="" - emit_and_exit - fi - - # Hand the merge-base SHA to pnpm's native selector. The leading `...` - # pulls in transitive dependents so consumers of a changed package - # also get re-tested. - FILTER="...[${MERGE_BASE}]" - echo "Computed pnpm filter: ${FILTER}" - emit_and_exit + npx --yes tsx@4.19.4 scripts/detect-changed-packages.ts diff --git a/tools/pipelines/templates/include-test-task.yml b/tools/pipelines/templates/include-test-task.yml index eed51173e210..5334a096f936 100644 --- a/tools/pipelines/templates/include-test-task.yml +++ b/tools/pipelines/templates/include-test-task.yml @@ -18,6 +18,15 @@ parameters: # set as `npm_config_filter` so pnpm scopes the recursive run to the listed # packages plus their dependents. Empty means "no filter" — pnpm falls back # to running across every workspace package (the historical behavior). +# +# IMPORTANT: `npm_config_filter` propagates to every `pnpm` invocation the +# npm script transitively makes, not just `pnpm -r`. Non-recursive `pnpm` +# calls (e.g. `pnpm puppeteer ...`, `pnpm exec ...`) will fail with +# ERR_PNPM_RECURSIVE_EXEC_NO_PACKAGE when a filter is set. Any root-level +# script that chains a non-recursive pnpm call before a recursive one must +# clear the filter for the non-recursive portion via +# `cross-env npm_config_filter= pnpm `. See test:jest in the +# root package.json for the pattern. - name: pnpmFilter type: string default: '' From c8363044043a209e7c98fa949431f25078754531 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Fri, 24 Apr 2026 11:19:01 -0700 Subject: [PATCH 09/19] ci(client): pin tsx as a devDep, invoke via pnpm exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ad-hoc `npx --yes tsx@` invocations with a lockfile- pinned `tsx` devDependency called via `pnpm exec tsx`. Version is managed in pnpm-lock.yaml rather than hardcoded in pipeline YAML, so bumps go through the same flow as every other dep. detect_changes now installs workspace-root deps before invoking tsx — mirrors repo-policy-check.yml's fast-path (full workspace install is still avoided). The build job's "Scripts unit tests" step moved to run right after include-install.yml so tsx is already resolvable when it runs. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- package.json | 1 + pnpm-lock.yaml | 316 ++++++++++++++++-- .../templates/build-npm-client-package.yml | 21 +- .../include-detect-changed-packages.yml | 25 +- 4 files changed, 321 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 043bbb91778b..3b3ad83d24a9 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,7 @@ "rimraf": "^6.1.3", "run-script-os": "^1.1.6", "syncpack": "^14.0.2", + "tsx": "^4.19.4", "type-fest": "^2.19.0", "typescript": "~5.4.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba7de104b010..3dedef941c3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: syncpack: specifier: ^14.0.2 version: 14.0.2 + tsx: + specifier: ^4.19.4 + version: 4.21.0 type-fest: specifier: ^2.19.0 version: 2.19.0 @@ -17793,6 +17796,162 @@ packages: resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-plugin-eslint-comments@4.5.0': resolution: {integrity: sha512-MAhuTKlr4y/CE3WYX26raZjy+I/kS2PLKSzvfmDCGrBLTFHOYwqROZdr4XwPgXwX3K9rjzMr4pSmUWGnzsUyMg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -22369,6 +22528,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -27124,6 +27288,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -28552,6 +28721,84 @@ snapshots: '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-plugin-eslint-comments@4.5.0(eslint@9.39.1(jiti@2.6.1))': dependencies: escape-string-regexp: 4.0.0 @@ -30555,11 +30802,11 @@ snapshots: '@eslint/js': 9.39.4 '@fluid-internal/eslint-plugin-fluid': 0.4.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@rushstack/eslint-plugin': 0.22.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-config-biome: 2.1.3 eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1)) - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-depend: 1.4.0 eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsdoc: 61.4.2(eslint@9.39.1(jiti@2.6.1)) @@ -30568,7 +30815,7 @@ snapshots: eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-tsdoc: 0.5.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-unicorn: 54.0.0(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1)) globals: 14.0.0 typescript-eslint: 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: @@ -33403,10 +33650,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5) '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) @@ -35825,6 +36072,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -35873,21 +36149,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): - dependencies: - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.1(jiti@2.6.1) - eslint-import-context: 0.1.9(unrs-resolver@1.11.1) - get-tsconfig: 4.13.7 - is-bun-module: 2.0.0 - stable-hash-x: 0.2.0 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-plugin-chai-expect@3.1.0(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -36051,12 +36312,6 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5) - eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): - dependencies: - eslint: 9.39.1(jiti@2.6.1) - optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -41814,6 +42069,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -41919,7 +42181,7 @@ snapshots: typescript-eslint@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) '@typescript-eslint/utils': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 357808c7dc48..9acef36ae4e2 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -358,12 +358,19 @@ extends: - template: /tools/pipelines/templates/include-use-node-version.yml@self + - template: /tools/pipelines/templates/include-install.yml@self + parameters: + packageManager: '${{ parameters.packageManager }}' + buildDirectory: '${{ parameters.buildDirectory }}' + packageManagerInstallCommand: '${{ parameters.packageManagerInstallCommand }}' + # Unit tests for helper scripts under scripts/. detect-changed-packages.ts # gates whether the Coverage_tests / Test_* jobs run at all (see # include-detect-changed-packages.yml); a regression in the change - # detection logic could silently suppress every package test, so we - # run its tests early in the build job — before install — so they - # fail fast and independently of package dependencies. + # detection logic could silently suppress every package test, so + # we run its tests here in the build job — right after install so + # tsx (a root devDep) is resolvable via `pnpm exec` — before any + # heavy build work, so they fail fast. - task: Bash@3 displayName: Scripts unit tests inputs: @@ -371,13 +378,7 @@ extends: workingDirectory: $(Pipeline.Workspace)/${{ parameters.buildDirectory }} script: | set -eu -o pipefail - npx --yes tsx@4.19.4 --test scripts/test/*.test.ts - - - template: /tools/pipelines/templates/include-install.yml@self - parameters: - packageManager: '${{ parameters.packageManager }}' - buildDirectory: '${{ parameters.buildDirectory }}' - packageManagerInstallCommand: '${{ parameters.packageManagerInstallCommand }}' + pnpm exec tsx --test scripts/test/*.test.ts # The bundle-size-artifacts pipeline runs a client build but doesn't publish packages, # so we skip version setting for it. diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index caec28b0479e..ce9132f71c2d 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -21,10 +21,11 @@ # On any error path (missing merge-base, unsupported ref format) the script # degrades safely to a full-run outcome, never to a silent skip. # -# Why tsx (and not a pre-compiled .js or global install): tsx is invoked via -# `npx --yes tsx@` so this step needs zero prior install — no pnpm -# install, no root deps — keeping the gate as close to "git + node" fast as -# possible. npx caches the download per-agent across runs. +# The script is invoked via `pnpm exec tsx` after a workspace-root-only +# install; tsx is a root devDep so its version is pinned in pnpm-lock.yaml. +# The workspace-root install path mirrors repo-policy-check.yml and +# deliberately skips the full workspace install, keeping the gate close to +# "git + node + tsx" fast. parameters: - name: buildDirectory @@ -44,6 +45,20 @@ steps: - template: /tools/pipelines/templates/include-use-node-version.yml@self + - template: /tools/pipelines/templates/include-install-pnpm.yml@self + parameters: + buildDirectory: ${{ parameters.buildDirectory }} + + - task: Bash@3 + displayName: Install root dependencies + inputs: + targetType: 'inline' + workingDirectory: $(Pipeline.Workspace)/${{ parameters.buildDirectory }} + script: | + set -eu -o pipefail + # Workspace-root only — we just need tsx and its transitive deps. + pnpm install --workspace-root --frozen-lockfile + - task: Bash@3 name: setChangedPackages displayName: Detect changed packages @@ -54,4 +69,4 @@ steps: workingDirectory: '$(Pipeline.Workspace)/${{ parameters.buildDirectory }}' script: | set -eu -o pipefail - npx --yes tsx@4.19.4 scripts/detect-changed-packages.ts + pnpm exec tsx scripts/detect-changed-packages.ts From b5522366cef29a7e5e97b0050d17601922f30c3a Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Fri, 24 Apr 2026 11:29:09 -0700 Subject: [PATCH 10/19] format --- scripts/detect-changed-packages.ts | 8 +++-- scripts/test/detect-changed-packages.test.ts | 35 ++++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/scripts/detect-changed-packages.ts b/scripts/detect-changed-packages.ts index faa52d93e3d0..0418b1c1a5ef 100644 --- a/scripts/detect-changed-packages.ts +++ b/scripts/detect-changed-packages.ts @@ -181,7 +181,9 @@ function emitVsoOutputs(shouldRunTests: boolean, scopedPnpmFilter: string): void console.log(`shouldRunTests=${flag}`); console.log(`scopedPnpmFilter=${scopedPnpmFilter}`); console.log(`##vso[task.setvariable variable=shouldRunTests;isOutput=true]${flag}`); - console.log(`##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`); + console.log( + `##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`, + ); } function logWarning(message: string): void { @@ -229,7 +231,9 @@ export function main(): void { // and the package-change check, silently suppressing all test jobs. const diffOut = git(["diff", "--name-only", mergeBase]); if (diffOut === undefined) { - logWarning(`git diff against merge-base ${mergeBase} failed; falling back to full test run.`); + logWarning( + `git diff against merge-base ${mergeBase} failed; falling back to full test run.`, + ); emitVsoOutputs(true, ""); return; } diff --git a/scripts/test/detect-changed-packages.test.ts b/scripts/test/detect-changed-packages.test.ts index e9d04b3b34d9..ac788a6329db 100644 --- a/scripts/test/detect-changed-packages.test.ts +++ b/scripts/test/detect-changed-packages.test.ts @@ -103,22 +103,38 @@ describe("buildPackageDirSet", () => { it("unions historical and current packages", () => { const historical = ["packages/old/package.json", "packages/shared/package.json"]; const current = ["packages/shared/package.json", "packages/new/package.json"]; - const dirs = buildPackageDirSet("sha", () => historical, () => current); - deepStrictEqual( - [...dirs].sort(), - ["packages/new", "packages/old", "packages/shared"], + const dirs = buildPackageDirSet( + "sha", + () => historical, + () => current, ); + deepStrictEqual([...dirs].sort(), ["packages/new", "packages/old", "packages/shared"]); }); it("maps a root-level package.json to '.'", () => { - const dirs = buildPackageDirSet("sha", () => ["package.json"], () => []); + const dirs = buildPackageDirSet( + "sha", + () => ["package.json"], + () => [], + ); deepStrictEqual([...dirs], ["."]); }); it("tolerates either list being empty", () => { - strictEqual(buildPackageDirSet("sha", () => [], () => []).size, 0); strictEqual( - buildPackageDirSet("sha", () => ["packages/a/package.json"], () => []).size, + buildPackageDirSet( + "sha", + () => [], + () => [], + ).size, + 0, + ); + strictEqual( + buildPackageDirSet( + "sha", + () => ["packages/a/package.json"], + () => [], + ).size, 1, ); }); @@ -160,10 +176,7 @@ describe("findChangedPackages", () => { }); it("walks up from nested paths to find an ancestor package dir", () => { - strictEqual( - findChangedPackages(["packages/alive/src/deeply/nested/x.ts"], pkgDirs), - true, - ); + strictEqual(findChangedPackages(["packages/alive/src/deeply/nested/x.ts"], pkgDirs), true); }); it("does not treat the root pseudo-dir '.' as a per-package hit", () => { From 4d67558dd63ad72b00f568538df9ec782394ab74 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Fri, 24 Apr 2026 14:47:45 -0700 Subject: [PATCH 11/19] switch to python --- .gitignore | 4 + package.json | 1 - pnpm-lock.yaml | 549 +----------------- scripts/detect-changed-packages.ts | 279 --------- scripts/detect_changed_packages.py | 285 +++++++++ scripts/test/detect-changed-packages.test.ts | 188 ------ scripts/test/test_detect_changed_packages.py | 185 ++++++ .../templates/build-npm-client-package.yml | 10 +- .../include-detect-changed-packages.yml | 36 +- 9 files changed, 489 insertions(+), 1048 deletions(-) delete mode 100644 scripts/detect-changed-packages.ts create mode 100644 scripts/detect_changed_packages.py delete mode 100644 scripts/test/detect-changed-packages.test.ts create mode 100644 scripts/test/test_detect_changed_packages.py diff --git a/.gitignore b/.gitignore index c2781617762b..25fe3ec9963e 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,7 @@ CLAUDE.local.md # Output from benchmarks **/benchmark*Output.json + +# Python bytecode cache (scripts/ contains a small Python helper + tests). +__pycache__/ +*.pyc diff --git a/package.json b/package.json index 3b3ad83d24a9..043bbb91778b 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,6 @@ "rimraf": "^6.1.3", "run-script-os": "^1.1.6", "syncpack": "^14.0.2", - "tsx": "^4.19.4", "type-fest": "^2.19.0", "typescript": "~5.4.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dedef941c3a..c7192e2ee723 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,9 +148,6 @@ importers: syncpack: specifier: ^14.0.2 version: 14.0.2 - tsx: - specifier: ^4.19.4 - version: 4.21.0 type-fest: specifier: ^2.19.0 version: 2.19.0 @@ -175,7 +172,7 @@ importers: version: 0.65.0(@types/node@22.19.17) '@fluidframework/eslint-config-fluid': specifier: catalog:eslint - version: 9.0.0(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + version: 9.0.0(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5) eslint: specifier: catalog:eslint version: 9.39.1(jiti@2.6.1) @@ -17796,162 +17793,6 @@ packages: resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} engines: {node: '>=10'} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@eslint-community/eslint-plugin-eslint-comments@4.5.0': resolution: {integrity: sha512-MAhuTKlr4y/CE3WYX26raZjy+I/kS2PLKSzvfmDCGrBLTFHOYwqROZdr4XwPgXwX3K9rjzMr4pSmUWGnzsUyMg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -22528,11 +22369,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -27288,11 +27124,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -28721,84 +28552,6 @@ snapshots: '@es-joy/resolve.exports@1.2.0': {} - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.5.0(eslint@9.39.1(jiti@2.6.1))': dependencies: escape-string-regexp: 4.0.0 @@ -30092,17 +29845,6 @@ snapshots: - supports-color - typescript - '@fluid-internal/eslint-plugin-fluid@0.4.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@microsoft/tsdoc': 0.15.1 - '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - ts-morph: 22.0.0 - transitivePeerDependencies: - - supports-color - - typescript - '@fluid-internal/test-driver-definitions@2.92.0': dependencies: '@fluidframework/core-interfaces': 2.92.0 @@ -30795,37 +30537,6 @@ snapshots: - supports-color - typescript - '@fluidframework/eslint-config-fluid@9.0.0(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-plugin-eslint-comments': 4.5.0(eslint@9.39.1(jiti@2.6.1)) - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.4 - '@fluid-internal/eslint-plugin-fluid': 0.4.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@rushstack/eslint-plugin': 0.22.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-config-biome: 2.1.3 - eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1)) - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-depend: 1.4.0 - eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-jsdoc: 61.4.2(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-promise: 7.2.1(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-tsdoc: 0.5.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-unicorn: 54.0.0(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1)) - globals: 14.0.0 - typescript-eslint: 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - '@typescript-eslint/utils' - - eslint - - eslint-import-resolver-node - - eslint-plugin-import - - supports-color - - typescript - '@fluidframework/file-driver@2.92.0': dependencies: '@fluid-internal/client-utils': 2.92.0 @@ -32950,15 +32661,6 @@ snapshots: - supports-color - typescript - '@rushstack/eslint-plugin@0.22.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@rushstack/tree-pattern': 0.3.4 - '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - transitivePeerDependencies: - - supports-color - - typescript - '@rushstack/node-core-library@3.66.1(@types/node@22.19.17)': dependencies: colors: 1.2.5 @@ -33650,22 +33352,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.1(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@typescript-eslint/scope-manager': 8.46.4 @@ -33678,18 +33364,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.4 - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 @@ -33702,18 +33376,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.46.4(typescript@5.4.5)': dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.4.5) @@ -33723,15 +33385,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - debug: 4.4.3(supports-color@8.1.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.4.5)': dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.4.5) @@ -33741,15 +33394,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - debug: 4.4.3(supports-color@8.1.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.4.5)': dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.4.5) @@ -33759,15 +33403,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - debug: 4.4.3(supports-color@8.1.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/scope-manager@8.46.4': dependencies: '@typescript-eslint/types': 8.46.4 @@ -33787,26 +33422,14 @@ snapshots: dependencies: typescript: 5.4.5 - '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.4.5)': dependencies: typescript: 5.4.5 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.4.5)': dependencies: typescript: 5.4.5 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@typescript-eslint/types': 8.54.0 @@ -33819,18 +33442,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.1(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/types@8.46.4': {} '@typescript-eslint/types@8.54.0': {} @@ -33855,22 +33466,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.46.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.46.4(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 - debug: 4.4.3(supports-color@8.1.1) - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.9 - semver: 7.7.3 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.4.5)': dependencies: '@typescript-eslint/project-service': 8.54.0(typescript@5.4.5) @@ -33886,21 +33481,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 - debug: 4.4.3(supports-color@8.1.1) - minimatch: 9.0.9 - semver: 7.7.3 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.4.5)': dependencies: '@typescript-eslint/project-service': 8.56.1(typescript@5.4.5) @@ -33916,21 +33496,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@8.1.1) - minimatch: 10.2.4 - semver: 7.7.3 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.6.1)) @@ -33942,17 +33507,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.6.1)) @@ -33964,17 +33518,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.6.1)) @@ -33986,17 +33529,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/visitor-keys@8.46.4': dependencies: '@typescript-eslint/types': 8.46.4 @@ -36072,35 +35604,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - escalade@3.2.0: {} escape-html@1.0.3: {} @@ -36177,24 +35680,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): - dependencies: - '@package-json/types': 0.0.12 - '@typescript-eslint/types': 8.58.0 - comment-parser: 1.4.6 - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.1(jiti@2.6.1) - eslint-import-context: 0.1.9(unrs-resolver@1.11.1) - is-glob: 4.0.3 - minimatch: 9.0.9 - semver: 7.7.3 - stable-hash-x: 0.2.0 - unrs-resolver: 1.11.1 - optionalDependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - eslint-plugin-jest@29.5.0(@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(jest@29.7.0(@types/node@22.19.17)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.17)(typescript@5.4.5)))(typescript@5.4.5): dependencies: '@typescript-eslint/utils': 8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5) @@ -36274,16 +35759,6 @@ snapshots: - supports-color - typescript - eslint-plugin-tsdoc@0.5.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@microsoft/tsdoc': 0.16.0 - '@microsoft/tsdoc-config': 0.18.1 - '@typescript-eslint/utils': 8.56.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint - - supports-color - - typescript - eslint-plugin-unicorn@54.0.0(eslint@9.39.1(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -41983,10 +41458,6 @@ snapshots: dependencies: typescript: 5.4.5 - ts-api-utils@2.4.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - ts-deepmerge@7.0.3: {} ts-interface-checker@0.1.13: {} @@ -42069,13 +41540,6 @@ snapshots: tslib@2.8.1: {} - tsx@4.21.0: - dependencies: - esbuild: 0.27.7 - get-tsconfig: 4.13.7 - optionalDependencies: - fsevents: 2.3.3 - tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -42179,17 +41643,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript-eslint@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - typescript@4.9.5: {} typescript@5.4.5: {} diff --git a/scripts/detect-changed-packages.ts b/scripts/detect-changed-packages.ts deleted file mode 100644 index 0418b1c1a5ef..000000000000 --- a/scripts/detect-changed-packages.ts +++ /dev/null @@ -1,279 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -/** - * detect-changed-packages - * - * Decides whether a PR's diff warrants scoping downstream test execution to a - * subset of workspace packages. Emits two ADO output variables: - * - * shouldRunTests "true" | "false" — whether any test work is needed - * scopedPnpmFilter pnpm filter string "...[]" when scoping is active, - * empty when a full test run is required. Downstream jobs - * pass this verbatim into `npm_config_filter`; pnpm treats - * an empty value as "no filter applied" so recursive `-r` - * runs fall back to the historical every-package behavior. - * - * Safe-fallback policy: any unexpected error (missing merge-base, git failure, - * unparseable ref) MUST result in a full run — never a silent skip. An - * accidental silent skip would suppress all tests and hide real regressions. - * - * Why merge-base (and not just `origin/` directly): pnpm's - * `--filter "[ref]"` uses a two-dot diff internally (see pnpm/pnpm#9907), so - * commits that landed on `origin/` after this PR diverged would show - * up as "changed." Computing the merge-base SHA ourselves and feeding that - * SHA into the selector gives three-dot (merge-base) semantics. - * - * This module exports pure helpers so the decision logic can be unit tested - * without an ADO pipeline context or a populated git repo. - */ - -import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync, type Dirent } from "node:fs"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Full-run trigger patterns. A diff touching any of these paths forces running - * every package's tests (filter stays empty → pnpm -r runs across the whole - * workspace). Keep this list conservative — it's the safety net for changes - * that could plausibly invalidate assumptions across the entire workspace. - * - * This list partially overlaps with `pr: paths: include:` in - * tools/pipelines/build-client.yml (which decides whether the pipeline runs - * at all). The concepts differ — one gates the pipeline, the other gates - * scoping within a pipeline that's already running — but adding a new - * cross-cutting root-level file generally warrants updating both. There's no - * programmatic link, so keep them in sync by convention. - */ -export const FULL_RUN_PATTERNS: readonly RegExp[] = [ - /^package\.json$/, - /^pnpm-lock\.yaml$/, - /^pnpm-workspace\.yaml$/, - /^\.pnpmfile\.cjs$/, - /^\.npmrc$/, - /^\.nvmrc$/, - /^fluidBuild\.config\.cjs$/, - /^tsconfig[^/]*\.json$/, - /^biome\./, - /^tools\//, - /^common\//, - /^scripts\//, - /^\.changeset\/config\.json$/, -]; - -/** Azure Repos emits "refs/heads/main"; GitHub emits just "main". Normalize. */ -export function normalizeTargetBranch(branch: string): string { - return branch.replace(/^refs\/heads\//, ""); -} - -/** - * Return the first pattern that any of the given files match, or `undefined` - * if none match. Used by callers to surface *why* a full run was forced. - */ -export function checkFullRunPatterns( - files: readonly string[], - patterns: readonly RegExp[] = FULL_RUN_PATTERNS, -): RegExp | undefined { - for (const pattern of patterns) { - if (files.some((f) => pattern.test(f))) { - return pattern; - } - } - return undefined; -} - -/** - * Build the set of directories that hold (or held, at `mergeBase`) a - * package.json. Unions the merge-base tree with the working tree so a - * package DELETED on this branch still maps correctly — the reviewer-flagged - * case the bash implementation missed. - * - * `listHistoricalPackages` and `listCurrentPackages` are injected so tests - * can drive this logic without spinning up a real git repo. - */ -export function buildPackageDirSet( - mergeBase: string, - listHistoricalPackages: (ref: string) => readonly string[], - listCurrentPackages: () => readonly string[], -): ReadonlySet { - const dirs = new Set(); - const record = (file: string): void => { - // file is like "packages/foo/package.json" or "package.json". - const dir = path.posix.dirname(file); - dirs.add(dir === "" ? "." : dir); - }; - for (const f of listHistoricalPackages(mergeBase)) record(f); - for (const f of listCurrentPackages()) record(f); - return dirs; -} - -/** - * Return `true` if any changed file lives under a known package directory. - * A file at `packages/foo/src/x.ts` matches if `packages/foo` (or any - * ancestor above it, stopping at the root) is in `packageDirs`. - * - * The root pseudo-dir `"."` is deliberately ignored here: root-level package - * changes are already caught by `FULL_RUN_PATTERNS` and should not double- - * count as a per-package signal. - */ -export function findChangedPackages( - changedFiles: readonly string[], - packageDirs: ReadonlySet, -): boolean { - for (const file of changedFiles) { - if (!file) continue; - let dir = path.posix.dirname(file); - while (dir !== "." && dir !== "/" && dir !== "") { - if (packageDirs.has(dir)) { - return true; - } - dir = path.posix.dirname(dir); - } - } - return false; -} - -/** Thin wrapper for `git` calls. Returns stdout or `undefined` on failure. */ -function git(args: string[]): string | undefined { - try { - return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - } catch { - return undefined; - } -} - -/** Git-backed implementation of `listHistoricalPackages`. */ -function gitHistoricalPackages(ref: string): string[] { - const out = git(["ls-tree", "-r", "--name-only", ref]); - if (out === undefined) return []; - return out.split("\n").filter((f) => /(^|\/)package\.json$/.test(f)); -} - -/** Walk the working tree for package.json files, skipping node_modules. */ -function currentPackages(cwd: string = process.cwd()): string[] { - const results: string[] = []; - const walk = (dir: string): void => { - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (entry.name === "node_modules" || entry.name.startsWith(".git")) continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.name === "package.json") { - results.push(path.relative(cwd, full).split(path.sep).join("/")); - } - } - }; - walk(cwd); - return results; -} - -function emitVsoOutputs(shouldRunTests: boolean, scopedPnpmFilter: string): void { - const flag = shouldRunTests ? "true" : "false"; - console.log(`shouldRunTests=${flag}`); - console.log(`scopedPnpmFilter=${scopedPnpmFilter}`); - console.log(`##vso[task.setvariable variable=shouldRunTests;isOutput=true]${flag}`); - console.log( - `##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`, - ); -} - -function logWarning(message: string): void { - console.log(`##vso[task.logissue type=warning]${message}`); -} - -/** Pipeline entry point. Reads TARGET_BRANCH from env, writes vso outputs. */ -export function main(): void { - const raw = process.env.TARGET_BRANCH ?? ""; - const targetBranch = normalizeTargetBranch(raw); - if (!targetBranch) { - logWarning("TARGET_BRANCH not set; falling back to full test run."); - emitVsoOutputs(true, ""); - return; - } - console.log(`Target branch: ${targetBranch}`); - - if (git(["fetch", "origin", targetBranch]) === undefined) { - logWarning(`Could not fetch origin/${targetBranch}; falling back to full test run.`); - emitVsoOutputs(true, ""); - return; - } - - // Try to resolve the merge-base in the shallow clone first. If the PR - // diverged further back than the shallow boundary, unshallow and retry - // once. `.git/shallow` is how git marks a shallow repo; skip the - // unshallow on a full clone (which would error with "--unshallow on a - // complete repository"). - let mergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim(); - const gitDir = git(["rev-parse", "--git-dir"])?.trim(); - if (!mergeBase && gitDir && existsSync(path.join(gitDir, "shallow"))) { - console.log("Merge-base not found in shallow clone; unshallowing and retrying."); - git(["fetch", "--unshallow", "origin", targetBranch]); - mergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim(); - } - if (!mergeBase) { - logWarning(`No merge-base with origin/${targetBranch}; falling back to full test run.`); - emitVsoOutputs(true, ""); - return; - } - console.log(`Merge base: ${mergeBase}`); - - // On diff failure, fall back to a full run rather than swallowing the - // error — an empty changed-files list would bypass the full-run patterns - // and the package-change check, silently suppressing all test jobs. - const diffOut = git(["diff", "--name-only", mergeBase]); - if (diffOut === undefined) { - logWarning( - `git diff against merge-base ${mergeBase} failed; falling back to full test run.`, - ); - emitVsoOutputs(true, ""); - return; - } - const changedFiles = diffOut.split("\n").filter((f) => f.length > 0); - console.log(`Changed files (${changedFiles.length}):`); - for (const f of changedFiles.slice(0, 30)) console.log(f); - if (changedFiles.length > 30) console.log(`... and ${changedFiles.length - 30} more`); - - const match = checkFullRunPatterns(changedFiles); - if (match !== undefined) { - console.log(`Match for full-run pattern '${match.source}' — forcing full test run.`); - emitVsoOutputs(true, ""); - return; - } - - const packageDirs = buildPackageDirSet(mergeBase, gitHistoricalPackages, currentPackages); - if (!findChangedPackages(changedFiles, packageDirs)) { - // Most aggressive skip path: no test jobs run. Surface as a pipeline - // warning (not plain console output) and dump the file list so an - // accidental silent-suppression bug is auditable from the pipeline - // summary without needing to re-run the build. - logWarning( - `No changed files mapped to a workspace package — skipping all test execution. Files considered (${changedFiles.length}):`, - ); - for (const f of changedFiles) console.log(` ${f}`); - emitVsoOutputs(false, ""); - return; - } - - // Hand the merge-base SHA to pnpm's native selector. The leading `...` - // pulls in transitive dependents so consumers of a changed package also - // get re-tested. - const filter = `...[${mergeBase}]`; - console.log(`Computed pnpm filter: ${filter}`); - emitVsoOutputs(true, filter); -} - -// Run main only when invoked directly, not when imported by tests. -// fileURLToPath avoids Windows drive-letter mismatches that caught older -// `process.argv[1]`-based guards. -if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) { - main(); -} diff --git a/scripts/detect_changed_packages.py b/scripts/detect_changed_packages.py new file mode 100644 index 000000000000..4fc1b6aedf1d --- /dev/null +++ b/scripts/detect_changed_packages.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation and contributors. All rights reserved. +# Licensed under the MIT License. + +"""detect_changed_packages. + +Decides whether a PR's diff warrants scoping downstream test execution to a +subset of workspace packages. Emits two ADO output variables: + + shouldRunTests "true" | "false" — whether any test work is needed + scopedPnpmFilter pnpm filter string "...[]" when scoping is active, + empty when a full test run is required. Downstream jobs + pass this verbatim into ``npm_config_filter``; pnpm treats + an empty value as "no filter applied" so recursive ``-r`` + runs fall back to the historical every-package behavior. + +Safe-fallback policy: any unexpected error (missing merge-base, git failure, +unparseable ref) MUST result in a full run — never a silent skip. An +accidental silent skip would suppress all tests and hide real regressions. + +Why merge-base (and not just ``origin/`` directly): pnpm's +``--filter "[ref]"`` uses a two-dot diff internally (see pnpm/pnpm#9907), so +commits that landed on ``origin/`` after this PR diverged would show +up as "changed." Computing the merge-base SHA ourselves and feeding that SHA +into the selector gives three-dot (merge-base) semantics. + +This module exports pure helpers so the decision logic can be unit tested +without an ADO pipeline context or a populated git repo. Python stdlib only +(no third-party deps) so the pipeline gate stays close to "git + python" fast. +""" + +from __future__ import annotations + +import os +import posixpath +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable, Iterable + +# Full-run trigger patterns. A diff touching any of these paths forces running +# every package's tests (filter stays empty → pnpm -r runs across the whole +# workspace). Keep this list conservative — it's the safety net for changes +# that could plausibly invalidate assumptions across the entire workspace. +# +# This list partially overlaps with `pr: paths: include:` in +# tools/pipelines/build-client.yml (which decides whether the pipeline runs +# at all). The concepts differ — one gates the pipeline, the other gates +# scoping within a pipeline that's already running — but adding a new +# cross-cutting root-level file generally warrants updating both. There's no +# programmatic link, so keep them in sync by convention. +FULL_RUN_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"^package\.json$"), + re.compile(r"^pnpm-lock\.yaml$"), + re.compile(r"^pnpm-workspace\.yaml$"), + re.compile(r"^\.pnpmfile\.cjs$"), + re.compile(r"^\.npmrc$"), + re.compile(r"^\.nvmrc$"), + re.compile(r"^fluidBuild\.config\.cjs$"), + re.compile(r"^tsconfig[^/]*\.json$"), + re.compile(r"^biome\."), + re.compile(r"^tools/"), + re.compile(r"^common/"), + re.compile(r"^scripts/"), + re.compile(r"^\.changeset/config\.json$"), +) + + +def normalize_target_branch(branch: str) -> str: + """Azure Repos emits ``refs/heads/main``; GitHub emits just ``main``. Normalize.""" + if branch.startswith("refs/heads/"): + return branch[len("refs/heads/") :] + return branch + + +def check_full_run_patterns( + files: Iterable[str], + patterns: Iterable[re.Pattern[str]] = FULL_RUN_PATTERNS, +) -> re.Pattern[str] | None: + """Return the first pattern that any of the given files match, or ``None``. + + Used by callers to surface *why* a full run was forced. + """ + file_list = list(files) + for pattern in patterns: + if any(pattern.search(f) for f in file_list): + return pattern + return None + + +def build_package_dir_set( + merge_base: str, + list_historical_packages: Callable[[str], Iterable[str]], + list_current_packages: Callable[[], Iterable[str]], +) -> set[str]: + """Build the set of directories that hold (or held, at ``merge_base``) a package.json. + + Unions the merge-base tree with the working tree so a package DELETED on + this branch still maps correctly — the reviewer-flagged case the bash + implementation missed. + + ``list_historical_packages`` and ``list_current_packages`` are injected so + tests can drive this logic without spinning up a real git repo. + """ + dirs: set[str] = set() + + def record(file: str) -> None: + # file is like "packages/foo/package.json" or "package.json". + d = posixpath.dirname(file) + dirs.add("." if d == "" else d) + + for f in list_historical_packages(merge_base): + record(f) + for f in list_current_packages(): + record(f) + return dirs + + +def find_changed_packages( + changed_files: Iterable[str], + package_dirs: set[str], +) -> bool: + """Return True if any changed file lives under a known package directory. + + A file at ``packages/foo/src/x.ts`` matches if ``packages/foo`` (or any + ancestor above it, stopping at the root) is in ``package_dirs``. + + The root pseudo-dir ``"."`` is deliberately ignored here: root-level + package changes are already caught by ``FULL_RUN_PATTERNS`` and should not + double-count as a per-package signal. + """ + for file in changed_files: + if not file: + continue + d = posixpath.dirname(file) + while d not in (".", "/", ""): + if d in package_dirs: + return True + d = posixpath.dirname(d) + return False + + +def _git(args: list[str]) -> str | None: + """Thin wrapper for ``git`` calls. Returns stdout or None on failure.""" + try: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + return result.stdout + + +def _git_historical_packages(ref: str) -> list[str]: + """Git-backed implementation of ``list_historical_packages``.""" + out = _git(["ls-tree", "-r", "--name-only", ref]) + if out is None: + return [] + pattern = re.compile(r"(^|/)package\.json$") + return [f for f in out.split("\n") if pattern.search(f)] + + +def _current_packages(cwd: str | None = None) -> list[str]: + """Walk the working tree for package.json files, skipping node_modules.""" + root = Path(cwd) if cwd is not None else Path.cwd() + results: list[str] = [] + for dirpath, dirnames, filenames in os.walk(root): + # Mutate dirnames in place to prune traversal. + dirnames[:] = [ + d for d in dirnames if d != "node_modules" and not d.startswith(".git") + ] + if "package.json" in filenames: + rel = os.path.relpath(os.path.join(dirpath, "package.json"), root) + results.append(rel.replace(os.sep, "/")) + return results + + +def _emit_vso_outputs(should_run_tests: bool, scoped_pnpm_filter: str) -> None: + flag = "true" if should_run_tests else "false" + print(f"shouldRunTests={flag}") + print(f"scopedPnpmFilter={scoped_pnpm_filter}") + print(f"##vso[task.setvariable variable=shouldRunTests;isOutput=true]{flag}") + print( + f"##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]{scoped_pnpm_filter}" + ) + + +def _log_warning(message: str) -> None: + print(f"##vso[task.logissue type=warning]{message}") + + +def main() -> None: + """Pipeline entry point. Reads TARGET_BRANCH from env, writes vso outputs.""" + raw = os.environ.get("TARGET_BRANCH", "") + target_branch = normalize_target_branch(raw) + if not target_branch: + _log_warning("TARGET_BRANCH not set; falling back to full test run.") + _emit_vso_outputs(True, "") + return + print(f"Target branch: {target_branch}") + + if _git(["fetch", "origin", target_branch]) is None: + _log_warning( + f"Could not fetch origin/{target_branch}; falling back to full test run." + ) + _emit_vso_outputs(True, "") + return + + # Try to resolve the merge-base in the shallow clone first. If the PR + # diverged further back than the shallow boundary, unshallow and retry + # once. `.git/shallow` is how git marks a shallow repo; skip the + # unshallow on a full clone (which would error with "--unshallow on a + # complete repository"). + mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) + merge_base = mb.strip() if mb else "" + git_dir_out = _git(["rev-parse", "--git-dir"]) + git_dir = git_dir_out.strip() if git_dir_out else "" + if not merge_base and git_dir and (Path(git_dir) / "shallow").exists(): + print("Merge-base not found in shallow clone; unshallowing and retrying.") + _git(["fetch", "--unshallow", "origin", target_branch]) + mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) + merge_base = mb.strip() if mb else "" + if not merge_base: + _log_warning( + f"No merge-base with origin/{target_branch}; falling back to full test run." + ) + _emit_vso_outputs(True, "") + return + print(f"Merge base: {merge_base}") + + # On diff failure, fall back to a full run rather than swallowing the + # error — an empty changed-files list would bypass the full-run patterns + # and the package-change check, silently suppressing all test jobs. + diff_out = _git(["diff", "--name-only", merge_base]) + if diff_out is None: + _log_warning( + f"git diff against merge-base {merge_base} failed; falling back to full test run." + ) + _emit_vso_outputs(True, "") + return + changed_files = [f for f in diff_out.split("\n") if f] + print(f"Changed files ({len(changed_files)}):") + for f in changed_files[:30]: + print(f) + if len(changed_files) > 30: + print(f"... and {len(changed_files) - 30} more") + + match = check_full_run_patterns(changed_files) + if match is not None: + print(f"Match for full-run pattern '{match.pattern}' — forcing full test run.") + _emit_vso_outputs(True, "") + return + + package_dirs = build_package_dir_set( + merge_base, _git_historical_packages, _current_packages + ) + if not find_changed_packages(changed_files, package_dirs): + # Most aggressive skip path: no test jobs run. Surface as a pipeline + # warning (not plain console output) and dump the file list so an + # accidental silent-suppression bug is auditable from the pipeline + # summary without needing to re-run the build. + _log_warning( + f"No changed files mapped to a workspace package — skipping all test execution. " + f"Files considered ({len(changed_files)}):" + ) + for f in changed_files: + print(f" {f}") + _emit_vso_outputs(False, "") + return + + # Hand the merge-base SHA to pnpm's native selector. The leading `...` + # pulls in transitive dependents so consumers of a changed package also + # get re-tested. + filt = f"...[{merge_base}]" + print(f"Computed pnpm filter: {filt}") + _emit_vso_outputs(True, filt) + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/scripts/test/detect-changed-packages.test.ts b/scripts/test/detect-changed-packages.test.ts deleted file mode 100644 index ac788a6329db..000000000000 --- a/scripts/test/detect-changed-packages.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -/** - * Tests for detect-changed-packages.ts. These exercise the exported pure - * helpers so regressions in the change-detection logic are caught before - * landing. Uses Node's built-in test runner (node:test) — no mocha/jest - * needed, keeping the pipeline cost of running these low. - * - * Pipeline invocation (from repo root, after `pnpm install`): - * pnpm run test:scripts - */ - -import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; -import { describe, it } from "node:test"; - -import { - FULL_RUN_PATTERNS, - buildPackageDirSet, - checkFullRunPatterns, - findChangedPackages, - normalizeTargetBranch, -} from "../detect-changed-packages.ts"; - -describe("normalizeTargetBranch", () => { - it("strips refs/heads/ prefix", () => { - strictEqual(normalizeTargetBranch("refs/heads/main"), "main"); - }); - - it("passes plain branch names through", () => { - strictEqual(normalizeTargetBranch("next"), "next"); - }); - - it("preserves slashes after the prefix", () => { - strictEqual(normalizeTargetBranch("refs/heads/release/2.x"), "release/2.x"); - }); - - it("returns empty string for empty input", () => { - strictEqual(normalizeTargetBranch(""), ""); - }); -}); - -describe("checkFullRunPatterns", () => { - it("matches pnpm-lock.yaml", () => { - const match = checkFullRunPatterns(["pnpm-lock.yaml"]); - strictEqual(match?.source, "^pnpm-lock\\.yaml$"); - }); - - it("matches .pnpmfile.cjs (added in review)", () => { - const match = checkFullRunPatterns([".pnpmfile.cjs"]); - strictEqual(match?.source, "^\\.pnpmfile\\.cjs$"); - }); - - it("matches .npmrc (added in review)", () => { - const match = checkFullRunPatterns([".npmrc"]); - strictEqual(match?.source, "^\\.npmrc$"); - }); - - it("matches .nvmrc (added in review)", () => { - const match = checkFullRunPatterns([".nvmrc"]); - strictEqual(match?.source, "^\\.nvmrc$"); - }); - - it("matches tools/ prefix", () => { - const match = checkFullRunPatterns(["tools/pipelines/build-client.yml"]); - ok(match, "expected tools/ prefix to match"); - }); - - it("matches ROOT package.json, not a nested one", () => { - strictEqual(checkFullRunPatterns(["packages/foo/package.json"]), undefined); - strictEqual(checkFullRunPatterns(["package.json"])?.source, "^package\\.json$"); - }); - - it("matches root tsconfig (anchored), not nested", () => { - ok(checkFullRunPatterns(["tsconfig.base.json"])); - strictEqual(checkFullRunPatterns(["packages/foo/tsconfig.json"]), undefined); - }); - - it("returns undefined when nothing matches", () => { - strictEqual(checkFullRunPatterns(["packages/foo/src/x.ts"]), undefined); - }); - - it("returns the first pattern hit when several qualify", () => { - // Stability matters for readable pipeline logs. - const match = checkFullRunPatterns(["pnpm-lock.yaml", "biome.jsonc"]); - strictEqual(match?.source, "^pnpm-lock\\.yaml$"); - }); - - it("exposes the pattern list for external audits", () => { - ok(FULL_RUN_PATTERNS.length > 0); - // Ensure each of the three review-added patterns made it into the - // exported list (not just the checker). - const sources = FULL_RUN_PATTERNS.map((r) => r.source); - ok(sources.includes("^\\.pnpmfile\\.cjs$")); - ok(sources.includes("^\\.npmrc$")); - ok(sources.includes("^\\.nvmrc$")); - }); -}); - -describe("buildPackageDirSet", () => { - it("unions historical and current packages", () => { - const historical = ["packages/old/package.json", "packages/shared/package.json"]; - const current = ["packages/shared/package.json", "packages/new/package.json"]; - const dirs = buildPackageDirSet( - "sha", - () => historical, - () => current, - ); - deepStrictEqual([...dirs].sort(), ["packages/new", "packages/old", "packages/shared"]); - }); - - it("maps a root-level package.json to '.'", () => { - const dirs = buildPackageDirSet( - "sha", - () => ["package.json"], - () => [], - ); - deepStrictEqual([...dirs], ["."]); - }); - - it("tolerates either list being empty", () => { - strictEqual( - buildPackageDirSet( - "sha", - () => [], - () => [], - ).size, - 0, - ); - strictEqual( - buildPackageDirSet( - "sha", - () => ["packages/a/package.json"], - () => [], - ).size, - 1, - ); - }); -}); - -describe("findChangedPackages", () => { - const pkgDirs = new Set(["packages/alive", "packages/doomed"]); - - it("detects a file inside a known package dir", () => { - strictEqual(findChangedPackages(["packages/alive/src/x.ts"], pkgDirs), true); - }); - - it("detects a deleted package's file (regression — see review #3133324370)", () => { - // Working-tree check would MISS this because packages/doomed/package.json - // no longer exists on disk. The historical-set merge in - // buildPackageDirSet is what keeps this path live. - strictEqual(findChangedPackages(["packages/doomed/package.json"], pkgDirs), true); - }); - - it("detects a NEW package (added on this branch)", () => { - const withNew = new Set(["packages/new"]); - strictEqual( - findChangedPackages(["packages/new/package.json", "packages/new/src.ts"], withNew), - true, - ); - }); - - it("returns false for root-only changes", () => { - // Root-level file changes are handled by FULL_RUN_PATTERNS, not here. - strictEqual(findChangedPackages(["README.md"], pkgDirs), false); - }); - - it("returns false when file lives in an unrelated sibling dir", () => { - strictEqual(findChangedPackages(["packages/other/src.ts"], pkgDirs), false); - }); - - it("ignores empty file entries", () => { - strictEqual(findChangedPackages(["", "packages/alive/src.ts"], pkgDirs), true); - }); - - it("walks up from nested paths to find an ancestor package dir", () => { - strictEqual(findChangedPackages(["packages/alive/src/deeply/nested/x.ts"], pkgDirs), true); - }); - - it("does not treat the root pseudo-dir '.' as a per-package hit", () => { - // Even if '.' is in packageDirs (root package.json case), we should - // not declare per-package changes for a random root file. - const dirsWithRoot = new Set([".", "packages/alive"]); - strictEqual(findChangedPackages(["some-root-file.md"], dirsWithRoot), false); - }); -}); diff --git a/scripts/test/test_detect_changed_packages.py b/scripts/test/test_detect_changed_packages.py new file mode 100644 index 000000000000..46bcd3400d3c --- /dev/null +++ b/scripts/test/test_detect_changed_packages.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft Corporation and contributors. All rights reserved. +# Licensed under the MIT License. + +"""Tests for detect_changed_packages. + +Exercises the exported pure helpers so regressions in the change-detection +logic are caught before landing. Uses Python's stdlib ``unittest`` — no +third-party deps, keeping the pipeline cost of running these low. + +Pipeline invocation (from repo root): + + python3 -m unittest discover -s scripts/test -p 'test_*.py' +""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +# Make scripts/ importable so we can pull in the module under test. +_SCRIPTS_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_SCRIPTS_DIR)) + +from detect_changed_packages import ( # noqa: E402 + FULL_RUN_PATTERNS, + build_package_dir_set, + check_full_run_patterns, + find_changed_packages, + normalize_target_branch, +) + + +class NormalizeTargetBranchTests(unittest.TestCase): + def test_strips_refs_heads_prefix(self) -> None: + self.assertEqual(normalize_target_branch("refs/heads/main"), "main") + + def test_passes_plain_branch_names_through(self) -> None: + self.assertEqual(normalize_target_branch("next"), "next") + + def test_preserves_slashes_after_the_prefix(self) -> None: + self.assertEqual( + normalize_target_branch("refs/heads/release/2.x"), "release/2.x" + ) + + def test_returns_empty_string_for_empty_input(self) -> None: + self.assertEqual(normalize_target_branch(""), "") + + +class CheckFullRunPatternsTests(unittest.TestCase): + def test_matches_pnpm_lock_yaml(self) -> None: + match = check_full_run_patterns(["pnpm-lock.yaml"]) + assert match is not None + self.assertEqual(match.pattern, r"^pnpm-lock\.yaml$") + + def test_matches_pnpmfile_cjs(self) -> None: + match = check_full_run_patterns([".pnpmfile.cjs"]) + assert match is not None + self.assertEqual(match.pattern, r"^\.pnpmfile\.cjs$") + + def test_matches_npmrc(self) -> None: + match = check_full_run_patterns([".npmrc"]) + assert match is not None + self.assertEqual(match.pattern, r"^\.npmrc$") + + def test_matches_nvmrc(self) -> None: + match = check_full_run_patterns([".nvmrc"]) + assert match is not None + self.assertEqual(match.pattern, r"^\.nvmrc$") + + def test_matches_tools_prefix(self) -> None: + match = check_full_run_patterns(["tools/pipelines/build-client.yml"]) + self.assertIsNotNone(match, "expected tools/ prefix to match") + + def test_matches_root_package_json_not_nested(self) -> None: + self.assertIsNone(check_full_run_patterns(["packages/foo/package.json"])) + match = check_full_run_patterns(["package.json"]) + assert match is not None + self.assertEqual(match.pattern, r"^package\.json$") + + def test_matches_root_tsconfig_anchored_not_nested(self) -> None: + self.assertIsNotNone(check_full_run_patterns(["tsconfig.base.json"])) + self.assertIsNone(check_full_run_patterns(["packages/foo/tsconfig.json"])) + + def test_returns_none_when_nothing_matches(self) -> None: + self.assertIsNone(check_full_run_patterns(["packages/foo/src/x.ts"])) + + def test_returns_first_pattern_hit_when_several_qualify(self) -> None: + # Stability matters for readable pipeline logs. + match = check_full_run_patterns(["pnpm-lock.yaml", "biome.jsonc"]) + assert match is not None + self.assertEqual(match.pattern, r"^pnpm-lock\.yaml$") + + def test_exposes_pattern_list_for_external_audits(self) -> None: + self.assertGreater(len(FULL_RUN_PATTERNS), 0) + # Ensure each of the three review-added patterns made it into the + # exported list (not just the checker). + sources = [p.pattern for p in FULL_RUN_PATTERNS] + self.assertIn(r"^\.pnpmfile\.cjs$", sources) + self.assertIn(r"^\.npmrc$", sources) + self.assertIn(r"^\.nvmrc$", sources) + + +class BuildPackageDirSetTests(unittest.TestCase): + def test_unions_historical_and_current_packages(self) -> None: + historical = ["packages/old/package.json", "packages/shared/package.json"] + current = ["packages/shared/package.json", "packages/new/package.json"] + dirs = build_package_dir_set("sha", lambda _: historical, lambda: current) + self.assertEqual( + sorted(dirs), ["packages/new", "packages/old", "packages/shared"] + ) + + def test_maps_a_root_level_package_json_to_dot(self) -> None: + dirs = build_package_dir_set("sha", lambda _: ["package.json"], lambda: []) + self.assertEqual(list(dirs), ["."]) + + def test_tolerates_either_list_being_empty(self) -> None: + self.assertEqual( + len(build_package_dir_set("sha", lambda _: [], lambda: [])), 0 + ) + self.assertEqual( + len( + build_package_dir_set( + "sha", lambda _: ["packages/a/package.json"], lambda: [] + ) + ), + 1, + ) + + +class FindChangedPackagesTests(unittest.TestCase): + PKG_DIRS = {"packages/alive", "packages/doomed"} + + def test_detects_file_inside_known_package_dir(self) -> None: + self.assertTrue( + find_changed_packages(["packages/alive/src/x.ts"], self.PKG_DIRS) + ) + + def test_detects_deleted_packages_file(self) -> None: + # Regression — see review #3133324370. + # Working-tree check would MISS this because packages/doomed/package.json + # no longer exists on disk. The historical-set merge in + # build_package_dir_set is what keeps this path live. + self.assertTrue( + find_changed_packages(["packages/doomed/package.json"], self.PKG_DIRS) + ) + + def test_detects_new_package_added_on_this_branch(self) -> None: + with_new = {"packages/new"} + self.assertTrue( + find_changed_packages( + ["packages/new/package.json", "packages/new/src.ts"], with_new + ) + ) + + def test_returns_false_for_root_only_changes(self) -> None: + # Root-level file changes are handled by FULL_RUN_PATTERNS, not here. + self.assertFalse(find_changed_packages(["README.md"], self.PKG_DIRS)) + + def test_returns_false_when_file_lives_in_unrelated_sibling_dir(self) -> None: + self.assertFalse( + find_changed_packages(["packages/other/src.ts"], self.PKG_DIRS) + ) + + def test_ignores_empty_file_entries(self) -> None: + self.assertTrue( + find_changed_packages(["", "packages/alive/src.ts"], self.PKG_DIRS) + ) + + def test_walks_up_from_nested_paths_to_find_ancestor(self) -> None: + self.assertTrue( + find_changed_packages( + ["packages/alive/src/deeply/nested/x.ts"], self.PKG_DIRS + ) + ) + + def test_does_not_treat_root_pseudo_dir_as_per_package_hit(self) -> None: + # Even if '.' is in package_dirs (root package.json case), we should + # not declare per-package changes for a random root file. + dirs_with_root = {".", "packages/alive"} + self.assertFalse(find_changed_packages(["some-root-file.md"], dirs_with_root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 9acef36ae4e2..562c1b8b64de 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -364,13 +364,13 @@ extends: buildDirectory: '${{ parameters.buildDirectory }}' packageManagerInstallCommand: '${{ parameters.packageManagerInstallCommand }}' - # Unit tests for helper scripts under scripts/. detect-changed-packages.ts + # Unit tests for helper scripts under scripts/. detect_changed_packages.py # gates whether the Coverage_tests / Test_* jobs run at all (see # include-detect-changed-packages.yml); a regression in the change # detection logic could silently suppress every package test, so - # we run its tests here in the build job — right after install so - # tsx (a root devDep) is resolvable via `pnpm exec` — before any - # heavy build work, so they fail fast. + # we run its tests here in the build job — early, before any heavy + # build work, so they fail fast. Python stdlib only; no install + # step needed beyond whatever python3 ships on the agent image. - task: Bash@3 displayName: Scripts unit tests inputs: @@ -378,7 +378,7 @@ extends: workingDirectory: $(Pipeline.Workspace)/${{ parameters.buildDirectory }} script: | set -eu -o pipefail - pnpm exec tsx --test scripts/test/*.test.ts + python3 -m unittest discover -s scripts/test -p 'test_*.py' -v # The bundle-size-artifacts pipeline runs a client build but doesn't publish packages, # so we skip version setting for it. diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index ce9132f71c2d..24611a53e44b 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -3,11 +3,11 @@ # include-detect-changed-packages # -# Thin pipeline wrapper around scripts/detect-changed-packages.ts. The -# detection logic lives in the TypeScript module (extracted in response to -# PR review feedback so it can be run/debugged locally and covered by unit -# tests under scripts/test/). Keeping this template minimal ensures the -# template itself doesn't drift from the tested script. +# Thin pipeline wrapper around scripts/detect_changed_packages.py. The +# detection logic lives in the Python module (extracted in response to PR +# review feedback so it can be run/debugged locally and covered by unit tests +# under scripts/test/). Keeping this template minimal ensures the template +# itself doesn't drift from the tested script. # # Output variables (from the `setChangedPackages` step): # - shouldRunTests "true" | "false" — whether any test work is needed @@ -21,11 +21,9 @@ # On any error path (missing merge-base, unsupported ref format) the script # degrades safely to a full-run outcome, never to a silent skip. # -# The script is invoked via `pnpm exec tsx` after a workspace-root-only -# install; tsx is a root devDep so its version is pinned in pnpm-lock.yaml. -# The workspace-root install path mirrors repo-policy-check.yml and -# deliberately skips the full workspace install, keeping the gate close to -# "git + node + tsx" fast. +# The script is invoked via `python3` directly — Python 3 ships on ADO Linux +# agent images, and this script uses only stdlib, so the gate stays close to +# "git + python" fast (no pnpm or node install step required here). parameters: - name: buildDirectory @@ -43,22 +41,6 @@ steps: # few hundred commits, so this is cheap in the common case. fetchDepth: 200 - - template: /tools/pipelines/templates/include-use-node-version.yml@self - - - template: /tools/pipelines/templates/include-install-pnpm.yml@self - parameters: - buildDirectory: ${{ parameters.buildDirectory }} - - - task: Bash@3 - displayName: Install root dependencies - inputs: - targetType: 'inline' - workingDirectory: $(Pipeline.Workspace)/${{ parameters.buildDirectory }} - script: | - set -eu -o pipefail - # Workspace-root only — we just need tsx and its transitive deps. - pnpm install --workspace-root --frozen-lockfile - - task: Bash@3 name: setChangedPackages displayName: Detect changed packages @@ -69,4 +51,4 @@ steps: workingDirectory: '$(Pipeline.Workspace)/${{ parameters.buildDirectory }}' script: | set -eu -o pipefail - pnpm exec tsx scripts/detect-changed-packages.ts + python3 scripts/detect_changed_packages.py From ac05fadc51a6c47767951cf5587092e4447eb144 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Fri, 24 Apr 2026 15:06:15 -0700 Subject: [PATCH 12/19] ci(client): address remaining review feedback on detect-changed-packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _git() now logs a pipeline warning (command + stderr) on failure instead of swallowing the error silently. - Rename find_changed_packages → any_changed_file_in_packages so the name reflects the bool return. - Drop the pattern-list-audit test and collapse the three redundant any_changed_file_in_packages happy-path tests; the deleted/new package cases are already covered by the build_package_dir_set union test. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- scripts/detect_changed_packages.py | 17 +++++-- scripts/test/test_detect_changed_packages.py | 47 +++++--------------- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/scripts/detect_changed_packages.py b/scripts/detect_changed_packages.py index 4fc1b6aedf1d..9739de39a5d8 100644 --- a/scripts/detect_changed_packages.py +++ b/scripts/detect_changed_packages.py @@ -117,7 +117,7 @@ def record(file: str) -> None: return dirs -def find_changed_packages( +def any_changed_file_in_packages( changed_files: Iterable[str], package_dirs: set[str], ) -> bool: @@ -142,7 +142,11 @@ def find_changed_packages( def _git(args: list[str]) -> str | None: - """Thin wrapper for ``git`` calls. Returns stdout or None on failure.""" + """Thin wrapper for ``git`` calls. Returns stdout or None on failure. + + On failure, logs a pipeline warning with the git command and its stderr + so the reason for any safe-fallback path is visible in the pipeline run. + """ try: result = subprocess.run( ["git", *args], @@ -150,7 +154,12 @@ def _git(args: list[str]) -> str | None: text=True, check=True, ) - except (subprocess.CalledProcessError, FileNotFoundError): + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + _log_warning(f"git {' '.join(args)} failed (exit {e.returncode}): {stderr}") + return None + except FileNotFoundError as e: + _log_warning(f"git executable not found: {e}") return None return result.stdout @@ -258,7 +267,7 @@ def main() -> None: package_dirs = build_package_dir_set( merge_base, _git_historical_packages, _current_packages ) - if not find_changed_packages(changed_files, package_dirs): + if not any_changed_file_in_packages(changed_files, package_dirs): # Most aggressive skip path: no test jobs run. Surface as a pipeline # warning (not plain console output) and dump the file list so an # accidental silent-suppression bug is auditable from the pipeline diff --git a/scripts/test/test_detect_changed_packages.py b/scripts/test/test_detect_changed_packages.py index 46bcd3400d3c..f6c782da6b5a 100644 --- a/scripts/test/test_detect_changed_packages.py +++ b/scripts/test/test_detect_changed_packages.py @@ -23,10 +23,9 @@ sys.path.insert(0, str(_SCRIPTS_DIR)) from detect_changed_packages import ( # noqa: E402 - FULL_RUN_PATTERNS, + any_changed_file_in_packages, build_package_dir_set, check_full_run_patterns, - find_changed_packages, normalize_target_branch, ) @@ -91,15 +90,6 @@ def test_returns_first_pattern_hit_when_several_qualify(self) -> None: assert match is not None self.assertEqual(match.pattern, r"^pnpm-lock\.yaml$") - def test_exposes_pattern_list_for_external_audits(self) -> None: - self.assertGreater(len(FULL_RUN_PATTERNS), 0) - # Ensure each of the three review-added patterns made it into the - # exported list (not just the checker). - sources = [p.pattern for p in FULL_RUN_PATTERNS] - self.assertIn(r"^\.pnpmfile\.cjs$", sources) - self.assertIn(r"^\.npmrc$", sources) - self.assertIn(r"^\.nvmrc$", sources) - class BuildPackageDirSetTests(unittest.TestCase): def test_unions_historical_and_current_packages(self) -> None: @@ -128,48 +118,31 @@ def test_tolerates_either_list_being_empty(self) -> None: ) -class FindChangedPackagesTests(unittest.TestCase): - PKG_DIRS = {"packages/alive", "packages/doomed"} +class AnyChangedFileInPackagesTests(unittest.TestCase): + PKG_DIRS = {"packages/alive"} def test_detects_file_inside_known_package_dir(self) -> None: self.assertTrue( - find_changed_packages(["packages/alive/src/x.ts"], self.PKG_DIRS) - ) - - def test_detects_deleted_packages_file(self) -> None: - # Regression — see review #3133324370. - # Working-tree check would MISS this because packages/doomed/package.json - # no longer exists on disk. The historical-set merge in - # build_package_dir_set is what keeps this path live. - self.assertTrue( - find_changed_packages(["packages/doomed/package.json"], self.PKG_DIRS) - ) - - def test_detects_new_package_added_on_this_branch(self) -> None: - with_new = {"packages/new"} - self.assertTrue( - find_changed_packages( - ["packages/new/package.json", "packages/new/src.ts"], with_new - ) + any_changed_file_in_packages(["packages/alive/src/x.ts"], self.PKG_DIRS) ) def test_returns_false_for_root_only_changes(self) -> None: # Root-level file changes are handled by FULL_RUN_PATTERNS, not here. - self.assertFalse(find_changed_packages(["README.md"], self.PKG_DIRS)) + self.assertFalse(any_changed_file_in_packages(["README.md"], self.PKG_DIRS)) def test_returns_false_when_file_lives_in_unrelated_sibling_dir(self) -> None: self.assertFalse( - find_changed_packages(["packages/other/src.ts"], self.PKG_DIRS) + any_changed_file_in_packages(["packages/other/src.ts"], self.PKG_DIRS) ) def test_ignores_empty_file_entries(self) -> None: self.assertTrue( - find_changed_packages(["", "packages/alive/src.ts"], self.PKG_DIRS) + any_changed_file_in_packages(["", "packages/alive/src.ts"], self.PKG_DIRS) ) def test_walks_up_from_nested_paths_to_find_ancestor(self) -> None: self.assertTrue( - find_changed_packages( + any_changed_file_in_packages( ["packages/alive/src/deeply/nested/x.ts"], self.PKG_DIRS ) ) @@ -178,7 +151,9 @@ def test_does_not_treat_root_pseudo_dir_as_per_package_hit(self) -> None: # Even if '.' is in package_dirs (root package.json case), we should # not declare per-package changes for a random root file. dirs_with_root = {".", "packages/alive"} - self.assertFalse(find_changed_packages(["some-root-file.md"], dirs_with_root)) + self.assertFalse( + any_changed_file_in_packages(["some-root-file.md"], dirs_with_root) + ) if __name__ == "__main__": From 122f6fdb6d370a4650ef75f89e8f076ee555962c Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Fri, 24 Apr 2026 15:14:42 -0700 Subject: [PATCH 13/19] ci(client): simplify detect_changed_packages and trim duplicate YAML comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace os.walk with git ls-files in _current_packages (symmetric with the historical helper, respects .gitignore). - Diff merge_base..HEAD instead of merge_base alone, so working-tree mutations from any pre-step can't leak into the changed-files list. - Switch --unshallow to --deepen 1000 to avoid pulling full history when the merge-base is just past the shallow boundary. - Use rev-parse --is-shallow-repository instead of probing .git/shallow. - Extract _resolve_merge_base and _fallback_full_run helpers. - Strip meta-narrating comments and trim duplicated YAML docstrings. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- scripts/detect_changed_packages.py | 103 +++++++++--------- .../templates/build-npm-client-package.yml | 21 +--- .../include-detect-changed-packages.yml | 32 +----- .../pipelines/templates/include-test-task.yml | 6 - 4 files changed, 63 insertions(+), 99 deletions(-) diff --git a/scripts/detect_changed_packages.py b/scripts/detect_changed_packages.py index 9739de39a5d8..1d80e2a5c3a1 100644 --- a/scripts/detect_changed_packages.py +++ b/scripts/detect_changed_packages.py @@ -36,7 +36,6 @@ import re import subprocess import sys -from pathlib import Path from typing import Callable, Iterable # Full-run trigger patterns. A diff touching any of these paths forces running @@ -96,9 +95,8 @@ def build_package_dir_set( ) -> set[str]: """Build the set of directories that hold (or held, at ``merge_base``) a package.json. - Unions the merge-base tree with the working tree so a package DELETED on - this branch still maps correctly — the reviewer-flagged case the bash - implementation missed. + Unions the merge-base tree with HEAD so a package deleted on this branch + still maps correctly. ``list_historical_packages`` and ``list_current_packages`` are injected so tests can drive this logic without spinning up a real git repo. @@ -164,28 +162,28 @@ def _git(args: list[str]) -> str | None: return result.stdout +_PACKAGE_JSON_RE = re.compile(r"(^|/)package\.json$") + + def _git_historical_packages(ref: str) -> list[str]: """Git-backed implementation of ``list_historical_packages``.""" out = _git(["ls-tree", "-r", "--name-only", ref]) if out is None: return [] - pattern = re.compile(r"(^|/)package\.json$") - return [f for f in out.split("\n") if pattern.search(f)] + return [f for f in out.split("\n") if _PACKAGE_JSON_RE.search(f)] -def _current_packages(cwd: str | None = None) -> list[str]: - """Walk the working tree for package.json files, skipping node_modules.""" - root = Path(cwd) if cwd is not None else Path.cwd() - results: list[str] = [] - for dirpath, dirnames, filenames in os.walk(root): - # Mutate dirnames in place to prune traversal. - dirnames[:] = [ - d for d in dirnames if d != "node_modules" and not d.startswith(".git") - ] - if "package.json" in filenames: - rel = os.path.relpath(os.path.join(dirpath, "package.json"), root) - results.append(rel.replace(os.sep, "/")) - return results +def _current_packages() -> list[str]: + """Git-backed implementation of ``list_current_packages``. + + ``git ls-files`` honors ``.gitignore`` and the workspace's tracked-file + set, which is what we want — pnpm-workspace.yaml's globs operate over the + same set, and node_modules is gitignored. + """ + out = _git(["ls-files", "--", "package.json", "*/package.json"]) + if out is None: + return [] + return [f for f in out.split("\n") if _PACKAGE_JSON_RE.search(f)] def _emit_vso_outputs(should_run_tests: bool, scoped_pnpm_filter: str) -> None: @@ -202,54 +200,57 @@ def _log_warning(message: str) -> None: print(f"##vso[task.logissue type=warning]{message}") +def _fallback_full_run(reason: str) -> None: + """Warn and emit a full-run outcome. Use for any safe-fallback path.""" + _log_warning(f"{reason} Falling back to full test run.") + _emit_vso_outputs(True, "") + + +def _resolve_merge_base(target_branch: str) -> str | None: + """Resolve the merge-base of HEAD with origin/. + + On a shallow clone, deepen incrementally before retrying. ``--unshallow`` + is avoided because pulling full history is expensive and rarely needed — + most PRs merge-base within a few thousand commits. Returns None on miss. + """ + mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) + if mb and mb.strip(): + return mb.strip() + is_shallow = _git(["rev-parse", "--is-shallow-repository"]) + if not (is_shallow and is_shallow.strip() == "true"): + return None + print("Merge-base not found in shallow clone; deepening and retrying.") + _git(["fetch", "--deepen", "1000", "origin", target_branch]) + mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) + return mb.strip() if mb and mb.strip() else None + + def main() -> None: """Pipeline entry point. Reads TARGET_BRANCH from env, writes vso outputs.""" raw = os.environ.get("TARGET_BRANCH", "") target_branch = normalize_target_branch(raw) if not target_branch: - _log_warning("TARGET_BRANCH not set; falling back to full test run.") - _emit_vso_outputs(True, "") + _fallback_full_run("TARGET_BRANCH not set;") return print(f"Target branch: {target_branch}") if _git(["fetch", "origin", target_branch]) is None: - _log_warning( - f"Could not fetch origin/{target_branch}; falling back to full test run." - ) - _emit_vso_outputs(True, "") + _fallback_full_run(f"Could not fetch origin/{target_branch};") return - # Try to resolve the merge-base in the shallow clone first. If the PR - # diverged further back than the shallow boundary, unshallow and retry - # once. `.git/shallow` is how git marks a shallow repo; skip the - # unshallow on a full clone (which would error with "--unshallow on a - # complete repository"). - mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) - merge_base = mb.strip() if mb else "" - git_dir_out = _git(["rev-parse", "--git-dir"]) - git_dir = git_dir_out.strip() if git_dir_out else "" - if not merge_base and git_dir and (Path(git_dir) / "shallow").exists(): - print("Merge-base not found in shallow clone; unshallowing and retrying.") - _git(["fetch", "--unshallow", "origin", target_branch]) - mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) - merge_base = mb.strip() if mb else "" + merge_base = _resolve_merge_base(target_branch) if not merge_base: - _log_warning( - f"No merge-base with origin/{target_branch}; falling back to full test run." - ) - _emit_vso_outputs(True, "") + _fallback_full_run(f"No merge-base with origin/{target_branch};") return print(f"Merge base: {merge_base}") - # On diff failure, fall back to a full run rather than swallowing the - # error — an empty changed-files list would bypass the full-run patterns - # and the package-change check, silently suppressing all test jobs. - diff_out = _git(["diff", "--name-only", merge_base]) + # Diff merge_base..HEAD (commit-only, immune to working-tree mutations + # from any future pre-step). On diff failure, fall back to a full run — + # an empty changed-files list would bypass full-run patterns and the + # package-change check, silently suppressing all test jobs. + diff_out = _git(["diff", "--name-only", merge_base, "HEAD"]) if diff_out is None: - _log_warning( - f"git diff against merge-base {merge_base} failed; falling back to full test run." - ) - _emit_vso_outputs(True, "") + _fallback_full_run(f"git diff against merge-base {merge_base} failed;") return changed_files = [f for f in diff_out.split("\n") if f] print(f"Changed files ({len(changed_files)}):") diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 562c1b8b64de..ace9a40a0da6 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -601,14 +601,9 @@ extends: dependsOn: - build - detect_changes - # Skip the job entirely when detect_changes explicitly concluded no - # packages were affected — saves agent allocation, checkout, and - # install on scoped PRs. Empty (detect_changes was skipped) still - # runs. See include-detect-changed-packages.yml for the invariants. - # - # We check `build.result` directly instead of using `succeeded()` - # because the latter treats a Skipped `detect_changes` dependency - # as non-success and false-skips this job on non-opt-in PR builds. + # Skip when detect_changes explicitly returned shouldRunTests=false. + # Use `build.result` (not `succeeded()`) so a Skipped detect_changes + # on non-opt-in PR builds doesn't false-skip this job. condition: and(in(dependencies.build.result, 'Succeeded', 'SucceededWithIssues'), ne(dependencies.detect_changes.outputs['setChangedPackages.shouldRunTests'], 'false')) variables: - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: @@ -625,10 +620,7 @@ extends: value: 'false' - name: COMMIT_SHA value: $[ dependencies.build.outputs['setCommitSHA.COMMIT_SHA'] ] - # Output from detect_changes; empty when that job was skipped - # (interpreted downstream as "full run" — the historical behavior). - # `shouldRunTests` is consumed via the job-level condition above, - # so only `scopedPnpmFilter` needs a job-scoped variable here. + # Empty when detect_changes was skipped — pnpm treats that as no filter. - name: scopedPnpmFilter value: $[ dependencies.detect_changes.outputs['setChangedPackages.scopedPnpmFilter'] ] @@ -804,10 +796,7 @@ extends: value: 'false' - name: COMMIT_SHA value: $[ dependencies.build.outputs['setCommitSHA.COMMIT_SHA'] ] - # Output from detect_changes; empty when that job was skipped - # (interpreted downstream as "full run" — the historical behavior). - # `shouldRunTests` is consumed via the job-level condition above, - # so only `scopedPnpmFilter` needs a job-scoped variable here. + # Empty when detect_changes was skipped — pnpm treats that as no filter. - name: scopedPnpmFilter value: $[ dependencies.detect_changes.outputs['setChangedPackages.scopedPnpmFilter'] ] steps: diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index 24611a53e44b..2ac58f37583a 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -1,29 +1,10 @@ # Copyright (c) Microsoft Corporation and contributors. All rights reserved. # Licensed under the MIT License. -# include-detect-changed-packages -# -# Thin pipeline wrapper around scripts/detect_changed_packages.py. The -# detection logic lives in the Python module (extracted in response to PR -# review feedback so it can be run/debugged locally and covered by unit tests -# under scripts/test/). Keeping this template minimal ensures the template -# itself doesn't drift from the tested script. -# -# Output variables (from the `setChangedPackages` step): -# - shouldRunTests "true" | "false" — whether any test work is needed -# - scopedPnpmFilter The pnpm filter string ("...[]") when scoping is -# active, or an empty string when a full test run is -# required. Downstream jobs pass this into -# `npm_config_filter` verbatim; pnpm treats an empty -# value as "no filter applied" so recursive `-r` runs -# fall back to the historical every-package behavior. -# -# On any error path (missing merge-base, unsupported ref format) the script -# degrades safely to a full-run outcome, never to a silent skip. -# -# The script is invoked via `python3` directly — Python 3 ships on ADO Linux -# agent images, and this script uses only stdlib, so the gate stays close to -# "git + python" fast (no pnpm or node install step required here). +# Pipeline wrapper around scripts/detect_changed_packages.py. The Python +# module owns the decision logic and its own docstring; see that file for +# output-variable semantics, safe-fallback policy, and full-run patterns. +# Python 3 stdlib only, so no install step is needed here. parameters: - name: buildDirectory @@ -36,9 +17,8 @@ steps: - checkout: self path: $(FluidFrameworkDirectory) clean: true - # Shallow clone; the script unshallows on demand if the merge-base - # can't be resolved within this depth. Most PRs merge-base within a - # few hundred commits, so this is cheap in the common case. + # Shallow clone; the script deepens on demand if the merge-base falls + # outside this depth. Most PRs merge-base within a few hundred commits. fetchDepth: 200 - task: Bash@3 diff --git a/tools/pipelines/templates/include-test-task.yml b/tools/pipelines/templates/include-test-task.yml index 5334a096f936..9de0e1e8e6b8 100644 --- a/tools/pipelines/templates/include-test-task.yml +++ b/tools/pipelines/templates/include-test-task.yml @@ -42,9 +42,6 @@ steps: customCommand: 'run ${{ parameters.taskTestStep }}:coverage' condition: and(succeededOrFailed(), eq(variables['startTest'], 'true')) env: - # Only emit npm_config_filter when a filter is actually set. pnpm 10 - # happens to treat an empty value the same as unset, but the conditional - # keeps the contract explicit and avoids relying on that behavior. ${{ if ne(parameters.pnpmFilter, '') }}: npm_config_filter: ${{ parameters.pnpmFilter }} # Tests can use this environment variable to behave differently when running from a test branch @@ -63,9 +60,6 @@ steps: customCommand: 'run ${{ parameters.taskTestStep }}' condition: and(succeededOrFailed(), eq(variables['startTest'], 'true')) env: - # Only emit npm_config_filter when a filter is actually set. pnpm 10 - # happens to treat an empty value the same as unset, but the conditional - # keeps the contract explicit and avoids relying on that behavior. ${{ if ne(parameters.pnpmFilter, '') }}: npm_config_filter: ${{ parameters.pnpmFilter }} # Tests can use this environment variable to behave differently when running from a test branch From da40d48feebcebf652cb372222505d312a1f82a8 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 11 May 2026 14:41:15 -0700 Subject: [PATCH 14/19] ci: port changed-package script to node --- .gitignore | 4 - scripts/detect_changed_packages.mjs | 249 +++++++++++++++ scripts/detect_changed_packages.py | 295 ------------------ scripts/test/test_detect_changed_packages.mjs | 150 +++++++++ scripts/test/test_detect_changed_packages.py | 160 ---------- .../templates/build-npm-client-package.yml | 8 +- .../include-detect-changed-packages.yml | 8 +- 7 files changed, 407 insertions(+), 467 deletions(-) create mode 100644 scripts/detect_changed_packages.mjs delete mode 100644 scripts/detect_changed_packages.py create mode 100644 scripts/test/test_detect_changed_packages.mjs delete mode 100644 scripts/test/test_detect_changed_packages.py diff --git a/.gitignore b/.gitignore index f7338a87f1c3..bcf8b0388096 100644 --- a/.gitignore +++ b/.gitignore @@ -70,7 +70,3 @@ CLAUDE.local.md # Output from benchmarks **/benchmark*Output.json - -# Python bytecode cache (scripts/ contains a small Python helper + tests). -__pycache__/ -*.pyc diff --git a/scripts/detect_changed_packages.mjs b/scripts/detect_changed_packages.mjs new file mode 100644 index 000000000000..5c2120ba47c5 --- /dev/null +++ b/scripts/detect_changed_packages.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { dirname } from "node:path/posix"; + +/** + * Decides whether a PR's diff warrants scoping downstream test execution to a + * subset of workspace packages. Emits two ADO output variables: + * + * shouldRunTests "true" | "false" - whether any test work is needed + * scopedPnpmFilter pnpm filter string "...[]" when scoping is active, + * empty when a full test run is required. Downstream jobs + * pass this verbatim into npm_config_filter; pnpm treats + * an empty value as "no filter applied" so recursive -r + * runs fall back to the historical every-package behavior. + * + * Safe-fallback policy: any unexpected error (missing merge-base, git failure, + * unparseable ref) MUST result in a full run - never a silent skip. An + * accidental silent skip would suppress all tests and hide real regressions. + * + * Why merge-base (and not just origin/ directly): pnpm's + * --filter "[ref]" uses a two-dot diff internally (see pnpm/pnpm#9907), so + * commits that landed on origin/ after this PR diverged would show up + * as "changed." Computing the merge-base SHA ourselves and feeding that SHA + * into the selector gives three-dot (merge-base) semantics. + */ + +// Full-run trigger patterns. A diff touching any of these paths forces running +// every package's tests (filter stays empty, so pnpm -r runs across the whole +// workspace). Keep this list conservative: it's the safety net for changes +// that could plausibly invalidate assumptions across the entire workspace. +// +// This list partially overlaps with `pr: paths: include:` in +// tools/pipelines/build-client.yml (which decides whether the pipeline runs +// at all). The concepts differ - one gates the pipeline, the other gates +// scoping within a pipeline that's already running - but adding a new +// cross-cutting root-level file generally warrants updating both. There's no +// programmatic link, so keep them in sync by convention. +export const FULL_RUN_PATTERNS = [ + /^package\.json$/, + /^pnpm-lock\.yaml$/, + /^pnpm-workspace\.yaml$/, + /^\.pnpmfile\.cjs$/, + /^\.npmrc$/, + /^\.nvmrc$/, + /^fluidBuild\.config\.cjs$/, + /^tsconfig[^/]*\.json$/, + /^biome\./, + /^tools\//, + /^common\//, + /^scripts\//, + /^\.changeset\/config\.json$/, +]; + +export function normalizeTargetBranch(branch) { + const prefix = "refs/heads/"; + return branch.startsWith(prefix) ? branch.slice(prefix.length) : branch; +} + +export function checkFullRunPatterns(files, patterns = FULL_RUN_PATTERNS) { + const fileList = [...files]; + for (const pattern of patterns) { + if (fileList.some((file) => pattern.test(file))) { + return pattern; + } + } + return undefined; +} + +export function buildPackageDirSet(mergeBase, listHistoricalPackages, listCurrentPackages) { + const dirs = new Set(); + + const record = (file) => { + const dir = dirname(file); + dirs.add(dir === "" || dir === "." ? "." : dir); + }; + + for (const file of listHistoricalPackages(mergeBase)) { + record(file); + } + for (const file of listCurrentPackages()) { + record(file); + } + return dirs; +} + +export function anyChangedFileInPackages(changedFiles, packageDirs) { + for (const file of changedFiles) { + if (!file) { + continue; + } + let dir = dirname(file); + while (dir !== "." && dir !== "/" && dir !== "") { + if (packageDirs.has(dir)) { + return true; + } + dir = dirname(dir); + } + } + return false; +} + +function git(args) { + const result = spawnSync("git", args, { encoding: "utf8" }); + if (result.error !== undefined) { + logWarning(`git executable not found: ${result.error.message}`); + return undefined; + } + if (result.status !== 0) { + const stderr = (result.stderr ?? "").trim(); + logWarning(`git ${args.join(" ")} failed (exit ${result.status}): ${stderr}`); + return undefined; + } + return result.stdout; +} + +const packageJsonPattern = /(^|\/)package\.json$/; + +function gitHistoricalPackages(ref) { + const out = git(["ls-tree", "-r", "--name-only", ref]); + if (out === undefined) { + return []; + } + return out.split("\n").filter((file) => packageJsonPattern.test(file)); +} + +function currentPackages() { + const out = git(["ls-files", "--", "package.json", "*/package.json"]); + if (out === undefined) { + return []; + } + return out.split("\n").filter((file) => packageJsonPattern.test(file)); +} + +function emitVsoOutputs(shouldRunTests, scopedPnpmFilter) { + const flag = shouldRunTests ? "true" : "false"; + console.log(`shouldRunTests=${flag}`); + console.log(`scopedPnpmFilter=${scopedPnpmFilter}`); + console.log(`##vso[task.setvariable variable=shouldRunTests;isOutput=true]${flag}`); + console.log( + `##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`, + ); +} + +function logWarning(message) { + console.log(`##vso[task.logissue type=warning]${message}`); +} + +function fallbackFullRun(reason) { + logWarning(`${reason} Falling back to full test run.`); + emitVsoOutputs(true, ""); +} + +function resolveMergeBase(targetBranch) { + const firstMergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`]); + if (firstMergeBase?.trim()) { + return firstMergeBase.trim(); + } + + const isShallow = git(["rev-parse", "--is-shallow-repository"]); + if (isShallow?.trim() !== "true") { + return undefined; + } + + console.log("Merge-base not found in shallow clone; deepening and retrying."); + git(["fetch", "--deepen", "1000", "origin", targetBranch]); + const secondMergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`]); + return secondMergeBase?.trim() ? secondMergeBase.trim() : undefined; +} + +export function main() { + const targetBranch = normalizeTargetBranch(process.env.TARGET_BRANCH ?? ""); + if (!targetBranch) { + fallbackFullRun("TARGET_BRANCH not set;"); + return; + } + console.log(`Target branch: ${targetBranch}`); + + if (git(["fetch", "origin", targetBranch]) === undefined) { + fallbackFullRun(`Could not fetch origin/${targetBranch};`); + return; + } + + const mergeBase = resolveMergeBase(targetBranch); + if (!mergeBase) { + fallbackFullRun(`No merge-base with origin/${targetBranch};`); + return; + } + console.log(`Merge base: ${mergeBase}`); + + // Diff merge_base..HEAD (commit-only, immune to working-tree mutations + // from any future pre-step). On diff failure, fall back to a full run: + // an empty changed-files list would bypass full-run patterns and the + // package-change check, silently suppressing all test jobs. + const diffOut = git(["diff", "--name-only", mergeBase, "HEAD"]); + if (diffOut === undefined) { + fallbackFullRun(`git diff against merge-base ${mergeBase} failed;`); + return; + } + + const changedFiles = diffOut.split("\n").filter(Boolean); + console.log(`Changed files (${changedFiles.length}):`); + for (const file of changedFiles.slice(0, 30)) { + console.log(file); + } + if (changedFiles.length > 30) { + console.log(`... and ${changedFiles.length - 30} more`); + } + + const match = checkFullRunPatterns(changedFiles); + if (match !== undefined) { + console.log(`Match for full-run pattern '${match.source}' - forcing full test run.`); + emitVsoOutputs(true, ""); + return; + } + + const packageDirs = buildPackageDirSet(mergeBase, gitHistoricalPackages, currentPackages); + if (!anyChangedFileInPackages(changedFiles, packageDirs)) { + logWarning( + `No changed files mapped to a workspace package - skipping all test execution. Files considered (${changedFiles.length}):`, + ); + for (const file of changedFiles) { + console.log(` ${file}`); + } + emitVsoOutputs(false, ""); + return; + } + + // Hand the merge-base SHA to pnpm's native selector. The leading `...` + // pulls in transitive dependents so consumers of a changed package also + // get re-tested. + const filter = `...[${mergeBase}]`; + console.log(`Computed pnpm filter: ${filter}`); + emitVsoOutputs(true, filter); +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); + process.exit(0); +} diff --git a/scripts/detect_changed_packages.py b/scripts/detect_changed_packages.py deleted file mode 100644 index 1d80e2a5c3a1..000000000000 --- a/scripts/detect_changed_packages.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) Microsoft Corporation and contributors. All rights reserved. -# Licensed under the MIT License. - -"""detect_changed_packages. - -Decides whether a PR's diff warrants scoping downstream test execution to a -subset of workspace packages. Emits two ADO output variables: - - shouldRunTests "true" | "false" — whether any test work is needed - scopedPnpmFilter pnpm filter string "...[]" when scoping is active, - empty when a full test run is required. Downstream jobs - pass this verbatim into ``npm_config_filter``; pnpm treats - an empty value as "no filter applied" so recursive ``-r`` - runs fall back to the historical every-package behavior. - -Safe-fallback policy: any unexpected error (missing merge-base, git failure, -unparseable ref) MUST result in a full run — never a silent skip. An -accidental silent skip would suppress all tests and hide real regressions. - -Why merge-base (and not just ``origin/`` directly): pnpm's -``--filter "[ref]"`` uses a two-dot diff internally (see pnpm/pnpm#9907), so -commits that landed on ``origin/`` after this PR diverged would show -up as "changed." Computing the merge-base SHA ourselves and feeding that SHA -into the selector gives three-dot (merge-base) semantics. - -This module exports pure helpers so the decision logic can be unit tested -without an ADO pipeline context or a populated git repo. Python stdlib only -(no third-party deps) so the pipeline gate stays close to "git + python" fast. -""" - -from __future__ import annotations - -import os -import posixpath -import re -import subprocess -import sys -from typing import Callable, Iterable - -# Full-run trigger patterns. A diff touching any of these paths forces running -# every package's tests (filter stays empty → pnpm -r runs across the whole -# workspace). Keep this list conservative — it's the safety net for changes -# that could plausibly invalidate assumptions across the entire workspace. -# -# This list partially overlaps with `pr: paths: include:` in -# tools/pipelines/build-client.yml (which decides whether the pipeline runs -# at all). The concepts differ — one gates the pipeline, the other gates -# scoping within a pipeline that's already running — but adding a new -# cross-cutting root-level file generally warrants updating both. There's no -# programmatic link, so keep them in sync by convention. -FULL_RUN_PATTERNS: tuple[re.Pattern[str], ...] = ( - re.compile(r"^package\.json$"), - re.compile(r"^pnpm-lock\.yaml$"), - re.compile(r"^pnpm-workspace\.yaml$"), - re.compile(r"^\.pnpmfile\.cjs$"), - re.compile(r"^\.npmrc$"), - re.compile(r"^\.nvmrc$"), - re.compile(r"^fluidBuild\.config\.cjs$"), - re.compile(r"^tsconfig[^/]*\.json$"), - re.compile(r"^biome\."), - re.compile(r"^tools/"), - re.compile(r"^common/"), - re.compile(r"^scripts/"), - re.compile(r"^\.changeset/config\.json$"), -) - - -def normalize_target_branch(branch: str) -> str: - """Azure Repos emits ``refs/heads/main``; GitHub emits just ``main``. Normalize.""" - if branch.startswith("refs/heads/"): - return branch[len("refs/heads/") :] - return branch - - -def check_full_run_patterns( - files: Iterable[str], - patterns: Iterable[re.Pattern[str]] = FULL_RUN_PATTERNS, -) -> re.Pattern[str] | None: - """Return the first pattern that any of the given files match, or ``None``. - - Used by callers to surface *why* a full run was forced. - """ - file_list = list(files) - for pattern in patterns: - if any(pattern.search(f) for f in file_list): - return pattern - return None - - -def build_package_dir_set( - merge_base: str, - list_historical_packages: Callable[[str], Iterable[str]], - list_current_packages: Callable[[], Iterable[str]], -) -> set[str]: - """Build the set of directories that hold (or held, at ``merge_base``) a package.json. - - Unions the merge-base tree with HEAD so a package deleted on this branch - still maps correctly. - - ``list_historical_packages`` and ``list_current_packages`` are injected so - tests can drive this logic without spinning up a real git repo. - """ - dirs: set[str] = set() - - def record(file: str) -> None: - # file is like "packages/foo/package.json" or "package.json". - d = posixpath.dirname(file) - dirs.add("." if d == "" else d) - - for f in list_historical_packages(merge_base): - record(f) - for f in list_current_packages(): - record(f) - return dirs - - -def any_changed_file_in_packages( - changed_files: Iterable[str], - package_dirs: set[str], -) -> bool: - """Return True if any changed file lives under a known package directory. - - A file at ``packages/foo/src/x.ts`` matches if ``packages/foo`` (or any - ancestor above it, stopping at the root) is in ``package_dirs``. - - The root pseudo-dir ``"."`` is deliberately ignored here: root-level - package changes are already caught by ``FULL_RUN_PATTERNS`` and should not - double-count as a per-package signal. - """ - for file in changed_files: - if not file: - continue - d = posixpath.dirname(file) - while d not in (".", "/", ""): - if d in package_dirs: - return True - d = posixpath.dirname(d) - return False - - -def _git(args: list[str]) -> str | None: - """Thin wrapper for ``git`` calls. Returns stdout or None on failure. - - On failure, logs a pipeline warning with the git command and its stderr - so the reason for any safe-fallback path is visible in the pipeline run. - """ - try: - result = subprocess.run( - ["git", *args], - capture_output=True, - text=True, - check=True, - ) - except subprocess.CalledProcessError as e: - stderr = (e.stderr or "").strip() - _log_warning(f"git {' '.join(args)} failed (exit {e.returncode}): {stderr}") - return None - except FileNotFoundError as e: - _log_warning(f"git executable not found: {e}") - return None - return result.stdout - - -_PACKAGE_JSON_RE = re.compile(r"(^|/)package\.json$") - - -def _git_historical_packages(ref: str) -> list[str]: - """Git-backed implementation of ``list_historical_packages``.""" - out = _git(["ls-tree", "-r", "--name-only", ref]) - if out is None: - return [] - return [f for f in out.split("\n") if _PACKAGE_JSON_RE.search(f)] - - -def _current_packages() -> list[str]: - """Git-backed implementation of ``list_current_packages``. - - ``git ls-files`` honors ``.gitignore`` and the workspace's tracked-file - set, which is what we want — pnpm-workspace.yaml's globs operate over the - same set, and node_modules is gitignored. - """ - out = _git(["ls-files", "--", "package.json", "*/package.json"]) - if out is None: - return [] - return [f for f in out.split("\n") if _PACKAGE_JSON_RE.search(f)] - - -def _emit_vso_outputs(should_run_tests: bool, scoped_pnpm_filter: str) -> None: - flag = "true" if should_run_tests else "false" - print(f"shouldRunTests={flag}") - print(f"scopedPnpmFilter={scoped_pnpm_filter}") - print(f"##vso[task.setvariable variable=shouldRunTests;isOutput=true]{flag}") - print( - f"##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]{scoped_pnpm_filter}" - ) - - -def _log_warning(message: str) -> None: - print(f"##vso[task.logissue type=warning]{message}") - - -def _fallback_full_run(reason: str) -> None: - """Warn and emit a full-run outcome. Use for any safe-fallback path.""" - _log_warning(f"{reason} Falling back to full test run.") - _emit_vso_outputs(True, "") - - -def _resolve_merge_base(target_branch: str) -> str | None: - """Resolve the merge-base of HEAD with origin/. - - On a shallow clone, deepen incrementally before retrying. ``--unshallow`` - is avoided because pulling full history is expensive and rarely needed — - most PRs merge-base within a few thousand commits. Returns None on miss. - """ - mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) - if mb and mb.strip(): - return mb.strip() - is_shallow = _git(["rev-parse", "--is-shallow-repository"]) - if not (is_shallow and is_shallow.strip() == "true"): - return None - print("Merge-base not found in shallow clone; deepening and retrying.") - _git(["fetch", "--deepen", "1000", "origin", target_branch]) - mb = _git(["merge-base", "HEAD", f"origin/{target_branch}"]) - return mb.strip() if mb and mb.strip() else None - - -def main() -> None: - """Pipeline entry point. Reads TARGET_BRANCH from env, writes vso outputs.""" - raw = os.environ.get("TARGET_BRANCH", "") - target_branch = normalize_target_branch(raw) - if not target_branch: - _fallback_full_run("TARGET_BRANCH not set;") - return - print(f"Target branch: {target_branch}") - - if _git(["fetch", "origin", target_branch]) is None: - _fallback_full_run(f"Could not fetch origin/{target_branch};") - return - - merge_base = _resolve_merge_base(target_branch) - if not merge_base: - _fallback_full_run(f"No merge-base with origin/{target_branch};") - return - print(f"Merge base: {merge_base}") - - # Diff merge_base..HEAD (commit-only, immune to working-tree mutations - # from any future pre-step). On diff failure, fall back to a full run — - # an empty changed-files list would bypass full-run patterns and the - # package-change check, silently suppressing all test jobs. - diff_out = _git(["diff", "--name-only", merge_base, "HEAD"]) - if diff_out is None: - _fallback_full_run(f"git diff against merge-base {merge_base} failed;") - return - changed_files = [f for f in diff_out.split("\n") if f] - print(f"Changed files ({len(changed_files)}):") - for f in changed_files[:30]: - print(f) - if len(changed_files) > 30: - print(f"... and {len(changed_files) - 30} more") - - match = check_full_run_patterns(changed_files) - if match is not None: - print(f"Match for full-run pattern '{match.pattern}' — forcing full test run.") - _emit_vso_outputs(True, "") - return - - package_dirs = build_package_dir_set( - merge_base, _git_historical_packages, _current_packages - ) - if not any_changed_file_in_packages(changed_files, package_dirs): - # Most aggressive skip path: no test jobs run. Surface as a pipeline - # warning (not plain console output) and dump the file list so an - # accidental silent-suppression bug is auditable from the pipeline - # summary without needing to re-run the build. - _log_warning( - f"No changed files mapped to a workspace package — skipping all test execution. " - f"Files considered ({len(changed_files)}):" - ) - for f in changed_files: - print(f" {f}") - _emit_vso_outputs(False, "") - return - - # Hand the merge-base SHA to pnpm's native selector. The leading `...` - # pulls in transitive dependents so consumers of a changed package also - # get re-tested. - filt = f"...[{merge_base}]" - print(f"Computed pnpm filter: {filt}") - _emit_vso_outputs(True, filt) - - -if __name__ == "__main__": - main() - sys.exit(0) diff --git a/scripts/test/test_detect_changed_packages.mjs b/scripts/test/test_detect_changed_packages.mjs new file mode 100644 index 000000000000..b453ad3b7cee --- /dev/null +++ b/scripts/test/test_detect_changed_packages.mjs @@ -0,0 +1,150 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + anyChangedFileInPackages, + buildPackageDirSet, + checkFullRunPatterns, + normalizeTargetBranch, +} from "../detect_changed_packages.mjs"; + +test("normalizeTargetBranch strips refs/heads prefix", () => { + assert.equal(normalizeTargetBranch("refs/heads/main"), "main"); +}); + +test("normalizeTargetBranch passes plain branch names through", () => { + assert.equal(normalizeTargetBranch("next"), "next"); +}); + +test("normalizeTargetBranch preserves slashes after the prefix", () => { + assert.equal(normalizeTargetBranch("refs/heads/release/2.x"), "release/2.x"); +}); + +test("normalizeTargetBranch returns empty string for empty input", () => { + assert.equal(normalizeTargetBranch(""), ""); +}); + +test("checkFullRunPatterns matches pnpm-lock.yaml", () => { + const match = checkFullRunPatterns(["pnpm-lock.yaml"]); + assert.ok(match); + assert.equal(match.source, "^pnpm-lock\\.yaml$"); +}); + +test("checkFullRunPatterns matches .pnpmfile.cjs", () => { + const match = checkFullRunPatterns([".pnpmfile.cjs"]); + assert.ok(match); + assert.equal(match.source, "^\\.pnpmfile\\.cjs$"); +}); + +test("checkFullRunPatterns matches .npmrc", () => { + const match = checkFullRunPatterns([".npmrc"]); + assert.ok(match); + assert.equal(match.source, "^\\.npmrc$"); +}); + +test("checkFullRunPatterns matches .nvmrc", () => { + const match = checkFullRunPatterns([".nvmrc"]); + assert.ok(match); + assert.equal(match.source, "^\\.nvmrc$"); +}); + +test("checkFullRunPatterns matches tools prefix", () => { + const match = checkFullRunPatterns(["tools/pipelines/build-client.yml"]); + assert.ok(match, "expected tools/ prefix to match"); +}); + +test("checkFullRunPatterns matches root package.json but not nested package.json", () => { + assert.equal(checkFullRunPatterns(["packages/foo/package.json"]), undefined); + const match = checkFullRunPatterns(["package.json"]); + assert.ok(match); + assert.equal(match.source, "^package\\.json$"); +}); + +test("checkFullRunPatterns matches root tsconfig only", () => { + assert.ok(checkFullRunPatterns(["tsconfig.base.json"])); + assert.equal(checkFullRunPatterns(["packages/foo/tsconfig.json"]), undefined); +}); + +test("checkFullRunPatterns returns undefined when nothing matches", () => { + assert.equal(checkFullRunPatterns(["packages/foo/src/x.ts"]), undefined); +}); + +test("checkFullRunPatterns returns the first pattern hit when several qualify", () => { + const match = checkFullRunPatterns(["pnpm-lock.yaml", "biome.jsonc"]); + assert.ok(match); + assert.equal(match.source, "^pnpm-lock\\.yaml$"); +}); + +test("buildPackageDirSet unions historical and current packages", () => { + const historical = ["packages/old/package.json", "packages/shared/package.json"]; + const current = ["packages/shared/package.json", "packages/new/package.json"]; + const dirs = buildPackageDirSet( + "sha", + () => historical, + () => current, + ); + assert.deepEqual([...dirs].sort(), ["packages/new", "packages/old", "packages/shared"]); +}); + +test("buildPackageDirSet maps a root-level package.json to dot", () => { + const dirs = buildPackageDirSet( + "sha", + () => ["package.json"], + () => [], + ); + assert.deepEqual([...dirs], ["."]); +}); + +test("buildPackageDirSet tolerates either package list being empty", () => { + assert.equal( + buildPackageDirSet( + "sha", + () => [], + () => [], + ).size, + 0, + ); + assert.equal( + buildPackageDirSet( + "sha", + () => ["packages/a/package.json"], + () => [], + ).size, + 1, + ); +}); + +const packageDirs = new Set(["packages/alive"]); + +test("anyChangedFileInPackages detects file inside known package dir", () => { + assert.equal(anyChangedFileInPackages(["packages/alive/src/x.ts"], packageDirs), true); +}); + +test("anyChangedFileInPackages returns false for root-only changes", () => { + assert.equal(anyChangedFileInPackages(["README.md"], packageDirs), false); +}); + +test("anyChangedFileInPackages returns false for unrelated sibling directory", () => { + assert.equal(anyChangedFileInPackages(["packages/other/src.ts"], packageDirs), false); +}); + +test("anyChangedFileInPackages ignores empty file entries", () => { + assert.equal(anyChangedFileInPackages(["", "packages/alive/src.ts"], packageDirs), true); +}); + +test("anyChangedFileInPackages walks up from nested paths to find ancestor", () => { + assert.equal( + anyChangedFileInPackages(["packages/alive/src/deeply/nested/x.ts"], packageDirs), + true, + ); +}); + +test("anyChangedFileInPackages does not treat root pseudo-dir as a per-package hit", () => { + const dirsWithRoot = new Set([".", "packages/alive"]); + assert.equal(anyChangedFileInPackages(["some-root-file.md"], dirsWithRoot), false); +}); diff --git a/scripts/test/test_detect_changed_packages.py b/scripts/test/test_detect_changed_packages.py deleted file mode 100644 index f6c782da6b5a..000000000000 --- a/scripts/test/test_detect_changed_packages.py +++ /dev/null @@ -1,160 +0,0 @@ -# Copyright (c) Microsoft Corporation and contributors. All rights reserved. -# Licensed under the MIT License. - -"""Tests for detect_changed_packages. - -Exercises the exported pure helpers so regressions in the change-detection -logic are caught before landing. Uses Python's stdlib ``unittest`` — no -third-party deps, keeping the pipeline cost of running these low. - -Pipeline invocation (from repo root): - - python3 -m unittest discover -s scripts/test -p 'test_*.py' -""" - -from __future__ import annotations - -import sys -import unittest -from pathlib import Path - -# Make scripts/ importable so we can pull in the module under test. -_SCRIPTS_DIR = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(_SCRIPTS_DIR)) - -from detect_changed_packages import ( # noqa: E402 - any_changed_file_in_packages, - build_package_dir_set, - check_full_run_patterns, - normalize_target_branch, -) - - -class NormalizeTargetBranchTests(unittest.TestCase): - def test_strips_refs_heads_prefix(self) -> None: - self.assertEqual(normalize_target_branch("refs/heads/main"), "main") - - def test_passes_plain_branch_names_through(self) -> None: - self.assertEqual(normalize_target_branch("next"), "next") - - def test_preserves_slashes_after_the_prefix(self) -> None: - self.assertEqual( - normalize_target_branch("refs/heads/release/2.x"), "release/2.x" - ) - - def test_returns_empty_string_for_empty_input(self) -> None: - self.assertEqual(normalize_target_branch(""), "") - - -class CheckFullRunPatternsTests(unittest.TestCase): - def test_matches_pnpm_lock_yaml(self) -> None: - match = check_full_run_patterns(["pnpm-lock.yaml"]) - assert match is not None - self.assertEqual(match.pattern, r"^pnpm-lock\.yaml$") - - def test_matches_pnpmfile_cjs(self) -> None: - match = check_full_run_patterns([".pnpmfile.cjs"]) - assert match is not None - self.assertEqual(match.pattern, r"^\.pnpmfile\.cjs$") - - def test_matches_npmrc(self) -> None: - match = check_full_run_patterns([".npmrc"]) - assert match is not None - self.assertEqual(match.pattern, r"^\.npmrc$") - - def test_matches_nvmrc(self) -> None: - match = check_full_run_patterns([".nvmrc"]) - assert match is not None - self.assertEqual(match.pattern, r"^\.nvmrc$") - - def test_matches_tools_prefix(self) -> None: - match = check_full_run_patterns(["tools/pipelines/build-client.yml"]) - self.assertIsNotNone(match, "expected tools/ prefix to match") - - def test_matches_root_package_json_not_nested(self) -> None: - self.assertIsNone(check_full_run_patterns(["packages/foo/package.json"])) - match = check_full_run_patterns(["package.json"]) - assert match is not None - self.assertEqual(match.pattern, r"^package\.json$") - - def test_matches_root_tsconfig_anchored_not_nested(self) -> None: - self.assertIsNotNone(check_full_run_patterns(["tsconfig.base.json"])) - self.assertIsNone(check_full_run_patterns(["packages/foo/tsconfig.json"])) - - def test_returns_none_when_nothing_matches(self) -> None: - self.assertIsNone(check_full_run_patterns(["packages/foo/src/x.ts"])) - - def test_returns_first_pattern_hit_when_several_qualify(self) -> None: - # Stability matters for readable pipeline logs. - match = check_full_run_patterns(["pnpm-lock.yaml", "biome.jsonc"]) - assert match is not None - self.assertEqual(match.pattern, r"^pnpm-lock\.yaml$") - - -class BuildPackageDirSetTests(unittest.TestCase): - def test_unions_historical_and_current_packages(self) -> None: - historical = ["packages/old/package.json", "packages/shared/package.json"] - current = ["packages/shared/package.json", "packages/new/package.json"] - dirs = build_package_dir_set("sha", lambda _: historical, lambda: current) - self.assertEqual( - sorted(dirs), ["packages/new", "packages/old", "packages/shared"] - ) - - def test_maps_a_root_level_package_json_to_dot(self) -> None: - dirs = build_package_dir_set("sha", lambda _: ["package.json"], lambda: []) - self.assertEqual(list(dirs), ["."]) - - def test_tolerates_either_list_being_empty(self) -> None: - self.assertEqual( - len(build_package_dir_set("sha", lambda _: [], lambda: [])), 0 - ) - self.assertEqual( - len( - build_package_dir_set( - "sha", lambda _: ["packages/a/package.json"], lambda: [] - ) - ), - 1, - ) - - -class AnyChangedFileInPackagesTests(unittest.TestCase): - PKG_DIRS = {"packages/alive"} - - def test_detects_file_inside_known_package_dir(self) -> None: - self.assertTrue( - any_changed_file_in_packages(["packages/alive/src/x.ts"], self.PKG_DIRS) - ) - - def test_returns_false_for_root_only_changes(self) -> None: - # Root-level file changes are handled by FULL_RUN_PATTERNS, not here. - self.assertFalse(any_changed_file_in_packages(["README.md"], self.PKG_DIRS)) - - def test_returns_false_when_file_lives_in_unrelated_sibling_dir(self) -> None: - self.assertFalse( - any_changed_file_in_packages(["packages/other/src.ts"], self.PKG_DIRS) - ) - - def test_ignores_empty_file_entries(self) -> None: - self.assertTrue( - any_changed_file_in_packages(["", "packages/alive/src.ts"], self.PKG_DIRS) - ) - - def test_walks_up_from_nested_paths_to_find_ancestor(self) -> None: - self.assertTrue( - any_changed_file_in_packages( - ["packages/alive/src/deeply/nested/x.ts"], self.PKG_DIRS - ) - ) - - def test_does_not_treat_root_pseudo_dir_as_per_package_hit(self) -> None: - # Even if '.' is in package_dirs (root package.json case), we should - # not declare per-package changes for a random root file. - dirs_with_root = {".", "packages/alive"} - self.assertFalse( - any_changed_file_in_packages(["some-root-file.md"], dirs_with_root) - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 3691efc8ec15..8fd831568a07 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -356,13 +356,13 @@ extends: buildDirectory: '${{ parameters.buildDirectory }}' packageManagerInstallCommand: '${{ parameters.packageManagerInstallCommand }}' - # Unit tests for helper scripts under scripts/. detect_changed_packages.py + # Unit tests for helper scripts under scripts/. detect_changed_packages.mjs # gates whether the Coverage_tests / Test_* jobs run at all (see # include-detect-changed-packages.yml); a regression in the change # detection logic could silently suppress every package test, so # we run its tests here in the build job — early, before any heavy - # build work, so they fail fast. Python stdlib only; no install - # step needed beyond whatever python3 ships on the agent image. + # build work, so they fail fast. Node built-ins only; no extra + # install step is needed. - task: Bash@3 displayName: Scripts unit tests inputs: @@ -370,7 +370,7 @@ extends: workingDirectory: $(Pipeline.Workspace)/${{ parameters.buildDirectory }} script: | set -eu -o pipefail - python3 -m unittest discover -s scripts/test -p 'test_*.py' -v + node --test scripts/test/test_detect_changed_packages.mjs # The bundle-size-artifacts pipeline runs a client build but doesn't publish packages, # so we skip version setting for it. diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index 2ac58f37583a..cc3214856edb 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -1,10 +1,10 @@ # Copyright (c) Microsoft Corporation and contributors. All rights reserved. # Licensed under the MIT License. -# Pipeline wrapper around scripts/detect_changed_packages.py. The Python -# module owns the decision logic and its own docstring; see that file for +# Pipeline wrapper around scripts/detect_changed_packages.mjs. The Node ESM +# module owns the decision logic and its own doc comment; see that file for # output-variable semantics, safe-fallback policy, and full-run patterns. -# Python 3 stdlib only, so no install step is needed here. +# Node built-ins only, so no install step is needed here. parameters: - name: buildDirectory @@ -31,4 +31,4 @@ steps: workingDirectory: '$(Pipeline.Workspace)/${{ parameters.buildDirectory }}' script: | set -eu -o pipefail - python3 scripts/detect_changed_packages.py + node scripts/detect_changed_packages.mjs From ba372c11b4d87bee2bb5b023746357695351f942 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 11 May 2026 14:52:42 -0700 Subject: [PATCH 15/19] refactor(ci): simplify detect_changed_packages helpers and tests - Drop dead branches relying on posix dirname returning "" (it doesn't). - Inline buildPackageDirSet's record helper now that the dot fallback is unnecessary. - Trim merge-base output once at the call site. - Collapse repetitive checkFullRunPatterns "matches X" tests into a table-driven loop and split the combined root/nested package.json case. --- scripts/detect_changed_packages.mjs | 28 +++++------- scripts/test/test_detect_changed_packages.mjs | 44 +++++++------------ 2 files changed, 25 insertions(+), 47 deletions(-) diff --git a/scripts/detect_changed_packages.mjs b/scripts/detect_changed_packages.mjs index 5c2120ba47c5..408483826f7c 100644 --- a/scripts/detect_changed_packages.mjs +++ b/scripts/detect_changed_packages.mjs @@ -64,9 +64,8 @@ export function normalizeTargetBranch(branch) { } export function checkFullRunPatterns(files, patterns = FULL_RUN_PATTERNS) { - const fileList = [...files]; for (const pattern of patterns) { - if (fileList.some((file) => pattern.test(file))) { + if (files.some((file) => pattern.test(file))) { return pattern; } } @@ -75,17 +74,11 @@ export function checkFullRunPatterns(files, patterns = FULL_RUN_PATTERNS) { export function buildPackageDirSet(mergeBase, listHistoricalPackages, listCurrentPackages) { const dirs = new Set(); - - const record = (file) => { - const dir = dirname(file); - dirs.add(dir === "" || dir === "." ? "." : dir); - }; - for (const file of listHistoricalPackages(mergeBase)) { - record(file); + dirs.add(dirname(file)); } for (const file of listCurrentPackages()) { - record(file); + dirs.add(dirname(file)); } return dirs; } @@ -96,7 +89,7 @@ export function anyChangedFileInPackages(changedFiles, packageDirs) { continue; } let dir = dirname(file); - while (dir !== "." && dir !== "/" && dir !== "") { + while (dir !== "." && dir !== "/") { if (packageDirs.has(dir)) { return true; } @@ -158,20 +151,19 @@ function fallbackFullRun(reason) { } function resolveMergeBase(targetBranch) { - const firstMergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`]); - if (firstMergeBase?.trim()) { - return firstMergeBase.trim(); + const firstMergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim(); + if (firstMergeBase) { + return firstMergeBase; } - const isShallow = git(["rev-parse", "--is-shallow-repository"]); - if (isShallow?.trim() !== "true") { + const isShallow = git(["rev-parse", "--is-shallow-repository"])?.trim(); + if (isShallow !== "true") { return undefined; } console.log("Merge-base not found in shallow clone; deepening and retrying."); git(["fetch", "--deepen", "1000", "origin", targetBranch]); - const secondMergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`]); - return secondMergeBase?.trim() ? secondMergeBase.trim() : undefined; + return git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim() || undefined; } export function main() { diff --git a/scripts/test/test_detect_changed_packages.mjs b/scripts/test/test_detect_changed_packages.mjs index b453ad3b7cee..6f4d50373804 100644 --- a/scripts/test/test_detect_changed_packages.mjs +++ b/scripts/test/test_detect_changed_packages.mjs @@ -29,40 +29,26 @@ test("normalizeTargetBranch returns empty string for empty input", () => { assert.equal(normalizeTargetBranch(""), ""); }); -test("checkFullRunPatterns matches pnpm-lock.yaml", () => { - const match = checkFullRunPatterns(["pnpm-lock.yaml"]); - assert.ok(match); - assert.equal(match.source, "^pnpm-lock\\.yaml$"); -}); - -test("checkFullRunPatterns matches .pnpmfile.cjs", () => { - const match = checkFullRunPatterns([".pnpmfile.cjs"]); - assert.ok(match); - assert.equal(match.source, "^\\.pnpmfile\\.cjs$"); -}); - -test("checkFullRunPatterns matches .npmrc", () => { - const match = checkFullRunPatterns([".npmrc"]); - assert.ok(match); - assert.equal(match.source, "^\\.npmrc$"); -}); - -test("checkFullRunPatterns matches .nvmrc", () => { - const match = checkFullRunPatterns([".nvmrc"]); - assert.ok(match); - assert.equal(match.source, "^\\.nvmrc$"); -}); +for (const [file, expectedSource] of [ + ["pnpm-lock.yaml", "^pnpm-lock\\.yaml$"], + [".pnpmfile.cjs", "^\\.pnpmfile\\.cjs$"], + [".npmrc", "^\\.npmrc$"], + [".nvmrc", "^\\.nvmrc$"], + ["package.json", "^package\\.json$"], +]) { + test(`checkFullRunPatterns matches ${file}`, () => { + const match = checkFullRunPatterns([file]); + assert.ok(match); + assert.equal(match.source, expectedSource); + }); +} test("checkFullRunPatterns matches tools prefix", () => { - const match = checkFullRunPatterns(["tools/pipelines/build-client.yml"]); - assert.ok(match, "expected tools/ prefix to match"); + assert.ok(checkFullRunPatterns(["tools/pipelines/build-client.yml"])); }); -test("checkFullRunPatterns matches root package.json but not nested package.json", () => { +test("checkFullRunPatterns does not match nested package.json", () => { assert.equal(checkFullRunPatterns(["packages/foo/package.json"]), undefined); - const match = checkFullRunPatterns(["package.json"]); - assert.ok(match); - assert.equal(match.source, "^package\\.json$"); }); test("checkFullRunPatterns matches root tsconfig only", () => { From bb5bea77861fa8230082ef5d097ca0001c7a0f6e Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 11 May 2026 14:54:53 -0700 Subject: [PATCH 16/19] docs(ci): add JSDoc typing to detect_changed_packages helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotate every function (exports and internals) with @param/@returns and a short description focused on the why — return-on-error semantics, the endpoint union in buildPackageDirSet, the root pseudo-dir exclusion in anyChangedFileInPackages, and the shallow-clone deepen retry in resolveMergeBase. Also type FULL_RUN_PATTERNS as readonly RegExp[]. --- scripts/detect_changed_packages.mjs | 109 ++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/scripts/detect_changed_packages.mjs b/scripts/detect_changed_packages.mjs index 408483826f7c..056120ee31d7 100644 --- a/scripts/detect_changed_packages.mjs +++ b/scripts/detect_changed_packages.mjs @@ -42,6 +42,7 @@ import { dirname } from "node:path/posix"; // scoping within a pipeline that's already running - but adding a new // cross-cutting root-level file generally warrants updating both. There's no // programmatic link, so keep them in sync by convention. +/** @type {readonly RegExp[]} */ export const FULL_RUN_PATTERNS = [ /^package\.json$/, /^pnpm-lock\.yaml$/, @@ -58,11 +59,28 @@ export const FULL_RUN_PATTERNS = [ /^\.changeset\/config\.json$/, ]; +/** + * Strips the `refs/heads/` prefix from a branch name. ADO pipeline variables + * sometimes deliver the fully-qualified ref instead of the short name, but + * git plumbing commands like `merge-base origin/` want the short form. + * + * @param {string} branch + * @returns {string} + */ export function normalizeTargetBranch(branch) { const prefix = "refs/heads/"; return branch.startsWith(prefix) ? branch.slice(prefix.length) : branch; } +/** + * Returns the first pattern in `patterns` that matches any file in `files`, + * or `undefined` when nothing matches. The matched pattern is returned (rather + * than a boolean) so callers can log which rule fired. + * + * @param {readonly string[]} files + * @param {readonly RegExp[]} [patterns] + * @returns {RegExp | undefined} + */ export function checkFullRunPatterns(files, patterns = FULL_RUN_PATTERNS) { for (const pattern of patterns) { if (files.some((file) => pattern.test(file))) { @@ -72,6 +90,23 @@ export function checkFullRunPatterns(files, patterns = FULL_RUN_PATTERNS) { return undefined; } +/** + * Computes the union of package directories across the merge-base commit and + * the current working tree. Both endpoints are unioned so that a `package.json` + * which was added or deleted across the range still counts as a package dir — + * otherwise a deletion at HEAD would orphan the historical files under it. + * + * The list callbacks are injected to keep this function testable without + * shelling out to git. + * + * @param {string} mergeBase + * @param {(ref: string) => readonly string[]} listHistoricalPackages + * Returns `package.json` paths recorded at `ref`. + * @param {() => readonly string[]} listCurrentPackages + * Returns `package.json` paths tracked in the current working tree. + * @returns {Set} Posix-style directory paths (e.g. `packages/foo`, + * or `.` for a root-level `package.json`). + */ export function buildPackageDirSet(mergeBase, listHistoricalPackages, listCurrentPackages) { const dirs = new Set(); for (const file of listHistoricalPackages(mergeBase)) { @@ -83,6 +118,18 @@ export function buildPackageDirSet(mergeBase, listHistoricalPackages, listCurren return dirs; } +/** + * Returns true if any entry in `changedFiles` lives under a known package dir. + * Walks each path up toward the root so deeply-nested changes (e.g. + * `packages/foo/src/a/b/c.ts`) match the ancestor package dir + * (`packages/foo`). The root pseudo-dir `"."` is intentionally NOT treated as + * a per-package hit — root-level changes are handled separately by + * {@link checkFullRunPatterns}. + * + * @param {readonly string[]} changedFiles + * @param {ReadonlySet} packageDirs + * @returns {boolean} + */ export function anyChangedFileInPackages(changedFiles, packageDirs) { for (const file of changedFiles) { if (!file) { @@ -99,6 +146,15 @@ export function anyChangedFileInPackages(changedFiles, packageDirs) { return false; } +/** + * Runs `git` with the given args and returns stdout, or `undefined` if the + * executable was missing or the command exited non-zero. Errors are logged as + * ADO warnings rather than thrown so callers can decide whether to fall back + * to a full run or continue with partial data. + * + * @param {readonly string[]} args + * @returns {string | undefined} + */ function git(args) { const result = spawnSync("git", args, { encoding: "utf8" }); if (result.error !== undefined) { @@ -115,6 +171,14 @@ function git(args) { const packageJsonPattern = /(^|\/)package\.json$/; +/** + * Lists every `package.json` recorded at the given git ref (typically the + * merge-base commit). Used to recover package dirs that existed historically + * but may have been removed at HEAD. + * + * @param {string} ref + * @returns {string[]} + */ function gitHistoricalPackages(ref) { const out = git(["ls-tree", "-r", "--name-only", ref]); if (out === undefined) { @@ -123,6 +187,11 @@ function gitHistoricalPackages(ref) { return out.split("\n").filter((file) => packageJsonPattern.test(file)); } +/** + * Lists every `package.json` currently tracked in the working tree. + * + * @returns {string[]} + */ function currentPackages() { const out = git(["ls-files", "--", "package.json", "*/package.json"]); if (out === undefined) { @@ -131,6 +200,15 @@ function currentPackages() { return out.split("\n").filter((file) => packageJsonPattern.test(file)); } +/** + * Writes both output variables consumed by the downstream ADO jobs. Always + * emits both, even when one is empty, so consumers can rely on the variables + * existing. + * + * @param {boolean} shouldRunTests + * @param {string} scopedPnpmFilter + * @returns {void} + */ function emitVsoOutputs(shouldRunTests, scopedPnpmFilter) { const flag = shouldRunTests ? "true" : "false"; console.log(`shouldRunTests=${flag}`); @@ -141,15 +219,38 @@ function emitVsoOutputs(shouldRunTests, scopedPnpmFilter) { ); } +/** + * Surfaces a warning to the ADO pipeline log without failing the task. + * + * @param {string} message + * @returns {void} + */ function logWarning(message) { console.log(`##vso[task.logissue type=warning]${message}`); } +/** + * Emits the safe-fallback outputs (run everything, no filter) and logs why. + * Use any time the scoping logic can't reach a confident decision; never + * silently skip tests on error. + * + * @param {string} reason + * @returns {void} + */ function fallbackFullRun(reason) { logWarning(`${reason} Falling back to full test run.`); emitVsoOutputs(true, ""); } +/** + * Returns the merge-base SHA between HEAD and `origin/`, or + * `undefined` if it can't be determined. On a shallow clone the merge-base + * may not be reachable; in that case we deepen once and retry rather than + * fetching the full history up front. + * + * @param {string} targetBranch + * @returns {string | undefined} + */ function resolveMergeBase(targetBranch) { const firstMergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim(); if (firstMergeBase) { @@ -166,6 +267,14 @@ function resolveMergeBase(targetBranch) { return git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim() || undefined; } +/** + * Pipeline entry point. Resolves the merge-base, classifies the diff, and + * emits the two ADO output variables (`shouldRunTests`, `scopedPnpmFilter`). + * Never throws — every failure path falls back to a full test run via + * {@link fallbackFullRun}. + * + * @returns {void} + */ export function main() { const targetBranch = normalizeTargetBranch(process.env.TARGET_BRANCH ?? ""); if (!targetBranch) { From fe15defbe69f2aa47ff315f531e6f427ae39ff1c Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 11 May 2026 15:08:54 -0700 Subject: [PATCH 17/19] docs(ci): explain detect script rationale --- scripts/detect_changed_packages.mjs | 9 +++++++++ .../templates/include-detect-changed-packages.yml | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/detect_changed_packages.mjs b/scripts/detect_changed_packages.mjs index 056120ee31d7..238fd3bfc68b 100644 --- a/scripts/detect_changed_packages.mjs +++ b/scripts/detect_changed_packages.mjs @@ -29,6 +29,15 @@ import { dirname } from "node:path/posix"; * commits that landed on origin/ after this PR diverged would show up * as "changed." Computing the merge-base SHA ourselves and feeding that SHA * into the selector gives three-dot (merge-base) semantics. + * + * Why this is a standalone script instead of a flub command: the pipeline runs + * this in the detect_changes job immediately after checkout, before dependency + * installation and before include-install-build-tools makes `flub` available. + * Moving this logic into flub would require installing/linking + * @fluid-tools/build-cli just to decide whether downstream test jobs should + * run. Keeping this script limited to Node built-ins preserves the early, + * fail-fast check and avoids paying that install cost in jobs that may skip + * the heavier work. */ // Full-run trigger patterns. A diff touching any of these paths forces running diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index cc3214856edb..f271b31b6397 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -4,7 +4,10 @@ # Pipeline wrapper around scripts/detect_changed_packages.mjs. The Node ESM # module owns the decision logic and its own doc comment; see that file for # output-variable semantics, safe-fallback policy, and full-run patterns. -# Node built-ins only, so no install step is needed here. +# Node built-ins only, so no install step is needed here. This intentionally +# runs before dependency installation / build-tools setup; using flub here would +# require first installing or linking @fluid-tools/build-cli, delaying the early +# decision about whether downstream test jobs need to run. parameters: - name: buildDirectory From e55644595b2ec84f72fadd05d8f5f43d822ab26a Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 11 May 2026 15:45:51 -0700 Subject: [PATCH 18/19] build: move change detection to flub --- .../src/commands/check/changedPackages.ts | 287 ++++++++++++++ .../commands/check/changedPackages.test.ts | 177 +++++++++ scripts/detect_changed_packages.mjs | 359 ------------------ scripts/test/test_detect_changed_packages.mjs | 136 ------- .../templates/build-npm-client-package.yml | 29 +- .../include-detect-changed-packages.yml | 30 +- 6 files changed, 492 insertions(+), 526 deletions(-) create mode 100644 build-tools/packages/build-cli/src/commands/check/changedPackages.ts create mode 100644 build-tools/packages/build-cli/src/test/commands/check/changedPackages.test.ts delete mode 100644 scripts/detect_changed_packages.mjs delete mode 100644 scripts/test/test_detect_changed_packages.mjs diff --git a/build-tools/packages/build-cli/src/commands/check/changedPackages.ts b/build-tools/packages/build-cli/src/commands/check/changedPackages.ts new file mode 100644 index 000000000000..3a245e9d670e --- /dev/null +++ b/build-tools/packages/build-cli/src/commands/check/changedPackages.ts @@ -0,0 +1,287 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import path from "node:path"; +import { dirname } from "node:path/posix"; + +import { getChangedSinceRef, getRemote } from "@fluid-tools/build-infrastructure"; +import { Flags } from "@oclif/core"; +import type { SimpleGit } from "simple-git"; + +import { BaseCommandWithBuildProject } from "../../library/commands/base.js"; + +/** + * Full-run trigger patterns. A diff touching any of these paths forces running + * every package's tests. Keep this conservative since these files can affect + * dependency resolution, build behavior, or pipeline behavior across packages. + */ +export const fullRunPatterns: readonly RegExp[] = [ + /^package\.json$/, + /^pnpm-lock\.yaml$/, + /^pnpm-workspace\.yaml$/, + /^\.pnpmfile\.cjs$/, + /^\.npmrc$/, + /^\.nvmrc$/, + /^fluidBuild\.config\.cjs$/, + /^tsconfig[^/]*\.json$/, + /^biome\./, + /^tools\//, + /^common\//, + /^scripts\//, + /^\.changeset\/config\.json$/, +]; + +export interface ChangedPackagesResult { + shouldRunTests: boolean; + scopedPnpmFilter: string; + targetBranch: string; + mergeBase?: string; + changedFiles: string[]; + forcedFullRunPattern?: string; + changedPackageCount: number; +} + +export function normalizeTargetBranch(branch: string): string { + const prefix = "refs/heads/"; + return branch.startsWith(prefix) ? branch.slice(prefix.length) : branch; +} + +export function checkFullRunPatterns( + files: readonly string[], + patterns: readonly RegExp[] = fullRunPatterns, +): RegExp | undefined { + for (const pattern of patterns) { + if (files.some((file) => pattern.test(file))) { + return pattern; + } + } + return undefined; +} + +export function buildPackageDirSet( + mergeBase: string, + listHistoricalPackages: (ref: string) => readonly string[], + listCurrentPackages: () => readonly string[], +): Set { + const dirs = new Set(); + for (const file of listHistoricalPackages(mergeBase)) { + dirs.add(dirname(file)); + } + for (const file of listCurrentPackages()) { + dirs.add(dirname(file)); + } + return dirs; +} + +export function anyChangedFileInPackages( + changedFiles: readonly string[], + packageDirs: ReadonlySet, +): boolean { + for (const file of changedFiles) { + if (file === "") { + continue; + } + + let dir = dirname(file); + while (dir !== "." && dir !== "/") { + if (packageDirs.has(dir)) { + return true; + } + dir = dirname(dir); + } + } + return false; +} + +const packageJsonPattern = /(^|\/)package\.json$/; + +function packageJsonFilesFromGitOutput(output: string): string[] { + return output.split("\n").filter((file) => packageJsonPattern.test(file)); +} + +async function resolveMergeBase( + git: Readonly, + remote: string, + targetBranch: string, + log: (message: string) => void, +): Promise { + try { + return ( + await git.raw("merge-base", "HEAD", `refs/remotes/${remote}/${targetBranch}`) + ).trim(); + } catch { + const isShallow = (await git.raw("rev-parse", "--is-shallow-repository")).trim(); + if (isShallow !== "true") { + throw new Error(`No merge-base with ${remote}/${targetBranch}`); + } + + log("Merge-base not found in shallow clone; deepening and retrying."); + await git.fetch(["--deepen", "1000", remote, targetBranch]); + return ( + await git.raw("merge-base", "HEAD", `refs/remotes/${remote}/${targetBranch}`) + ).trim(); + } +} + +export default class CheckChangedPackagesCommand extends BaseCommandWithBuildProject< + typeof CheckChangedPackagesCommand +> { + static readonly summary = + "Computes Azure DevOps output variables for changed-package-scoped test runs."; + + static readonly description = + "Compares the current PR branch to the merge base with a target branch, then emits shouldRunTests and scopedPnpmFilter output variables. Unexpected errors conservatively fall back to a full test run."; + + static readonly enableJsonFlag = true; + + static readonly flags = { + targetBranch: Flags.string({ + description: + "Target branch to compare against. Defaults to the TARGET_BRANCH environment variable.", + }), + searchPath: Flags.directory({ + description: + "Path used to locate the build project. Defaults to the current working directory.", + exists: true, + }), + ...BaseCommandWithBuildProject.flags, + } as const; + + public async run(): Promise { + const targetBranch = normalizeTargetBranch( + this.flags.targetBranch ?? process.env.TARGET_BRANCH ?? "", + ); + + if (targetBranch === "") { + return this.fallbackFullRun("TARGET_BRANCH not set;", targetBranch); + } + + this.info(`Target branch: ${targetBranch}`); + + try { + const buildProject = this.getBuildProject( + path.resolve(this.flags.searchPath ?? process.cwd()), + ); + const git = await buildProject.getGitRepository(); + const remote = await getRemote(git, buildProject.upstreamRemotePartialUrl); + if (remote === undefined) { + return this.fallbackFullRun( + `Could not find upstream remote for ${buildProject.upstreamRemotePartialUrl};`, + targetBranch, + ); + } + + await git.fetch([remote, targetBranch]); + const mergeBase = await resolveMergeBase(git, remote, targetBranch, (message) => + this.info(message), + ); + this.info(`Merge base: ${mergeBase}`); + + const changed = await getChangedSinceRef(buildProject, targetBranch, remote); + const changedFiles = changed.files; + this.info(`Changed files (${changedFiles.length}):`); + for (const file of changedFiles.slice(0, 30)) { + this.info(file); + } + if (changedFiles.length > 30) { + this.info(`... and ${changedFiles.length - 30} more`); + } + + const match = checkFullRunPatterns(changedFiles); + if (match !== undefined) { + this.info(`Match for full-run pattern '${match.source}' - forcing full test run.`); + this.emitVsoOutputs(true, ""); + return { + shouldRunTests: true, + scopedPnpmFilter: "", + targetBranch, + mergeBase, + changedFiles, + forcedFullRunPattern: match.source, + changedPackageCount: changed.packages.length, + }; + } + + const historicalPackageJsonFiles = packageJsonFilesFromGitOutput( + await git.raw("ls-tree", "-r", "--name-only", mergeBase), + ); + const currentPackageJsonFiles = packageJsonFilesFromGitOutput( + await git.raw("ls-files", "--", "package.json", "*/package.json"), + ); + const packageDirs = buildPackageDirSet( + mergeBase, + () => historicalPackageJsonFiles, + () => currentPackageJsonFiles, + ); + + if (!anyChangedFileInPackages(changedFiles, packageDirs)) { + this.logWarning( + `No changed files mapped to a workspace package - skipping all test execution. Files considered (${changedFiles.length}):`, + ); + for (const file of changedFiles) { + this.info(` ${file}`); + } + this.emitVsoOutputs(false, ""); + return { + shouldRunTests: false, + scopedPnpmFilter: "", + targetBranch, + mergeBase, + changedFiles, + changedPackageCount: 0, + }; + } + + const scopedPnpmFilter = `...[${mergeBase}]`; + this.info(`Computed pnpm filter: ${scopedPnpmFilter}`); + this.emitVsoOutputs(true, scopedPnpmFilter); + return { + shouldRunTests: true, + scopedPnpmFilter, + targetBranch, + mergeBase, + changedFiles, + changedPackageCount: changed.packages.length, + }; + } catch (error) { + return this.fallbackFullRun( + error instanceof Error ? `${error.message};` : `${String(error)};`, + targetBranch, + ); + } + } + + private emitVsoOutputs(shouldRunTests: boolean, scopedPnpmFilter: string): void { + if (this.jsonEnabled()) { + return; + } + + const flag = shouldRunTests ? "true" : "false"; + this.log(`shouldRunTests=${flag}`); + this.log(`scopedPnpmFilter=${scopedPnpmFilter}`); + this.log(`##vso[task.setvariable variable=shouldRunTests;isOutput=true]${flag}`); + this.log( + `##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`, + ); + } + + private logWarning(message: string): void { + if (!this.jsonEnabled()) { + this.log(`##vso[task.logissue type=warning]${message}`); + } + } + + private fallbackFullRun(reason: string, targetBranch: string): ChangedPackagesResult { + this.logWarning(`${reason} Falling back to full test run.`); + this.emitVsoOutputs(true, ""); + return { + shouldRunTests: true, + scopedPnpmFilter: "", + targetBranch, + changedFiles: [], + changedPackageCount: 0, + }; + } +} diff --git a/build-tools/packages/build-cli/src/test/commands/check/changedPackages.test.ts b/build-tools/packages/build-cli/src/test/commands/check/changedPackages.test.ts new file mode 100644 index 000000000000..008c504309f5 --- /dev/null +++ b/build-tools/packages/build-cli/src/test/commands/check/changedPackages.test.ts @@ -0,0 +1,177 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { runCommand } from "@oclif/test"; +import { expect } from "chai"; +import { describe, it } from "mocha"; + +import { + anyChangedFileInPackages, + buildPackageDirSet, + checkFullRunPatterns, + normalizeTargetBranch, +} from "../../../commands/check/changedPackages.js"; +import { testRepoRoot } from "../../init.js"; + +describe("flub check changedPackages helpers", () => { + it("normalizeTargetBranch strips refs/heads prefix", () => { + expect(normalizeTargetBranch("refs/heads/main")).to.equal("main"); + }); + + it("normalizeTargetBranch passes plain branch names through", () => { + expect(normalizeTargetBranch("next")).to.equal("next"); + }); + + it("normalizeTargetBranch preserves slashes after the prefix", () => { + expect(normalizeTargetBranch("refs/heads/release/2.x")).to.equal("release/2.x"); + }); + + it("normalizeTargetBranch returns empty string for empty input", () => { + expect(normalizeTargetBranch("")).to.equal(""); + }); + + for (const [file, expectedSource] of [ + ["pnpm-lock.yaml", "^pnpm-lock\\.yaml$"], + [".pnpmfile.cjs", "^\\.pnpmfile\\.cjs$"], + [".npmrc", "^\\.npmrc$"], + [".nvmrc", "^\\.nvmrc$"], + ["package.json", "^package\\.json$"], + ] as const) { + it(`checkFullRunPatterns matches ${file}`, () => { + const match = checkFullRunPatterns([file]); + expect(match).to.not.equal(undefined); + expect(match?.source).to.equal(expectedSource); + }); + } + + it("checkFullRunPatterns matches tools prefix", () => { + expect(checkFullRunPatterns(["tools/pipelines/build-client.yml"])).to.not.equal(undefined); + }); + + it("checkFullRunPatterns does not match nested package.json", () => { + expect(checkFullRunPatterns(["packages/foo/package.json"])).to.equal(undefined); + }); + + it("checkFullRunPatterns matches root tsconfig only", () => { + expect(checkFullRunPatterns(["tsconfig.base.json"])).to.not.equal(undefined); + expect(checkFullRunPatterns(["packages/foo/tsconfig.json"])).to.equal(undefined); + }); + + it("checkFullRunPatterns returns undefined when nothing matches", () => { + expect(checkFullRunPatterns(["packages/foo/src/x.ts"])).to.equal(undefined); + }); + + it("checkFullRunPatterns returns the first pattern hit when several qualify", () => { + const match = checkFullRunPatterns(["pnpm-lock.yaml", "biome.jsonc"]); + expect(match).to.not.equal(undefined); + expect(match?.source).to.equal("^pnpm-lock\\.yaml$"); + }); + + it("buildPackageDirSet unions historical and current packages", () => { + const dirs = buildPackageDirSet( + "sha", + () => ["packages/old/package.json", "packages/shared/package.json"], + () => ["packages/shared/package.json", "packages/new/package.json"], + ); + expect([...dirs].sort()).to.deep.equal([ + "packages/new", + "packages/old", + "packages/shared", + ]); + }); + + it("buildPackageDirSet maps a root-level package.json to dot", () => { + const dirs = buildPackageDirSet( + "sha", + () => ["package.json"], + () => [], + ); + expect([...dirs]).to.deep.equal(["."]); + }); + + it("buildPackageDirSet tolerates either package list being empty", () => { + expect( + buildPackageDirSet( + "sha", + () => [], + () => [], + ).size, + ).to.equal(0); + expect( + buildPackageDirSet( + "sha", + () => ["packages/a/package.json"], + () => [], + ).size, + ).to.equal(1); + }); + + const packageDirs = new Set(["packages/alive"]); + + it("anyChangedFileInPackages detects file inside known package dir", () => { + expect(anyChangedFileInPackages(["packages/alive/src/x.ts"], packageDirs)).to.equal(true); + }); + + it("anyChangedFileInPackages returns false for root-only changes", () => { + expect(anyChangedFileInPackages(["README.md"], packageDirs)).to.equal(false); + }); + + it("anyChangedFileInPackages returns false for unrelated sibling directory", () => { + expect(anyChangedFileInPackages(["packages/other/src.ts"], packageDirs)).to.equal(false); + }); + + it("anyChangedFileInPackages ignores empty file entries", () => { + expect(anyChangedFileInPackages(["", "packages/alive/src.ts"], packageDirs)).to.equal( + true, + ); + }); + + it("anyChangedFileInPackages walks up from nested paths to find ancestor", () => { + expect( + anyChangedFileInPackages(["packages/alive/src/deeply/nested/x.ts"], packageDirs), + ).to.equal(true); + }); + + it("anyChangedFileInPackages does not treat root pseudo-dir as a per-package hit", () => { + expect( + anyChangedFileInPackages(["some-root-file.md"], new Set([".", "packages/alive"])), + ).to.equal(false); + }); +}); + +describe("flub check changedPackages", () => { + it("falls back to a full test run when target branch is missing", async () => { + const { stdout } = await runCommand( + ["check changedPackages", "--searchPath", testRepoRoot, "--quiet"], + { root: import.meta.url }, + ); + + expect(stdout).to.contain("shouldRunTests=true"); + expect(stdout).to.contain("scopedPnpmFilter="); + expect(stdout).to.contain( + "##vso[task.setvariable variable=shouldRunTests;isOutput=true]true", + ); + expect(stdout).to.contain( + "##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]", + ); + }); + + it("returns structured JSON without throwing on safe fallback", async () => { + const { stdout, error } = await runCommand( + ["check changedPackages", "--searchPath", testRepoRoot, "--json", "--quiet"], + { root: import.meta.url }, + ); + + expect(error).to.equal(undefined); + const output = JSON.parse(stdout) as { + shouldRunTests: boolean; + scopedPnpmFilter: string; + changedPackageCount: number; + }; + expect(output.shouldRunTests).to.equal(true); + expect(output.scopedPnpmFilter).to.equal(""); + expect(output.changedPackageCount).to.equal(0); + }); +}); diff --git a/scripts/detect_changed_packages.mjs b/scripts/detect_changed_packages.mjs deleted file mode 100644 index 238fd3bfc68b..000000000000 --- a/scripts/detect_changed_packages.mjs +++ /dev/null @@ -1,359 +0,0 @@ -#!/usr/bin/env node -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; -import { dirname } from "node:path/posix"; - -/** - * Decides whether a PR's diff warrants scoping downstream test execution to a - * subset of workspace packages. Emits two ADO output variables: - * - * shouldRunTests "true" | "false" - whether any test work is needed - * scopedPnpmFilter pnpm filter string "...[]" when scoping is active, - * empty when a full test run is required. Downstream jobs - * pass this verbatim into npm_config_filter; pnpm treats - * an empty value as "no filter applied" so recursive -r - * runs fall back to the historical every-package behavior. - * - * Safe-fallback policy: any unexpected error (missing merge-base, git failure, - * unparseable ref) MUST result in a full run - never a silent skip. An - * accidental silent skip would suppress all tests and hide real regressions. - * - * Why merge-base (and not just origin/ directly): pnpm's - * --filter "[ref]" uses a two-dot diff internally (see pnpm/pnpm#9907), so - * commits that landed on origin/ after this PR diverged would show up - * as "changed." Computing the merge-base SHA ourselves and feeding that SHA - * into the selector gives three-dot (merge-base) semantics. - * - * Why this is a standalone script instead of a flub command: the pipeline runs - * this in the detect_changes job immediately after checkout, before dependency - * installation and before include-install-build-tools makes `flub` available. - * Moving this logic into flub would require installing/linking - * @fluid-tools/build-cli just to decide whether downstream test jobs should - * run. Keeping this script limited to Node built-ins preserves the early, - * fail-fast check and avoids paying that install cost in jobs that may skip - * the heavier work. - */ - -// Full-run trigger patterns. A diff touching any of these paths forces running -// every package's tests (filter stays empty, so pnpm -r runs across the whole -// workspace). Keep this list conservative: it's the safety net for changes -// that could plausibly invalidate assumptions across the entire workspace. -// -// This list partially overlaps with `pr: paths: include:` in -// tools/pipelines/build-client.yml (which decides whether the pipeline runs -// at all). The concepts differ - one gates the pipeline, the other gates -// scoping within a pipeline that's already running - but adding a new -// cross-cutting root-level file generally warrants updating both. There's no -// programmatic link, so keep them in sync by convention. -/** @type {readonly RegExp[]} */ -export const FULL_RUN_PATTERNS = [ - /^package\.json$/, - /^pnpm-lock\.yaml$/, - /^pnpm-workspace\.yaml$/, - /^\.pnpmfile\.cjs$/, - /^\.npmrc$/, - /^\.nvmrc$/, - /^fluidBuild\.config\.cjs$/, - /^tsconfig[^/]*\.json$/, - /^biome\./, - /^tools\//, - /^common\//, - /^scripts\//, - /^\.changeset\/config\.json$/, -]; - -/** - * Strips the `refs/heads/` prefix from a branch name. ADO pipeline variables - * sometimes deliver the fully-qualified ref instead of the short name, but - * git plumbing commands like `merge-base origin/` want the short form. - * - * @param {string} branch - * @returns {string} - */ -export function normalizeTargetBranch(branch) { - const prefix = "refs/heads/"; - return branch.startsWith(prefix) ? branch.slice(prefix.length) : branch; -} - -/** - * Returns the first pattern in `patterns` that matches any file in `files`, - * or `undefined` when nothing matches. The matched pattern is returned (rather - * than a boolean) so callers can log which rule fired. - * - * @param {readonly string[]} files - * @param {readonly RegExp[]} [patterns] - * @returns {RegExp | undefined} - */ -export function checkFullRunPatterns(files, patterns = FULL_RUN_PATTERNS) { - for (const pattern of patterns) { - if (files.some((file) => pattern.test(file))) { - return pattern; - } - } - return undefined; -} - -/** - * Computes the union of package directories across the merge-base commit and - * the current working tree. Both endpoints are unioned so that a `package.json` - * which was added or deleted across the range still counts as a package dir — - * otherwise a deletion at HEAD would orphan the historical files under it. - * - * The list callbacks are injected to keep this function testable without - * shelling out to git. - * - * @param {string} mergeBase - * @param {(ref: string) => readonly string[]} listHistoricalPackages - * Returns `package.json` paths recorded at `ref`. - * @param {() => readonly string[]} listCurrentPackages - * Returns `package.json` paths tracked in the current working tree. - * @returns {Set} Posix-style directory paths (e.g. `packages/foo`, - * or `.` for a root-level `package.json`). - */ -export function buildPackageDirSet(mergeBase, listHistoricalPackages, listCurrentPackages) { - const dirs = new Set(); - for (const file of listHistoricalPackages(mergeBase)) { - dirs.add(dirname(file)); - } - for (const file of listCurrentPackages()) { - dirs.add(dirname(file)); - } - return dirs; -} - -/** - * Returns true if any entry in `changedFiles` lives under a known package dir. - * Walks each path up toward the root so deeply-nested changes (e.g. - * `packages/foo/src/a/b/c.ts`) match the ancestor package dir - * (`packages/foo`). The root pseudo-dir `"."` is intentionally NOT treated as - * a per-package hit — root-level changes are handled separately by - * {@link checkFullRunPatterns}. - * - * @param {readonly string[]} changedFiles - * @param {ReadonlySet} packageDirs - * @returns {boolean} - */ -export function anyChangedFileInPackages(changedFiles, packageDirs) { - for (const file of changedFiles) { - if (!file) { - continue; - } - let dir = dirname(file); - while (dir !== "." && dir !== "/") { - if (packageDirs.has(dir)) { - return true; - } - dir = dirname(dir); - } - } - return false; -} - -/** - * Runs `git` with the given args and returns stdout, or `undefined` if the - * executable was missing or the command exited non-zero. Errors are logged as - * ADO warnings rather than thrown so callers can decide whether to fall back - * to a full run or continue with partial data. - * - * @param {readonly string[]} args - * @returns {string | undefined} - */ -function git(args) { - const result = spawnSync("git", args, { encoding: "utf8" }); - if (result.error !== undefined) { - logWarning(`git executable not found: ${result.error.message}`); - return undefined; - } - if (result.status !== 0) { - const stderr = (result.stderr ?? "").trim(); - logWarning(`git ${args.join(" ")} failed (exit ${result.status}): ${stderr}`); - return undefined; - } - return result.stdout; -} - -const packageJsonPattern = /(^|\/)package\.json$/; - -/** - * Lists every `package.json` recorded at the given git ref (typically the - * merge-base commit). Used to recover package dirs that existed historically - * but may have been removed at HEAD. - * - * @param {string} ref - * @returns {string[]} - */ -function gitHistoricalPackages(ref) { - const out = git(["ls-tree", "-r", "--name-only", ref]); - if (out === undefined) { - return []; - } - return out.split("\n").filter((file) => packageJsonPattern.test(file)); -} - -/** - * Lists every `package.json` currently tracked in the working tree. - * - * @returns {string[]} - */ -function currentPackages() { - const out = git(["ls-files", "--", "package.json", "*/package.json"]); - if (out === undefined) { - return []; - } - return out.split("\n").filter((file) => packageJsonPattern.test(file)); -} - -/** - * Writes both output variables consumed by the downstream ADO jobs. Always - * emits both, even when one is empty, so consumers can rely on the variables - * existing. - * - * @param {boolean} shouldRunTests - * @param {string} scopedPnpmFilter - * @returns {void} - */ -function emitVsoOutputs(shouldRunTests, scopedPnpmFilter) { - const flag = shouldRunTests ? "true" : "false"; - console.log(`shouldRunTests=${flag}`); - console.log(`scopedPnpmFilter=${scopedPnpmFilter}`); - console.log(`##vso[task.setvariable variable=shouldRunTests;isOutput=true]${flag}`); - console.log( - `##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`, - ); -} - -/** - * Surfaces a warning to the ADO pipeline log without failing the task. - * - * @param {string} message - * @returns {void} - */ -function logWarning(message) { - console.log(`##vso[task.logissue type=warning]${message}`); -} - -/** - * Emits the safe-fallback outputs (run everything, no filter) and logs why. - * Use any time the scoping logic can't reach a confident decision; never - * silently skip tests on error. - * - * @param {string} reason - * @returns {void} - */ -function fallbackFullRun(reason) { - logWarning(`${reason} Falling back to full test run.`); - emitVsoOutputs(true, ""); -} - -/** - * Returns the merge-base SHA between HEAD and `origin/`, or - * `undefined` if it can't be determined. On a shallow clone the merge-base - * may not be reachable; in that case we deepen once and retry rather than - * fetching the full history up front. - * - * @param {string} targetBranch - * @returns {string | undefined} - */ -function resolveMergeBase(targetBranch) { - const firstMergeBase = git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim(); - if (firstMergeBase) { - return firstMergeBase; - } - - const isShallow = git(["rev-parse", "--is-shallow-repository"])?.trim(); - if (isShallow !== "true") { - return undefined; - } - - console.log("Merge-base not found in shallow clone; deepening and retrying."); - git(["fetch", "--deepen", "1000", "origin", targetBranch]); - return git(["merge-base", "HEAD", `origin/${targetBranch}`])?.trim() || undefined; -} - -/** - * Pipeline entry point. Resolves the merge-base, classifies the diff, and - * emits the two ADO output variables (`shouldRunTests`, `scopedPnpmFilter`). - * Never throws — every failure path falls back to a full test run via - * {@link fallbackFullRun}. - * - * @returns {void} - */ -export function main() { - const targetBranch = normalizeTargetBranch(process.env.TARGET_BRANCH ?? ""); - if (!targetBranch) { - fallbackFullRun("TARGET_BRANCH not set;"); - return; - } - console.log(`Target branch: ${targetBranch}`); - - if (git(["fetch", "origin", targetBranch]) === undefined) { - fallbackFullRun(`Could not fetch origin/${targetBranch};`); - return; - } - - const mergeBase = resolveMergeBase(targetBranch); - if (!mergeBase) { - fallbackFullRun(`No merge-base with origin/${targetBranch};`); - return; - } - console.log(`Merge base: ${mergeBase}`); - - // Diff merge_base..HEAD (commit-only, immune to working-tree mutations - // from any future pre-step). On diff failure, fall back to a full run: - // an empty changed-files list would bypass full-run patterns and the - // package-change check, silently suppressing all test jobs. - const diffOut = git(["diff", "--name-only", mergeBase, "HEAD"]); - if (diffOut === undefined) { - fallbackFullRun(`git diff against merge-base ${mergeBase} failed;`); - return; - } - - const changedFiles = diffOut.split("\n").filter(Boolean); - console.log(`Changed files (${changedFiles.length}):`); - for (const file of changedFiles.slice(0, 30)) { - console.log(file); - } - if (changedFiles.length > 30) { - console.log(`... and ${changedFiles.length - 30} more`); - } - - const match = checkFullRunPatterns(changedFiles); - if (match !== undefined) { - console.log(`Match for full-run pattern '${match.source}' - forcing full test run.`); - emitVsoOutputs(true, ""); - return; - } - - const packageDirs = buildPackageDirSet(mergeBase, gitHistoricalPackages, currentPackages); - if (!anyChangedFileInPackages(changedFiles, packageDirs)) { - logWarning( - `No changed files mapped to a workspace package - skipping all test execution. Files considered (${changedFiles.length}):`, - ); - for (const file of changedFiles) { - console.log(` ${file}`); - } - emitVsoOutputs(false, ""); - return; - } - - // Hand the merge-base SHA to pnpm's native selector. The leading `...` - // pulls in transitive dependents so consumers of a changed package also - // get re-tested. - const filter = `...[${mergeBase}]`; - console.log(`Computed pnpm filter: ${filter}`); - emitVsoOutputs(true, filter); -} - -if ( - process.argv[1] !== undefined && - resolve(process.argv[1]) === fileURLToPath(import.meta.url) -) { - main(); - process.exit(0); -} diff --git a/scripts/test/test_detect_changed_packages.mjs b/scripts/test/test_detect_changed_packages.mjs deleted file mode 100644 index 6f4d50373804..000000000000 --- a/scripts/test/test_detect_changed_packages.mjs +++ /dev/null @@ -1,136 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - anyChangedFileInPackages, - buildPackageDirSet, - checkFullRunPatterns, - normalizeTargetBranch, -} from "../detect_changed_packages.mjs"; - -test("normalizeTargetBranch strips refs/heads prefix", () => { - assert.equal(normalizeTargetBranch("refs/heads/main"), "main"); -}); - -test("normalizeTargetBranch passes plain branch names through", () => { - assert.equal(normalizeTargetBranch("next"), "next"); -}); - -test("normalizeTargetBranch preserves slashes after the prefix", () => { - assert.equal(normalizeTargetBranch("refs/heads/release/2.x"), "release/2.x"); -}); - -test("normalizeTargetBranch returns empty string for empty input", () => { - assert.equal(normalizeTargetBranch(""), ""); -}); - -for (const [file, expectedSource] of [ - ["pnpm-lock.yaml", "^pnpm-lock\\.yaml$"], - [".pnpmfile.cjs", "^\\.pnpmfile\\.cjs$"], - [".npmrc", "^\\.npmrc$"], - [".nvmrc", "^\\.nvmrc$"], - ["package.json", "^package\\.json$"], -]) { - test(`checkFullRunPatterns matches ${file}`, () => { - const match = checkFullRunPatterns([file]); - assert.ok(match); - assert.equal(match.source, expectedSource); - }); -} - -test("checkFullRunPatterns matches tools prefix", () => { - assert.ok(checkFullRunPatterns(["tools/pipelines/build-client.yml"])); -}); - -test("checkFullRunPatterns does not match nested package.json", () => { - assert.equal(checkFullRunPatterns(["packages/foo/package.json"]), undefined); -}); - -test("checkFullRunPatterns matches root tsconfig only", () => { - assert.ok(checkFullRunPatterns(["tsconfig.base.json"])); - assert.equal(checkFullRunPatterns(["packages/foo/tsconfig.json"]), undefined); -}); - -test("checkFullRunPatterns returns undefined when nothing matches", () => { - assert.equal(checkFullRunPatterns(["packages/foo/src/x.ts"]), undefined); -}); - -test("checkFullRunPatterns returns the first pattern hit when several qualify", () => { - const match = checkFullRunPatterns(["pnpm-lock.yaml", "biome.jsonc"]); - assert.ok(match); - assert.equal(match.source, "^pnpm-lock\\.yaml$"); -}); - -test("buildPackageDirSet unions historical and current packages", () => { - const historical = ["packages/old/package.json", "packages/shared/package.json"]; - const current = ["packages/shared/package.json", "packages/new/package.json"]; - const dirs = buildPackageDirSet( - "sha", - () => historical, - () => current, - ); - assert.deepEqual([...dirs].sort(), ["packages/new", "packages/old", "packages/shared"]); -}); - -test("buildPackageDirSet maps a root-level package.json to dot", () => { - const dirs = buildPackageDirSet( - "sha", - () => ["package.json"], - () => [], - ); - assert.deepEqual([...dirs], ["."]); -}); - -test("buildPackageDirSet tolerates either package list being empty", () => { - assert.equal( - buildPackageDirSet( - "sha", - () => [], - () => [], - ).size, - 0, - ); - assert.equal( - buildPackageDirSet( - "sha", - () => ["packages/a/package.json"], - () => [], - ).size, - 1, - ); -}); - -const packageDirs = new Set(["packages/alive"]); - -test("anyChangedFileInPackages detects file inside known package dir", () => { - assert.equal(anyChangedFileInPackages(["packages/alive/src/x.ts"], packageDirs), true); -}); - -test("anyChangedFileInPackages returns false for root-only changes", () => { - assert.equal(anyChangedFileInPackages(["README.md"], packageDirs), false); -}); - -test("anyChangedFileInPackages returns false for unrelated sibling directory", () => { - assert.equal(anyChangedFileInPackages(["packages/other/src.ts"], packageDirs), false); -}); - -test("anyChangedFileInPackages ignores empty file entries", () => { - assert.equal(anyChangedFileInPackages(["", "packages/alive/src.ts"], packageDirs), true); -}); - -test("anyChangedFileInPackages walks up from nested paths to find ancestor", () => { - assert.equal( - anyChangedFileInPackages(["packages/alive/src/deeply/nested/x.ts"], packageDirs), - true, - ); -}); - -test("anyChangedFileInPackages does not treat root pseudo-dir as a per-package hit", () => { - const dirsWithRoot = new Set([".", "packages/alive"]); - assert.equal(anyChangedFileInPackages(["some-root-file.md"], dirsWithRoot), false); -}); diff --git a/tools/pipelines/templates/build-npm-client-package.yml b/tools/pipelines/templates/build-npm-client-package.yml index 8fd831568a07..2c15b7ef4920 100644 --- a/tools/pipelines/templates/build-npm-client-package.yml +++ b/tools/pipelines/templates/build-npm-client-package.yml @@ -215,6 +215,19 @@ extends: - name: targetBranchName value: $(System.PullRequest.TargetBranch) steps: + - checkout: self + path: $(FluidFrameworkDirectory) + clean: true + # Shallow clone; `flub check changedPackages` deepens on demand + # if the merge-base falls outside this depth. Most PRs + # merge-base within a few hundred commits. + fetchDepth: 200 + + - template: /tools/pipelines/templates/include-install-build-tools.yml@self + parameters: + buildDirectory: ${{ parameters.buildDirectory }} + buildToolsVersionToInstall: repo + - template: /tools/pipelines/templates/include-detect-changed-packages.yml@self parameters: buildDirectory: ${{ parameters.buildDirectory }} @@ -356,22 +369,6 @@ extends: buildDirectory: '${{ parameters.buildDirectory }}' packageManagerInstallCommand: '${{ parameters.packageManagerInstallCommand }}' - # Unit tests for helper scripts under scripts/. detect_changed_packages.mjs - # gates whether the Coverage_tests / Test_* jobs run at all (see - # include-detect-changed-packages.yml); a regression in the change - # detection logic could silently suppress every package test, so - # we run its tests here in the build job — early, before any heavy - # build work, so they fail fast. Node built-ins only; no extra - # install step is needed. - - task: Bash@3 - displayName: Scripts unit tests - inputs: - targetType: 'inline' - workingDirectory: $(Pipeline.Workspace)/${{ parameters.buildDirectory }} - script: | - set -eu -o pipefail - node --test scripts/test/test_detect_changed_packages.mjs - # The bundle-size-artifacts pipeline runs a client build but doesn't publish packages, # so we skip version setting for it. - ${{ if eq(parameters.isBundleSizeArtifactsPipeline, false) }}: diff --git a/tools/pipelines/templates/include-detect-changed-packages.yml b/tools/pipelines/templates/include-detect-changed-packages.yml index f271b31b6397..fcb40046d444 100644 --- a/tools/pipelines/templates/include-detect-changed-packages.yml +++ b/tools/pipelines/templates/include-detect-changed-packages.yml @@ -1,13 +1,20 @@ # Copyright (c) Microsoft Corporation and contributors. All rights reserved. # Licensed under the MIT License. -# Pipeline wrapper around scripts/detect_changed_packages.mjs. The Node ESM -# module owns the decision logic and its own doc comment; see that file for -# output-variable semantics, safe-fallback policy, and full-run patterns. -# Node built-ins only, so no install step is needed here. This intentionally -# runs before dependency installation / build-tools setup; using flub here would -# require first installing or linking @fluid-tools/build-cli, delaying the early -# decision about whether downstream test jobs need to run. +# Pipeline wrapper around `flub check changedPackages`. +# +# This template intentionally has no checkout or build-tools installation steps. +# Callers own those prerequisites so this reusable template has no hidden side +# effects: +# - the repository must already be checked out at +# `$(Pipeline.Workspace)/${{ parameters.buildDirectory }}`; +# - `flub` must already be available on PATH, usually via +# include-install-build-tools.yml; +# - callers choose checkout depth/clean behavior. A shallow checkout is OK; the +# command deepens on demand when the merge-base is outside the current depth. +# +# Keep the task name `setChangedPackages`: downstream jobs read its ADO output +# variables (`shouldRunTests` and `scopedPnpmFilter`) by that name. parameters: - name: buildDirectory @@ -17,13 +24,6 @@ parameters: type: string steps: - - checkout: self - path: $(FluidFrameworkDirectory) - clean: true - # Shallow clone; the script deepens on demand if the merge-base falls - # outside this depth. Most PRs merge-base within a few hundred commits. - fetchDepth: 200 - - task: Bash@3 name: setChangedPackages displayName: Detect changed packages @@ -34,4 +34,4 @@ steps: workingDirectory: '$(Pipeline.Workspace)/${{ parameters.buildDirectory }}' script: | set -eu -o pipefail - node scripts/detect_changed_packages.mjs + flub check changedPackages --targetBranch "${TARGET_BRANCH}" From 5cadca798d2cd3041ededcab9e72b3c717b5cae6 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 11 May 2026 16:40:50 -0700 Subject: [PATCH 19/19] refactor(build-cli): slim check changedPackages and reuse shared helpers Reorganizes the check changedPackages command and supporting library code so the command file contains only command logic. - Moves normalizeTargetBranch from the command into library/branches.ts. - Replaces the local resolveMergeBase helper with the shallow-aware getMergeBaseRemote from @fluid-tools/build-infrastructure. - Replaces the local package-dir helpers (buildPackageDirSet, anyChangedFileInPackages, packageJsonFilesFromGitOutput) with the new getPackageDirsAtRef and isFileInPackageDir primitives from @fluid-tools/build-infrastructure. - Switches the command's ##vso[...] emitters to the shared formatSetVariable and formatLogIssue helpers in library/azureDevops/pipelineCommands.ts. The command now exports only the public ChangedPackagesResult type and the oclif class. The fullRunPatterns array and findFullRunPatternMatch helper remain command-local since they encode CI-specific scoping policy. Also moves the targetBranch flag's TARGET_BRANCH default into the flag definition via the env property. This change depends on PR #27283 (build-infra helpers) and PR #27284 (ADO pipeline command helpers); rebase onto main after both merge to drop the duplicated content from this branch's diff. --- build-tools/packages/build-cli/docs/check.md | 30 ++++ .../src/commands/check/changedPackages.ts | 150 ++++++------------ .../src/commands/check/latestVersions.ts | 13 +- .../src/commands/generate/buildVersion.ts | 7 +- .../commands/vnext/check/latestVersions.ts | 13 +- .../library/azureDevops/pipelineCommands.ts | 50 ++++++ .../build-cli/src/library/branches.ts | 11 ++ .../commands/check/changedPackages.test.ts | 125 +-------------- .../vnext/check/latestVersions.test.ts | 6 +- .../api-report/build-infrastructure.api.md | 11 +- .../packages/build-infrastructure/src/git.ts | 80 +++++++++- .../build-infrastructure/src/index.ts | 3 + .../build-infrastructure/src/test/git.test.ts | 40 ++++- 13 files changed, 300 insertions(+), 239 deletions(-) create mode 100644 build-tools/packages/build-cli/src/library/azureDevops/pipelineCommands.ts diff --git a/build-tools/packages/build-cli/docs/check.md b/build-tools/packages/build-cli/docs/check.md index 3de84b9d2e70..a495e27b86b6 100644 --- a/build-tools/packages/build-cli/docs/check.md +++ b/build-tools/packages/build-cli/docs/check.md @@ -4,6 +4,7 @@ Check commands are used to verify repo state, apply policy, etc. * [`flub check buildVersion`](#flub-check-buildversion) +* [`flub check changedPackages`](#flub-check-changedpackages) * [`flub check changeset`](#flub-check-changeset) * [`flub check latestVersions VERSION PACKAGE_OR_RELEASE_GROUP`](#flub-check-latestversions-version-package_or_release_group) * [`flub check layers`](#flub-check-layers) @@ -66,6 +67,35 @@ DESCRIPTION _See code: [src/commands/check/buildVersion.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/check/buildVersion.ts)_ +## `flub check changedPackages` + +Computes Azure DevOps output variables used by pipelines to conditionally skip tests. + +``` +USAGE + $ flub check changedPackages [--json] [-v | --quiet] [--targetBranch ] [--searchPath ] + +FLAGS + --searchPath= Path used to locate the build project. Defaults to the current working directory. + --targetBranch= [env: TARGET_BRANCH] Target branch to compare against. Defaults to the TARGET_BRANCH + environment variable. + +LOGGING FLAGS + -v, --verbose Enable verbose logging. + --quiet Disable all logging. + +GLOBAL FLAGS + --json Format output as json. + +DESCRIPTION + Computes Azure DevOps output variables used by pipelines to conditionally skip tests. + + Compares the current PR branch to the merge base with a target branch, then emits 'shouldRunTests' and + 'scopedPnpmFilter' as Azure DevOps output variables. Unexpected errors conservatively fall back to a full test run. +``` + +_See code: [src/commands/check/changedPackages.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/check/changedPackages.ts)_ + ## `flub check changeset` Checks if a changeset was added when compared against a branch. This is used in CI to enforce that changesets are present for a PR. diff --git a/build-tools/packages/build-cli/src/commands/check/changedPackages.ts b/build-tools/packages/build-cli/src/commands/check/changedPackages.ts index 3a245e9d670e..aec244c0a0d6 100644 --- a/build-tools/packages/build-cli/src/commands/check/changedPackages.ts +++ b/build-tools/packages/build-cli/src/commands/check/changedPackages.ts @@ -4,12 +4,21 @@ */ import path from "node:path"; -import { dirname } from "node:path/posix"; -import { getChangedSinceRef, getRemote } from "@fluid-tools/build-infrastructure"; +import { + getChangedSinceRef, + getMergeBaseRemote, + getPackageDirsAtRef, + getRemote, + isFileInPackageDir, +} from "@fluid-tools/build-infrastructure"; import { Flags } from "@oclif/core"; -import type { SimpleGit } from "simple-git"; +import { + formatLogIssue, + formatSetVariable, +} from "../../library/azureDevops/pipelineCommands.js"; +import { normalizeTargetBranch } from "../../library/branches.js"; import { BaseCommandWithBuildProject } from "../../library/commands/base.js"; /** @@ -17,7 +26,7 @@ import { BaseCommandWithBuildProject } from "../../library/commands/base.js"; * every package's tests. Keep this conservative since these files can affect * dependency resolution, build behavior, or pipeline behavior across packages. */ -export const fullRunPatterns: readonly RegExp[] = [ +const fullRunPatterns: readonly RegExp[] = [ /^package\.json$/, /^pnpm-lock\.yaml$/, /^pnpm-workspace\.yaml$/, @@ -33,24 +42,37 @@ export const fullRunPatterns: readonly RegExp[] = [ /^\.changeset\/config\.json$/, ]; +/** + * Result of computing which packages have changed since the target branch. + * + * The same shape is returned both for full-run fallbacks (e.g. unexpected errors or trigger-pattern + * matches) and for the normal scoped-filter case. Consumers can inspect {@link forcedFullRunPattern} + * to disambiguate. + */ export interface ChangedPackagesResult { + /** Whether any tests should run at all. `false` only when no changed file maps to a workspace package. */ shouldRunTests: boolean; + /** The computed `pnpm --filter` expression, or an empty string for full / no-op runs. */ scopedPnpmFilter: string; + /** The (normalized) target branch the comparison was performed against. */ targetBranch: string; + /** The merge-base commit between HEAD and the target branch, when it could be determined. */ mergeBase?: string; + /** The list of files that changed since the merge base. Empty on the error fallback path. */ changedFiles: string[]; + /** The source of the first {@link fullRunPatterns} entry that matched a changed file, if any. */ forcedFullRunPattern?: string; + /** Number of workspace packages reported as changed by `getChangedSinceRef`. */ changedPackageCount: number; } -export function normalizeTargetBranch(branch: string): string { - const prefix = "refs/heads/"; - return branch.startsWith(prefix) ? branch.slice(prefix.length) : branch; -} - -export function checkFullRunPatterns( +/** + * Returns the first pattern in `patterns` that matches any path in `files`, or `undefined` if + * none match. + */ +function findFullRunPatternMatch( files: readonly string[], - patterns: readonly RegExp[] = fullRunPatterns, + patterns: readonly RegExp[], ): RegExp | undefined { for (const pattern of patterns) { if (files.some((file) => pattern.test(file))) { @@ -60,79 +82,14 @@ export function checkFullRunPatterns( return undefined; } -export function buildPackageDirSet( - mergeBase: string, - listHistoricalPackages: (ref: string) => readonly string[], - listCurrentPackages: () => readonly string[], -): Set { - const dirs = new Set(); - for (const file of listHistoricalPackages(mergeBase)) { - dirs.add(dirname(file)); - } - for (const file of listCurrentPackages()) { - dirs.add(dirname(file)); - } - return dirs; -} - -export function anyChangedFileInPackages( - changedFiles: readonly string[], - packageDirs: ReadonlySet, -): boolean { - for (const file of changedFiles) { - if (file === "") { - continue; - } - - let dir = dirname(file); - while (dir !== "." && dir !== "/") { - if (packageDirs.has(dir)) { - return true; - } - dir = dirname(dir); - } - } - return false; -} - -const packageJsonPattern = /(^|\/)package\.json$/; - -function packageJsonFilesFromGitOutput(output: string): string[] { - return output.split("\n").filter((file) => packageJsonPattern.test(file)); -} - -async function resolveMergeBase( - git: Readonly, - remote: string, - targetBranch: string, - log: (message: string) => void, -): Promise { - try { - return ( - await git.raw("merge-base", "HEAD", `refs/remotes/${remote}/${targetBranch}`) - ).trim(); - } catch { - const isShallow = (await git.raw("rev-parse", "--is-shallow-repository")).trim(); - if (isShallow !== "true") { - throw new Error(`No merge-base with ${remote}/${targetBranch}`); - } - - log("Merge-base not found in shallow clone; deepening and retrying."); - await git.fetch(["--deepen", "1000", remote, targetBranch]); - return ( - await git.raw("merge-base", "HEAD", `refs/remotes/${remote}/${targetBranch}`) - ).trim(); - } -} - export default class CheckChangedPackagesCommand extends BaseCommandWithBuildProject< typeof CheckChangedPackagesCommand > { static readonly summary = - "Computes Azure DevOps output variables for changed-package-scoped test runs."; + "Computes Azure DevOps output variables used by pipelines to conditionally skip tests."; static readonly description = - "Compares the current PR branch to the merge base with a target branch, then emits shouldRunTests and scopedPnpmFilter output variables. Unexpected errors conservatively fall back to a full test run."; + "Compares the current PR branch to the merge base with a target branch, then emits 'shouldRunTests' and 'scopedPnpmFilter' as Azure DevOps output variables. Unexpected errors conservatively fall back to a full test run."; static readonly enableJsonFlag = true; @@ -140,6 +97,7 @@ export default class CheckChangedPackagesCommand extends BaseCommandWithBuildPro targetBranch: Flags.string({ description: "Target branch to compare against. Defaults to the TARGET_BRANCH environment variable.", + env: "TARGET_BRANCH", }), searchPath: Flags.directory({ description: @@ -174,8 +132,12 @@ export default class CheckChangedPackagesCommand extends BaseCommandWithBuildPro } await git.fetch([remote, targetBranch]); - const mergeBase = await resolveMergeBase(git, remote, targetBranch, (message) => - this.info(message), + const mergeBase = await getMergeBaseRemote( + git, + targetBranch, + remote, + "HEAD", + (message) => this.info(message), ); this.info(`Merge base: ${mergeBase}`); @@ -189,7 +151,7 @@ export default class CheckChangedPackagesCommand extends BaseCommandWithBuildPro this.info(`... and ${changedFiles.length - 30} more`); } - const match = checkFullRunPatterns(changedFiles); + const match = findFullRunPatternMatch(changedFiles, fullRunPatterns); if (match !== undefined) { this.info(`Match for full-run pattern '${match.source}' - forcing full test run.`); this.emitVsoOutputs(true, ""); @@ -204,19 +166,13 @@ export default class CheckChangedPackagesCommand extends BaseCommandWithBuildPro }; } - const historicalPackageJsonFiles = packageJsonFilesFromGitOutput( - await git.raw("ls-tree", "-r", "--name-only", mergeBase), - ); - const currentPackageJsonFiles = packageJsonFilesFromGitOutput( - await git.raw("ls-files", "--", "package.json", "*/package.json"), - ); - const packageDirs = buildPackageDirSet( - mergeBase, - () => historicalPackageJsonFiles, - () => currentPackageJsonFiles, - ); + // Union of package directories at the merge-base tree and the current working tree so + // that packages added, removed, or moved between the two refs are all considered. + const historicalDirs = await getPackageDirsAtRef(git, mergeBase); + const currentDirs = await getPackageDirsAtRef(git); + const packageDirs = new Set([...historicalDirs, ...currentDirs]); - if (!anyChangedFileInPackages(changedFiles, packageDirs)) { + if (!changedFiles.some((file) => isFileInPackageDir(file, packageDirs))) { this.logWarning( `No changed files mapped to a workspace package - skipping all test execution. Files considered (${changedFiles.length}):`, ); @@ -261,15 +217,13 @@ export default class CheckChangedPackagesCommand extends BaseCommandWithBuildPro const flag = shouldRunTests ? "true" : "false"; this.log(`shouldRunTests=${flag}`); this.log(`scopedPnpmFilter=${scopedPnpmFilter}`); - this.log(`##vso[task.setvariable variable=shouldRunTests;isOutput=true]${flag}`); - this.log( - `##vso[task.setvariable variable=scopedPnpmFilter;isOutput=true]${scopedPnpmFilter}`, - ); + this.log(formatSetVariable("shouldRunTests", flag, { isOutput: true })); + this.log(formatSetVariable("scopedPnpmFilter", scopedPnpmFilter, { isOutput: true })); } private logWarning(message: string): void { if (!this.jsonEnabled()) { - this.log(`##vso[task.logissue type=warning]${message}`); + this.log(formatLogIssue("warning", message)); } } diff --git a/build-tools/packages/build-cli/src/commands/check/latestVersions.ts b/build-tools/packages/build-cli/src/commands/check/latestVersions.ts index 6d5af6db80c5..11ac071de8d2 100644 --- a/build-tools/packages/build-cli/src/commands/check/latestVersions.ts +++ b/build-tools/packages/build-cli/src/commands/check/latestVersions.ts @@ -4,6 +4,7 @@ */ import { findPackageOrReleaseGroup, packageOrReleaseGroupArg, semverArg } from "../../args.js"; +import { formatSetVariable } from "../../library/azureDevops/pipelineCommands.js"; import { BaseCommand } from "../../library/commands/base.js"; import { isLatestInMajor } from "../../library/latestVersions.js"; @@ -52,9 +53,9 @@ export default class LatestVersionsCommand extends BaseCommand { - it("normalizeTargetBranch strips refs/heads prefix", () => { +describe("normalizeTargetBranch", () => { + it("strips refs/heads prefix", () => { expect(normalizeTargetBranch("refs/heads/main")).to.equal("main"); }); - it("normalizeTargetBranch passes plain branch names through", () => { + it("passes plain branch names through", () => { expect(normalizeTargetBranch("next")).to.equal("next"); }); - it("normalizeTargetBranch preserves slashes after the prefix", () => { + it("preserves slashes after the prefix", () => { expect(normalizeTargetBranch("refs/heads/release/2.x")).to.equal("release/2.x"); }); - it("normalizeTargetBranch returns empty string for empty input", () => { + it("returns empty string for empty input", () => { expect(normalizeTargetBranch("")).to.equal(""); }); - - for (const [file, expectedSource] of [ - ["pnpm-lock.yaml", "^pnpm-lock\\.yaml$"], - [".pnpmfile.cjs", "^\\.pnpmfile\\.cjs$"], - [".npmrc", "^\\.npmrc$"], - [".nvmrc", "^\\.nvmrc$"], - ["package.json", "^package\\.json$"], - ] as const) { - it(`checkFullRunPatterns matches ${file}`, () => { - const match = checkFullRunPatterns([file]); - expect(match).to.not.equal(undefined); - expect(match?.source).to.equal(expectedSource); - }); - } - - it("checkFullRunPatterns matches tools prefix", () => { - expect(checkFullRunPatterns(["tools/pipelines/build-client.yml"])).to.not.equal(undefined); - }); - - it("checkFullRunPatterns does not match nested package.json", () => { - expect(checkFullRunPatterns(["packages/foo/package.json"])).to.equal(undefined); - }); - - it("checkFullRunPatterns matches root tsconfig only", () => { - expect(checkFullRunPatterns(["tsconfig.base.json"])).to.not.equal(undefined); - expect(checkFullRunPatterns(["packages/foo/tsconfig.json"])).to.equal(undefined); - }); - - it("checkFullRunPatterns returns undefined when nothing matches", () => { - expect(checkFullRunPatterns(["packages/foo/src/x.ts"])).to.equal(undefined); - }); - - it("checkFullRunPatterns returns the first pattern hit when several qualify", () => { - const match = checkFullRunPatterns(["pnpm-lock.yaml", "biome.jsonc"]); - expect(match).to.not.equal(undefined); - expect(match?.source).to.equal("^pnpm-lock\\.yaml$"); - }); - - it("buildPackageDirSet unions historical and current packages", () => { - const dirs = buildPackageDirSet( - "sha", - () => ["packages/old/package.json", "packages/shared/package.json"], - () => ["packages/shared/package.json", "packages/new/package.json"], - ); - expect([...dirs].sort()).to.deep.equal([ - "packages/new", - "packages/old", - "packages/shared", - ]); - }); - - it("buildPackageDirSet maps a root-level package.json to dot", () => { - const dirs = buildPackageDirSet( - "sha", - () => ["package.json"], - () => [], - ); - expect([...dirs]).to.deep.equal(["."]); - }); - - it("buildPackageDirSet tolerates either package list being empty", () => { - expect( - buildPackageDirSet( - "sha", - () => [], - () => [], - ).size, - ).to.equal(0); - expect( - buildPackageDirSet( - "sha", - () => ["packages/a/package.json"], - () => [], - ).size, - ).to.equal(1); - }); - - const packageDirs = new Set(["packages/alive"]); - - it("anyChangedFileInPackages detects file inside known package dir", () => { - expect(anyChangedFileInPackages(["packages/alive/src/x.ts"], packageDirs)).to.equal(true); - }); - - it("anyChangedFileInPackages returns false for root-only changes", () => { - expect(anyChangedFileInPackages(["README.md"], packageDirs)).to.equal(false); - }); - - it("anyChangedFileInPackages returns false for unrelated sibling directory", () => { - expect(anyChangedFileInPackages(["packages/other/src.ts"], packageDirs)).to.equal(false); - }); - - it("anyChangedFileInPackages ignores empty file entries", () => { - expect(anyChangedFileInPackages(["", "packages/alive/src.ts"], packageDirs)).to.equal( - true, - ); - }); - - it("anyChangedFileInPackages walks up from nested paths to find ancestor", () => { - expect( - anyChangedFileInPackages(["packages/alive/src/deeply/nested/x.ts"], packageDirs), - ).to.equal(true); - }); - - it("anyChangedFileInPackages does not treat root pseudo-dir as a per-package hit", () => { - expect( - anyChangedFileInPackages(["some-root-file.md"], new Set([".", "packages/alive"])), - ).to.equal(false); - }); }); describe("flub check changedPackages", () => { diff --git a/build-tools/packages/build-cli/src/test/commands/vnext/check/latestVersions.test.ts b/build-tools/packages/build-cli/src/test/commands/vnext/check/latestVersions.test.ts index f5efd9d2123c..650f086b03d5 100644 --- a/build-tools/packages/build-cli/src/test/commands/vnext/check/latestVersions.test.ts +++ b/build-tools/packages/build-cli/src/test/commands/vnext/check/latestVersions.test.ts @@ -63,7 +63,7 @@ describe("vnext:check:latestVersions", () => { stdoutLineEquals( stdout, 1, - "##vso[task.setvariable variable=shouldDeploy;isoutput=true]true", + "##vso[task.setvariable variable=shouldDeploy;isOutput=true]true", ); }); @@ -96,7 +96,7 @@ describe("vnext:check:latestVersions", () => { stdoutLineEquals( stdout, 1, - "##vso[task.setvariable variable=shouldDeploy;isoutput=true]false", + "##vso[task.setvariable variable=shouldDeploy;isOutput=true]false", ); }); @@ -129,7 +129,7 @@ describe("vnext:check:latestVersions", () => { stdoutLineEquals( stdout, 1, - "##vso[task.setvariable variable=shouldDeploy;isoutput=true]false", + "##vso[task.setvariable variable=shouldDeploy;isOutput=true]false", ); }); }); diff --git a/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md b/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md index c6274fdc3613..2eeca932d1db 100644 --- a/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md +++ b/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md @@ -67,7 +67,10 @@ export function getChangedSinceRef

(buildProject: IBuildProje export function getFiles(git: SimpleGit, directory: string): Promise; // @public -export function getMergeBaseRemote(git: SimpleGit, branch: string, remote?: string, localRef?: string): Promise; +export function getMergeBaseRemote(git: SimpleGit, branch: string, remote?: string, localRef?: string, onDeepen?: (message: string) => void): Promise; + +// @public +export function getPackageDirsAtRef(git: SimpleGit, ref?: string): Promise>; // @public export function getRemote(git: SimpleGit, partialUrl: string | undefined): Promise; @@ -148,6 +151,9 @@ export interface IReleaseGroup extends Reloadable { readonly workspace: IWorkspace; } +// @public +export function isFileInPackageDir(file: string, packageDirs: ReadonlySet): boolean; + // @public export function isIPackage(pkg: any): pkg is IPackage; @@ -166,6 +172,9 @@ export interface IWorkspace extends Installable, Reloadable { toString(): string; } +// @public +export function listPackageJsonPaths(git: SimpleGit, ref?: string): Promise; + // @public export function loadBuildProject

(searchPath: string, upstreamRemotePartialUrl?: string): IBuildProject

; diff --git a/build-tools/packages/build-infrastructure/src/git.ts b/build-tools/packages/build-infrastructure/src/git.ts index 4cc8faa33dcb..ab56552a23de 100644 --- a/build-tools/packages/build-infrastructure/src/git.ts +++ b/build-tools/packages/build-infrastructure/src/git.ts @@ -59,9 +59,13 @@ export function findGitRootSync(cwd = process.cwd()): string { /** * Get the merge base between the current HEAD and a remote branch. * + * If the repository is a shallow clone and no merge base is found, the clone will be deepened + * and the merge base computation retried once. + * * @param branch - The branch to compare against. * @param remote - The remote to compare against. If this is undefined, then the local branch is compared with. * @param localRef - The local ref to compare against. Defaults to HEAD. + * @param onDeepen - Optional callback invoked with a status message if a shallow-clone deepen is performed. * @returns The ref of the merge base between the current HEAD and the remote branch. */ export async function getMergeBaseRemote( @@ -69,6 +73,7 @@ export async function getMergeBaseRemote( branch: string, remote?: string, localRef = "HEAD", + onDeepen?: (message: string) => void, ): Promise { if (remote !== undefined) { // make sure we have the latest remote refs @@ -76,8 +81,22 @@ export async function getMergeBaseRemote( } const compareRef = remote === undefined ? branch : `refs/remotes/${remote}/${branch}`; - const base = await git.raw("merge-base", compareRef, localRef); - return base; + try { + const base = await git.raw("merge-base", compareRef, localRef); + return base.trim(); + } catch (error) { + const isShallow = (await git.raw("rev-parse", "--is-shallow-repository")).trim(); + if (isShallow !== "true" || remote === undefined) { + throw error; + } + + onDeepen?.( + `Merge-base with ${compareRef} not found in shallow clone; deepening and retrying.`, + ); + await git.fetch(["--deepen", "1000", remote, branch]); + const base = await git.raw("merge-base", compareRef, localRef); + return base.trim(); + } } /** @@ -121,6 +140,63 @@ function filePathsToDirectories(files: string[]): string[] { return [...dirs]; } +/** + * Matches paths that end in `package.json` (either at the root or under a directory). + */ +const packageJsonPathPattern = /(^|\/)package\.json$/; + +/** + * Lists all `package.json` file paths tracked at the given ref, or in the current working tree + * when no ref is provided. Paths are repo-relative and use POSIX separators (as returned by git). + * + * @param git - The git instance. + * @param ref - Optional ref. When provided, uses `git ls-tree -r --name-only ` to enumerate + * tracked files at that historical snapshot. When omitted, uses `git ls-files` against the + * current working tree. + */ +export async function listPackageJsonPaths(git: SimpleGit, ref?: string): Promise { + const raw = + ref === undefined + ? await git.raw("ls-files", "--", "package.json", "*/package.json") + : await git.raw("ls-tree", "-r", "--name-only", ref); + return raw.split("\n").filter((file) => packageJsonPathPattern.test(file)); +} + +/** + * Returns the set of repo-relative directories that contain a `package.json` at the given ref + * (or in the current working tree when `ref` is omitted). + * + * Useful for attributing changed files to packages when the set of packages may have changed + * between two refs (e.g. packages added, removed, or moved between a merge base and HEAD). + */ +export async function getPackageDirsAtRef(git: SimpleGit, ref?: string): Promise> { + const files = await listPackageJsonPaths(git, ref); + return new Set(files.map((file) => path.posix.dirname(file))); +} + +/** + * Returns `true` if `file` lives inside any directory in `packageDirs` (or any nested + * subdirectory of one). + * + * Walks `file`'s POSIX-style ancestor directories upward toward the repo root, returning `true` + * on the first hit. Empty strings return `false`. The repo-root pseudo-directory (`"."`) is not + * walked into, so a root-level file does not match `packageDirs` containing `"."`. + */ +export function isFileInPackageDir(file: string, packageDirs: ReadonlySet): boolean { + if (file === "") { + return false; + } + + let dir = path.posix.dirname(file); + while (dir !== "." && dir !== "/") { + if (packageDirs.has(dir)) { + return true; + } + dir = path.posix.dirname(dir); + } + return false; +} + /** * Gets the changed files, directories, release groups, and packages since the given ref. * diff --git a/build-tools/packages/build-infrastructure/src/index.ts b/build-tools/packages/build-infrastructure/src/index.ts index 62046f8e801a..456a5220c815 100644 --- a/build-tools/packages/build-infrastructure/src/index.ts +++ b/build-tools/packages/build-infrastructure/src/index.ts @@ -34,7 +34,10 @@ export { getChangedSinceRef, getFiles, getMergeBaseRemote, + getPackageDirsAtRef, getRemote, + isFileInPackageDir, + listPackageJsonPaths, } from "./git.js"; export { PackageBase } from "./package.js"; export { updatePackageJsonFile, updatePackageJsonFileAsync } from "./packageJsonUtils.js"; diff --git a/build-tools/packages/build-infrastructure/src/test/git.test.ts b/build-tools/packages/build-infrastructure/src/test/git.test.ts index 3b3dd6c31673..94894e3b9bfb 100644 --- a/build-tools/packages/build-infrastructure/src/test/git.test.ts +++ b/build-tools/packages/build-infrastructure/src/test/git.test.ts @@ -15,7 +15,13 @@ import { CleanOptions, simpleGit } from "simple-git"; import { loadBuildProject } from "../buildProject.js"; import { NotInGitRepository } from "../errors.js"; -import { findGitRootSync, getChangedSinceRef, getFiles, getRemote } from "../git.js"; +import { + findGitRootSync, + getChangedSinceRef, + getFiles, + getRemote, + isFileInPackageDir, +} from "../git.js"; import type { PackageJson } from "../types.js"; import { packageRootPath, testRepoRoot } from "./init.js"; @@ -161,3 +167,35 @@ describe("getFiles", () => { ); }); }); + +describe("isFileInPackageDir", () => { + const packageDirs = new Set(["packages/alive"]); + + it("detects file inside known package dir", () => { + expect(isFileInPackageDir("packages/alive/src/x.ts", packageDirs)).to.equal(true); + }); + + it("walks up from deeply nested paths", () => { + expect(isFileInPackageDir("packages/alive/src/deep/nested/x.ts", packageDirs)).to.equal( + true, + ); + }); + + it("returns false for root-only changes", () => { + expect(isFileInPackageDir("README.md", packageDirs)).to.equal(false); + }); + + it("returns false for unrelated sibling directory", () => { + expect(isFileInPackageDir("packages/other/src.ts", packageDirs)).to.equal(false); + }); + + it("returns false for empty input", () => { + expect(isFileInPackageDir("", packageDirs)).to.equal(false); + }); + + it("does not treat root pseudo-dir as a per-package hit", () => { + expect(isFileInPackageDir("some-root-file.md", new Set([".", "packages/alive"]))).to.equal( + false, + ); + }); +});