From 5e7f519690152350e9ca86f2c408c3056b194489 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 7 Sep 2026 23:00:15 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20add=20dotnet-nuget-update=20s?= =?UTF-8?q?kill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce complete NuGet dependency audit and update workflow with support for central package management and project-level versioning. Handles TFM-aware updates, stable/prerelease intent tracking, major version approval batching, and offline-testable scenarios. Includes bundled scripts for audit, comparison, and structured file edits. --- skills/dotnet-nuget-update/SKILL.md | 190 +++++++++++ skills/dotnet-nuget-update/evals/evals.json | 108 +++++++ .../flat-stable-only/Directory.Build.props | 5 + .../flat-stable-only/Directory.Packages.props | 9 + .../Directory.Build.props | 5 + .../Directory.Packages.props | 15 + .../multi-tfm-bands/Directory.Build.props | 5 + .../multi-tfm-bands/Directory.Packages.props | 13 + .../plain-project-refs/Directory.Build.props | 5 + .../plain-project-refs/src/App/App.csproj | 9 + .../Directory.Build.props | 5 + .../Directory.Packages.props | 11 + .../scripts/Apply-PackageUpdates.ps1 | 282 +++++++++++++++++ .../scripts/Compare-Version.ps1 | 31 ++ .../scripts/Get-DependencyAudit.ps1 | 269 ++++++++++++++++ .../scripts/Get-NuGetSources.ps1 | 94 ++++++ .../scripts/Get-PackageGraph.ps1 | 112 +++++++ .../scripts/Get-TargetFrameworks.ps1 | 148 +++++++++ .../scripts/Resolve-NuGetVersion.ps1 | 44 +++ .../dotnet-nuget-update/scripts/_common.ps1 | 297 ++++++++++++++++++ .../dotnet-nuget-update/scripts/run-tests.ps1 | 25 ++ .../scripts/test-apply-updates.ps1 | 64 ++++ .../scripts/test-dependency-audit.ps1 | 81 +++++ .../scripts/test-package-graph.ps1 | 54 ++++ .../scripts/test-project-package-refs.ps1 | 51 +++ .../scripts/test-tfm-band.ps1 | 79 +++++ .../scripts/test-version-comparison.ps1 | 60 ++++ .../scripts/validate-skill.ps1 | 87 +++++ 28 files changed, 2158 insertions(+) create mode 100644 skills/dotnet-nuget-update/SKILL.md create mode 100644 skills/dotnet-nuget-update/evals/evals.json create mode 100644 skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Build.props create mode 100644 skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Packages.props create mode 100644 skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Build.props create mode 100644 skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Packages.props create mode 100644 skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Build.props create mode 100644 skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Packages.props create mode 100644 skills/dotnet-nuget-update/evals/files/plain-project-refs/Directory.Build.props create mode 100644 skills/dotnet-nuget-update/evals/files/plain-project-refs/src/App/App.csproj create mode 100644 skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Build.props create mode 100644 skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Packages.props create mode 100644 skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/Compare-Version.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/Get-NuGetSources.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/_common.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/run-tests.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/test-apply-updates.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/test-dependency-audit.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/test-package-graph.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/test-project-package-refs.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/test-tfm-band.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/test-version-comparison.ps1 create mode 100644 skills/dotnet-nuget-update/scripts/validate-skill.ps1 diff --git a/skills/dotnet-nuget-update/SKILL.md b/skills/dotnet-nuget-update/SKILL.md new file mode 100644 index 0000000..e37f349 --- /dev/null +++ b/skills/dotnet-nuget-update/SKILL.md @@ -0,0 +1,190 @@ +--- +name: dotnet-nuget-update +description: > + Use when the user wants to update, audit, or check NuGet package dependencies + in a .NET repository. Handles both central package management + (Directory.Packages.props) and project-level PackageReference. Invoke as + "dotnet-nuget-update" for interactive normal mode (auto-applies patch/minor, + asks about majors) or "dotnet-nuget-update yolo" for silent patch/minor-only + mode. Use any time packages need updating, versions need auditing, or the + user asks about outdated dependencies. +--- + +# .NET NuGet Update + +Use this skill when a .NET repository needs a complete dependency audit or a controlled package update pass. + +## Start with the audit, not intuition + +Your first deterministic step is: + +```powershell +pwsh -NoProfile -File "/scripts/Get-DependencyAudit.ps1" -RepoRoot "" +``` + +Use `scripts/Get-DependencyAudit.ps1 -RepoRoot ` to enumerate every declaration before touching anything. The skill is complete only when every declared package version is accounted for. + +The bundled scripts do the mechanical work: + +- `scripts/Get-DependencyAudit.ps1` enumerates and classifies each declaration. +- `scripts/Get-PackageGraph.ps1` exposes the central-package condition graph. +- `scripts/Get-TargetFrameworks.ps1` exposes the repository TFM matrix. +- `scripts/Resolve-NuGetVersion.ps1` and `scripts/Compare-Version.ps1` investigate one package or version pair. +- `scripts/Apply-PackageUpdates.ps1` performs the minimal structural edit. +- `scripts/Get-NuGetSources.ps1` shows configured package feeds without exposing secrets. + +The agent orchestrates. The scripts own the deterministic enumeration, comparison, and file edits. + +## Two modes + +### Normal mode + +Normal mode is for an attended update run. + +1. Audit the whole graph first. +2. Auto-apply only `revision`, `patch`, `minor`, and same-major `prerelease` steps. +3. Do not interrupt the user for each package. +4. Batch all `major` candidates into one approval question after the full audit is complete. +5. Preserve any deliberately held pins and explain why they were held. + +### Yolo mode + +Yolo mode means no approval prompts, not broader authority. + +1. Audit the whole graph first. +2. Auto-apply only `revision`, `patch`, `minor`, and same-major `prerelease` steps. +3. Hold all `major` candidates. +4. Report the held majors explicitly at the end. + +Yolo never means “apply majors silently,” and it never means commit or push anything. + +## The complete-audit invariant + +The audit is not optional scaffolding. It is the work list. + +For every run, ensure the summary closes: + +- `declared` +- `current` +- `auto` +- `approval` +- `unresolved` + +The invariant is: + +```text +current + auto + approval + unresolved == declared +``` + +Do not report the repository as updated unless every declaration is in exactly one bucket. A package that appears under two different conditions is two declarations and must produce two audit rows. + +## TFM-band rule + +Conditional central package graphs are load-bearing. Never flatten them. + +If a declaration lives under a modern .NET TFM condition and its pinned major matches that band, keep resolution inside that band. + +Examples: + +- `$(TargetFramework.StartsWith('net9'))` + `Microsoft.Extensions.Logging` `9.0.0` → resolve within `9.x` +- `$(TargetFramework.StartsWith('net10'))` + `Microsoft.EntityFrameworkCore` `10.0.0` → resolve within `10.x` +- `$(TargetFramework.StartsWith('net9'))` + `Asp.Versioning.Http` `8.1.0` → no band restriction, because package major `8` does not match band `9` +- `$(TargetFramework.StartsWith('net10')) OR $(TargetFramework.StartsWith('net11'))` + `10.0.0` → resolve within `10.x`, because `10` is one of the declared bands and matches the pinned major + +The band rule is a compatibility safeguard, not a guess about package policy. + +## Stable versus prerelease intent + +Infer package intent from the pin unless the user asks for something else. + +- If the pinned version is stable, prefer stable candidates only. +- If the pinned version is prerelease, allow prerelease candidates for that package. +- If the caller explicitly requests prerelease review, use `-IncludePrerelease`. +- Same-major prerelease movement is an `auto` class, not an approval class. + +That means `1.0.0-rc.1` → `1.0.0-rc.2` is a `prerelease` bump and may be auto-applied, while `13.0.3` → `14.0.0` remains `approval`. + +## History first: comments and past decisions + +Before changing a held or surprising pin, inspect its context. + +1. Read the adjacent XML comment through the audit `note` field. +2. Treat comments as maintainer intent, not decoration. +3. Review file or repository history when the pin looks deliberate or compatibility-sensitive. +4. If an `auto` candidate has a note, pause and read it before applying the update. + +A version comparison can tell you what is newer. It cannot tell you why a repository deliberately stayed behind. + +## Structural editing only + +NuGet props files and project files are structured XML, so edit them structurally and minimally. + +Use `scripts/Apply-PackageUpdates.ps1` to update only the targeted declaration. Preserve: + +- conditions and item-group boundaries, +- comments and blank lines, +- package ordering, +- indentation, +- encoding, +- existing line endings. + +Do not rewrite the file wholesale. Change only the relevant `Version` attribute or project-level `` value. + +## Central package management and project-level references + +Handle both repository styles. + +### Central package management + +When `Directory.Packages.props` exists: + +- inspect it with `scripts/Get-PackageGraph.ps1`, +- audit it with `scripts/Get-DependencyAudit.ps1`, +- apply updates through `scripts/Apply-PackageUpdates.ps1`. + +### Project-level package references + +When the repository does not use central package management: + +- audit explicit project-level `PackageReference` versions, +- update only the affected `.csproj` files, +- preserve unrelated project content. + +Do not invent central package management for a repository that does not already use it. + +## Dirty working trees and conflicts + +Treat in-place dependency work as a surgical edit in a potentially dirty repository. + +- Do not overwrite a declaration whose current file value no longer matches the audited `from` version. +- If `scripts/Apply-PackageUpdates.ps1` reports `conflict`, stop and report it instead of guessing. +- Leave unrelated dirty files alone. +- Re-run the audit after applying updates when the repository state changed materially. + +A conflict is evidence that the file changed after the audit. Respect that evidence. + +## Validation + +Prefer the smallest deterministic validation that proves the update is safe. + +1. Discover target frameworks with `scripts/Get-TargetFrameworks.ps1 -RepoRoot `. +2. If source selection matters, inspect feeds with `scripts/Get-NuGetSources.ps1`. +3. Run the narrowest restore, build, or test command that covers the affected projects and target frameworks. +4. If the repository already has a targeted test or validation command, use it rather than inventing one. + +If no code changed because every declaration was already current, say so and report the audit summary anyway. + +## Reporting + +The final report must contain: + +1. Repository path and whether it used central or project-level package management. +2. Audit summary with `declared/current/auto/approval/unresolved` counts. +3. Every package actually updated, including condition or file context. +4. Every major candidate held for approval or yolo holdback. +5. Every unresolved package. +6. Every note-bearing declaration that was held or required human review. +7. Validation commands run and their outcomes. +8. Any conflicts or manual follow-up required. + +If nothing changed, say that explicitly and still include the complete audit summary. diff --git a/skills/dotnet-nuget-update/evals/evals.json b/skills/dotnet-nuget-update/evals/evals.json new file mode 100644 index 0000000..aa7be87 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/evals.json @@ -0,0 +1,108 @@ +{ + "skill_name": "dotnet-nuget-update", + "evals": [ + { + "id": 1, + "prompt": "Audit the attached repository and update NuGet packages without breaking its TFM-specific package strategy. The repo targets net9.0 and net10.0, and the same Microsoft package is pinned separately for each band. Resolve every declaration first, then update each band to the newest version that still fits that band instead of proposing the overall newest major.", + "expected_output": "The agent performs a complete dependency audit before editing, updates Microsoft.Extensions.Logging separately within 9.x and 10.x, leaves Newtonsoft.Json as already current, and does not propose an 11.x jump for the band-tracked package.", + "expectations": [ + "Runs the complete dependency audit before changing any package", + "Treats the net9 declaration and the net10 declaration as separate rows", + "Updates the net9 package only within 9.x and the net10 package only within 10.x", + "Does not flatten the conditional graph into one shared package version", + "Reports the full audit summary with declared/current/auto/approval/unresolved counts" + ], + "files": [ + "evals/files/multi-tfm-bands/Directory.Packages.props", + "evals/files/multi-tfm-bands/Directory.Build.props" + ] + }, + { + "id": 2, + "prompt": "Update the attached central package graph. The latest overall Microsoft.Extensions.Logging release is a newer major, but the repository intentionally pins separate versions under net9 and net10 conditions. Finish the full audit and apply only the in-band updates that remain patch or minor steps.", + "expected_output": "The agent completes the audit without interruption, applies only in-band patch or minor updates, and reports any out-of-band latest major as held by the TFM-band rule instead of proposing it as the target.", + "expectations": [ + "Completes the audit before asking anything", + "Uses the TFM-band rule rather than the overall latest package version", + "Finishes without interruption when all in-band updates are auto classes", + "Explains when a newer overall version exists outside the allowed band" + ], + "files": [ + "evals/files/multi-tfm-bands/Directory.Packages.props", + "evals/files/multi-tfm-bands/Directory.Build.props" + ] + }, + { + "id": 3, + "prompt": "Review the attached mixed stable and prerelease package graph. Keep stable dependencies on stable releases, allow prerelease movement for the dependency that is already pinned to a prerelease, and avoid asking for approval when that prerelease update stays inside the same major.", + "expected_output": "Stable pins stay on stable candidates, the prerelease pin advances only within the intended prerelease line, and the same-major prerelease step is treated as an auto update instead of an approval candidate.", + "expectations": [ + "Infers stable intent from stable pins", + "Infers prerelease intent from the prerelease pin", + "Treats same-major prerelease movement as auto rather than approval", + "Still reports the complete audit summary" + ], + "files": [ + "evals/files/mixed-stable-prerelease/Directory.Packages.props", + "evals/files/mixed-stable-prerelease/Directory.Build.props" + ] + }, + { + "id": 4, + "prompt": "Use dotnet-nuget-update on the attached repository and apply all patch and minor NuGet updates. There are no majors that need approval, so the normal-mode run should complete without interrupting me.", + "expected_output": "The agent audits the repository, applies the safe updates, preserves the XML structure, and finishes without asking a question because only auto classes are present.", + "expectations": [ + "Normal mode does not interrupt the user when only auto updates are present", + "Applies patch and minor updates structurally rather than rewriting the file", + "Reports what changed and what remained current" + ], + "files": [ + "evals/files/flat-stable-only/Directory.Packages.props", + "evals/files/flat-stable-only/Directory.Build.props" + ] + }, + { + "id": 5, + "prompt": "Run dotnet-nuget-update in normal mode on the attached repository. Some dependencies have major upgrades available. Complete the full audit first, then ask only one batched approval question for the held majors instead of stopping package by package.", + "expected_output": "The agent finishes the full dependency audit before any approval prompt, batches the major candidates into one decision point, and separates those approval items from the auto-updatable packages.", + "expectations": [ + "Performs the complete audit before any approval question", + "Batches major candidates into one approval decision", + "Keeps auto candidates separate from approval candidates", + "Reports unresolved or note-bearing rows alongside the rest of the audit" + ], + "files": [ + "evals/files/with-xml-comment-pin/Directory.Packages.props", + "evals/files/with-xml-comment-pin/Directory.Build.props" + ] + }, + { + "id": 6, + "prompt": "Run dotnet-nuget-update yolo on the attached repository. Apply patch and minor updates silently, hold major upgrades without asking, and show the held majors in the final report.", + "expected_output": "The agent applies only the auto classes, leaves majors untouched, asks no follow-up question, and reports the held majors explicitly at the end.", + "expectations": [ + "Yolo mode never applies majors silently", + "Yolo mode asks no approval question", + "The final report lists held major candidates separately from applied updates" + ], + "files": [ + "evals/files/with-xml-comment-pin/Directory.Packages.props", + "evals/files/with-xml-comment-pin/Directory.Build.props" + ] + }, + { + "id": 7, + "prompt": "Update the attached repository, but pay attention to any inline notes explaining why a package is pinned. Preserve the comment, surface its warning in the audit, and avoid bulldozing through a deliberate hold just because the version step looks safe.", + "expected_output": "The agent preserves the XML comment, surfaces the note in the audit, and treats the note-bearing declaration as a deliberate human decision that must be read before applying any update.", + "expectations": [ + "Carries the adjacent XML comment into the audit note field", + "Preserves the comment and surrounding formatting during any edit", + "Marks a note-bearing auto candidate as needing the note to be read first" + ], + "files": [ + "evals/files/with-xml-comment-pin/Directory.Packages.props", + "evals/files/with-xml-comment-pin/Directory.Build.props" + ] + } + ] +} diff --git a/skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Build.props b/skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Build.props new file mode 100644 index 0000000..020f2b0 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Build.props @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Packages.props b/skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Packages.props new file mode 100644 index 0000000..8081d3b --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/flat-stable-only/Directory.Packages.props @@ -0,0 +1,9 @@ + + + true + + + + + + diff --git a/skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Build.props b/skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Build.props new file mode 100644 index 0000000..ccf46c5 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Build.props @@ -0,0 +1,5 @@ + + + net9.0;net10.0 + + diff --git a/skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Packages.props b/skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Packages.props new file mode 100644 index 0000000..6ae0ed0 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/mixed-stable-prerelease/Directory.Packages.props @@ -0,0 +1,15 @@ + + + true + + + + + + + + + + + + diff --git a/skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Build.props b/skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Build.props new file mode 100644 index 0000000..fe5c3ec --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Build.props @@ -0,0 +1,5 @@ + + + net9.0;net10.0 + + diff --git a/skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Packages.props b/skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Packages.props new file mode 100644 index 0000000..0c558b6 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/multi-tfm-bands/Directory.Packages.props @@ -0,0 +1,13 @@ + + + true + + + + + + + + + + diff --git a/skills/dotnet-nuget-update/evals/files/plain-project-refs/Directory.Build.props b/skills/dotnet-nuget-update/evals/files/plain-project-refs/Directory.Build.props new file mode 100644 index 0000000..5f9708a --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/plain-project-refs/Directory.Build.props @@ -0,0 +1,5 @@ + + + false + + diff --git a/skills/dotnet-nuget-update/evals/files/plain-project-refs/src/App/App.csproj b/skills/dotnet-nuget-update/evals/files/plain-project-refs/src/App/App.csproj new file mode 100644 index 0000000..a5b106d --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/plain-project-refs/src/App/App.csproj @@ -0,0 +1,9 @@ + + + net10.0 + + + + + + diff --git a/skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Build.props b/skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Build.props new file mode 100644 index 0000000..3b7a0b3 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Build.props @@ -0,0 +1,5 @@ + + + netstandard2.0 + + diff --git a/skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Packages.props b/skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Packages.props new file mode 100644 index 0000000..aed0669 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/with-xml-comment-pin/Directory.Packages.props @@ -0,0 +1,11 @@ + + + true + + + + + + + + diff --git a/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 new file mode 100644 index 0000000..46ffa4f --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 @@ -0,0 +1,282 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [string]$Updates, + [string]$UpdatesFile, + [switch]$DryRun, + [switch]$AsJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +function Get-NodeVersion { + param([Parameter(Mandatory)][System.Xml.XmlNode]$Node) + + if ($Node.Attributes['Version']) { + return [string]$Node.Attributes['Version'].Value + } + + $versionNode = $Node.SelectSingleNode('./Version') + if ($versionNode) { + return [string]$versionNode.InnerText + } + + return $null +} + +function Get-ConditionValue { + param($Node) + + if ($null -eq $Node) { return $null } + if ($Node.Attributes['Condition']) { return [string]$Node.Attributes['Condition'].Value } + return $null +} + +function Parse-Updates { + param([string]$Json, [string]$JsonFile) + + if (-not [string]::IsNullOrWhiteSpace($Json) -and -not [string]::IsNullOrWhiteSpace($JsonFile)) { + throw 'Specify either -Updates or -UpdatesFile, not both.' + } + + if (-not [string]::IsNullOrWhiteSpace($JsonFile)) { + $Json = Get-Content -Raw -LiteralPath $JsonFile + } + + if ([string]::IsNullOrWhiteSpace($Json)) { + throw 'One of -Updates or -UpdatesFile is required.' + } + + $parsed = $Json | ConvertFrom-Json + if ($parsed -is [string]) { return @($parsed) } + return @($parsed) +} + +function Replace-VersionInLine { + param( + [Parameter(Mandatory)][string]$Line, + [Parameter(Mandatory)][string]$From, + [Parameter(Mandatory)][string]$To + ) + + $attributePattern = '(Version\s*=\s*")' + [regex]::Escape($From) + '(")' + if ($Line -match $attributePattern) { + return ($Line -replace $attributePattern, "`${1}$To`${2}") + } + + $elementPattern = '(\s*)' + [regex]::Escape($From) + '(\s*)' + if ($Line -match $elementPattern) { + return ($Line -replace $elementPattern, "`${1}$To`${2}") + } + + return $null +} + +function Update-DeclarationLine { + param( + [Parameter(Mandatory)][string]$Text, + [Parameter(Mandatory)][string]$ElementName, + [Parameter(Mandatory)][string]$Id, + [string]$Condition, + [Parameter(Mandatory)][string]$From, + [Parameter(Mandatory)][string]$To + ) + + $lines = $Text -split '\r?\n', -1 + $currentCondition = $null + + for ($index = 0; $index -lt $lines.Length; $index++) { + $line = $lines[$index] + + if ($line -match '') { + $currentCondition = $null + } + } + + return $null +} + +function Get-CentralDeclarations { + param([Parameter(Mandatory)][string]$Path) + + [xml]$xml = Get-Content -Raw -LiteralPath $Path + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($itemGroup in @($xml.Project.ItemGroup)) { + $condition = Get-ConditionValue -Node $itemGroup + foreach ($node in @($itemGroup.ChildNodes)) { + if ($node.NodeType -ne 'Element' -or $node.Name -ne 'PackageVersion') { continue } + $id = if ($node.Attributes['Include']) { [string]$node.Attributes['Include'].Value } else { $null } + if ([string]::IsNullOrWhiteSpace($id)) { continue } + $rows.Add([pscustomobject]@{ + id = $id + current = Get-NodeVersion -Node $node + condition = $condition + element = 'PackageVersion' + }) + } + } + return @($rows) +} + +function Get-ProjectDeclarations { + param([Parameter(Mandatory)][string]$Root) + + $rows = [System.Collections.Generic.List[object]]::new() + $projectFiles = Get-ChildItem -LiteralPath $Root -Recurse -Filter *.csproj -File | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } + + foreach ($projectFile in $projectFiles) { + [xml]$xml = Get-Content -Raw -LiteralPath $projectFile.FullName + foreach ($itemGroup in @($xml.Project.ItemGroup)) { + $groupCondition = Get-ConditionValue -Node $itemGroup + foreach ($node in @($itemGroup.ChildNodes)) { + if ($node.NodeType -ne 'Element' -or $node.Name -notin @('PackageReference', 'GlobalPackageReference')) { continue } + $id = if ($node.Attributes['Include']) { [string]$node.Attributes['Include'].Value } else { $null } + if ([string]::IsNullOrWhiteSpace($id)) { continue } + $version = Get-NodeVersion -Node $node + if ([string]::IsNullOrWhiteSpace($version)) { continue } + $nodeCondition = Get-ConditionValue -Node $node + $rows.Add([pscustomobject]@{ + id = $id + current = $version + condition = if ($nodeCondition) { $nodeCondition } else { $groupCondition } + element = $node.Name + sourceFile = [System.IO.Path]::GetRelativePath($Root, $projectFile.FullName) + }) + } + } + } + return @($rows) +} + +$repoPath = (Resolve-Path -LiteralPath $RepoRoot).Path +$updatesList = Parse-Updates -Json $Updates -JsonFile $UpdatesFile +$centralPath = Join-Path $repoPath 'Directory.Packages.props' +$hasCentral = Test-Path -LiteralPath $centralPath +$results = [System.Collections.Generic.List[object]]::new() + +if ($hasCentral) { + $text = Get-Content -Raw -LiteralPath $centralPath + $declarations = Get-CentralDeclarations -Path $centralPath + $changed = $false + + foreach ($update in $updatesList) { + $matches = @($declarations | Where-Object { + $_.id -eq $update.id -and (($_.condition ?? '') -eq (($update.condition ?? ''))) + }) + + if ($matches.Count -eq 0) { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'not-found'; from = $update.from; to = $update.to }) + continue + } + + $match = $matches[0] + if ($match.current -ne $update.from) { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'conflict'; from = $update.from; to = $update.to }) + continue + } + + if ($DryRun) { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'dry-run'; from = $update.from; to = $update.to }) + continue + } + + $updatedText = Update-DeclarationLine -Text $text -ElementName 'PackageVersion' -Id $update.id -Condition $update.condition -From $update.from -To $update.to + if ($null -eq $updatedText) { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'not-found'; from = $update.from; to = $update.to }) + continue + } + + $text = $updatedText + $match.current = $update.to + $changed = $true + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'applied'; from = $update.from; to = $update.to }) + } + + if ($changed -and -not $DryRun) { + Write-TextPreservingEol -Path $centralPath -Text $text | Out-Null + } + + $result = [pscustomobject]@{ repoRoot = $repoPath; mode = 'central'; results = @($results) } + if ($AsJson) { $result | ConvertTo-Json -Depth 12 } else { $result } + return +} + +$projectDeclarations = Get-ProjectDeclarations -Root $repoPath +if ($projectDeclarations.Count -eq 0) { + $empty = [pscustomobject]@{ repoRoot = $repoPath; mode = 'project'; results = @($results) } + if ($AsJson) { $empty | ConvertTo-Json -Depth 12 } else { $empty } + return +} + +$fileTexts = @{} +$fileChanged = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($update in $updatesList) { + $matches = @($projectDeclarations | Where-Object { + $_.id -eq $update.id -and $_.current -eq $update.from -and (($_.condition ?? '') -eq (($update.condition ?? ''))) + }) + + if ($matches.Count -eq 0) { + $conflicts = @($projectDeclarations | Where-Object { + $_.id -eq $update.id -and (($_.condition ?? '') -eq (($update.condition ?? ''))) + }) + if ($conflicts.Count -gt 0) { + foreach ($conflict in $conflicts) { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $conflict.sourceFile; outcome = 'conflict'; from = $update.from; to = $update.to }) + } + } else { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'not-found'; from = $update.from; to = $update.to }) + } + continue + } + + foreach ($match in $matches) { + $filePath = Join-Path $repoPath $match.sourceFile + if (-not $fileTexts.ContainsKey($match.sourceFile)) { + $fileTexts[$match.sourceFile] = Get-Content -Raw -LiteralPath $filePath + } + + if ($DryRun) { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $match.sourceFile; outcome = 'dry-run'; from = $update.from; to = $update.to }) + continue + } + + $updatedText = Update-DeclarationLine -Text $fileTexts[$match.sourceFile] -ElementName $match.element -Id $update.id -Condition $update.condition -From $update.from -To $update.to + if ($null -eq $updatedText) { + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $match.sourceFile; outcome = 'not-found'; from = $update.from; to = $update.to }) + continue + } + + $fileTexts[$match.sourceFile] = $updatedText + $match.current = $update.to + $null = $fileChanged.Add($match.sourceFile) + $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $match.sourceFile; outcome = 'applied'; from = $update.from; to = $update.to }) + } +} + +if (-not $DryRun) { + foreach ($relativePath in $fileChanged) { + Write-TextPreservingEol -Path (Join-Path $repoPath $relativePath) -Text $fileTexts[$relativePath] | Out-Null + } +} + +$result = [pscustomobject]@{ repoRoot = $repoPath; mode = 'project'; results = @($results) } +if ($AsJson) { $result | ConvertTo-Json -Depth 12 } else { $result } diff --git a/skills/dotnet-nuget-update/scripts/Compare-Version.ps1 b/skills/dotnet-nuget-update/scripts/Compare-Version.ps1 new file mode 100644 index 0000000..0592ba7 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/Compare-Version.ps1 @@ -0,0 +1,31 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$A, + [Parameter(Mandatory)][string]$B, + [switch]$AsJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +$compare = Compare-NuGetVersion -A $A -B $B +$relation = switch ($compare) { + -1 { 'lt' } + 0 { 'eq' } + 1 { 'gt' } +} +$result = [pscustomobject]@{ + a = $A + b = $B + compare = $compare + relation = $relation + newer = if ($compare -gt 0) { $A } elseif ($compare -lt 0) { $B } else { $A } +} + +if ($AsJson) { + $result | ConvertTo-Json -Depth 6 +} else { + $result +} diff --git a/skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 b/skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 new file mode 100644 index 0000000..be40ae1 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 @@ -0,0 +1,269 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [string]$Package, + [switch]$IncludePrerelease, + [switch]$OutdatedOnly, + [string]$Source = 'https://api.nuget.org/v3-flatcontainer', + [switch]$AsJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +$autoClasses = @('revision', 'patch', 'minor', 'prerelease') +$approvalClasses = @('major') + +function Get-NodeVersion { + param([Parameter(Mandatory)][System.Xml.XmlNode]$Node) + + if ($Node.Attributes['Version']) { + return [string]$Node.Attributes['Version'].Value + } + + $versionNode = $Node.SelectSingleNode('./Version') + if ($versionNode) { + return [string]$versionNode.InnerText + } + + return $null +} + +function Get-CentralDeclarations { + param([Parameter(Mandatory)][string]$Path, [string]$PackageFilter) + + [xml]$xml = Get-Content -Raw -LiteralPath $Path + $rows = [System.Collections.Generic.List[object]]::new() + + foreach ($itemGroup in @($xml.Project.ItemGroup)) { + $condition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } + foreach ($node in @($itemGroup.ChildNodes)) { + if ($node.NodeType -ne 'Element') { continue } + if ($node.Name -ne 'PackageVersion') { continue } + $id = if ($node.Attributes['Include']) { [string]$node.Attributes['Include'].Value } else { $null } + if ([string]::IsNullOrWhiteSpace($id)) { continue } + if ($PackageFilter -and $id -ne $PackageFilter) { continue } + + $rows.Add([pscustomobject]@{ + id = $id + current = Get-NodeVersion -Node $node + element = $node.Name + condition = $condition + note = Get-AdjacentXmlComment -Node $node + sourceFile = 'Directory.Packages.props' + }) + } + } + + return @($rows) +} + +function Get-ProjectDeclarations { + param([Parameter(Mandatory)][string]$Root, [string]$PackageFilter) + + $rows = [System.Collections.Generic.List[object]]::new() + $projectFiles = Get-ChildItem -LiteralPath $Root -Recurse -Filter *.csproj -File | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } + + foreach ($projectFile in $projectFiles) { + [xml]$xml = Get-Content -Raw -LiteralPath $projectFile.FullName + foreach ($itemGroup in @($xml.Project.ItemGroup)) { + $groupCondition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } + foreach ($node in @($itemGroup.ChildNodes)) { + if ($node.NodeType -ne 'Element') { continue } + if ($node.Name -notin @('PackageReference', 'GlobalPackageReference')) { continue } + $id = if ($node.Attributes['Include']) { [string]$node.Attributes['Include'].Value } else { $null } + if ([string]::IsNullOrWhiteSpace($id)) { continue } + if ($PackageFilter -and $id -ne $PackageFilter) { continue } + + $current = Get-NodeVersion -Node $node + if ([string]::IsNullOrWhiteSpace($current)) { continue } + + $nodeCondition = if ($node.Attributes['Condition']) { [string]$node.Attributes['Condition'].Value } else { $null } + $rows.Add([pscustomobject]@{ + id = $id + current = $current + element = $node.Name + condition = if ($nodeCondition) { $nodeCondition } else { $groupCondition } + note = Get-AdjacentXmlComment -Node $node + sourceFile = [System.IO.Path]::GetRelativePath($Root, $projectFile.FullName) + }) + } + } + } + + return @($rows) +} + +function Resolve-Candidate { + param( + [Parameter(Mandatory)]$Declaration, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Versions, + [bool]$AllowPrerelease + ) + + $pool = @(if ($AllowPrerelease) { $Versions } else { $Versions | Where-Object { -not (ConvertTo-NuGetSemVer -Version $_).isPrerelease } }) + $latestOverall = if ($pool.Count) { $pool[-1] } else { $null } + $bandCandidates = @(Get-TfmBand -Condition $Declaration.condition) + $pinnedMajor = if ($Declaration.current) { (ConvertTo-NuGetSemVer -Version $Declaration.current).major } else { $null } + $band = $null + + if ($bandCandidates.Count -gt 0 -and $null -ne $pinnedMajor -and $bandCandidates -contains $pinnedMajor) { + $band = $pinnedMajor + } + + $allowed = @(if ($null -ne $band) { $pool | Where-Object { (ConvertTo-NuGetSemVer -Version $_).major -eq $band } } else { $pool }) + $candidate = if ($allowed.Count) { $allowed[-1] } else { $null } + + [pscustomobject]@{ + candidate = $candidate + latestOverall = $latestOverall + band = $band + heldByBand = ($null -ne $band -and $candidate -and $latestOverall -and (Compare-NuGetVersion -A $candidate -B $latestOverall) -lt 0) + } +} + +$repoPath = (Resolve-Path -LiteralPath $RepoRoot).Path +$centralPath = Join-Path $repoPath 'Directory.Packages.props' +$management = if (Test-Path -LiteralPath $centralPath) { 'central' } else { 'project' } +$declarations = if ($management -eq 'central') { + Get-CentralDeclarations -Path $centralPath -PackageFilter $Package +} else { + Get-ProjectDeclarations -Root $repoPath -PackageFilter $Package +} + +if ($management -eq 'project' -and $declarations.Count -eq 0) { + $missing = [pscustomobject]@{ + repoRoot = $repoPath + found = $false + management = 'project' + declared = 0 + summary = [pscustomobject]@{ declared = 0; current = 0; auto = 0; approval = 0; unresolved = 0 } + rows = @() + } + if ($AsJson) { $missing | ConvertTo-Json -Depth 12 } else { $missing } + return +} + +if ($management -eq 'central' -and -not (Test-Path -LiteralPath $centralPath)) { + $missing = [pscustomobject]@{ + repoRoot = $repoPath + found = $false + management = 'central' + declared = 0 + summary = [pscustomobject]@{ declared = 0; current = 0; auto = 0; approval = 0; unresolved = 0 } + rows = @() + } + if ($AsJson) { $missing | ConvertTo-Json -Depth 12 } else { $missing } + return +} + +$rows = foreach ($declaration in $declarations) { + $feed = Get-NuGetVersionList -Id $declaration.id -Source $Source + if (-not $feed.found) { + [pscustomobject]@{ + id = $declaration.id + current = $declaration.current + condition = $declaration.condition + sourceFile = $declaration.sourceFile + note = $declaration.note + candidate = $null + latestOverall = $null + band = $null + heldByBand = $false + bump = 'unknown' + action = 'unresolved' + reason = "not resolvable: $($feed.error)" + } + continue + } + + $allowPrerelease = $IncludePrerelease -or ((ConvertTo-NuGetSemVer -Version $declaration.current).isPrerelease) + $candidateResolution = Resolve-Candidate -Declaration $declaration -Versions $feed.versions -AllowPrerelease:$allowPrerelease + + if ([string]::IsNullOrWhiteSpace($declaration.current) -or [string]::IsNullOrWhiteSpace($candidateResolution.candidate)) { + [pscustomobject]@{ + id = $declaration.id + current = $declaration.current + condition = $declaration.condition + sourceFile = $declaration.sourceFile + note = $declaration.note + candidate = $candidateResolution.candidate + latestOverall = $candidateResolution.latestOverall + band = $candidateResolution.band + heldByBand = $candidateResolution.heldByBand + bump = 'unknown' + action = 'unresolved' + reason = if ([string]::IsNullOrWhiteSpace($declaration.current)) { 'declaration has no explicit version' } else { 'no candidate available within the allowed set' } + } + continue + } + + $bump = Get-NuGetVersionBump -From $declaration.current -To $candidateResolution.candidate + $action = if ($bump -eq 'none') { + 'current' + } elseif ($approvalClasses -contains $bump) { + 'approval' + } elseif ($autoClasses -contains $bump) { + 'auto' + } else { + 'approval' + } + + $reason = if ($bump -eq 'none' -and $candidateResolution.heldByBand) { + "newest within the net$($candidateResolution.band) band; $($candidateResolution.latestOverall) exists outside it" + } elseif ($bump -eq 'none') { + 'already newest allowed' + } elseif ($action -eq 'approval') { + 'major step - requires human approval before applying' + } elseif ($candidateResolution.heldByBand) { + "$bump step within the net$($candidateResolution.band) band; $($candidateResolution.latestOverall) exists outside it" + } else { + "$bump step" + } + + if ($declaration.note -and $action -eq 'auto') { + $reason = "$reason - READ THE NOTE before applying" + } + + [pscustomobject]@{ + id = $declaration.id + current = $declaration.current + condition = $declaration.condition + sourceFile = $declaration.sourceFile + note = $declaration.note + candidate = $candidateResolution.candidate + latestOverall = $candidateResolution.latestOverall + band = $candidateResolution.band + heldByBand = $candidateResolution.heldByBand + bump = $bump + action = $action + reason = $reason + } +} + +$rows = @($rows) +$summary = [pscustomobject]@{ + declared = $rows.Count + current = @($rows | Where-Object { $_.action -eq 'current' }).Count + auto = @($rows | Where-Object { $_.action -eq 'auto' }).Count + approval = @($rows | Where-Object { $_.action -eq 'approval' }).Count + unresolved = @($rows | Where-Object { $_.action -eq 'unresolved' }).Count +} + +$result = [pscustomobject]@{ + repoRoot = $repoPath + found = $true + management = $management + declared = $rows.Count + summary = $summary + rows = if ($OutdatedOnly) { @($rows | Where-Object { $_.action -ne 'current' }) } else { $rows } +} + +if ($AsJson) { + $result | ConvertTo-Json -Depth 12 +} else { + $result +} diff --git a/skills/dotnet-nuget-update/scripts/Get-NuGetSources.ps1 b/skills/dotnet-nuget-update/scripts/Get-NuGetSources.ps1 new file mode 100644 index 0000000..2ca453f --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/Get-NuGetSources.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param( + [string]$RepoRoot, + [switch]$AsJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-ConfigPath { + param([string]$RepositoryRoot) + + if (-not [string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $candidate = Join-Path (Resolve-Path -LiteralPath $RepositoryRoot).Path 'NuGet.Config' + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + + if ($IsWindows) { + $userConfig = Join-Path $env:APPDATA 'NuGet\NuGet.Config' + } else { + $userConfig = Join-Path $HOME '.nuget/NuGet/NuGet.Config' + } + + if (Test-Path -LiteralPath $userConfig) { return $userConfig } + return $null +} + +function Get-CredentialMap { + param([xml]$Xml) + + $map = @{} + $credentials = $Xml.configuration.packageSourceCredentials + if (-not $credentials) { return $map } + + foreach ($sourceNode in @($credentials.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { + $username = $null + $hasCredentials = $false + foreach ($addNode in @($sourceNode.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -eq 'add' })) { + $key = if ($addNode.Attributes['key']) { [string]$addNode.Attributes['key'].Value } else { $null } + $value = if ($addNode.Attributes['value']) { [string]$addNode.Attributes['value'].Value } else { $null } + if ($key -eq 'Username') { $username = $value } + if ($key -in @('Username', 'Password', 'ClearTextPassword', 'ValidAuthenticationTypes')) { $hasCredentials = $true } + } + $map[$sourceNode.Name] = [pscustomobject]@{ username = $username; hasCredentials = $hasCredentials } + } + + return $map +} + +$configPath = Get-ConfigPath -RepositoryRoot $RepoRoot +if (-not $configPath) { + $default = [pscustomobject]@{ + configPath = $null + sources = @([pscustomobject]@{ + key = 'nuget.org' + value = 'https://api.nuget.org/v3/index.json' + protocolVersion = $null + username = $null + hasCredentials = $false + }) + } + if ($AsJson) { $default | ConvertTo-Json -Depth 8 } else { $default } + return +} + +[xml]$xml = Get-Content -Raw -LiteralPath $configPath +$credentialMap = Get-CredentialMap -Xml $xml +$sources = [System.Collections.Generic.List[object]]::new() + +foreach ($addNode in @($xml.configuration.packageSources.add)) { + $key = if ($addNode.Attributes['key']) { [string]$addNode.Attributes['key'].Value } else { $null } + $value = if ($addNode.Attributes['value']) { [string]$addNode.Attributes['value'].Value } else { $null } + $protocolVersion = if ($addNode.Attributes['protocolVersion']) { [string]$addNode.Attributes['protocolVersion'].Value } else { $null } + $credential = if ($key -and $credentialMap.ContainsKey($key)) { $credentialMap[$key] } else { $null } + + $sources.Add([pscustomobject]@{ + key = $key + value = $value + protocolVersion = $protocolVersion + username = if ($credential) { $credential.username } else { $null } + hasCredentials = if ($credential) { $credential.hasCredentials } else { $false } + }) +} + +$result = [pscustomobject]@{ + configPath = $configPath + sources = @($sources) +} + +if ($AsJson) { + $result | ConvertTo-Json -Depth 8 +} else { + $result +} diff --git a/skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 b/skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 new file mode 100644 index 0000000..0d783c2 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 @@ -0,0 +1,112 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [string]$Package, + [switch]$AsJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +function Get-NodeVersion { + param([Parameter(Mandatory)][System.Xml.XmlNode]$Node) + + if ($Node.Attributes['Version']) { + return [string]$Node.Attributes['Version'].Value + } + + $versionNode = $Node.SelectSingleNode('./Version') + if ($versionNode) { + return [string]$versionNode.InnerText + } + + return $null +} + +$repoPath = (Resolve-Path -LiteralPath $RepoRoot).Path +$file = Join-Path $repoPath 'Directory.Packages.props' +if (-not (Test-Path -LiteralPath $file)) { + $missing = [pscustomobject]@{ repoRoot = $repoPath; found = $false } + if ($AsJson) { $missing | ConvertTo-Json -Depth 8 } else { $missing } + return +} + +[xml]$xml = Get-Content -Raw -LiteralPath $file +$groups = [System.Collections.Generic.List[object]]::new() +$packages = [System.Collections.Generic.List[object]]::new() +$centrallyManaged = $null + +foreach ($propertyGroup in @($xml.Project.PropertyGroup)) { + foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { + if ($node.Name -eq 'ManagePackageVersionsCentrally') { + $centrallyManaged = [string]$node.InnerText + } + } +} + +foreach ($itemGroup in @($xml.Project.ItemGroup)) { + $condition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } + $items = [System.Collections.Generic.List[object]]::new() + + foreach ($node in @($itemGroup.ChildNodes)) { + if ($node.NodeType -ne 'Element') { continue } + if ($node.Name -notin @('PackageVersion', 'PackageReference', 'GlobalPackageReference')) { continue } + + $id = if ($node.Attributes['Include']) { [string]$node.Attributes['Include'].Value } else { $null } + if ([string]::IsNullOrWhiteSpace($id)) { continue } + + $entry = [pscustomobject]@{ + id = $id + version = Get-NodeVersion -Node $node + element = $node.Name + condition = $condition + note = Get-AdjacentXmlComment -Node $node + } + $items.Add($entry) + $packages.Add($entry) + } + + $groups.Add([pscustomobject]@{ + condition = $condition + count = $items.Count + packages = @($items) + }) +} + +if ($Package) { + $filteredGroups = [System.Collections.Generic.List[object]]::new() + $filteredPackages = @($packages | Where-Object { $_.id -eq $Package }) + foreach ($group in @($groups)) { + $matches = @($group.packages | Where-Object { $_.id -eq $Package }) + if ($matches.Count -gt 0) { + $filteredGroups.Add([pscustomobject]@{ + condition = $group.condition + count = $matches.Count + packages = $matches + }) + } + } + $groups = $filteredGroups + $packages = [System.Collections.Generic.List[object]]::new() + foreach ($packageEntry in $filteredPackages) { $packages.Add($packageEntry) } +} + +$result = [pscustomobject]@{ + repoRoot = $repoPath + file = $file + found = $true + managePackageVersionsCentrally = $centrallyManaged + conditions = @($groups | Where-Object { $_.condition } | ForEach-Object { $_.condition } | Sort-Object -Unique) + groupCount = $groups.Count + packageCount = $packages.Count + groups = @($groups) + packages = @($packages) +} + +if ($AsJson) { + $result | ConvertTo-Json -Depth 12 +} else { + $result +} diff --git a/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 new file mode 100644 index 0000000..fa944f6 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 @@ -0,0 +1,148 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [switch]$AsJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-PropertyMap { + param([string]$Path) + + $map = @{} + if (-not (Test-Path -LiteralPath $Path)) { return $map } + + [xml]$xml = Get-Content -Raw -LiteralPath $Path + foreach ($propertyGroup in @($xml.Project.PropertyGroup)) { + foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { + if (-not [string]::IsNullOrWhiteSpace($node.InnerText)) { + $map[$node.Name] = [string]$node.InnerText + } + } + } + + return $map +} + +function Resolve-PropertyTokens { + param( + [string]$Value, + [hashtable]$Map, + [int]$Depth = 0 + ) + + if ([string]::IsNullOrWhiteSpace($Value) -or $Depth -ge 10) { + return $Value + } + + $resolved = [regex]::Replace($Value, '\$\(([A-Za-z0-9_]+)\)', { + param($match) + $name = $match.Groups[1].Value + if ($Map.ContainsKey($name)) { + return [string]$Map[$name] + } + return $match.Value + }) + + if ($resolved -ne $Value -and $resolved -match '\$\(') { + return Resolve-PropertyTokens -Value $resolved -Map $Map -Depth ($Depth + 1) + } + + return $resolved +} + +function Split-Tfms { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return @() } + return @($Value -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} + +$repoPath = (Resolve-Path -LiteralPath $RepoRoot).Path +$directoryBuildProps = Join-Path $repoPath 'Directory.Build.props' +$propertyMap = Get-PropertyMap -Path $directoryBuildProps +$declaredIn = [System.Collections.Generic.List[object]]::new() +$allTfms = [System.Collections.Generic.List[string]]::new() +$unresolved = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +if (Test-Path -LiteralPath $directoryBuildProps) { + [xml]$propsXml = Get-Content -Raw -LiteralPath $directoryBuildProps + foreach ($propertyGroup in @($propsXml.Project.PropertyGroup)) { + $condition = if ($propertyGroup.Attributes['Condition']) { [string]$propertyGroup.Attributes['Condition'].Value } else { $null } + foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -in @('TargetFramework', 'TargetFrameworks') })) { + $raw = [string]$node.InnerText + $resolved = Resolve-PropertyTokens -Value $raw -Map $propertyMap + $tfms = Split-Tfms -Value $resolved + foreach ($tfm in $tfms) { + if ($tfm -match '\$\(') { $null = $unresolved.Add($tfm) } else { $allTfms.Add($tfm) } + } + $declaredIn.Add([pscustomobject]@{ + scope = 'Directory.Build.props' + path = 'Directory.Build.props' + property = $node.Name + condition = $condition + rawValue = $raw + resolvedValue = $resolved + targetFrameworks = $tfms + }) + } + } +} + +$projects = [System.Collections.Generic.List[object]]::new() +$projectFiles = Get-ChildItem -LiteralPath $repoPath -Recurse -Filter *.csproj -File | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } + +foreach ($projectFile in $projectFiles) { + [xml]$projectXml = Get-Content -Raw -LiteralPath $projectFile.FullName + $projectTfms = [System.Collections.Generic.List[string]]::new() + $projectUnresolved = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($propertyGroup in @($projectXml.Project.PropertyGroup)) { + $condition = if ($propertyGroup.Attributes['Condition']) { [string]$propertyGroup.Attributes['Condition'].Value } else { $null } + foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -in @('TargetFramework', 'TargetFrameworks') })) { + $raw = [string]$node.InnerText + $resolved = Resolve-PropertyTokens -Value $raw -Map $propertyMap + $tfms = Split-Tfms -Value $resolved + foreach ($tfm in $tfms) { + if ($tfm -match '\$\(') { + $null = $projectUnresolved.Add($tfm) + $null = $unresolved.Add($tfm) + } else { + $projectTfms.Add($tfm) + $allTfms.Add($tfm) + } + } + $declaredIn.Add([pscustomobject]@{ + scope = 'Project' + path = [System.IO.Path]::GetRelativePath($repoPath, $projectFile.FullName) + property = $node.Name + condition = $condition + rawValue = $raw + resolvedValue = $resolved + targetFrameworks = $tfms + }) + } + } + + $projects.Add([pscustomobject]@{ + path = [System.IO.Path]::GetRelativePath($repoPath, $projectFile.FullName) + targetFrameworks = @($projectTfms | Sort-Object -Unique) + unresolvedTokens = @($projectUnresolved | Sort-Object) + }) +} + +$result = [pscustomobject]@{ + repoRoot = $repoPath + targetFrameworks = @($allTfms | Sort-Object -Unique) + unresolvedTokens = @($unresolved | Sort-Object) + declaredIn = @($declaredIn) + projects = @($projects) +} + +if ($AsJson) { + $result | ConvertTo-Json -Depth 12 +} else { + $result +} diff --git a/skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 b/skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 new file mode 100644 index 0000000..67852b4 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Id, + [switch]$IncludePrerelease, + [string]$Source = 'https://api.nuget.org/v3-flatcontainer', + [switch]$AsJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +$feed = Get-NuGetVersionList -Id $Id -Source $Source +if (-not $feed.found) { + $missing = [pscustomobject]@{ + id = $Id + found = $false + error = $feed.error + source = $Source + versions = @() + } + if ($AsJson) { $missing | ConvertTo-Json -Depth 8 } else { $missing } + return +} + +$versions = @($feed.versions) +$stable = @($versions | Where-Object { -not (ConvertTo-NuGetSemVer -Version $_).isPrerelease }) +$result = [pscustomobject]@{ + id = $Id + found = $true + source = $Source + count = $versions.Count + latest = if ($IncludePrerelease) { if ($versions.Count) { $versions[-1] } else { $null } } else { if ($stable.Count) { $stable[-1] } else { $null } } + latestStable = if ($stable.Count) { $stable[-1] } else { $null } + latestAny = if ($versions.Count) { $versions[-1] } else { $null } + versions = $versions +} + +if ($AsJson) { + $result | ConvertTo-Json -Depth 8 +} else { + $result +} diff --git a/skills/dotnet-nuget-update/scripts/_common.ps1 b/skills/dotnet-nuget-update/scripts/_common.ps1 new file mode 100644 index 0000000..9d0d89c --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/_common.ps1 @@ -0,0 +1,297 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:NuGetVersionCache = @{} + +function Get-FileLineEndingStyle { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { return 'none' } + + $bytes = [System.IO.File]::ReadAllBytes($Path) + $crlf = 0 + $lf = 0 + + for ($index = 0; $index -lt $bytes.Length; $index++) { + if ($bytes[$index] -ne 10) { continue } + if ($index -gt 0 -and $bytes[$index - 1] -eq 13) { + $crlf++ + } else { + $lf++ + } + } + + if ($crlf -gt 0 -and $lf -eq 0) { return 'CRLF' } + if ($lf -gt 0 -and $crlf -eq 0) { return 'LF' } + if ($lf -eq 0 -and $crlf -eq 0) { return 'none' } + return 'mixed' +} + +function Get-FileEncoding { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return [System.Text.UTF8Encoding]::new($false) + } + + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + return [System.Text.UTF8Encoding]::new($true) + } + + return [System.Text.UTF8Encoding]::new($false) +} + +function Write-TextPreservingEol { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][AllowEmptyString()][string]$Text, + [ValidateSet('preserve', 'LF', 'CRLF')][string]$LineEnding = 'preserve' + ) + + $style = $LineEnding + if ($style -eq 'preserve') { + $style = 'LF' + if (Test-Path -LiteralPath $Path) { + $existing = Get-FileLineEndingStyle -Path $Path + if ($existing -eq 'CRLF') { + $style = 'CRLF' + } elseif ($existing -eq 'mixed') { + $style = 'LF' + } + } + } + + $normalized = $Text -replace "`r`n", "`n" -replace "`r", "`n" + $final = if ($style -eq 'CRLF') { $normalized -replace "`n", "`r`n" } else { $normalized } + + $directory = Split-Path -Parent $Path + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + [System.IO.File]::WriteAllText($Path, $final, (Get-FileEncoding -Path $Path)) + return $style +} + +function ConvertTo-NuGetSemVer { + param([Parameter(Mandatory)][string]$Version) + + $value = $Version.Trim() + $value = ($value -split '\+', 2)[0] + $prerelease = $null + $core = $value + if ($value.Contains('-')) { + $core, $prerelease = $value -split '-', 2 + } + + $numbers = @($core -split '\.' | ForEach-Object { + $token = ($_ -replace '[^\d].*$', '') + if ([string]::IsNullOrWhiteSpace($token)) { 0 } else { [int]$token } + }) + + while ($numbers.Count -lt 3) { $numbers += 0 } + + [pscustomobject]@{ + major = $numbers[0] + minor = $numbers[1] + patch = $numbers[2] + rest = @($numbers | Select-Object -Skip 3) + pre = $prerelease + isPrerelease = [bool]$prerelease + raw = $Version + } +} + +function Compare-NuGetVersion { + param( + [Parameter(Mandatory)][string]$A, + [Parameter(Mandatory)][string]$B + ) + + $left = ConvertTo-NuGetSemVer -Version $A + $right = ConvertTo-NuGetSemVer -Version $B + $leftNumbers = @($left.major, $left.minor, $left.patch) + $left.rest + $rightNumbers = @($right.major, $right.minor, $right.patch) + $right.rest + $count = [Math]::Max($leftNumbers.Count, $rightNumbers.Count) + + for ($index = 0; $index -lt $count; $index++) { + $leftValue = if ($index -lt $leftNumbers.Count) { $leftNumbers[$index] } else { 0 } + $rightValue = if ($index -lt $rightNumbers.Count) { $rightNumbers[$index] } else { 0 } + if ($leftValue -ne $rightValue) { + return [int][Math]::Sign($leftValue - $rightValue) + } + } + + if (-not $left.isPrerelease -and -not $right.isPrerelease) { return 0 } + if (-not $left.isPrerelease) { return 1 } + if (-not $right.isPrerelease) { return -1 } + + $leftPre = $left.pre -split '\.' + $rightPre = $right.pre -split '\.' + $preCount = [Math]::Max($leftPre.Count, $rightPre.Count) + + for ($index = 0; $index -lt $preCount; $index++) { + if ($index -ge $leftPre.Count) { return -1 } + if ($index -ge $rightPre.Count) { return 1 } + + $leftToken = $leftPre[$index] + $rightToken = $rightPre[$index] + $leftNumber = 0 + $rightNumber = 0 + $leftIsNumber = [int]::TryParse($leftToken, [ref]$leftNumber) + $rightIsNumber = [int]::TryParse($rightToken, [ref]$rightNumber) + + if ($leftIsNumber -and $rightIsNumber) { + if ($leftNumber -ne $rightNumber) { + return [int][Math]::Sign($leftNumber - $rightNumber) + } + } elseif ($leftIsNumber) { + return -1 + } elseif ($rightIsNumber) { + return 1 + } else { + $compare = [string]::CompareOrdinal($leftToken, $rightToken) + if ($compare -ne 0) { + return [int][Math]::Sign($compare) + } + } + } + + return 0 +} + +function Sort-NuGetVersion { + param([Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Version) + + if ($Version.Count -le 1) { + return ,([string[]]$Version) + } + + $list = [System.Collections.Generic.List[string]]::new([string[]]$Version) + $list.Sort([System.Comparison[string]]{ param($a, $b) Compare-NuGetVersion $a $b }) + return ,$list.ToArray() +} + +function Get-NuGetVersionBump { + param( + [Parameter(Mandatory)][string]$From, + [Parameter(Mandatory)][string]$To + ) + + if ((Compare-NuGetVersion -A $From -B $To) -ge 0) { + return 'none' + } + + $fromVersion = ConvertTo-NuGetSemVer -Version $From + $toVersion = ConvertTo-NuGetSemVer -Version $To + + if ($fromVersion.major -ne $toVersion.major) { return 'major' } + if ($fromVersion.minor -ne $toVersion.minor) { return 'minor' } + if ($fromVersion.patch -ne $toVersion.patch) { return 'patch' } + + $fromRest = @($fromVersion.rest) + $toRest = @($toVersion.rest) + $count = [Math]::Max($fromRest.Count, $toRest.Count) + + for ($index = 0; $index -lt $count; $index++) { + $leftValue = if ($index -lt $fromRest.Count) { $fromRest[$index] } else { 0 } + $rightValue = if ($index -lt $toRest.Count) { $toRest[$index] } else { 0 } + if ($leftValue -ne $rightValue) { return 'revision' } + } + + return 'prerelease' +} + +function Get-TfmBand { + param([string]$Condition) + + if ([string]::IsNullOrWhiteSpace($Condition)) { return @() } + + $bands = [System.Collections.Generic.List[int]]::new() + foreach ($match in [regex]::Matches($Condition, "net(\d+)(?:\.\d+)?'")) { + $bands.Add([int]$match.Groups[1].Value) + } + + return @($bands | Where-Object { $_ -ge 5 } | Sort-Object -Unique) +} + +function Get-NuGetVersionList { + param( + [Parameter(Mandatory)][string]$Id, + [string]$Source = 'https://api.nuget.org/v3-flatcontainer' + ) + + $base = $Source.Trim() + $packageId = $Id.ToLowerInvariant() + $isHttp = $base -match '^https?://' + if ($isHttp) { + $normalizedBase = $base.TrimEnd('/') + $location = "$normalizedBase/$packageId/index.json" + } else { + $normalizedBase = $base.TrimEnd('\', '/') + $location = Join-Path (Join-Path $normalizedBase $packageId) 'index.json' + } + + $cacheKey = "$normalizedBase|$packageId" + if ($script:NuGetVersionCache.ContainsKey($cacheKey)) { + return $script:NuGetVersionCache[$cacheKey] + } + + try { + $response = if ($isHttp) { + Invoke-RestMethod -Uri $location -Method Get -TimeoutSec 30 -ErrorAction Stop + } else { + Get-Content -Raw -LiteralPath $location -ErrorAction Stop | ConvertFrom-Json + } + + $result = [pscustomobject]@{ + id = $Id + found = $true + source = $Source + url = $location + error = $null + versions = (Sort-NuGetVersion -Version @($response.versions)) + } + } + catch { + $result = [pscustomobject]@{ + id = $Id + found = $false + source = $Source + url = $location + error = $_.Exception.Message + versions = @() + } + } + + $script:NuGetVersionCache[$cacheKey] = $result + return $result +} + +function Get-AdjacentXmlComment { + param([Parameter(Mandatory)][System.Xml.XmlNode]$Node) + + foreach ($direction in @('PreviousSibling', 'NextSibling')) { + $cursor = $Node.$direction + while ($null -ne $cursor) { + if ($cursor.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) { + $cursor = $cursor.$direction + continue + } + + if ($cursor.NodeType -eq [System.Xml.XmlNodeType]::Text -and [string]::IsNullOrWhiteSpace($cursor.Value)) { + $cursor = $cursor.$direction + continue + } + + if ($cursor.NodeType -eq [System.Xml.XmlNodeType]::Comment) { + return ([string]$cursor.Value).Trim() + } + + break + } + } + + return $null +} diff --git a/skills/dotnet-nuget-update/scripts/run-tests.ps1 b/skills/dotnet-nuget-update/scripts/run-tests.ps1 new file mode 100644 index 0000000..21f01b8 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/run-tests.ps1 @@ -0,0 +1,25 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptRoot = $PSScriptRoot +$failures = [System.Collections.Generic.List[string]]::new() +$testScripts = Get-ChildItem -LiteralPath $scriptRoot -Filter 'test-*.ps1' -File | Sort-Object Name + +foreach ($testScript in $testScripts) { + Write-Host "Running $($testScript.Name)..." + try { + & pwsh -NoProfile -File $testScript.FullName + if ($LASTEXITCODE -ne 0) { + throw "Exited with $LASTEXITCODE." + } + } + catch { + $failures.Add("$($testScript.Name): $($_.Exception.Message)") + } +} + +if ($failures.Count -gt 0) { + throw ("dotnet-nuget-update tests failed:`n- " + ($failures -join "`n- ")) +} + +Write-Host 'dotnet-nuget-update test suite: PASS' diff --git a/skills/dotnet-nuget-update/scripts/test-apply-updates.ps1 b/skills/dotnet-nuget-update/scripts/test-apply-updates.ps1 new file mode 100644 index 0000000..8621673 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/test-apply-updates.ps1 @@ -0,0 +1,64 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$workspace = Join-Path $repoRoot ('.bot\dotnet-nuget-update-tests\apply-' + [Guid]::NewGuid().ToString('N')) +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Write-File { + param([string]$Path, [string]$Content, [ValidateSet('LF', 'CRLF')][string]$LineEnding = 'LF') + $directory = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + $normalized = $Content -replace "`r`n", "`n" -replace "`r", "`n" + $final = if ($LineEnding -eq 'CRLF') { $normalized -replace "`n", "`r`n" } else { $normalized } + [System.IO.File]::WriteAllText($Path, $final, $utf8NoBom) +} + +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -ne $Expected) { throw "$Name failed. Expected '$Expected' but found '$Actual'." } +} + +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +try { + $repoPath = Join-Path $workspace 'repo' + Write-File -Path (Join-Path $repoPath 'Directory.Packages.props') -LineEnding CRLF -Content @' + + + true + + + + + + + + +'@ + Write-File -Path (Join-Path $repoPath 'README.txt') -Content 'do not touch' + + $updates = '[{"id":"Newtonsoft.Json","condition":null,"from":"13.0.3","to":"13.0.4"},{"id":"Asp.Versioning.Http","condition":null,"from":"8.1.0","to":"8.1.1"}]' + $result = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Apply-PackageUpdates.ps1') -RepoRoot $repoPath -Updates $updates -AsJson | ConvertFrom-Json + + Assert-Equal 'two central updates applied' (@($result.results | Where-Object { $_.outcome -eq 'applied' }).Count) 2 + + $updatedText = Get-Content -Raw -LiteralPath (Join-Path $repoPath 'Directory.Packages.props') + if ($updatedText -notmatch 'Newtonsoft\.Json" Version="13\.0\.4"' -or $updatedText -notmatch 'Asp\.Versioning\.Http" Version="8\.1\.1"') { + throw 'Updated package versions were not written back.' + } + if ($updatedText -notmatch '') { + throw 'XML comment was not preserved.' + } + Assert-Equal 'CRLF line endings are preserved' (Get-FileLineEndingStyle -Path (Join-Path $repoPath 'Directory.Packages.props')) 'CRLF' + Assert-Equal 'unrelated file stays untouched' (Get-Content -Raw -LiteralPath (Join-Path $repoPath 'README.txt')) 'do not touch' + + $conflict = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Apply-PackageUpdates.ps1') -RepoRoot $repoPath -Updates '[{"id":"Newtonsoft.Json","condition":null,"from":"13.0.3","to":"13.0.5"}]' -AsJson | ConvertFrom-Json + Assert-Equal 'conflict is reported when from no longer matches' $conflict.results[0].outcome 'conflict' + + Write-Host 'test-apply-updates.ps1: PASS' +} +finally { + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/skills/dotnet-nuget-update/scripts/test-dependency-audit.ps1 b/skills/dotnet-nuget-update/scripts/test-dependency-audit.ps1 new file mode 100644 index 0000000..70fc535 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/test-dependency-audit.ps1 @@ -0,0 +1,81 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$workspace = Join-Path $repoRoot ('.bot\dotnet-nuget-update-tests\audit-' + [Guid]::NewGuid().ToString('N')) +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Write-File { + param([string]$Path, [string]$Content) + $directory = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -ne $Expected) { throw "$Name failed. Expected '$Expected' but found '$Actual'." } +} + +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +try { + $sourceRoot = Join-Path $workspace 'nuget-fixtures' + Write-File -Path (Join-Path $sourceRoot 'microsoft.extensions.logging\index.json') -Content '{ "versions": ["9.0.0","9.0.1","10.0.0","11.0.0-preview.1"] }' + Write-File -Path (Join-Path $sourceRoot 'newtonsoft.json\index.json') -Content '{ "versions": ["12.0.0","13.0.0","13.0.1","13.0.2","13.0.3"] }' + Write-File -Path (Join-Path $sourceRoot 'asp.versioning.http\index.json') -Content '{ "versions": ["8.0.0","8.1.0","8.1.1","9.0.0"] }' + Write-File -Path (Join-Path $sourceRoot 'somepackage\index.json') -Content '{ "versions": ["1.0.0","1.0.1"] }' + + Write-File -Path (Join-Path $workspace 'repo\Directory.Packages.props') -Content @' + + + true + + + + + + + + + + + + +'@ + Write-File -Path (Join-Path $workspace 'repo\Directory.Build.props') -Content @' + + + net9.0 + + +'@ + + $audit = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Get-DependencyAudit.ps1') -RepoRoot (Join-Path $workspace 'repo') -Source $sourceRoot -AsJson | ConvertFrom-Json + + $logging = $audit.rows | Where-Object { $_.id -eq 'Microsoft.Extensions.Logging' } + Assert-Equal 'net9 logging stays auto within band' $logging.action 'auto' + Assert-Equal 'net9 logging resolves to newest 9.x' $logging.candidate '9.0.1' + + $asp = $audit.rows | Where-Object { $_.id -eq 'Asp.Versioning.Http' } + Assert-Equal 'major candidate remains approval' $asp.action 'approval' + Assert-Equal 'major candidate resolves newest overall' $asp.candidate '9.0.0' + + $somePackage = $audit.rows | Where-Object { $_.id -eq 'SomePackage' } + Assert-Equal 'note field is populated' $somePackage.note 'Broken until downstream package catches up' + Assert-Equal 'note-bearing auto update stays auto' $somePackage.action 'auto' + if ($somePackage.reason -notmatch 'READ THE NOTE before applying') { + throw 'Expected note-bearing auto update reason to include READ THE NOTE before applying.' + } + + $unresolved = $audit.rows | Where-Object { $_.id -eq 'Unresolvable.Pkg' } + Assert-Equal 'unresolvable package becomes unresolved row' $unresolved.action 'unresolved' + Assert-Equal 'audit continues after unresolved package' $audit.summary.declared 5 + + $total = $audit.summary.current + $audit.summary.auto + $audit.summary.approval + $audit.summary.unresolved + Assert-Equal 'summary arithmetic closes' $total $audit.summary.declared + + Write-Host 'test-dependency-audit.ps1: PASS' +} +finally { + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/skills/dotnet-nuget-update/scripts/test-package-graph.ps1 b/skills/dotnet-nuget-update/scripts/test-package-graph.ps1 new file mode 100644 index 0000000..2fbdff3 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/test-package-graph.ps1 @@ -0,0 +1,54 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$workspace = Join-Path $repoRoot ('.bot\dotnet-nuget-update-tests\graph-' + [Guid]::NewGuid().ToString('N')) +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Write-File { + param([string]$Path, [string]$Content) + $directory = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -ne $Expected) { throw "$Name failed. Expected '$Expected' but found '$Actual'." } +} + +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +try { + Write-File -Path (Join-Path $workspace 'repo\Directory.Packages.props') -Content @' + + + true + + + + + + + + + + + +'@ + + $graph = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Get-PackageGraph.ps1') -RepoRoot (Join-Path $workspace 'repo') -AsJson | ConvertFrom-Json + Assert-Equal 'graph reports file found' $graph.found $true + Assert-Equal 'graph counts all declarations' $graph.packageCount 4 + Assert-Equal 'same package under multiple conditions yields two rows' (@($graph.packages | Where-Object { $_.id -eq 'Microsoft.Extensions.Logging' }).Count) 2 + + $somePackage = $graph.packages | Where-Object { $_.id -eq 'SomePackage' } + Assert-Equal 'adjacent xml comment is preserved' $somePackage.note 'Pinned until downstream package catches up' + + $filtered = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Get-PackageGraph.ps1') -RepoRoot (Join-Path $workspace 'repo') -Package 'Microsoft.Extensions.Logging' -AsJson | ConvertFrom-Json + Assert-Equal 'package filter keeps both conditional declarations' $filtered.packageCount 2 + + Write-Host 'test-package-graph.ps1: PASS' +} +finally { + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/skills/dotnet-nuget-update/scripts/test-project-package-refs.ps1 b/skills/dotnet-nuget-update/scripts/test-project-package-refs.ps1 new file mode 100644 index 0000000..9c2c551 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/test-project-package-refs.ps1 @@ -0,0 +1,51 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$workspace = Join-Path $repoRoot ('.bot\dotnet-nuget-update-tests\project-' + [Guid]::NewGuid().ToString('N')) +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Write-File { + param([string]$Path, [string]$Content) + $directory = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -ne $Expected) { throw "$Name failed. Expected '$Expected' but found '$Actual'." } +} + +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +try { + $repoPath = Join-Path $workspace 'repo' + Write-File -Path (Join-Path $repoPath 'src\App\App.csproj') -Content @' + + + net10.0 + + + + + + +'@ + Write-File -Path (Join-Path $repoPath 'Directory.Build.props') -Content '' + + $updates = '[{"id":"Newtonsoft.Json","condition":null,"from":"13.0.3","to":"13.0.4"},{"id":"Asp.Versioning.Http","condition":null,"from":"8.1.0","to":"8.1.1"}]' + $result = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Apply-PackageUpdates.ps1') -RepoRoot $repoPath -Updates $updates -AsJson | ConvertFrom-Json + + Assert-Equal 'project-level mode is selected' $result.mode 'project' + Assert-Equal 'project-level updates applied' (@($result.results | Where-Object { $_.outcome -eq 'applied' }).Count) 2 + + $projectText = Get-Content -Raw -LiteralPath (Join-Path $repoPath 'src\App\App.csproj') + if ($projectText -notmatch 'Newtonsoft\.Json" Version="13\.0\.4"' -or $projectText -notmatch 'Asp\.Versioning\.Http" Version="8\.1\.1"') { + throw 'Project-level PackageReference versions were not updated.' + } + + Write-Host 'test-project-package-refs.ps1: PASS' +} +finally { + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/skills/dotnet-nuget-update/scripts/test-tfm-band.ps1 b/skills/dotnet-nuget-update/scripts/test-tfm-band.ps1 new file mode 100644 index 0000000..62592fa --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/test-tfm-band.ps1 @@ -0,0 +1,79 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$workspace = Join-Path $repoRoot ('.bot\dotnet-nuget-update-tests\tfm-' + [Guid]::NewGuid().ToString('N')) +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Write-File { + param([string]$Path, [string]$Content) + $directory = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -ne $Expected) { throw "$Name failed. Expected '$Expected' but found '$Actual'." } +} + +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +try { + $sourceRoot = Join-Path $workspace 'nuget-fixtures' + Write-File -Path (Join-Path $sourceRoot 'microsoft.extensions.logging\index.json') -Content '{ "versions": ["9.0.0","9.0.5","10.0.0","10.0.2","11.0.0-preview.1"] }' + Write-File -Path (Join-Path $sourceRoot 'microsoft.entityframeworkcore\index.json') -Content '{ "versions": ["10.0.0","10.0.7","11.0.0-preview.1"] }' + Write-File -Path (Join-Path $sourceRoot 'asp.versioning.http\index.json') -Content '{ "versions": ["8.0.0","8.1.0","8.1.1","9.0.0"] }' + + Write-File -Path (Join-Path $workspace 'repo\Directory.Packages.props') -Content @' + + + true + + + + + + + + + + + + +'@ + Write-File -Path (Join-Path $workspace 'repo\Directory.Build.props') -Content @' + + + net9.0;net10.0;net11.0 + + +'@ + + Assert-Equal 'net9 band extraction' ((Get-TfmBand -Condition '$(TargetFramework.StartsWith(''net9''))') -join ',') '9' + Assert-Equal 'combined band extraction keeps both bands' ((Get-TfmBand -Condition '$(TargetFramework.StartsWith(''net10'')) OR $(TargetFramework.StartsWith(''net11''))') -join ',') '10,11' + + $audit = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Get-DependencyAudit.ps1') -RepoRoot (Join-Path $workspace 'repo') -Source $sourceRoot -AsJson | ConvertFrom-Json + + $net9Logging = $audit.rows | Where-Object { $_.id -eq 'Microsoft.Extensions.Logging' -and $_.condition -eq '$(TargetFramework.StartsWith(''net9''))' } + Assert-Equal 'net9 package stays in 9.x band' $net9Logging.candidate '9.0.5' + Assert-Equal 'net9 package reports band 9' $net9Logging.band 9 + + $net10Logging = $audit.rows | Where-Object { $_.id -eq 'Microsoft.Extensions.Logging' -and $_.condition -eq '$(TargetFramework.StartsWith(''net10''))' } + Assert-Equal 'net10 package stays in 10.x band' $net10Logging.candidate '10.0.2' + Assert-Equal 'net10 package reports band 10' $net10Logging.band 10 + + $aspVersioning = $audit.rows | Where-Object { $_.id -eq 'Asp.Versioning.Http' } + Assert-Equal 'mismatched major infers no band' $aspVersioning.band $null + Assert-Equal 'mismatched major can select 9.0.0' $aspVersioning.candidate '9.0.0' + + $efCore = $audit.rows | Where-Object { $_.id -eq 'Microsoft.EntityFrameworkCore' } + Assert-Equal 'combined condition chooses band 10' $efCore.band 10 + Assert-Equal 'combined condition stays in 10.x' $efCore.candidate '10.0.7' + + Write-Host 'test-tfm-band.ps1: PASS' +} +finally { + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/skills/dotnet-nuget-update/scripts/test-version-comparison.ps1 b/skills/dotnet-nuget-update/scripts/test-version-comparison.ps1 new file mode 100644 index 0000000..e89b897 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/test-version-comparison.ps1 @@ -0,0 +1,60 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/_common.ps1" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$workspace = Join-Path $repoRoot ('.bot\dotnet-nuget-update-tests\version-' + [Guid]::NewGuid().ToString('N')) +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Write-File { + param([string]$Path, [string]$Content) + $directory = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -ne $Expected) { + throw "$Name failed. Expected '$Expected' but found '$Actual'." + } +} + +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +try { + $sourceRoot = Join-Path $workspace 'nuget-fixtures' + Write-File -Path (Join-Path $sourceRoot 'newtonsoft.json\index.json') -Content '{ "versions": ["12.0.0","13.0.0","13.0.1","13.0.2","13.0.3","13.0.4","14.0.0"] }' + Write-File -Path (Join-Path $sourceRoot 'awssdk.core\index.json') -Content '{ "versions": ["3.7.0.0","3.7.0.1","3.7.100.0","4.0.0.0","4.0.100.6","4.0.100.8"] }' + Write-File -Path (Join-Path $sourceRoot 'xunit.v3\index.json') -Content '{ "versions": ["1.0.0","1.1.0","2.0.0-pre.1","2.0.0-pre.2"] }' + Write-File -Path (Join-Path $sourceRoot 'somepackage.prerelease\index.json') -Content '{ "versions": ["1.0.0-rc.1","1.0.0-rc.2","2.0.0-rc.1"] }' + + $resolveStable = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Resolve-NuGetVersion.ps1') -Id 'xunit.v3' -Source $sourceRoot -AsJson | ConvertFrom-Json + Assert-Equal 'stable latest ignores prerelease' $resolveStable.latest '1.1.0' + Assert-Equal 'latestStable reports stable version' $resolveStable.latestStable '1.1.0' + Assert-Equal 'latestAny reports prerelease version' $resolveStable.latestAny '2.0.0-pre.2' + + $resolveAny = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Resolve-NuGetVersion.ps1') -Id 'somepackage.prerelease' -Source $sourceRoot -IncludePrerelease -AsJson | ConvertFrom-Json + Assert-Equal 'prerelease source reports latest prerelease' $resolveAny.latest '2.0.0-rc.1' + + $sorted = Sort-NuGetVersion -Version @('13.0.4', '13.0.2', '13.0.3') + Assert-Equal 'Sort-NuGetVersion uses semantic ordering' ($sorted -join ',') '13.0.2,13.0.3,13.0.4' + + $prereleaseVsRelease = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Compare-Version.ps1') -A '1.0.0-rc.2' -B '1.0.0' -AsJson | ConvertFrom-Json + Assert-Equal 'prerelease sorts below release' $prereleaseVsRelease.relation 'lt' + + $releaseVsPrerelease = & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'Compare-Version.ps1') -A '1.0.0' -B '1.0.0-rc.2' -AsJson | ConvertFrom-Json + Assert-Equal 'release sorts above prerelease' $releaseVsPrerelease.relation 'gt' + + Assert-Equal 'no bump when versions equal' (Get-NuGetVersionBump -From '13.0.3' -To '13.0.3') 'none' + Assert-Equal 'patch bump classification' (Get-NuGetVersionBump -From '13.0.3' -To '13.0.4') 'patch' + Assert-Equal 'minor bump classification' (Get-NuGetVersionBump -From '13.0.0' -To '13.1.0') 'minor' + Assert-Equal 'major bump classification' (Get-NuGetVersionBump -From '13.0.3' -To '14.0.0') 'major' + Assert-Equal 'revision bump classification' (Get-NuGetVersionBump -From '4.0.100.6' -To '4.0.100.8') 'revision' + Assert-Equal 'prerelease bump classification' (Get-NuGetVersionBump -From '1.0.0-rc.1' -To '1.0.0-rc.2') 'prerelease' + + Write-Host 'test-version-comparison.ps1: PASS' +} +finally { + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/skills/dotnet-nuget-update/scripts/validate-skill.ps1 b/skills/dotnet-nuget-update/scripts/validate-skill.ps1 new file mode 100644 index 0000000..3c56765 --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/validate-skill.ps1 @@ -0,0 +1,87 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$skillRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$skillName = Split-Path -Leaf $skillRoot + +$requiredFiles = @( + 'SKILL.md', + 'evals/evals.json', + 'scripts/_common.ps1', + 'scripts/Get-PackageGraph.ps1', + 'scripts/Get-DependencyAudit.ps1', + 'scripts/Get-TargetFrameworks.ps1', + 'scripts/Resolve-NuGetVersion.ps1', + 'scripts/Compare-Version.ps1', + 'scripts/Apply-PackageUpdates.ps1', + 'scripts/Get-NuGetSources.ps1', + 'scripts/run-tests.ps1', + 'scripts/validate-skill.ps1', + 'scripts/test-version-comparison.ps1', + 'scripts/test-tfm-band.ps1', + 'scripts/test-package-graph.ps1', + 'scripts/test-dependency-audit.ps1', + 'scripts/test-apply-updates.ps1', + 'scripts/test-project-package-refs.ps1' +) + +foreach ($relativePath in $requiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $skillRoot $relativePath) -PathType Leaf)) { + throw "Missing required file: $relativePath" + } +} + +$skillText = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'SKILL.md')) +$frontmatter = [regex]::Match($skillText, '(?s)^---\r?\n(.*?)\r?\n---\r?\n') +if (-not $frontmatter.Success) { + throw 'SKILL.md must start with YAML frontmatter.' +} + +$nameMatch = [regex]::Match($frontmatter.Groups[1].Value, '(?m)^name:\s*(.+)$') +if (-not $nameMatch.Success) { + throw 'SKILL.md frontmatter must contain a name field.' +} + +if ($nameMatch.Groups[1].Value.Trim() -ne $skillName) { + throw "SKILL.md frontmatter name must match folder name '$skillName'." +} + +$scriptReferences = [regex]::Matches($skillText, 'scripts/[A-Za-z0-9._-]+\.ps1') | + ForEach-Object { $_.Value.Replace('/', '\') } | + Sort-Object -Unique +foreach ($reference in $scriptReferences) { + if (-not (Test-Path -LiteralPath (Join-Path $skillRoot $reference) -PathType Leaf)) { + throw "SKILL.md references a missing script: $reference" + } +} + +$evals = Get-Content -Raw -LiteralPath (Join-Path $skillRoot 'evals/evals.json') | ConvertFrom-Json +if ($evals.skill_name -ne $skillName) { + throw "evals/evals.json skill_name must equal '$skillName'." +} + +foreach ($eval in @($evals.evals)) { + foreach ($relativePath in @($eval.files)) { + if (-not (Test-Path -LiteralPath (Join-Path $skillRoot $relativePath) -PathType Leaf)) { + throw "Missing eval fixture: $relativePath" + } + } +} + +$commonPath = Join-Path $skillRoot 'scripts/_common.ps1' +& pwsh -NoProfile -Command ". '$commonPath'" +if ($LASTEXITCODE -ne 0) { + throw "_common.ps1 failed to load with exit code $LASTEXITCODE." +} + +foreach ($testScript in Get-ChildItem -LiteralPath (Join-Path $skillRoot 'scripts') -Filter 'test-*.ps1' -File) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($testScript.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $message = $errors | Select-Object -First 1 | ForEach-Object { $_.Message } + throw "$($testScript.Name) has a syntax error: $message" + } +} + +Write-Host 'dotnet-nuget-update skill validation: PASS' From 9861cc15f34cf895616ebdf064ba8945715842b0 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 7 Sep 2026 23:00:21 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=92=AC=20update=20readme=20with=20d?= =?UTF-8?q?otnet-nuget-update=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dotnet-nuget-update to the Available Skills catalog table with full skill description, include the npx installation command in the setup section, and add a 'Why dotnet-nuget-update?' community health section explaining the need for complete dependency graph auditing, TFM-band awareness, and preservation of intentional pins and compatibility markers. --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index b2dce65..cf87599 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Each `SKILL.md` description is lean activation metadata. The catalog below expla | [dotnet-strong-name-signing](skills/dotnet-strong-name-signing/SKILL.md) | Generate a strong name key (`.snk`) file for signing .NET assemblies using pure .NET cryptography — no Visual Studio Developer PowerShell or `sn.exe` required. Works in any terminal. Defaults to 1024-bit RSA (matching `sn.exe`), with 2048 and 4096 available as options. | | [git-remote-release](skills/git-remote-release/SKILL.md) | Generate GitHub release notes by summarizing all commits and pull requests between two Git tags or branches in a remote GitHub repository. Accepts a compare URL or separate owner/repo, previous ref, and current ref values; falls back to comparing the current branch against the upstream default branch when no input is provided. Produces a human-friendly `## What's Changed` summary with optional GitHub alert blocks, a `Sources:` section preserving PR and commit references, and a full changelog compare link. | | [dotnet-change-impact](skills/dotnet-change-impact/SKILL.md) | Classify .NET library or NuGet package changes and recommend the correct release bump — `Major`, `Minor`, or `Patch` — for both Semantic Versioning (`MAJOR.MINOR.PATCH`) and .NET assembly/file versioning (`Major.Minor.Build.Revision`), grounded in Microsoft's official .NET compatibility rules. Uses the current Git branch by default when no explicit change details or compare range are provided, resolving it against the upstream/default base branch with local read-only git state. Always returns structured behavioral/binary/source/design-time/backwards compatibility reasoning with the recommendation, even when the bump is clear. | +| [dotnet-nuget-update](skills/dotnet-nuget-update/SKILL.md) | Audits and updates NuGet dependencies in .NET repositories with complete declaration accounting before any edit. It supports both `Directory.Packages.props` and project-level `PackageReference` versions, preserves XML structure and line endings, resolves live or offline flat-container version feeds with per-process memoization, keeps stable pins on stable candidates unless prerelease intent is explicit, and applies the TFM-band rule so conditional `net9`/`net10` package declarations stay within their matching major when that major is the compatibility signal rather than jumping to the newest overall release. Normal mode auto-applies revision/patch/minor and same-major prerelease updates, then batches majors for one approval decision; yolo mode applies only the auto classes and reports held majors without asking. | | [dotnet-docfx-digest](skills/dotnet-docfx-digest/SKILL.md) | Create and maintain developer-friendly DocFX documentation for .NET public APIs, including repo-wide no-input audits that inspect source, tests, DocFX config, DocFX `build.content` and `build.overwrite` Markdown inputs, namespace pages, and availability includes before asking for clarification, while treating bare direct skill invocations as autonomous repo-wide runs rather than human-driven checkpoint sessions. Enforces the workflow with two bundled .NET 10 file-based scripts resolved from the loaded skill directory, falling back to the repo-managed source path only when present: `scripts/agents.cs` writes an idempotent, marker-bounded DocFX maintenance block into the repository `AGENTS.md`; `scripts/docfx.cs` is **fast and build-free by default** — it validates Markdown, prose, DocFX overwrite layout, namespace overview pages, `Extension Members` tables, decorated receiver signatures such as `IDecorator`, generic method displays such as `As`, purpose-first summaries, and required per-type/extension examples without invoking `dotnet`, `msbuild`, `docfx`, or `gh`, discovering the public API from existing DocFX YAML metadata or a conservative source scan and ending every run with a `[processes] dotnet=0 msbuild=0 docfx=0 gh=0` summary plus per-phase timings. Compilation and network access are strictly opt-in: `--validate-samples` compiles each C# sample in an isolated project while batching all sample projects into one temporary `.slnx` graph build with bounded MSBuild parallelism and scoped references, `--build-api-model` (alias `--strict-api-discovery`) does reflection-backed discovery from compiled metadata via `MetadataLoadContext` through a single scoped `.slnx` graph build, `--verify-docfx-build` runs the DocFX CLI in a temp copy, and `--search-examples` runs `gh` code search. Final verification adapts to available processors and memory, overlaps isolated DocFX work on high-capacity machines, uses a 30-minute child timeout, and emits 10-second `stderr` heartbeats with active phase, workload, runner count, PID, elapsed time, last-output age, and current child output while preserving machine-readable JSON on `stdout`. Honors a single DocFX metadata `TargetFramework` when `--framework` is omitted, collapses C# 14 extension-block compiler containers such as `$...` back to the authored outer static class in both fast DocFX-YAML discovery and build-backed reflection discovery, validates namespace fly-ins that explain the problem solved/when to use/where to start plus example fly-ins before every C# fence, the Codebelt namespace-and-type-folder overwrite layout (`.docfx/api/namespaces/**/*.md` and `.docfx/api/types/**/*.md` under `build.overwrite` only), keeps `--changed-only` validation scoped to affected docs and APIs while still including brand-new untracked overwrite Markdown, uses the root Codebelt `.snk` when present and falls back to `-p:SkipSignAssembly=true` for keyless strong-name build verification, drains child stdout and stderr concurrently to avoid verbose-build deadlocks, writes deterministic `--assessment-queue` Markdown work queues for noisy audits, preserves working URL references unless a verified HTTP 404 justifies removal, treats unexpected new repo-root or DocFX-workspace files that are not known `dotnet-docfx-digest` deliverables as blocking cleanup diagnostics, keeps assessment/manifests/captured output/helper scripts in temp or session storage instead of the target repository, requires a namespace-first pass across the active queue before net-new type/example authoring during full audits, keeps deeper `EXTENSION_METHOD_MISSING` and `EXTENSION_METHOD_SIGNATURE_MISSING` follow-on diagnostics in that same namespace-layer table-repair phase when they appear after `EXTENSION_SECTION_MISSING` drops, preserves existing BOM and line-ending state while flagging actual mojibake instead of creating encoding-only diffs, and leaves generated DocFX YAML metadata untouched unless `--clean-generated-metadata` is explicitly requested (which runs only after the API model is built, never deleting metadata the run relied on). Documents public API only, uses bundled reference docs for overwrite rules, workflow details, and script behavior, keeps authored API overwrite Markdown under `.docfx/api/namespaces/` and `.docfx/api/types/`, moves legacy authored `.docfx/api/*.md` overwrite files there instead of widening the glob to `api/**/*.md`, teaches namespace and API prose to orient newcomers around purpose instead of inventorying contents, prefers inline or small sibling-batch prose repairs over slow per-page worker fan-out, makes examples start from package-ID usage evidence before type/member-only searches and requires each example to introduce the consumer task before the code, allows multi-type Microsoft Learn-style scenario samples when they better explain the consumer workflow, keeps extension-method examples on readable declaring-class type pages under `.docfx/api/types/` instead of synthetic method-UID filenames or namespace pages that mix extra `uid:` / `example:` blocks into the overview, flags weak skip-compile reasons, requires deterministic `.docfx/skip-compile-allowlist.json` entries for any pre-existing approved skip waivers, treats newly introduced or unallowlisted skip markers as fail-level diagnostics that do not suppress compilation, establishes reflection-backed packets with `--build-api-model --project-manifest` before full-run authoring, forces mid-audit continuations to name that manifest or the sequential assessment/namespace-first fallback explicitly, requires those continuations to restate the fast `docfx.cs --json` rerun cadence, the exact final `docfx.cs --build-api-model --validate-samples --verify-docfx-build --json` gate, and the clean JSON completion contract instead of generic “verify later” prose, treats batch size only as rerun cadence rather than permission to stop, runs a completion repair loop that treats every diagnostic as active work regardless of age or volume, treats newly surfaced follow-on diagnostics as the next repair queue instead of a stop point, reruns packet discovery with `--build-api-model --project-manifest` when fast source-scan packets are unnamed or zero-project, falls back to sequential namespace-first or assessment work queue order when packet discovery is still unusable, treats `EXAMPLE_MISSING`, `EXAMPLE_LEAD_MISSING`, `EXAMPLE_ADVANCED_LEAD_MISSING`, `FAMILY_ANCHOR_EXAMPLE_MISSING`, `SAMPLE_STRUCTURE_INVALID`, `FAIL_NEW_SKIP_MARKER_INTRODUCED`, `SAMPLE_SKIP_NOT_ALLOWLISTED`, and `INTERIM_ARTIFACT_IN_WORKTREE` queues as core work rather than checkpoints or quality backlog, drives large example and lead queues through a concrete fast-path micro-loop (next item or next 3-5 items → rerun → continue), suppresses progress-table/checkpoint output until the completion contract is clean or a real external blocker is reported, treats premature completion-shaped handoffs as execution-protocol failures while the queue is still dirty, reserves the final `--build-api-model --validate-samples --verify-docfx-build` verification for the real end of the queue, exposes `summary.fullVerificationRan`, `summary.canClaimCompletion`, `summary.remainingWorkItems`, `summary.remainingDiagnosticsByCode`, `summary.newlyIntroducedSkipMarkers`, and `summary.interimArtifacts` as machine-readable final gates, reruns the fast `docfx.cs --json` after edits until the queue is empty, then runs the build-backed verification before completion, preserves manual edits and authored Markdown during cleanup, skips recursive generated-output cleanup when a target directory contains documentation or source files, and returns deterministic exit codes plus `--json` reports (including process counts, phase timings, warning counts, and skip-marker accounting) so CI can gate on real failures instead of AI claims. | | [dotnet-test](skills/dotnet-test/SKILL.md) | Moves xUnit projects onto Codebelt's entrypoint-owned test hosts, replacing Microsoft's ASP.NET-only `WebApplicationFactory`—and the hand-rolled `HostBuilder` that console and worker tests reach for because Microsoft ships no equivalent—with one family of abstractions where the application's own entry point owns startup. Invocation is the request: it inspects and refactors immediately instead of opening with a menu or a questionnaire. It deterministically inspects project roles, target frameworks, xUnit generation, package ownership, inheritance, application entry points—including Bootstrapper `MinimalConsoleProgram`, `MinimalWorkerProgram`, and `MinimalWebProgram` hosts—and every selected `WebApplicationFactory` usage; classifies ordinary unit, ASP.NET Core functional, and console/worker functional tests; modernizes xUnit v2 projects to xUnit v3 plus Microsoft Testing Platform without moving package ownership or changing frameworks; and resolves current stable compatible packages through NuGet-backed isolated compatibility-project restores, including the selected combined package set. Focused web tests use `WebApplicationTestFactory` with an explicit entrypoint-owned `ManagedWebApplicationFixture`, directly or through a narrow `Test`-derived harness; shared web fixtures use `WebApplicationTest` with `ManagedWebApplicationFixture`; focused console/worker tests use `ApplicationTestFactory` with `ManagedApplicationFixture`; and shared non-web fixtures use `ApplicationTest` with `ManagedApplicationFixture`. Deprecated blocking fixtures are migration inputs only and are never emitted because they are scheduled for removal. Functional migrations fail closed unless the chosen Codebelt pattern and managed fixture are present, the legacy or blocking fixture is absent, and test code does not reconstruct the production composition root with its own `WebApplication`, `TestServer`, or `HostBuilder`. Migrations preserve entrypoint-owned startup, host configuration, lazy start, clients, services, configuration, sync/async disposal, isolation, and existing test names, while fresh bootstraps add source-grounded behavior tests. Non-web tests stay in-process and require a resolvable Generic Host; test-only scope reports the exact production adaptation instead of silently rewriting startup or launching a process. | | [dotnet-benchmark](skills/dotnet-benchmark/SKILL.md) | Discovers, prioritizes, and authors trustworthy BenchmarkDotNet experiments for a .NET type following codebelt conventions and using the `Codebelt.Extensions.BenchmarkDotNet.Console` runner. It inspects implementation code, call sites, tests, existing benchmarks, and available profiles instead of benchmarking every public member; ranks likely high-impact operations; selects representative typical, boundary, scaling, and adverse cases; and rejects external-I/O or service-level questions that need profiling, macrobenchmarks, or load tests. It creates fair current-versus-candidate comparisons only when observable work is equivalent, uses baseline-free single-operation characterization when no honest comparator exists, prevents unrelated construction/formatting/equality/hash ratios, requires exact per-case correctness oracles plus a semantic preflight for truthful workload labels, hard-gates interpretation on a complete valid BenchmarkDotNet summary, preserves workload invariants such as selectivity and hit/miss ratios as sizes scale, distinguishes deferred pipeline creation from terminal/materialization work, and performs Release build, discovery listing, and dry execution before any explicit full run. Explicit `yolo` mode auto-accepts routine repo-derived defaults and the proposed plan, then proceeds through build/list/dry validation without confirmation churn; only a separate explicit human instruction can start a full performance run. Its runner preflight recognizes the standard Slim/runtime setup and explains when `SkipBenchmarksWithReports = true` plus a matching `reports/tuning/` artifact deliberately filters a benchmark, preventing needless class renames, disassembly, or tool thrash; after the first valid full result it stops unless deeper diagnostics could change a real engineering decision. Harness setup remains adaptive: it detects `.slnx`/`.sln`, CPM, existing `tuning/` projects, and a reusable `tooling/` runner, onboards only missing pieces, resolves package versions dynamically, and keeps the benchmark class in the SUT namespace. | @@ -253,6 +254,11 @@ npx skills add https://github.com/codebeltnet/agentic --skill dotnet-docfx-diges ```bash npx skills add https://github.com/codebeltnet/agentic --skill dotnet-benchmark ``` +`dotnet-nuget-update` + +```bash +npx skills add https://github.com/codebeltnet/agentic --skill dotnet-nuget-update +``` `dotnet-test` ```bash @@ -621,6 +627,20 @@ Picking the wrong version number is one of the easiest ways to break downstream - **Precedence-aware** — mixed releases take the highest required bump, - **Special-case savvy** — dependency updates, bug fixes, new overloads, interface and enum changes, analyzers/source generators, TFM/platform support, and performance changes each get the right default and the right escalation triggers. +### Why dotnet-nuget-update? + +Dependency updates look simple until a repository encodes compatibility in its package graph. A `Directory.Packages.props` file can pin the same package differently for `net9` and `net10`, hold a package back with an inline comment because a newer release dropped a target framework, or mix stable and prerelease intent on purpose. A shallow “latest package wins” pass breaks those repos quietly. + +**dotnet-nuget-update** makes the complete audit the first-class artifact. It enumerates every declaration before editing, keeps `current + auto + approval + unresolved == declared` as a hard invariant, resolves versions from a live or offline flat-container feed with per-process memoization, and preserves XML comments, spacing, encoding, and line endings when it writes changes back. + +- **Complete graph first** — every `` declaration, and explicit project-level `PackageReference` when CPM is absent, becomes an audit row before any update is applied, +- **TFM-band aware** — a package pinned under `net9` or `net10` stays inside that matching major when the pinned major itself is the compatibility signal, while mismatched majors remain free to move, +- **Stable/prerelease intent inference** — stable pins stay on stable candidates; prerelease pins may move within prerelease lines; same-major prerelease movement is auto, not approval, +- **History-first notes** — adjacent XML comments surface as `note` fields, and note-bearing auto updates are explicitly marked `READ THE NOTE before applying`, +- **Normal and yolo modes** — normal mode batches majors into one approval decision after the full audit, while yolo mode applies only the safe classes and reports held majors without asking, +- **Structural edits only** — version updates touch only the targeted attribute or element and leave comments, blank lines, unrelated files, and existing line endings intact, +- **Offline-testable** — the bundled scripts accept filesystem flat-container fixtures so audit, comparison, and update logic can be regression-tested without network dependency. + ### Why dotnet-docfx-digest? API documentation rots the moment code changes. A new public type ships without a namespace page, an extension method never makes it into the `Extension Members` table, a copy/paste example silently stops compiling, and "availability" drifts away from the real target frameworks. The usual fix — telling an agent to "remember to update the docs" — relies on AI memory, which is exactly the thing that fails on the next change. From 2b2dd03cd428dc8bd53bb84daffbf444663337ff Mon Sep 17 00:00:00 2001 From: Eval Worker Date: Mon, 7 Sep 2026 23:04:37 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20document=20dotnet-nuget-updat?= =?UTF-8?q?e=20in=20changelog=20and=20add=20version=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f0132..845d597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-09-08 + +This is a minor release that introduces `dotnet-nuget-update`, a deterministic NuGet dependency audit and update workflow for .NET repositories. The skill owns the complete-audit invariant, ensuring every declared package version is accounted for before any update is applied. It handles both central package management (`Directory.Packages.props`) and project-level `PackageReference` versioning, tracks stable and prerelease intent, preserves TFM-band pins (keeping `net9` or `net10` packages within their matching major when that major is the compatibility signal), and supports both normal mode (auto-applies patch/minor/revision, batches majors for approval) and yolo mode (auto-applies safe classes only, reports held majors). All scripts are deterministic and offline-testable via bundled fixtures. + +### Added + +- `dotnet-nuget-update` skill for auditing and updating NuGet dependencies with complete declaration accounting, supporting both central package management and project-level versioning, two interactive modes (normal with approval batching, yolo for safe updates only), stable/prerelease intent inference, and TFM-band awareness so conditional `net9`/`net10` package declarations stay within their matching major when that major is the compatibility signal rather than jumping to the newest overall release, +- bundled deterministic scripts: `Get-DependencyAudit.ps1` for complete graph enumeration before any edit, `Get-PackageGraph.ps1` for central-package condition resolution, `Get-TargetFrameworks.ps1` for TFM matrix discovery, `Resolve-NuGetVersion.ps1` and `Compare-Version.ps1` for version investigation, `Apply-PackageUpdates.ps1` for minimal structural XML edits preserving comments and line endings, and `Get-NuGetSources.ps1` for feed configuration visibility, +- comprehensive test coverage: regression harnesses for dependency audit, package graph, TFM-band logic, project-level package references, version comparison, and update application, together with offline-testable fixtures covering central-package scenarios, mixed stable/prerelease intent, multi-TFM bands, plain project references, and XML comment pinning, +- per-process memoization for live or offline flat-container NuGet version feeds, with filesystem flat-container fixtures supporting deterministic offline testing, +- complete-audit invariant validation ensuring `current + auto + approval + unresolved == declared` before reporting the repository as updated, +- history-aware update tracking with adjacent XML comments surfacing as `note` fields so pin rationale remains visible during audits and auto-updates marked with `READ THE NOTE before applying` receive explicit attention, +- README catalog entry, installation snippet, and "Why dotnet-nuget-update?" community health section explaining the need for complete dependency graph auditing, TFM-band awareness, and preservation of intentional pins and compatibility markers. + ## [0.9.1] - 2026-09-07 This patch release adds harness-agnostic Eval Runner execution boundary infrastructure without changing the paired evaluation methodology or existing report schemas, while optimizing skill descriptions and refactoring repository-level authoring guidance. Prepared packages now carry `execution-profile.json`, package-local runner protocol tools, and normalized `execution-result.json` evidence. The deterministic fake runner is the conformance reference, with Codex, GitHub Copilot CLI, and OpenCode as supported real adapters. Repository automation remains model-free; only a human-directed external Eval Orchestrator may invoke the selected runner, and unsupported isolation fails closed. @@ -638,6 +652,7 @@ This is a minor release that introduces two complementary git workflow skills, e - Improved scaffold fidelity with hidden `.bot` asset preservation, explicit UTF-8 and BOM handling, and checks aimed at preventing mojibake or incomplete generated output. +[0.10.0]: https://github.com/codebeltnet/agentic/compare/v0.9.1...v0.10.0 [0.9.1]: https://github.com/codebeltnet/agentic/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/codebeltnet/agentic/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/codebeltnet/agentic/compare/v0.8.1...v0.8.2 From 4be6ae590f4352335dfb0fa3e6a1abd6571867e8 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 7 Sep 2026 23:35:08 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20improve=20dotnet-nug?= =?UTF-8?q?et-update=20script=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance file encoding detection to support UTF-32 and UTF-16 variants. Improve line-ending preservation with better handling of mixed styles. Refactor XML and NuGet version parsing with more sophisticated patterns and error handling. Strengthen cross-line tag parsing and version validation for edge cases in package updates and audit workflows. --- .../scripts/Apply-PackageUpdates.ps1 | 285 ++++++++++++---- .../scripts/Get-DependencyAudit.ps1 | 125 +++++-- .../scripts/Get-PackageGraph.ps1 | 20 +- .../scripts/Get-TargetFrameworks.ps1 | 131 +++++++- .../scripts/Resolve-NuGetVersion.ps1 | 8 +- .../dotnet-nuget-update/scripts/_common.ps1 | 312 +++++++++++++++--- 6 files changed, 723 insertions(+), 158 deletions(-) diff --git a/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 index 46ffa4f..ff03a1b 100644 --- a/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 +++ b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 @@ -18,15 +18,35 @@ function Get-NodeVersion { if ($Node.Attributes['Version']) { return [string]$Node.Attributes['Version'].Value } + if ($Node.Attributes['VersionOverride']) { + return [string]$Node.Attributes['VersionOverride'].Value + } $versionNode = $Node.SelectSingleNode('./Version') if ($versionNode) { return [string]$versionNode.InnerText } + $overrideNode = $Node.SelectSingleNode('./VersionOverride') + if ($overrideNode) { + return [string]$overrideNode.InnerText + } return $null } +function Get-UpdateField { + param($Update, [Parameter(Mandatory)][string]$Name) + + if ($null -eq $Update) { return $null } + if ($Update -is [System.Collections.IDictionary]) { + if ($Update.Contains($Name)) { return $Update[$Name] } + return $null + } + $property = $Update.PSObject.Properties[$Name] + if ($null -ne $property) { return $property.Value } + return $null +} + function Get-ConditionValue { param($Node) @@ -55,21 +75,34 @@ function Parse-Updates { return @($parsed) } -function Replace-VersionInLine { +function Replace-VersionInBlock { param( - [Parameter(Mandatory)][string]$Line, + [Parameter(Mandatory)][string]$Block, [Parameter(Mandatory)][string]$From, [Parameter(Mandatory)][string]$To ) - $attributePattern = '(Version\s*=\s*")' + [regex]::Escape($From) + '(")' - if ($Line -match $attributePattern) { - return ($Line -replace $attributePattern, "`${1}$To`${2}") + $escaped = [regex]::Escape($From) + + foreach ($attributeName in @('Version', 'VersionOverride')) { + $pattern = '(' + $attributeName + '\s*=\s*)([''"])' + $escaped + '\2' + $match = [regex]::Match($Block, $pattern) + if ($match.Success) { + $prefix = $match.Groups[1].Value + $quote = $match.Groups[2].Value + $start = $match.Index + $prefix.Length + $quote.Length + return $Block.Substring(0, $start) + $To + $Block.Substring($start + $From.Length) + } } - $elementPattern = '(\s*)' + [regex]::Escape($From) + '(\s*)' - if ($Line -match $elementPattern) { - return ($Line -replace $elementPattern, "`${1}$To`${2}") + foreach ($elementName in @('Version', 'VersionOverride')) { + $pattern = '(<' + $elementName + '\s*>\s*)' + $escaped + '(\s*)' + $match = [regex]::Match($Block, $pattern) + if ($match.Success) { + $prefix = $match.Groups[1].Value + $start = $match.Index + $prefix.Length + return $Block.Substring(0, $start) + $To + $Block.Substring($start + $From.Length) + } } return $null @@ -85,57 +118,146 @@ function Update-DeclarationLine { [Parameter(Mandatory)][string]$To ) - $lines = $Text -split '\r?\n', -1 - $currentCondition = $null + $lines = [regex]::Split($Text, "`r`n|`n|`r") + $groupCondition = $null - for ($index = 0; $index -lt $lines.Length; $index++) { + for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index] if ($line -match '' -and ($tagEnd + 1) -lt $lines.Count) { + $tagEnd++ + $tag += "`n" + $lines[$tagEnd] + } + $conditionMatch = [regex]::Match($tag, 'Condition\s*=\s*([''"])(.*?)\1') + $groupCondition = if ($conditionMatch.Success) { $conditionMatch.Groups[2].Value } else { $null } + if ($tagEnd -gt $index) { + $index = $tagEnd + $line = $lines[$index] + } + } + + if ($line -match '') { + $groupCondition = $null + } + + if ($line -notmatch ('<' + $ElementName + '\b')) { + continue } - if ($line -match "<$ElementName\b" -and $line -match ('Include\s*=\s*"' + [regex]::Escape($Id) + '"')) { - if (($Condition ?? '') -eq ($currentCondition ?? '')) { - $updatedLine = Replace-VersionInLine -Line $line -From $From -To $To - if ($null -ne $updatedLine) { - $lines[$index] = $updatedLine - return ($lines -join "`n") + $blockStart = $index + $tagText = $line + $tagEnd = $index + while ($tagText -notmatch '>' -and ($tagEnd + 1) -lt $lines.Count) { + $tagEnd++ + $tagText += "`n" + $lines[$tagEnd] + } + + $blockEnd = $tagEnd + $blockLines = @($lines[$blockStart..$blockEnd]) + if ($tagText -match '/\s*>\s*$' -or $tagText -match '/\s*>') { + $afterStart = $tagText.Substring($tagText.IndexOf('>') + 1) + if ($afterStart -match ('')) { + # Self-closing tag text already contains its close; block is complete. + } + } else { + $afterStart = '' + $greaterAt = $tagText.IndexOf('>') + if ($greaterAt -ge 0 -and $greaterAt + 1 -lt $tagText.Length) { + $afterStart = $tagText.Substring($greaterAt + 1) + } + if ($afterStart -notmatch ('')) { + $foundClose = $false + for ($closeIndex = $tagEnd + 1; $closeIndex -lt $lines.Count; $closeIndex++) { + $blockLines += $lines[$closeIndex] + $blockEnd = $closeIndex + if ($lines[$closeIndex] -match ('')) { + $foundClose = $true + break + } + if ($lines[$closeIndex] -match '') { - $currentCondition = $null + $blockText = $blockLines -join "`n" + if (-not ([regex]::IsMatch($blockText, 'Include\s*=\s*([''"])' + [regex]::Escape($Id) + '\1'))) { + $index = $blockEnd + continue + } + + $nodeConditionMatch = [regex]::Match($tagText, 'Condition\s*=\s*([''"])(.*?)\1') + $nodeCondition = if ($nodeConditionMatch.Success) { $nodeConditionMatch.Groups[2].Value } else { $null } + $effective = if ($nodeCondition) { $nodeCondition } else { $groupCondition } + if ((($Condition ?? '') -ne ($effective ?? ''))) { + $index = $blockEnd + continue + } + + $updatedBlock = Replace-VersionInBlock -Block $blockText -From $From -To $To + if ($null -eq $updatedBlock) { + $index = $blockEnd + continue + } + + $updatedLines = [regex]::Split($updatedBlock, "`r`n|`n|`r") + $newAll = [System.Collections.Generic.List[string]]::new() + for ($before = 0; $before -lt $blockStart; $before++) { + $newAll.Add($lines[$before]) + } + foreach ($updatedLine in $updatedLines) { + $newAll.Add($updatedLine) } + for ($after = $blockEnd + 1; $after -lt $lines.Count; $after++) { + $newAll.Add($lines[$after]) + } + return ($newAll -join "`n") } return $null } function Get-CentralDeclarations { - param([Parameter(Mandatory)][string]$Path) + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$RepoRoot) - [xml]$xml = Get-Content -Raw -LiteralPath $Path + $raw = Read-TextWithEncoding -Path $Path + [xml]$xml = $raw $rows = [System.Collections.Generic.List[object]]::new() - foreach ($itemGroup in @($xml.Project.ItemGroup)) { - $condition = Get-ConditionValue -Node $itemGroup + foreach ($itemGroup in @($xml.SelectNodes('/Project/ItemGroup'))) { + $groupCondition = Get-ConditionValue -Node $itemGroup foreach ($node in @($itemGroup.ChildNodes)) { if ($node.NodeType -ne 'Element' -or $node.Name -ne 'PackageVersion') { continue } $id = if ($node.Attributes['Include']) { [string]$node.Attributes['Include'].Value } else { $null } if ([string]::IsNullOrWhiteSpace($id)) { continue } + $nodeCondition = Get-ConditionValue -Node $node $rows.Add([pscustomobject]@{ - id = $id - current = Get-NodeVersion -Node $node - condition = $condition - element = 'PackageVersion' + id = $id + current = Get-NodeVersion -Node $node + condition = if ($nodeCondition) { $nodeCondition } else { $groupCondition } + element = 'PackageVersion' + sourceFile = [System.IO.Path]::GetRelativePath($RepoRoot, (Resolve-Path -LiteralPath $Path).Path) }) } } return @($rows) } +function Get-CentralPropsFiles { + param([Parameter(Mandatory)][string]$Root) + + return @(Get-ChildItem -LiteralPath $Root -Recurse -Filter Directory.Packages.props -File | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | + Sort-Object FullName) +} + function Get-ProjectDeclarations { param([Parameter(Mandatory)][string]$Root) @@ -144,8 +266,9 @@ function Get-ProjectDeclarations { Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } foreach ($projectFile in $projectFiles) { - [xml]$xml = Get-Content -Raw -LiteralPath $projectFile.FullName - foreach ($itemGroup in @($xml.Project.ItemGroup)) { + $raw = Read-TextWithEncoding -Path $projectFile.FullName + [xml]$xml = $raw + foreach ($itemGroup in @($xml.SelectNodes('/Project/ItemGroup'))) { $groupCondition = Get-ConditionValue -Node $itemGroup foreach ($node in @($itemGroup.ChildNodes)) { if ($node.NodeType -ne 'Element' -or $node.Name -notin @('PackageReference', 'GlobalPackageReference')) { continue } @@ -169,50 +292,71 @@ function Get-ProjectDeclarations { $repoPath = (Resolve-Path -LiteralPath $RepoRoot).Path $updatesList = Parse-Updates -Json $Updates -JsonFile $UpdatesFile -$centralPath = Join-Path $repoPath 'Directory.Packages.props' -$hasCentral = Test-Path -LiteralPath $centralPath +$centralFiles = @(Get-CentralPropsFiles -Root $repoPath) +$hasCentral = $centralFiles.Count -gt 0 $results = [System.Collections.Generic.List[object]]::new() if ($hasCentral) { - $text = Get-Content -Raw -LiteralPath $centralPath - $declarations = Get-CentralDeclarations -Path $centralPath - $changed = $false + $fileTexts = @{} + $fileChanged = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $declarations = [System.Collections.Generic.List[object]]::new() + foreach ($centralFile in $centralFiles) { + $fileTexts[$centralFile.FullName] = Read-TextWithEncoding -Path $centralFile.FullName + foreach ($declaration in @(Get-CentralDeclarations -Path $centralFile.FullName -RepoRoot $repoPath)) { + $declarations.Add($declaration) + } + } foreach ($update in $updatesList) { - $matches = @($declarations | Where-Object { - $_.id -eq $update.id -and (($_.condition ?? '') -eq (($update.condition ?? ''))) + $updateId = Get-UpdateField -Update $update -Name 'id' + $updateCondition = Get-UpdateField -Update $update -Name 'condition' + $updateFrom = Get-UpdateField -Update $update -Name 'from' + $updateTo = Get-UpdateField -Update $update -Name 'to' + $updateSource = Get-UpdateField -Update $update -Name 'sourceFile' + $updateElement = Get-UpdateField -Update $update -Name 'element' + + $candidates = @($declarations | Where-Object { + $_.id -eq $updateId -and (($_.condition ?? '') -eq (($updateCondition ?? ''))) -and + ([string]::IsNullOrWhiteSpace($updateSource) -or $_.sourceFile -eq $updateSource) -and + ([string]::IsNullOrWhiteSpace($updateElement) -or $_.element -eq $updateElement) }) - if ($matches.Count -eq 0) { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'not-found'; from = $update.from; to = $update.to }) + if ($candidates.Count -eq 0) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $updateSource; outcome = 'not-found'; from = $updateFrom; to = $updateTo }) continue } - $match = $matches[0] - if ($match.current -ne $update.from) { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'conflict'; from = $update.from; to = $update.to }) + $matchingFrom = @($candidates | Where-Object { $_.current -eq $updateFrom }) + $match = if ($matchingFrom.Count -gt 0) { $matchingFrom[0] } else { $null } + if ($null -eq $match) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $updateSource; outcome = 'conflict'; from = $updateFrom; to = $updateTo }) continue } if ($DryRun) { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'dry-run'; from = $update.from; to = $update.to }) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'dry-run'; from = $updateFrom; to = $updateTo }) continue } - $updatedText = Update-DeclarationLine -Text $text -ElementName 'PackageVersion' -Id $update.id -Condition $update.condition -From $update.from -To $update.to + $matchFullPath = Join-Path $repoPath $match.sourceFile + $matchKeyCandidates = @($fileTexts.Keys | Where-Object { $_ -ieq $matchFullPath }) + $matchKey = if ($matchKeyCandidates.Count -gt 0) { $matchKeyCandidates[0] } else { $matchFullPath } + $updatedText = Update-DeclarationLine -Text $fileTexts[$matchKey] -ElementName 'PackageVersion' -Id $updateId -Condition $updateCondition -From $updateFrom -To $updateTo if ($null -eq $updatedText) { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'not-found'; from = $update.from; to = $update.to }) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'not-found'; from = $updateFrom; to = $updateTo }) continue } - $text = $updatedText - $match.current = $update.to - $changed = $true - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'applied'; from = $update.from; to = $update.to }) + $fileTexts[$matchKey] = $updatedText + $match.current = $updateTo + $null = $fileChanged.Add($matchKey) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'applied'; from = $updateFrom; to = $updateTo }) } - if ($changed -and -not $DryRun) { - Write-TextPreservingEol -Path $centralPath -Text $text | Out-Null + if (-not $DryRun) { + foreach ($fullPath in $fileChanged) { + Write-TextPreservingEol -Path $fullPath -Text $fileTexts[$fullPath] | Out-Null + } } $result = [pscustomobject]@{ repoRoot = $repoPath; mode = 'central'; results = @($results) } @@ -230,20 +374,35 @@ if ($projectDeclarations.Count -eq 0) { $fileTexts = @{} $fileChanged = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($update in $updatesList) { + $updateId = Get-UpdateField -Update $update -Name 'id' + $updateCondition = Get-UpdateField -Update $update -Name 'condition' + $updateFrom = Get-UpdateField -Update $update -Name 'from' + $updateTo = Get-UpdateField -Update $update -Name 'to' + $updateSource = Get-UpdateField -Update $update -Name 'sourceFile' + $updateElement = Get-UpdateField -Update $update -Name 'element' + $matches = @($projectDeclarations | Where-Object { - $_.id -eq $update.id -and $_.current -eq $update.from -and (($_.condition ?? '') -eq (($update.condition ?? ''))) + $_.id -eq $updateId -and $_.current -eq $updateFrom -and (($_.condition ?? '') -eq (($updateCondition ?? ''))) -and + ([string]::IsNullOrWhiteSpace($updateSource) -or $_.sourceFile -eq $updateSource) -and + ([string]::IsNullOrWhiteSpace($updateElement) -or $_.element -eq $updateElement) }) if ($matches.Count -eq 0) { $conflicts = @($projectDeclarations | Where-Object { - $_.id -eq $update.id -and (($_.condition ?? '') -eq (($update.condition ?? ''))) + $_.id -eq $updateId -and (($_.condition ?? '') -eq (($updateCondition ?? ''))) -and + ([string]::IsNullOrWhiteSpace($updateSource) -or $_.sourceFile -eq $updateSource) -and + ([string]::IsNullOrWhiteSpace($updateElement) -or $_.element -eq $updateElement) }) if ($conflicts.Count -gt 0) { - foreach ($conflict in $conflicts) { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $conflict.sourceFile; outcome = 'conflict'; from = $update.from; to = $update.to }) + if ([string]::IsNullOrWhiteSpace($updateSource)) { + foreach ($conflict in $conflicts) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $conflict.sourceFile; outcome = 'conflict'; from = $updateFrom; to = $updateTo }) + } + } else { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $updateSource; outcome = 'conflict'; from = $updateFrom; to = $updateTo }) } } else { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; outcome = 'not-found'; from = $update.from; to = $update.to }) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $updateSource; outcome = 'not-found'; from = $updateFrom; to = $updateTo }) } continue } @@ -251,24 +410,24 @@ foreach ($update in $updatesList) { foreach ($match in $matches) { $filePath = Join-Path $repoPath $match.sourceFile if (-not $fileTexts.ContainsKey($match.sourceFile)) { - $fileTexts[$match.sourceFile] = Get-Content -Raw -LiteralPath $filePath + $fileTexts[$match.sourceFile] = Read-TextWithEncoding -Path $filePath } if ($DryRun) { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $match.sourceFile; outcome = 'dry-run'; from = $update.from; to = $update.to }) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'dry-run'; from = $updateFrom; to = $updateTo }) continue } - $updatedText = Update-DeclarationLine -Text $fileTexts[$match.sourceFile] -ElementName $match.element -Id $update.id -Condition $update.condition -From $update.from -To $update.to + $updatedText = Update-DeclarationLine -Text $fileTexts[$match.sourceFile] -ElementName $match.element -Id $updateId -Condition $updateCondition -From $updateFrom -To $updateTo if ($null -eq $updatedText) { - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $match.sourceFile; outcome = 'not-found'; from = $update.from; to = $update.to }) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'not-found'; from = $updateFrom; to = $updateTo }) continue } $fileTexts[$match.sourceFile] = $updatedText - $match.current = $update.to + $match.current = $updateTo $null = $fileChanged.Add($match.sourceFile) - $results.Add([pscustomobject]@{ id = $update.id; condition = $update.condition; sourceFile = $match.sourceFile; outcome = 'applied'; from = $update.from; to = $update.to }) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'applied'; from = $updateFrom; to = $updateTo }) } } diff --git a/skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 b/skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 index be40ae1..6649ed3 100644 --- a/skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 +++ b/skills/dotnet-nuget-update/scripts/Get-DependencyAudit.ps1 @@ -4,7 +4,7 @@ param( [string]$Package, [switch]$IncludePrerelease, [switch]$OutdatedOnly, - [string]$Source = 'https://api.nuget.org/v3-flatcontainer', + [string[]]$Source = @('https://api.nuget.org/v3-flatcontainer'), [switch]$AsJson ) @@ -22,23 +22,62 @@ function Get-NodeVersion { if ($Node.Attributes['Version']) { return [string]$Node.Attributes['Version'].Value } + if ($Node.Attributes['VersionOverride']) { + return [string]$Node.Attributes['VersionOverride'].Value + } $versionNode = $Node.SelectSingleNode('./Version') if ($versionNode) { return [string]$versionNode.InnerText } + $overrideNode = $Node.SelectSingleNode('./VersionOverride') + if ($overrideNode) { + return [string]$overrideNode.InnerText + } return $null } +function Get-CentralPropsFiles { + param([Parameter(Mandatory)][string]$Root) + + return @(Get-ChildItem -LiteralPath $Root -Recurse -Filter Directory.Packages.props -File | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | + Sort-Object FullName) +} + +function Get-CentralManagementEnabled { + param([Parameter(Mandatory)][string]$RepoRoot) + + $rootPath = Join-Path $RepoRoot 'Directory.Packages.props' + if (-not (Test-Path -LiteralPath $rootPath)) { return $false } + try { + $raw = Read-TextWithEncoding -Path $rootPath + [xml]$xml = $raw + foreach ($propertyGroup in @($xml.SelectNodes('/Project/PropertyGroup'))) { + foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { + if ($node.Name -eq 'ManagePackageVersionsCentrally') { + return ([string]$node.InnerText).Trim() -ne 'false' + } + } + } + } + catch { + return $true + } + return $true +} + function Get-CentralDeclarations { - param([Parameter(Mandatory)][string]$Path, [string]$PackageFilter) + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$RepoRoot, [string]$PackageFilter) - [xml]$xml = Get-Content -Raw -LiteralPath $Path + $raw = Read-TextWithEncoding -Path $Path + [xml]$xml = $raw $rows = [System.Collections.Generic.List[object]]::new() + $relative = [System.IO.Path]::GetRelativePath($RepoRoot, (Resolve-Path -LiteralPath $Path).Path) - foreach ($itemGroup in @($xml.Project.ItemGroup)) { - $condition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } + foreach ($itemGroup in @($xml.SelectNodes('/Project/ItemGroup'))) { + $groupCondition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } foreach ($node in @($itemGroup.ChildNodes)) { if ($node.NodeType -ne 'Element') { continue } if ($node.Name -ne 'PackageVersion') { continue } @@ -46,13 +85,14 @@ function Get-CentralDeclarations { if ([string]::IsNullOrWhiteSpace($id)) { continue } if ($PackageFilter -and $id -ne $PackageFilter) { continue } + $nodeCondition = if ($node.Attributes['Condition']) { [string]$node.Attributes['Condition'].Value } else { $null } $rows.Add([pscustomobject]@{ id = $id current = Get-NodeVersion -Node $node element = $node.Name - condition = $condition + condition = if ($nodeCondition) { $nodeCondition } else { $groupCondition } note = Get-AdjacentXmlComment -Node $node - sourceFile = 'Directory.Packages.props' + sourceFile = $relative }) } } @@ -68,8 +108,9 @@ function Get-ProjectDeclarations { Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } foreach ($projectFile in $projectFiles) { - [xml]$xml = Get-Content -Raw -LiteralPath $projectFile.FullName - foreach ($itemGroup in @($xml.Project.ItemGroup)) { + $raw = Read-TextWithEncoding -Path $projectFile.FullName + [xml]$xml = $raw + foreach ($itemGroup in @($xml.SelectNodes('/Project/ItemGroup'))) { $groupCondition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } foreach ($node in @($itemGroup.ChildNodes)) { if ($node.NodeType -ne 'Element') { continue } @@ -127,18 +168,25 @@ function Resolve-Candidate { $repoPath = (Resolve-Path -LiteralPath $RepoRoot).Path $centralPath = Join-Path $repoPath 'Directory.Packages.props' -$management = if (Test-Path -LiteralPath $centralPath) { 'central' } else { 'project' } +$centralFiles = @(Get-CentralPropsFiles -Root $repoPath) +$centralEnabled = Get-CentralManagementEnabled -RepoRoot $repoPath +$management = if ($centralEnabled) { 'central' } else { 'project' } +$centralRows = @() +foreach ($propsFile in $centralFiles) { + $centralRows += @(Get-CentralDeclarations -Path $propsFile.FullName -RepoRoot $repoPath -PackageFilter $Package) +} +$projectRows = @(Get-ProjectDeclarations -Root $repoPath -PackageFilter $Package) $declarations = if ($management -eq 'central') { - Get-CentralDeclarations -Path $centralPath -PackageFilter $Package + @($centralRows + $projectRows) } else { - Get-ProjectDeclarations -Root $repoPath -PackageFilter $Package + @($projectRows) } -if ($management -eq 'project' -and $declarations.Count -eq 0) { +if ($declarations.Count -eq 0) { $missing = [pscustomobject]@{ repoRoot = $repoPath found = $false - management = 'project' + management = $management declared = 0 summary = [pscustomobject]@{ declared = 0; current = 0; auto = 0; approval = 0; unresolved = 0 } rows = @() @@ -147,21 +195,44 @@ if ($management -eq 'project' -and $declarations.Count -eq 0) { return } -if ($management -eq 'central' -and -not (Test-Path -LiteralPath $centralPath)) { - $missing = [pscustomobject]@{ - repoRoot = $repoPath - found = $false - management = 'central' - declared = 0 - summary = [pscustomobject]@{ declared = 0; current = 0; auto = 0; approval = 0; unresolved = 0 } - rows = @() +$rows = foreach ($declaration in $declarations) { + if ([string]::IsNullOrWhiteSpace($declaration.current)) { + [pscustomobject]@{ + id = $declaration.id + current = $declaration.current + condition = $declaration.condition + sourceFile = $declaration.sourceFile + note = $declaration.note + candidate = $null + latestOverall = $null + band = $null + heldByBand = $false + bump = 'unknown' + action = 'unresolved' + reason = 'declaration has no explicit version' + } + continue } - if ($AsJson) { $missing | ConvertTo-Json -Depth 12 } else { $missing } - return -} -$rows = foreach ($declaration in $declarations) { - $feed = Get-NuGetVersionList -Id $declaration.id -Source $Source + if (-not (Test-NuGetLiteralVersion -Version $declaration.current)) { + [pscustomobject]@{ + id = $declaration.id + current = $declaration.current + condition = $declaration.condition + sourceFile = $declaration.sourceFile + note = $declaration.note + candidate = $null + latestOverall = $null + band = $null + heldByBand = $false + bump = 'unknown' + action = 'unresolved' + reason = "non-literal version expression '$($declaration.current)' requires human review; property indirection, floating versions, and ranges are never auto-updated" + } + continue + } + + $feed = Get-NuGetVersionListMerged -Id $declaration.id -Sources $Source if (-not $feed.found) { [pscustomobject]@{ id = $declaration.id diff --git a/skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 b/skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 index 0d783c2..2f1f98c 100644 --- a/skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 +++ b/skills/dotnet-nuget-update/scripts/Get-PackageGraph.ps1 @@ -16,11 +16,18 @@ function Get-NodeVersion { if ($Node.Attributes['Version']) { return [string]$Node.Attributes['Version'].Value } + if ($Node.Attributes['VersionOverride']) { + return [string]$Node.Attributes['VersionOverride'].Value + } $versionNode = $Node.SelectSingleNode('./Version') if ($versionNode) { return [string]$versionNode.InnerText } + $overrideNode = $Node.SelectSingleNode('./VersionOverride') + if ($overrideNode) { + return [string]$overrideNode.InnerText + } return $null } @@ -33,12 +40,12 @@ if (-not (Test-Path -LiteralPath $file)) { return } -[xml]$xml = Get-Content -Raw -LiteralPath $file +[xml]$xml = Read-TextWithEncoding -Path $file $groups = [System.Collections.Generic.List[object]]::new() $packages = [System.Collections.Generic.List[object]]::new() $centrallyManaged = $null -foreach ($propertyGroup in @($xml.Project.PropertyGroup)) { +foreach ($propertyGroup in @($xml.SelectNodes('/Project/PropertyGroup'))) { foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { if ($node.Name -eq 'ManagePackageVersionsCentrally') { $centrallyManaged = [string]$node.InnerText @@ -46,8 +53,8 @@ foreach ($propertyGroup in @($xml.Project.PropertyGroup)) { } } -foreach ($itemGroup in @($xml.Project.ItemGroup)) { - $condition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } +foreach ($itemGroup in @($xml.SelectNodes('/Project/ItemGroup'))) { + $groupCondition = if ($itemGroup.Attributes['Condition']) { [string]$itemGroup.Attributes['Condition'].Value } else { $null } $items = [System.Collections.Generic.List[object]]::new() foreach ($node in @($itemGroup.ChildNodes)) { @@ -57,11 +64,12 @@ foreach ($itemGroup in @($xml.Project.ItemGroup)) { $id = if ($node.Attributes['Include']) { [string]$node.Attributes['Include'].Value } else { $null } if ([string]::IsNullOrWhiteSpace($id)) { continue } + $nodeCondition = if ($node.Attributes['Condition']) { [string]$node.Attributes['Condition'].Value } else { $null } $entry = [pscustomobject]@{ id = $id version = Get-NodeVersion -Node $node element = $node.Name - condition = $condition + condition = if ($nodeCondition) { $nodeCondition } else { $groupCondition } note = Get-AdjacentXmlComment -Node $node } $items.Add($entry) @@ -69,7 +77,7 @@ foreach ($itemGroup in @($xml.Project.ItemGroup)) { } $groups.Add([pscustomobject]@{ - condition = $condition + condition = $groupCondition count = $items.Count packages = @($items) }) diff --git a/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 index fa944f6..afdca06 100644 --- a/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 +++ b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 @@ -7,14 +7,17 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. "$PSScriptRoot/_common.ps1" + function Get-PropertyMap { param([string]$Path) $map = @{} if (-not (Test-Path -LiteralPath $Path)) { return $map } - [xml]$xml = Get-Content -Raw -LiteralPath $Path - foreach ($propertyGroup in @($xml.Project.PropertyGroup)) { + $raw = Read-TextWithEncoding -Path $Path + [xml]$xml = $raw + foreach ($propertyGroup in @($xml.SelectNodes('/Project/PropertyGroup'))) { foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { if (-not [string]::IsNullOrWhiteSpace($node.InnerText)) { $map[$node.Name] = [string]$node.InnerText @@ -25,6 +28,46 @@ function Get-PropertyMap { return $map } +function Get-ImportPaths { + param([string]$Path) + + $imports = @() + if (-not (Test-Path -LiteralPath $Path)) { return @($imports) } + try { + $raw = Read-TextWithEncoding -Path $Path + [xml]$xml = $raw + foreach ($import in @($xml.SelectNodes('/Project/Import'))) { + if ($import.Attributes['Project']) { + $imports += [string]$import.Attributes['Project'].Value + } + } + } + catch { + } + return @($imports) +} + +function Resolve-ImportPath { + param([Parameter(Mandatory)][string]$FromFile, [Parameter(Mandatory)][string]$ImportProject, [Parameter(Mandatory)][string]$RepoRoot) + + $candidate = $ImportProject.Trim() + if ([string]::IsNullOrWhiteSpace($candidate)) { return $null } + if ($candidate -match '^\$\(') { return $null } + $candidate = $candidate -replace '\$\(MSBuildThisFileDirectory\)', ((Split-Path -Parent $FromFile) + [System.IO.Path]::DirectorySeparatorChar) + $candidate = $candidate -replace '\$\(MSBuildProjectDirectory\)', ((Split-Path -Parent $FromFile) + [System.IO.Path]::DirectorySeparatorChar) + try { + $baseDir = Split-Path -Parent $FromFile + $combined = if ([System.IO.Path]::IsPathRooted($candidate)) { $candidate } else { Join-Path $baseDir $candidate } + $full = [System.IO.Path]::GetFullPath($combined) + if ($full.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $full)) { + return $full + } + } + catch { + } + return $null +} + function Resolve-PropertyTokens { param( [string]$Value, @@ -61,28 +104,74 @@ function Split-Tfms { $repoPath = (Resolve-Path -LiteralPath $RepoRoot).Path $directoryBuildProps = Join-Path $repoPath 'Directory.Build.props' -$propertyMap = Get-PropertyMap -Path $directoryBuildProps +$propsFiles = @(Get-ChildItem -LiteralPath $repoPath -Recurse -File | + Where-Object { $_.Extension -in @('.props', '.targets') -and $_.FullName -notmatch '\\(bin|obj)\\' } | + Sort-Object FullName) +$projectFiles = @(Get-ChildItem -LiteralPath $repoPath -Recurse -Filter *.csproj -File | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | + Sort-Object FullName) + +$importedFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($propsFile in $propsFiles) { + foreach ($importProject in @(Get-ImportPaths -Path $propsFile.FullName)) { + $resolved = Resolve-ImportPath -FromFile $propsFile.FullName -ImportProject $importProject -RepoRoot $repoPath + if ($resolved -and -not (Test-Path -LiteralPath $resolved)) { continue } + if ($resolved) { $null = $importedFiles.Add($resolved) } + } +} +foreach ($projectFile in $projectFiles) { + foreach ($importProject in @(Get-ImportPaths -Path $projectFile.FullName)) { + $resolved = Resolve-ImportPath -FromFile $projectFile.FullName -ImportProject $importProject -RepoRoot $repoPath + if ($resolved) { $null = $importedFiles.Add($resolved) } + } +} +foreach ($importedPath in @($importedFiles)) { + if (-not (@($propsFiles | ForEach-Object { $_.FullName }) -contains $importedPath) -and (Test-Path -LiteralPath $importedPath)) { + $propsFiles += (Get-Item -LiteralPath $importedPath) + } +} +$propsFiles = @($propsFiles | Sort-Object FullName -Unique) + +$propertyMap = @{} +foreach ($propsFile in @($propsFiles | Sort-Object { $_.FullName.Length })) { + foreach ($entry in (Get-PropertyMap -Path $propsFile.FullName).GetEnumerator()) { + $propertyMap[$entry.Key] = $entry.Value + } +} +if (Test-Path -LiteralPath $directoryBuildProps) { + foreach ($entry in (Get-PropertyMap -Path $directoryBuildProps).GetEnumerator()) { + $propertyMap[$entry.Key] = $entry.Value + } +} $declaredIn = [System.Collections.Generic.List[object]]::new() $allTfms = [System.Collections.Generic.List[string]]::new() $unresolved = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) -if (Test-Path -LiteralPath $directoryBuildProps) { - [xml]$propsXml = Get-Content -Raw -LiteralPath $directoryBuildProps - foreach ($propertyGroup in @($propsXml.Project.PropertyGroup)) { +function Add-TfmDeclarations { + param( + [Parameter(Mandatory)][string]$FilePath, + [Parameter(Mandatory)][string]$Scope, + [Parameter(Mandatory)][string]$RelativePath, + [Parameter(Mandatory)][hashtable]$Map + ) + + $raw = Read-TextWithEncoding -Path $FilePath + [xml]$fileXml = $raw + foreach ($propertyGroup in @($fileXml.SelectNodes('/Project/PropertyGroup'))) { $condition = if ($propertyGroup.Attributes['Condition']) { [string]$propertyGroup.Attributes['Condition'].Value } else { $null } foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -in @('TargetFramework', 'TargetFrameworks') })) { - $raw = [string]$node.InnerText - $resolved = Resolve-PropertyTokens -Value $raw -Map $propertyMap + $rawValue = [string]$node.InnerText + $resolved = Resolve-PropertyTokens -Value $rawValue -Map $Map $tfms = Split-Tfms -Value $resolved foreach ($tfm in $tfms) { if ($tfm -match '\$\(') { $null = $unresolved.Add($tfm) } else { $allTfms.Add($tfm) } } $declaredIn.Add([pscustomobject]@{ - scope = 'Directory.Build.props' - path = 'Directory.Build.props' + scope = $Scope + path = $RelativePath property = $node.Name condition = $condition - rawValue = $raw + rawValue = $rawValue resolvedValue = $resolved targetFrameworks = $tfms }) @@ -90,20 +179,30 @@ if (Test-Path -LiteralPath $directoryBuildProps) { } } +foreach ($propsFile in $propsFiles) { + $relative = [System.IO.Path]::GetRelativePath($repoPath, $propsFile.FullName) + $scope = if ($propsFile.Name -ieq 'Directory.Build.props') { 'Directory.Build.props' } + elseif ($propsFile.Extension -ieq '.props') { 'Props' } + else { 'Targets' } + Add-TfmDeclarations -FilePath $propsFile.FullName -Scope $scope -RelativePath $relative -Map $propertyMap +} + $projects = [System.Collections.Generic.List[object]]::new() -$projectFiles = Get-ChildItem -LiteralPath $repoPath -Recurse -Filter *.csproj -File | - Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } foreach ($projectFile in $projectFiles) { - [xml]$projectXml = Get-Content -Raw -LiteralPath $projectFile.FullName + $localMap = @{} + foreach ($entry in $propertyMap.GetEnumerator()) { $localMap[$entry.Key] = $entry.Value } + foreach ($entry in (Get-PropertyMap -Path $projectFile.FullName).GetEnumerator()) { $localMap[$entry.Key] = $entry.Value } + $rawProject = Read-TextWithEncoding -Path $projectFile.FullName + [xml]$projectXml = $rawProject $projectTfms = [System.Collections.Generic.List[string]]::new() $projectUnresolved = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($propertyGroup in @($projectXml.Project.PropertyGroup)) { + foreach ($propertyGroup in @($projectXml.SelectNodes('/Project/PropertyGroup'))) { $condition = if ($propertyGroup.Attributes['Condition']) { [string]$propertyGroup.Attributes['Condition'].Value } else { $null } foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -in @('TargetFramework', 'TargetFrameworks') })) { $raw = [string]$node.InnerText - $resolved = Resolve-PropertyTokens -Value $raw -Map $propertyMap + $resolved = Resolve-PropertyTokens -Value $raw -Map $localMap $tfms = Split-Tfms -Value $resolved foreach ($tfm in $tfms) { if ($tfm -match '\$\(') { diff --git a/skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 b/skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 index 67852b4..078d7fb 100644 --- a/skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 +++ b/skills/dotnet-nuget-update/scripts/Resolve-NuGetVersion.ps1 @@ -2,7 +2,7 @@ param( [Parameter(Mandatory)][string]$Id, [switch]$IncludePrerelease, - [string]$Source = 'https://api.nuget.org/v3-flatcontainer', + [string[]]$Source = @('https://api.nuget.org/v3-flatcontainer'), [switch]$AsJson ) @@ -11,13 +11,13 @@ $ErrorActionPreference = 'Stop' . "$PSScriptRoot/_common.ps1" -$feed = Get-NuGetVersionList -Id $Id -Source $Source +$feed = Get-NuGetVersionListMerged -Id $Id -Sources $Source if (-not $feed.found) { $missing = [pscustomobject]@{ id = $Id found = $false error = $feed.error - source = $Source + source = $feed.source versions = @() } if ($AsJson) { $missing | ConvertTo-Json -Depth 8 } else { $missing } @@ -29,7 +29,7 @@ $stable = @($versions | Where-Object { -not (ConvertTo-NuGetSemVer -Version $_). $result = [pscustomobject]@{ id = $Id found = $true - source = $Source + source = $feed.source count = $versions.Count latest = if ($IncludePrerelease) { if ($versions.Count) { $versions[-1] } else { $null } } else { if ($stable.Count) { $stable[-1] } else { $null } } latestStable = if ($stable.Count) { $stable[-1] } else { $null } diff --git a/skills/dotnet-nuget-update/scripts/_common.ps1 b/skills/dotnet-nuget-update/scripts/_common.ps1 index 9d0d89c..0214395 100644 --- a/skills/dotnet-nuget-update/scripts/_common.ps1 +++ b/skills/dotnet-nuget-update/scripts/_common.ps1 @@ -8,22 +8,16 @@ function Get-FileLineEndingStyle { if (-not (Test-Path -LiteralPath $Path)) { return 'none' } - $bytes = [System.IO.File]::ReadAllBytes($Path) - $crlf = 0 - $lf = 0 - - for ($index = 0; $index -lt $bytes.Length; $index++) { - if ($bytes[$index] -ne 10) { continue } - if ($index -gt 0 -and $bytes[$index - 1] -eq 13) { - $crlf++ - } else { - $lf++ - } - } - - if ($crlf -gt 0 -and $lf -eq 0) { return 'CRLF' } - if ($lf -gt 0 -and $crlf -eq 0) { return 'LF' } - if ($lf -eq 0 -and $crlf -eq 0) { return 'none' } + $encoding = Get-FileEncoding -Path $Path + $text = [System.IO.File]::ReadAllText($Path, $encoding) + $crlf = ([regex]::Matches($text, "`r`n")).Count + $lf = ([regex]::Matches($text, "(? Date: Mon, 7 Sep 2026 23:59:51 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=9B=20fix=20conflict=20detection?= =?UTF-8?q?=20and=20property=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve central vs project package declaration routing to correctly handle updates targeting project files. Enhance conflict reporting to enumerate all conflicting sources when unspecified. Fix property resolution precedence to preserve first-matched declarations instead of overwriting with later files. --- .../scripts/Apply-PackageUpdates.ps1 | 72 ++++++++++++++----- .../scripts/Get-TargetFrameworks.ps1 | 9 +-- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 index ff03a1b..dcc64f6 100644 --- a/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 +++ b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 @@ -299,13 +299,14 @@ $results = [System.Collections.Generic.List[object]]::new() if ($hasCentral) { $fileTexts = @{} $fileChanged = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $declarations = [System.Collections.Generic.List[object]]::new() + $centralDeclarations = [System.Collections.Generic.List[object]]::new() foreach ($centralFile in $centralFiles) { $fileTexts[$centralFile.FullName] = Read-TextWithEncoding -Path $centralFile.FullName foreach ($declaration in @(Get-CentralDeclarations -Path $centralFile.FullName -RepoRoot $repoPath)) { - $declarations.Add($declaration) + $centralDeclarations.Add($declaration) } } + $projectDeclarations = @(Get-ProjectDeclarations -Root $repoPath) foreach ($update in $updatesList) { $updateId = Get-UpdateField -Update $update -Name 'id' @@ -315,7 +316,12 @@ if ($hasCentral) { $updateSource = Get-UpdateField -Update $update -Name 'sourceFile' $updateElement = Get-UpdateField -Update $update -Name 'element' - $candidates = @($declarations | Where-Object { + $isProjectSource = (-not [string]::IsNullOrWhiteSpace($updateSource)) -and + $updateSource.EndsWith('.csproj', [System.StringComparison]::OrdinalIgnoreCase) + + $targetDeclarations = if ($isProjectSource) { $projectDeclarations } else { $centralDeclarations } + + $candidates = @($targetDeclarations | Where-Object { $_.id -eq $updateId -and (($_.condition ?? '') -eq (($updateCondition ?? ''))) -and ([string]::IsNullOrWhiteSpace($updateSource) -or $_.sourceFile -eq $updateSource) -and ([string]::IsNullOrWhiteSpace($updateElement) -or $_.element -eq $updateElement) @@ -327,30 +333,60 @@ if ($hasCentral) { } $matchingFrom = @($candidates | Where-Object { $_.current -eq $updateFrom }) - $match = if ($matchingFrom.Count -gt 0) { $matchingFrom[0] } else { $null } - if ($null -eq $match) { - $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $updateSource; outcome = 'conflict'; from = $updateFrom; to = $updateTo }) + + if ($matchingFrom.Count -gt 1) { + if ([string]::IsNullOrWhiteSpace($updateSource)) { + foreach ($conflict in $matchingFrom) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $conflict.sourceFile; outcome = 'conflict'; from = $updateFrom; to = $updateTo }) + } + } else { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $updateSource; outcome = 'conflict'; from = $updateFrom; to = $updateTo }) + } continue } - if ($DryRun) { - $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'dry-run'; from = $updateFrom; to = $updateTo }) + $resolved = if ($matchingFrom.Count -gt 0) { $matchingFrom[0] } else { $null } + if ($null -eq $resolved) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $updateSource; outcome = 'conflict'; from = $updateFrom; to = $updateTo }) continue } - $matchFullPath = Join-Path $repoPath $match.sourceFile - $matchKeyCandidates = @($fileTexts.Keys | Where-Object { $_ -ieq $matchFullPath }) - $matchKey = if ($matchKeyCandidates.Count -gt 0) { $matchKeyCandidates[0] } else { $matchFullPath } - $updatedText = Update-DeclarationLine -Text $fileTexts[$matchKey] -ElementName 'PackageVersion' -Id $updateId -Condition $updateCondition -From $updateFrom -To $updateTo - if ($null -eq $updatedText) { - $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'not-found'; from = $updateFrom; to = $updateTo }) + if ($DryRun) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $resolved.sourceFile; outcome = 'dry-run'; from = $updateFrom; to = $updateTo }) continue } - $fileTexts[$matchKey] = $updatedText - $match.current = $updateTo - $null = $fileChanged.Add($matchKey) - $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $match.sourceFile; outcome = 'applied'; from = $updateFrom; to = $updateTo }) + if ($isProjectSource) { + $projFullPath = Join-Path $repoPath $resolved.sourceFile + if (-not $fileTexts.ContainsKey($projFullPath)) { + $fileTexts[$projFullPath] = Read-TextWithEncoding -Path $projFullPath + } + + $updatedText = Update-DeclarationLine -Text $fileTexts[$projFullPath] -ElementName $resolved.element -Id $updateId -Condition $updateCondition -From $updateFrom -To $updateTo + if ($null -eq $updatedText) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $resolved.sourceFile; outcome = 'not-found'; from = $updateFrom; to = $updateTo }) + continue + } + + $fileTexts[$projFullPath] = $updatedText + $resolved.current = $updateTo + $null = $fileChanged.Add($projFullPath) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $resolved.sourceFile; outcome = 'applied'; from = $updateFrom; to = $updateTo }) + } else { + $matchFullPath = Join-Path $repoPath $resolved.sourceFile + $matchKeyCandidates = @($fileTexts.Keys | Where-Object { $_ -ieq $matchFullPath }) + $matchKey = if ($matchKeyCandidates.Count -gt 0) { $matchKeyCandidates[0] } else { $matchFullPath } + $updatedText = Update-DeclarationLine -Text $fileTexts[$matchKey] -ElementName $resolved.element -Id $updateId -Condition $updateCondition -From $updateFrom -To $updateTo + if ($null -eq $updatedText) { + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $resolved.sourceFile; outcome = 'not-found'; from = $updateFrom; to = $updateTo }) + continue + } + + $fileTexts[$matchKey] = $updatedText + $resolved.current = $updateTo + $null = $fileChanged.Add($matchKey) + $results.Add([pscustomobject]@{ id = $updateId; condition = $updateCondition; sourceFile = $resolved.sourceFile; outcome = 'applied'; from = $updateFrom; to = $updateTo }) + } } if (-not $DryRun) { diff --git a/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 index afdca06..9669dc8 100644 --- a/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 +++ b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 @@ -135,12 +135,9 @@ $propsFiles = @($propsFiles | Sort-Object FullName -Unique) $propertyMap = @{} foreach ($propsFile in @($propsFiles | Sort-Object { $_.FullName.Length })) { foreach ($entry in (Get-PropertyMap -Path $propsFile.FullName).GetEnumerator()) { - $propertyMap[$entry.Key] = $entry.Value - } -} -if (Test-Path -LiteralPath $directoryBuildProps) { - foreach ($entry in (Get-PropertyMap -Path $directoryBuildProps).GetEnumerator()) { - $propertyMap[$entry.Key] = $entry.Value + if (-not $propertyMap.ContainsKey($entry.Key)) { + $propertyMap[$entry.Key] = $entry.Value + } } } $declaredIn = [System.Collections.Generic.List[object]]::new() From 0827577b644b50b25e788e503e56723f80c833bd Mon Sep 17 00:00:00 2001 From: Eval Worker Date: Tue, 8 Sep 2026 00:17:41 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20fix=20xml=20entity=20decod?= =?UTF-8?q?ing=20in=20condition=20value=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/Apply-PackageUpdates.ps1 | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 index dcc64f6..75c6355 100644 --- a/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 +++ b/skills/dotnet-nuget-update/scripts/Apply-PackageUpdates.ps1 @@ -108,6 +108,13 @@ function Replace-VersionInBlock { return $null } +function ConvertFrom-XmlText { + param([string]$Value) + + if ([string]::IsNullOrEmpty($Value)) { return $Value } + return [System.Net.WebUtility]::HtmlDecode($Value) +} + function Update-DeclarationLine { param( [Parameter(Mandatory)][string]$Text, @@ -195,8 +202,8 @@ function Update-DeclarationLine { } $nodeConditionMatch = [regex]::Match($tagText, 'Condition\s*=\s*([''"])(.*?)\1') - $nodeCondition = if ($nodeConditionMatch.Success) { $nodeConditionMatch.Groups[2].Value } else { $null } - $effective = if ($nodeCondition) { $nodeCondition } else { $groupCondition } + $nodeCondition = if ($nodeConditionMatch.Success) { ConvertFrom-XmlText $nodeConditionMatch.Groups[2].Value } else { $null } + $effective = if ($nodeCondition) { $nodeCondition } else { ConvertFrom-XmlText $groupCondition } if ((($Condition ?? '') -ne ($effective ?? ''))) { $index = $blockEnd continue From 8f577f1a209467438113ba2975495e944df93db5 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 8 Sep 2026 19:41:55 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20clarify=20target-framework?= =?UTF-8?q?=20scanner=20documentation=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Get-TargetFrameworks.ps1 static scanner resolves property declarations through Directory.Build.props/targets and recursive imports, but it does not evaluate MSBuild conditions or SDK-supplied properties. The documentation now clarifies this scope so users understand when to verify results with project-specific MSBuild evaluation. --- skills/dotnet-nuget-update/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/skills/dotnet-nuget-update/SKILL.md b/skills/dotnet-nuget-update/SKILL.md index e37f349..aeb815c 100644 --- a/skills/dotnet-nuget-update/SKILL.md +++ b/skills/dotnet-nuget-update/SKILL.md @@ -168,6 +168,7 @@ A conflict is evidence that the file changed after the audit. Respect that evide Prefer the smallest deterministic validation that proves the update is safe. 1. Discover target frameworks with `scripts/Get-TargetFrameworks.ps1 -RepoRoot `. + The static scanner resolves project properties through the nearest Directory.Build.props/targets and recursive explicit imports. It inventories declarations without evaluating MSBuild conditions or SDK imports; verify conditional results and unresolved tokens with project-specific MSBuild evaluation before selecting validation commands. 2. If source selection matters, inspect feeds with `scripts/Get-NuGetSources.ps1`. 3. Run the narrowest restore, build, or test command that covers the affected projects and target frameworks. 4. If the repository already has a targeted test or validation command, use it rather than inventing one. From 1fbb072ae14297897e8c487340b9dd7142dc77d3 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 8 Sep 2026 19:42:08 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20property?= =?UTF-8?q?=20resolution=20in=20get-targetframeworks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructured the property-map walker to handle import chains and property-token resolution recursively. The scanner now traverses Directory.Build.props and .targets files in the correct order, resolves property references within each scope, and preserves unresolved tokens for later discovery. This fixes edge cases where projects in different directories share property names or where scoped .props files define properties that should not leak across project boundaries. --- .../scripts/Get-TargetFrameworks.ps1 | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 index 9669dc8..1e96002 100644 --- a/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 +++ b/skills/dotnet-nuget-update/scripts/Get-TargetFrameworks.ps1 @@ -10,24 +10,43 @@ $ErrorActionPreference = 'Stop' . "$PSScriptRoot/_common.ps1" function Get-PropertyMap { - param([string]$Path) + param([string]$Path, [string]$ProjectDirectory = (Split-Path -Parent $Path), [hashtable]$Map = @{}, $Active = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)) - $map = @{} if (-not (Test-Path -LiteralPath $Path)) { return $map } + if (-not $Active.Add($Path)) { return $map } $raw = Read-TextWithEncoding -Path $Path [xml]$xml = $raw - foreach ($propertyGroup in @($xml.SelectNodes('/Project/PropertyGroup'))) { - foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { - if (-not [string]::IsNullOrWhiteSpace($node.InnerText)) { - $map[$node.Name] = [string]$node.InnerText + foreach ($element in @($xml.SelectNodes('/Project/PropertyGroup | /Project/Import | /Project/ImportGroup/Import'))) { + if ($element.Name -eq 'Import') { + $importProject = [string]$element.GetAttribute('Project') + $importProject = $importProject.Replace('$(MSBuildProjectDirectory)', $ProjectDirectory) + $importProject = Resolve-PropertyTokens -Value $importProject -Map $map + if ($importProject) { + $importPath = Resolve-ImportPath -FromFile $Path -ImportProject $importProject -RepoRoot $repoPath + if ($importPath) { $null = Get-PropertyMap -Path $importPath -ProjectDirectory $ProjectDirectory -Map $map -Active $Active } + } + } else { + foreach ($node in @($element.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { + if (-not [string]::IsNullOrWhiteSpace($node.InnerText)) { + $map[$node.Name] = Resolve-PropertyTokens -Value ([string]$node.InnerText) -Map $map + } } } } - + $null = $Active.Remove($Path) return $map } +function Get-DirectoryBuildFile { + param([string]$Directory, [string]$Name) + while ($Directory -and ($Directory -eq $repoPath -or $Directory.StartsWith($repoPath + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase))) { + $candidate = Join-Path $Directory $Name + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + $Directory = Split-Path -Parent $Directory + } +} + function Get-ImportPaths { param([string]$Path) @@ -52,7 +71,6 @@ function Resolve-ImportPath { $candidate = $ImportProject.Trim() if ([string]::IsNullOrWhiteSpace($candidate)) { return $null } - if ($candidate -match '^\$\(') { return $null } $candidate = $candidate -replace '\$\(MSBuildThisFileDirectory\)', ((Split-Path -Parent $FromFile) + [System.IO.Path]::DirectorySeparatorChar) $candidate = $candidate -replace '\$\(MSBuildProjectDirectory\)', ((Split-Path -Parent $FromFile) + [System.IO.Path]::DirectorySeparatorChar) try { @@ -132,14 +150,6 @@ foreach ($importedPath in @($importedFiles)) { } $propsFiles = @($propsFiles | Sort-Object FullName -Unique) -$propertyMap = @{} -foreach ($propsFile in @($propsFiles | Sort-Object { $_.FullName.Length })) { - foreach ($entry in (Get-PropertyMap -Path $propsFile.FullName).GetEnumerator()) { - if (-not $propertyMap.ContainsKey($entry.Key)) { - $propertyMap[$entry.Key] = $entry.Value - } - } -} $declaredIn = [System.Collections.Generic.List[object]]::new() $allTfms = [System.Collections.Generic.List[string]]::new() $unresolved = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -181,15 +191,18 @@ foreach ($propsFile in $propsFiles) { $scope = if ($propsFile.Name -ieq 'Directory.Build.props') { 'Directory.Build.props' } elseif ($propsFile.Extension -ieq '.props') { 'Props' } else { 'Targets' } - Add-TfmDeclarations -FilePath $propsFile.FullName -Scope $scope -RelativePath $relative -Map $propertyMap + Add-TfmDeclarations -FilePath $propsFile.FullName -Scope $scope -RelativePath $relative -Map (Get-PropertyMap -Path $propsFile.FullName) } $projects = [System.Collections.Generic.List[object]]::new() foreach ($projectFile in $projectFiles) { $localMap = @{} - foreach ($entry in $propertyMap.GetEnumerator()) { $localMap[$entry.Key] = $entry.Value } - foreach ($entry in (Get-PropertyMap -Path $projectFile.FullName).GetEnumerator()) { $localMap[$entry.Key] = $entry.Value } + $propsPath = Get-DirectoryBuildFile -Directory $projectFile.DirectoryName -Name 'Directory.Build.props' + if ($propsPath) { $null = Get-PropertyMap -Path $propsPath -ProjectDirectory $projectFile.DirectoryName -Map $localMap } + $null = Get-PropertyMap -Path $projectFile.FullName -ProjectDirectory $projectFile.DirectoryName -Map $localMap + $targetsPath = Get-DirectoryBuildFile -Directory $projectFile.DirectoryName -Name 'Directory.Build.targets' + if ($targetsPath) { $null = Get-PropertyMap -Path $targetsPath -ProjectDirectory $projectFile.DirectoryName -Map $localMap } $rawProject = Read-TextWithEncoding -Path $projectFile.FullName [xml]$projectXml = $rawProject $projectTfms = [System.Collections.Generic.List[string]]::new() @@ -199,7 +212,9 @@ foreach ($projectFile in $projectFiles) { $condition = if ($propertyGroup.Attributes['Condition']) { [string]$propertyGroup.Attributes['Condition'].Value } else { $null } foreach ($node in @($propertyGroup.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -in @('TargetFramework', 'TargetFrameworks') })) { $raw = [string]$node.InnerText - $resolved = Resolve-PropertyTokens -Value $raw -Map $localMap + # Preserve expansion at declaration time for unconditional project properties. + $value = if (-not $condition -and -not $node.Attributes['Condition'] -and $localMap.ContainsKey($node.Name)) { [string]$localMap[$node.Name] } else { $raw } + $resolved = Resolve-PropertyTokens -Value $value -Map $localMap $tfms = Split-Tfms -Value $resolved foreach ($tfm in $tfms) { if ($tfm -match '\$\(') { From 3fbb9839cd5b850803a7dea244ff6b194acb8cc2 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 8 Sep 2026 19:42:24 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=E2=9C=85=20add=20framework-scoping=20eva?= =?UTF-8?q?l=20case=20with=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added eval case #8 to verify that the framework scanner correctly handles projects in different directories with their own Directory.Build.props scope. The test fixture includes three projects with scoped properties to ensure the scanner does not borrow framework versions from sibling projects or resolve properties from unimported files. This eval validates the scoped-property fix in Get-TargetFrameworks. --- skills/dotnet-nuget-update/evals/evals.json | 18 ++++++++++++++++++ .../evals/files/scoped-frameworks/a/A.csproj | 1 + .../scoped-frameworks/a/Directory.Build.props | 1 + .../evals/files/scoped-frameworks/b/B.csproj | 1 + .../scoped-frameworks/b/Directory.Build.props | 1 + .../evals/files/scoped-frameworks/c/C.csproj | 1 + .../evals/files/scoped-frameworks/unused.props | 1 + 7 files changed, 24 insertions(+) create mode 100644 skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/A.csproj create mode 100644 skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/Directory.Build.props create mode 100644 skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/B.csproj create mode 100644 skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/Directory.Build.props create mode 100644 skills/dotnet-nuget-update/evals/files/scoped-frameworks/c/C.csproj create mode 100644 skills/dotnet-nuget-update/evals/files/scoped-frameworks/unused.props diff --git a/skills/dotnet-nuget-update/evals/evals.json b/skills/dotnet-nuget-update/evals/evals.json index aa7be87..1a300b0 100644 --- a/skills/dotnet-nuget-update/evals/evals.json +++ b/skills/dotnet-nuget-update/evals/evals.json @@ -1,6 +1,24 @@ { "skill_name": "dotnet-nuget-update", "evals": [ + { + "id": 8, + "prompt": "Inspect the target framework matrix in the attached multi-project repository before planning NuGet validation. Report each project's frameworks and any unresolved property references. Do not change package versions.", + "expected_output": "A resolves to net9.0, B resolves to net10.0, and C keeps $(PrivateFramework) unresolved because unused.props is not imported. Validation never borrows a sibling project's framework property.", + "expectations": [ + "Reports net9.0 for A and net10.0 for B", + "Reports C's PrivateFramework token as unresolved", + "Does not resolve C to net7.0 from the unimported props file" + ], + "files": [ + "evals/files/scoped-frameworks/a/Directory.Build.props", + "evals/files/scoped-frameworks/a/A.csproj", + "evals/files/scoped-frameworks/b/Directory.Build.props", + "evals/files/scoped-frameworks/b/B.csproj", + "evals/files/scoped-frameworks/c/C.csproj", + "evals/files/scoped-frameworks/unused.props" + ] + }, { "id": 1, "prompt": "Audit the attached repository and update NuGet packages without breaking its TFM-specific package strategy. The repo targets net9.0 and net10.0, and the same Microsoft package is pinned separately for each band. Resolve every declaration first, then update each band to the newest version that still fits that band instead of proposing the overall newest major.", diff --git a/skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/A.csproj b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/A.csproj new file mode 100644 index 0000000..36ff9df --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/A.csproj @@ -0,0 +1 @@ +$(Framework) diff --git a/skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/Directory.Build.props b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/Directory.Build.props new file mode 100644 index 0000000..9018788 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/a/Directory.Build.props @@ -0,0 +1 @@ +net9.0 diff --git a/skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/B.csproj b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/B.csproj new file mode 100644 index 0000000..36ff9df --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/B.csproj @@ -0,0 +1 @@ +$(Framework) diff --git a/skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/Directory.Build.props b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/Directory.Build.props new file mode 100644 index 0000000..d96e117 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/b/Directory.Build.props @@ -0,0 +1 @@ +net10.0 diff --git a/skills/dotnet-nuget-update/evals/files/scoped-frameworks/c/C.csproj b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/c/C.csproj new file mode 100644 index 0000000..ec00223 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/c/C.csproj @@ -0,0 +1 @@ +$(PrivateFramework) diff --git a/skills/dotnet-nuget-update/evals/files/scoped-frameworks/unused.props b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/unused.props new file mode 100644 index 0000000..ea4a503 --- /dev/null +++ b/skills/dotnet-nuget-update/evals/files/scoped-frameworks/unused.props @@ -0,0 +1 @@ +net7.0 From 6cafe936fd3adc94b8f6eb7faa6601647fca9a10 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 8 Sep 2026 19:42:37 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=85=20add=20test=20automation=20for?= =?UTF-8?q?=20target=20framework=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added test-target-frameworks.ps1 as a standalone validation harness for Get-TargetFrameworks.ps1. The script builds complex fixture hierarchies with nested Directory.Build.props, property tokens, and conditional framework declarations, then verifies the scanner correctly resolves each project's target frameworks. This enables rapid validation during development without requiring full skill evals. --- .../scripts/test-target-frameworks.ps1 | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 skills/dotnet-nuget-update/scripts/test-target-frameworks.ps1 diff --git a/skills/dotnet-nuget-update/scripts/test-target-frameworks.ps1 b/skills/dotnet-nuget-update/scripts/test-target-frameworks.ps1 new file mode 100644 index 0000000..25b047b --- /dev/null +++ b/skills/dotnet-nuget-update/scripts/test-target-frameworks.ps1 @@ -0,0 +1,40 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ('dotnet-nuget-update-workspace/' + [Guid]::NewGuid().ToString('N')) +function Write-Fixture { + param([string]$Path, [string]$Content) + $destination = Join-Path $workspace $Path + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + [System.IO.File]::WriteAllText($destination, $Content) +} +try { + Write-Fixture 'Directory.Build.props' 'net8.0' + Write-Fixture 'a/Directory.Build.props' 'net9.0' + Write-Fixture 'shared/first.props' '' + Write-Fixture 'shared/second.props' 'netstandard2.0' + Write-Fixture 'a/A.csproj' '$(Framework);$(ExtraFramework)' + Write-Fixture 'b/Directory.Build.props' 'net10.0' + Write-Fixture 'b/B.csproj' '$(Framework)' + Write-Fixture 'c/C.csproj' '$(PrivateFramework)' + Write-Fixture 'unused.props' 'net7.0' + Write-Fixture 'd/D.csproj' '$(Framework)' + Write-Fixture 'd/local.props' 'net6.0' + Write-Fixture 'e/E.csproj' 'net5.0$(Framework)' + Write-Fixture 'e/before.props' 'net6.0' + Write-Fixture 'e/after.targets' 'net7.0' + Write-Fixture 'e/Directory.Build.targets' 'net9.0' + $result = & "$PSScriptRoot/Get-TargetFrameworks.ps1" -RepoRoot $workspace + foreach ($case in @(@('a/A.csproj', 'net9.0,netstandard2.0'), @('b/B.csproj', 'net10.0'), @('c/C.csproj', ''), @('d/D.csproj', 'net6.0'), @('e/E.csproj', 'net5.0'))) { + $project = $result.projects | Where-Object { $_.path.Replace('\', '/') -eq $case[0] } + if (($project.targetFrameworks -join ',') -ne $case[1]) { throw "Wrong frameworks for $($case[0]): $($project.targetFrameworks -join ',')" } + } + if ($result.unresolvedTokens -notcontains '$(PrivateFramework)') { throw 'Unimported property must remain unresolved.' } + Write-Host 'test-target-frameworks.ps1: PASS' +} +finally { + $resolvedWorkspace = [System.IO.Path]::GetFullPath($workspace) + $allowedRoot = [System.IO.Path]::GetFullPath((Join-Path ([System.IO.Path]::GetTempPath()) 'dotnet-nuget-update-workspace')) + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedWorkspace.StartsWith($allowedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe fixture cleanup path.' } + if (Test-Path -LiteralPath $resolvedWorkspace) { Remove-Item -LiteralPath $resolvedWorkspace -Recurse -Force } +}