fix(ci): PR test selector could silently mis-attribute changed paths containing tabs or newlines - #19790
fix(ci): PR test selector could silently mis-attribute changed paths containing tabs or newlines#19790Ankit Jain (radical) wants to merge 6 commits into
Conversation
… path PR microsoft#19486 renamed eng/scripts/aspire-skills-bundle.common.ps1 to aspire-skills-bundles.common.ps1 (singular -> plural) and, in the same commit, updated test-trigger-map.yml's path_rules to reference the new plural name. CI run 33197019835 (head 1e46e48 3e963d2) selected ALL PR test projects + jobs, citing the old singular path as an unmapped leftover: selects ALL PR test projects + jobs -- run-all fallback: 'eng/scripts/aspire-skills-bundle.common.ps1' is neither Layer-1-owned nor matched by a Layer 2 rule Root cause: Layer 2's changed-file diff used `git diff --name-only --no-renames`, which decomposes a rename into a plain delete (old path) + add (new path) and discards git's own rename pairing. Once the map's rule was repointed at the new name in the same commit, the decomposed old path matched nothing, so TestSelector.Select treated it as a genuine unmapped leftover and forced the run-all fallback -- even though the new path was already correctly matched. Fix: Layer 2 now diffs with `git diff --name-status -M`, the same format Layer 1 already uses, and threads the set of rename old-paths into TestSelector.Select. An old path is still fully glob-matched like any other changed path first (a cross-directory rename must still hit the old directory's rule), but if nothing matches it, it is exempted from forcing ALL instead of being treated as an unmapped leftover. Exemption is additive-only: it can never remove a target a rule matched, only skip forcing ALL when nothing did. Scope, verified empirically against PR microsoft#19486's real diff: the exemption only covers renames git's own `-M` detection recognizes (default 50% content-similarity threshold). Of the PR's 3 renamed scripts, 2 are git-detected renames (R95, R54) and are now correctly exempted; the 3rd (verify-aspire-skills-bundle.ps1) was rewritten so heavily in the same commit (~30% similarity) that git reports a plain delete+add, not a rename -- it is correctly NOT exempted, and the PR's selection still (deliberately) falls back to ALL for that one file. Lowering git's similarity threshold to also catch that case was considered and rejected: it risks pairing an unrelated delete+add as a false rename, which could wrongly exempt a genuinely unmatched, unrelated deletion from the fallback -- reintroducing the same class of under-selection this fix is meant to avoid. Also investigated and confirmed NOT to be a risk, before or after this change: the old path incidentally matching some unrelated Layer 2 rule does not suppress or replace whatever the new path's own match contributes. Layer 2 matching is purely additive per changed path, so an incidental old-path match adds its (irrelevant) targets alongside the new path's real ones rather than instead of them -- see RenameWhoseOldPathIncidentallyMatchesAnUnrelatedRuleStillAddsBothTargets. Tests added under tests/Infrastructure.Tests/TestTriggerMap/: - SelectTestsAcceptanceTests: RenameOldPathUnmatchedDoesNotForceRunAll, RenameOldPathStillMatchesOwnRuleAdditively - SelectTestsCliTests: InPlaceRenameWithoutOwnMapEntryDoesNotForceRunAll, RenameWhoseOldPathIncidentallyMatchesAnUnrelatedRuleStillAddsBothTargets, RenameBelowGitSimilarityThresholdIsNotExemptedAndStillForcesRunAll - TestTriggerMapTests: SameCommitRenameWithMapEntryMovedToNewPathSelects ExactTargetsWithoutEscalating (golden scenario against the real production map and Aspire.slnx) docs/ci/test-trigger-map.md and test-trigger-selector-design.md updated to describe the exemption and its git-similarity-threshold boundary. Fixes the wasteful ALL-selection observed in microsoft#19486 (CI run 33197019835) without weakening the run-all fallback's safety for genuine unmapped changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7380ea02-bd9c-4638-a860-33cf32d59d46
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19790Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19790" |
There was a problem hiding this comment.
Pull request overview
Updates selective CI to distinguish Git-detected rename sources and avoid unnecessary full-matrix fallbacks.
Changes:
- Parses Layer 2 changes using
git diff --name-status -M. - Exempts unmatched rename-old paths from fallback selection.
- Adds regression tests and documents rename-threshold behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
tools/SelectTests/TestSelector.cs |
Adds rename-old-path exemption logic. |
tools/SelectTests/Program.cs |
Parses Git rename metadata. |
tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs |
Adds production-map regression coverage. |
tests/Infrastructure.Tests/TestTriggerMap/SelectTestsCliTests.cs |
Adds Git integration scenarios. |
tests/Infrastructure.Tests/TestTriggerMap/SelectTestsAcceptanceTests.cs |
Tests selector exemption behavior. |
docs/ci/test-trigger-selector-design.md |
Documents selector design changes. |
docs/ci/test-trigger-map.md |
Documents rename fallback semantics. |
Resolves review findings from Claude Opus 5 (high) and GPT-5.6 Sol (high) on the selector rename-handling fix. Quoted-path parsing bug (GPT F2): `ResolveChangedFiles` parsed `git diff --name-status -M` output by splitting on literal tabs and newlines while only disabling non-ASCII quoting via `-c core.quotePath=false`. Git's default quoting still C-style-escapes tabs, newlines, quotes, and backslashes in paths, which would corrupt attribution for any changed path containing those bytes. Switched to `git diff --name-status -M -z`, which is NUL-terminated and never quotes/escapes any byte (NUL cannot appear in a valid path), and rewrote the parser to walk NUL-delimited tokens instead of splitting on tabs/newlines. Added a regression test using a same-commit rename with a literal tab byte in the path. Stale comments and test doc clarifications (Opus F2/F4, GPT F3, Opus F3/F5): - Corrected a comment in `RenameOutOfMappedPathStillSelectsItsTests` that still described the old `--no-renames` approach. - Added a clarifying note to `RenameBelowGitSimilarityThresholdIsNot- ExemptedAndStillForcesRunAll`'s docstring, cross-referencing the tests that actually fail on reversion of the exemption. - Added a docstring note on the golden `TestTriggerMapTests` case acknowledging its coupling to the live map is by design. - Wrapped a temp directory in try/finally with cleanup in `SameCommitRenameWithMapEntryMovedToNewPathSelectsExactTargets- WithoutEscalating`, matching the project's established convention. Residual risk (both reviewers, F1): git's `-M` rename detection is a content-similarity heuristic, not a semantic guarantee, so it can in rare cases mispair two unrelated files that happen to be highly similar. Raising the similarity threshold isn't viable — PR microsoft#19486's own motivating rename was only detected at R054. Documented this as an accepted, bounded limitation in both `TestSelector.cs` and `docs/ci/test-trigger-map.md` rather than attempting a fragile mitigation. Layer 1's `GraphAffectedProjects.cs` has an analogous quoted-path parsing gap, but it is untouched by this PR's diff and is left as a follow-up rather than expanded scope here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7380ea02-bd9c-4638-a860-33cf32d59d46
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
tools/SelectTests/TestSelector.cs:267
- This exemption applies to every detected rename, not only the stated case where the map rule moves with the file. For example, if an otherwise-unmapped loose CI input is renamed to a path covered by
ignore(or filtered out beforeSelect), the new path contributes no target and this branch suppresses the old path's fail-safe, so the selector can return no tests where the previous delete+add handling returnedALL. Preserve the fallback unless the corresponding new path is demonstrably accounted for by a moved/equivalent rule (which requires retaining the old/new pairing and validating the mapping), rather than exempting old paths solely because Git labeled them as renames.
if (renameOldPathSet.Contains(file))
{
continue;
}
…ing old path The rename-old-path exemption from the run-all fallback was keyed only by the old path: it trusted that "the rename's new path already carries whatever the map says about this content moving" without ever checking that the new path was actually part of the diff the selector evaluated. If the caller's prefilter dropped the new path entirely (e.g. a rename INTO a doc-only path matched by ci-skip-entirely-patterns.txt), nothing downstream ever evaluated whether that destination's content needed CI, yet the old path was still silently exempted from forcing ALL -- a genuine under-selection risk flagged by automated review on PR microsoft#19790. Fix: change the rename representation from a flat set of old paths to an old->new dictionary, and require the paired new path be present in the selector's (post-prefilter) changed-file set before exempting the old side. When the new path is present but itself matches nothing, it becomes its own unmatched leftover and correctly still forces ALL, so no extra check of what it resolved to is needed -- only the "destination silently dropped" case needed closing. Also fixes a second review comment: a synthetic test's comment incorrectly claimed "the real map ignores changes to itself" -- the real eng/github-ci/test-trigger-map.yml has no such entry and instead routes self-changes to test:Infrastructure.Tests via a path_rule. Tests: - tests/Infrastructure.Tests/TestTriggerMap/SelectTestsAcceptanceTests.cs: reworked the two existing rename-exemption tests for the new dictionary-based API, and added RenameOldPathWithDestinationDroppedByPrefilterStillForcesRunAll (the regression test for the review-flagged gap) and RenameOldPathWithStillUnmatchedDestinationForcesRunAllViaDestination (the "self-corrects" case). - tests/Infrastructure.Tests/TestTriggerMap/SelectTestsCliTests.cs: added InPlaceRenameToPrefilteredDestinationStillForcesRunAll, a real-git-repo regression test driving the CLI end to end for the same scenario, and fixed the misleading "real map ignores itself" comment. - tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs: updated the golden real-map test and SelectWithRealMap helper to the dictionary-based API. Ran the full TestTriggerMap-scoped suite: 165/165 passed (162 previously + 3 new). Ran the full Infrastructure.Tests suite: 702/715 passed; the 13 failures are pre-existing and environment-caused (ExtensionChangelogFinalizedWorkflowTests and SplitTestProjectsTests), unrelated to this change and unaffected by it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7380ea02-bd9c-4638-a860-33cf32d59d46
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
tools/SelectTests/Program.cs:497
- This malformed-record branch also silently returns a partial diff, allowing changed files to disappear from selection. Abort so CI fails visibly rather than potentially under-selecting tests.
if (tokenIndex >= tokens.Length)
{
break; // truncated/malformed output; nothing left to parse
…a undetected rename Automated review on PR microsoft#19790 pointed out that the existing golden test only replayed 2 of the 3 files PR microsoft#19486 actually renamed, so it could pass while the PR's stated motivating scenario -- the literal PR microsoft#19486 diff -- still selects ALL today, for an undetected reason. That's true: `verify-aspire-skills-bundle.ps1` was rewritten heavily enough in the same commit that git's default -M50% similarity threshold reports it as a plain delete+add, not a rename, so this fix's exemption (keyed on git-detected renames) never applies to it. Reproduced directly against the real PR microsoft#19486 commits with the fixed selector (`--skip-layer1 --enforce`): the run still selects ALL, citing that file as the sole unattributed leftover. Add a companion golden test that replays PR microsoft#19486's complete three-path diff (not just the two git-detected renames) against the real production map, asserting the fallback still fires for the undetected file. This makes the existing, correct scope limitation explicit and falsifiable instead of only described in a comment. Also tightens the existing golden test's comment to point at the new test by name. No production code changes; this is test/documentation scope clarification only, following up on automated PR review feedback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7380ea02-bd9c-4638-a860-33cf32d59d46
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
tests/Infrastructure.Tests/TestTriggerMap/SelectTestsAcceptanceTests.cs:360
- This comment records within-branch review history rather than the enduring regression condition. Remove the current PR/review reference so the test remains understandable after merge.
// Regression test for PR #19790 review feedback: the exemption above must require the rename's
tests/Infrastructure.Tests/TestTriggerMap/SelectTestsCliTests.cs:1068
- This comment records within-branch review history rather than the enduring regression condition. Remove the current PR/review reference so the test remains understandable after merge.
// Regression test for PR #19790 review feedback: the rename-old-path exemption above must not fire
docs/ci/test-trigger-selector-design.md:308
- This design contract omits the implemented requirement that the paired destination survive the prefilter. It therefore describes a broader exemption than
TestSelectoractually applies and incorrectly states it cannot under-select despite the false-rename limitation documented intest-trigger-map.md. Document the destination-membership guard and avoid the absolute safety claim.
the prefilter in step 1, so they never reach this fallback. **Exception:** the
old path of a git-detected rename (see [Changed paths](#changed-paths)) that
matches nothing is not treated as an unmapped leftover — a same-commit rename
that also updates the map's own rule to the new name (as
[microsoft/aspire#19486](https://github.com/microsoft/aspire/pull/19486) did)
tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs:515
- “this PR fixes” narrates the current branch rather than the selector behavior that the committed test documents. Reword it as a durable statement of scope.
// this PR fixes the in-place-rename-with-moved-map-entry mechanism, not git's own rename-similarity
// detection, and does not claim to change the fallback outcome for content-heavy rewrite+renames.
…afety gap An earlier commit on this branch exempted a rename's old path from the selector's run-all fallback when its new path was already present in the changed-file set. An audit found that exemption unsafe: a rename out of a directory-scoped glob (e.g. tests/Shared/Logging/**) can leave other consumers of that glob with no signal, and a rename whose new path is ignored or dropped by the prefilter satisfied the exemption's presence check without anything ever evaluating the destination. Both cases could silently under-select tests. Remove the exemption; renames are intentionally un-special-cased again, and an unmatched rename old path forces ALL like any other unaccounted-for change. Separately, while tracing this, Layer 1's git-diff parser (GraphAffectedProjects.GetChangedPathsFromGit) was found to still use git's default text-mode --name-status output, split on raw newline/tab bytes, while Layer 2 (Program.cs) already used a NUL-delimited -z parser. Git's text mode double-quotes and backslash-escapes any path containing a tab, newline, double quote, or backslash; -c core.quotePath=false only suppresses non-ASCII-byte escaping, not this class of path. A quoted/escaped path never matches Layer 1's evaluated-item index or directory-containment fallback, so it silently fails to attribute to its owning project -- an under-selection risk, not a fail-safe one. Layer 1 now issues the same `git diff --name-status -M -z` Layer 2 uses, and both layers parse it through one shared method (Selection.ParseNameStatusOutput) instead of two independently maintained parsers that had drifted. Net effect for renames: replaying PR microsoft#19486's exact diff still selects ALL today (the exemption that would have avoided that is gone). That is the accepted, safe tradeoff. The bug this commit actually fixes is the narrower NUL-safety gap above, found during the same investigation. Adds regression coverage under tests/Infrastructure.Tests/TestTriggerMap/ for: a tab-containing renamed path being correctly attributed instead of garbled (RenamedFileWithTabInPathIsAttributedCorrectlyNotGarbledByQuoting, CrossProjectRenameIntoTabContainingPathAttributesTheNewOwner, RenameIntoTabContainingPathIsAttributedByLayer1InsteadOfUnderSelecting); truncated -z input failing loudly instead of silently returning a partial set (ParseNameStatusOutputThrowsOnTruncatedRenameRecord, ParseNameStatusOutputThrowsOnTruncatedPlainRecord); and renames correctly forcing ALL with no special-casing (InPlaceRenameWithoutOwnMapEntryForcesRunAll, InPlaceRenameToPrefilteredDestinationForcesRunAll, RenameWhoseOldPathIncidentallyMatchesAnUnrelatedRuleStillAddsBothTargets, RenameOldPathUnmatchedForcesRunAllLikeAnyOtherLeftover, RenameWithBothUnmatchedPathsListsBothInUnmatchedFiles, RenameWhereBothPathsMatchTheirOwnRuleAddsTargetAdditively, SameCommitRenameWithMapEntryMovedToNewPathForcesRunAll -- the last a golden-scenario replay of PR microsoft#19486's real diff at head 1e46e48). docs/ci/test-trigger-map.md and docs/ci/test-trigger-selector-design.md are updated to describe the rename decision and the shared -z parser. Refs microsoft#19486 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7380ea02-bd9c-4638-a860-33cf32d59d46
Fix six issues flagged by automated review on the selector rename- handling PR (microsoft#19790): - ParseNameStatusOutput accepted a -z stream missing its final NUL terminator, silently parsing a truncated record as valid instead of failing loudly. Now throws when the stream is non-empty and does not end with a NUL. - Raw -z-parsed paths (which can contain literal \r/\n/backticks) were interpolated unescaped into the job summary and PR comment Markdown. Added EscapeForDisplay and applied it at every raw-path interpolation site. - GraphAffectedProjects.ResolveChangedPaths unconditionally rewrote '\' to '/' in every changed path, even though git never uses '\' as a path separator on any OS -- a literal backslash byte in a filename could be corrupted into a fake directory boundary and mis-attributed to an unrelated project. Removed the rewrite; Layer 1 now trusts git's raw '/'-separated paths exactly like Layer 2 already does. - Two stale XML-doc/comment blocks describing the old tab-delimited "R###\told\tnew" format instead of the current -z NUL-delimited "R###\0old\0new\0" format. - A comment claiming a temp trigger map was "left exactly as checked out on disk (still referencing the OLD names)" when the code actually rewrites it to the NEW names to simulate the map update landing alongside the rename. Added regression tests: - ParseNameStatusOutputThrowsOnMissingFinalTerminator - BackslashContainingRootFileIsNotAttributedToSimilarlyNamedProject (a repo-root file literally named `Other\data.txt` was previously corrupted to `Other/data.txt` and spuriously attributed to the "Other" project via directory containment) Both were verified to fail against the pre-fix code and pass with it. 169/169 tests pass in tests/Infrastructure.Tests/TestTriggerMap/. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7380ea02-bd9c-4638-a860-33cf32d59d46
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs:466
- This regression test depends on #19486 not having merged: that PR's head already contains only the plural filenames, so once it lands these assertions for the old names fail before the scenario runs. Make the fixture durable by applying the replacements first and asserting that the resulting map contains the new names (or use a self-contained map), so it works whether the checked-out production map is pre- or post-#19486; update the accompanying “checked out on disk today” wording as well.
var realMapText = File.ReadAllText(Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml"));
Assert.Contains(oldCommonPath, realMapText);
Assert.Contains(oldUpdatePath, realMapText);
Assert.Contains(oldVerifyPath, realMapText);
tools/SelectTests/Program.cs:1058
- The new display escaping still misses the fail-safe reason:
TestSelectorembeds the unmatched raw filename inEscalationReason, andWriteSelectionComment/WriteSummaryemit that value verbatim (lines 835 and 1290). An unmatched path containing a newline or Markdown/HTML markup can therefore still spoof both audit surfaces. Escape that reason at both Markdown sinks (including HTML-sensitive characters because it is prose, not a code span) and cover an unmatched newline-containing path.
// Escapes bytes a `-z`-parsed path (see ParseNameStatusOutput) can legally contain but that would
// otherwise corrupt the Markdown this method renders into: `-z` intentionally preserves a path's raw
// bytes -- including a literal newline, carriage return, or backtick -- that git's older text-mode
// format would have quoted away. Left raw, such a path could inject extra lines/headings into the
// step summary or PR comment, or break out of its enclosing `code span`. Escaping is display-only;
// callers still match/attribute the unescaped path everywhere else.
private static string EscapeForDisplay(string value)
| // summary explains HOW the change reached the test, not just THAT it did. | ||
| CauseKind.Layer1Graph => cause.Path is { Count: > 0 } path | ||
| ? $"graph closure: {string.Join(" → ", path)}" | ||
| ? $"graph closure: {string.Join(" → ", path.Select(EscapeForDisplay))}" |
What broke
The selective PR CI test-selector (
tools/SelectTests) has two layers that independently parsegit diffoutput: Layer 1 (GraphAffectedProjects.cs, MSBuild project graph) and Layer 2 (Program.cs, curated map). Layer 1 used git's default text-mode--name-status -Moutput; Layer 2 used a NUL-delimited-zparser. Git's text mode quote-escapes tabs/newlines/quotes/backslashes in a path, so a changed file containing one of those bytes never matched Layer 1's attribution index — the change silently failed to select its own project, the opposite of the selector's fail-safe design.While tracing this (from PR #19486, CI run 33197019835), a few adjacent gaps surfaced in the same code:
\→/in every changed path, even though git never uses\as a separator — a literal backslash in a filename could be corrupted into a fake directory boundary and mis-attributed to an unrelated project.\r/\n/backticks) were interpolated into the job summary and PR comment Markdown unescaped.-zstream (missing the final NUL) parsed as if it were valid instead of failing loudly.Root cause
Layer 1 had its own hand-rolled parser, independent from and less safe than Layer 2's. The two layers had quietly drifted.
The fix
git diff --name-status -M -zas Layer 2, and both parse it through one shared method,ParseNameStatusOutput(tools/SelectTests/Program.cs).\→/path rewrite; both layers now trust git's raw/-separated paths, matching Layer 2's existing behavior.EscapeForDisplayhelper and applied it everywhere a raw path is rendered into Markdown.ParseNameStatusOutputnow throws if a-zstream doesn't end with a NUL terminator.Why this approach
A separate fix was also attempted and rejected: exempting a rename's old path from the run-all fallback when its new path was already present in the changed-file set. An audit found that unsafe — a directory-scoped glob can have consumers beyond the file that moved, and a rename landing on an
ignored glob or dropped by the prefilter satisfies the exemption's presence check without anything evaluating the real destination. Both could silently under-select tests. The exemption was removed; renames remain un-special-cased, and an unmatched rename old path still forcesALL, same as a plain deletion.docs/ci/test-trigger-map.mddocuments this under "Rename handling."Call-out
Replaying PR #19486's exact diff still selects
ALL— that PR's original symptom isn't "fixed" here, and that's intentional: an unnecessary full run is safer than a silently skipped test. The actual bug fixed is the narrower NUL-safety/escaping issue above, found while tracing the rename investigation.Testing
New coverage under
tests/Infrastructure.Tests/TestTriggerMap/(169 tests passing) covers: tab/newline-containing renamed paths attributed correctly by both layers, truncated-zstreams failing loudly, Markdown escaping of raw paths, the backslash-corruption false-attribution case, and the rename-exemption-removed behavior — including a golden replay of PR #19486's real diff (basefd9bbf76b4/ head1e46e48122335ba5e970a5f78777a44fe3e963d2) confirming it still forcesALL.Fixes # (no tracked issue — found during investigation of #19486)