From d0c85b7ffcbee2a47c6a67e64c9910ab6c91700c Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 14:09:02 -0700 Subject: [PATCH 1/6] chore(http-client-csharp): add cop static-analysis rules Add cop checks scoped to the C# generator: - braces.cop: control-flow statements (if/else/for/foreach/while) must use braces - snippet-factory.cop: expression types must be created via the Snippet factory (see https://github.com/microsoft/typespec/issues/3724) main.cop wires the per-rule predicates to the codebase and exposes one command per rule (CHECK-BRACES, CHECK-SNIPPETS). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../http-client-csharp/cop-checks/braces.cop | 49 +++++++++++++++++++ .../http-client-csharp/cop-checks/main.cop | 28 +++++++++++ .../cop-checks/snippet-factory.cop | 46 +++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 packages/http-client-csharp/cop-checks/braces.cop create mode 100644 packages/http-client-csharp/cop-checks/main.cop create mode 100644 packages/http-client-csharp/cop-checks/snippet-factory.cop diff --git a/packages/http-client-csharp/cop-checks/braces.cop b/packages/http-client-csharp/cop-checks/braces.cop new file mode 100644 index 00000000000..df4261d18aa --- /dev/null +++ b/packages/http-client-csharp/cop-checks/braces.cop @@ -0,0 +1,49 @@ +# Rule: enforce braces on control-flow statements in the C# emitter. +# +# Flags `if` / `else` / `else if` / `for` / `foreach` / `while` whose body is +# written inline on the same line without a `{ ... }` block, e.g.: +# +# if (a) DoX(); -> violation +# if (x is null) throw ...; -> violation (guard clause without braces) +# for (...) Step(); -> violation +# while (a) Tick(); -> violation +# else Two(); -> violation +# +# These are allowed (body is a brace-delimited block, Allman or K&R): +# +# if (a) { ... } +# if (a) # Allman: brace on the next line +# { +# ... +# } +# +# A line is reported when it is a control-flow header that also contains an +# inline body terminated by `;` and introduces no `{` block. Keying on a +# trailing `;` avoids false positives on multi-line conditions whose first +# line ends in an operator (e.g. `if (a &&`), which are not complete headers. +# +# Known limitation: an unbraced body written on a *separate* line (Allman +# header followed by a single unbraced statement) is not detected, because the +# header line is indistinguishable from a valid Allman braced header. +# +# Predicates only; the runnable command/test live in main.cop. + +import code + +# A control-flow header whose condition closes on this line. +predicate isControlHeader(Line) => Line.Text:matches('^\s*(else\s+)?(if|for|foreach|while)\s*\(.*\)') + +# An `else` that is not `else if`. +predicate isElseHeader(Line) => Line.Text:matches('^\s*else\b') && !Line.Text:matches('^\s*else\s+if\b') + +# The line introduces a `{ ... }` block. +predicate hasBrace(Line) => Line.Text:matches('\{') + +# The line ends with a `;`-terminated statement (the inline body), optionally +# followed by a trailing line comment. +predicate hasInlineBody(Line) => Line.Text:matches(';\s*(//.*)?$') + +predicate missingBraces(Line) => + !Line:hasBrace + && Line:hasInlineBody + && (Line:isControlHeader || Line:isElseHeader) diff --git a/packages/http-client-csharp/cop-checks/main.cop b/packages/http-client-csharp/cop-checks/main.cop new file mode 100644 index 00000000000..c7d8f30075e --- /dev/null +++ b/packages/http-client-csharp/cop-checks/main.cop @@ -0,0 +1,28 @@ +# Entry point for the C# emitter cop checks. +# +# Each rule's detection logic (predicates) lives in its own file: +# +# braces.cop -> control-flow statements must use braces +# snippet-factory.cop -> expressions must be created via the Snippet factory +# +# This file wires those predicates to the C# codebase and exposes one runnable +# command (and assertion) per rule. Run everything with: +# +# cop cop-checks/main.cop -t . +# +# Or a single rule with `-c CHECK-BRACES` / `-c CHECK-SNIPPETS`. + +import code +import csharp + +let cb = code.codebase('csharp') + +# Rule: braces.cop +command CHECK-BRACES = foreach cb.Lines:missingBraces => '{error:@red} {item.File.Path}:{item.Number}: missing braces -> {item.Text}' + +test no-missing-braces = assert(cb.Lines:missingBraces.Count == 0, 'control-flow statements must use braces') + +# Rule: snippet-factory.cop +command CHECK-SNIPPETS = foreach cb.Lines:usesCtorOverSnippet => '{error:@red} {item.File.Path}:{item.Number}: use the Snippet factory instead of constructing the expression directly -> {item.Text}' + +test no-direct-expression-construction = assert(cb.Lines:usesCtorOverSnippet.Count == 0, 'expression types should be created via the Snippet factory') diff --git a/packages/http-client-csharp/cop-checks/snippet-factory.cop b/packages/http-client-csharp/cop-checks/snippet-factory.cop new file mode 100644 index 00000000000..bea8ad2f7e5 --- /dev/null +++ b/packages/http-client-csharp/cop-checks/snippet-factory.cop @@ -0,0 +1,46 @@ +# Rule: prefer Snippet factory helpers over constructing expressions directly. +# +# Expression types should be created through the `Snippet` factory, not via +# their constructors, so call sites read intentionally and stay consistent +# (see https://github.com/microsoft/typespec/issues/3724). For example: +# +# Static().Invoke(...) // good +# new InvokeMethodExpression(...) // bad +# +# Only expression types that have a clear Snippet equivalent are flagged: +# +# new LiteralExpression(...) -> Literal(...) +# new TypeOfExpression(...) -> TypeOf(...) +# new TypeReferenceExpression(...) -> Static(...) +# new InvokeMethodExpression(...) -> .Invoke(...) / Static().Invoke(...) / Nameof(...) +# new NewInstanceExpression(...) -> New.Instance(...) +# new KeywordExpression("null"...) -> Null / This / Default +# new BinaryOperatorExpression("||"|"&&"|"or", ...) -> .Or(...) / .And(...) / .OrPattern(...) +# +# The Snippet factory layer (Snippets/) and the expression type definitions +# (Expressions/) legitimately use these constructors and are excluded, as are +# tests and TestData. +# +# Predicates only; the runnable command/test live in main.cop. + +import code + +predicate constructsViaCtor(Line) => + Line.Text:matches('\bnew\s+(LiteralExpression|TypeOfExpression|TypeReferenceExpression|InvokeMethodExpression|NewInstanceExpression|KeywordExpression)\s*\(') + || Line.Text:matches('\bnew\s+BinaryOperatorExpression\s*\(\s*"(\|\||&&|or)"') + +predicate isComment(Line) => Line.Text:matches('^\s*(//|\*|///)') + +# The Snippet factory layer and the expression type definitions are allowed to +# construct expressions directly. +predicate inFactoryOrDefs(Line) => + Line.File.Path:contains('/Snippets/') || Line.File.Path:contains('/Expressions/') + +predicate isTestCode(Line) => + Line.File.Path:contains('/test/') || Line.File.Path:contains('TestData') + +predicate usesCtorOverSnippet(Line) => + Line:constructsViaCtor + && !Line:isComment + && !Line:inFactoryOrDefs + && !Line:isTestCode From 4fed03aa3f9bc5feba13aacdbfa7507e263ff391 Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 14:19:53 -0700 Subject: [PATCH 2/6] fix(http-client-csharp): resolve cop violations and tighten snippet rule Apply the cop checks to the generator: - Add braces to inline guard clauses in PartialMethodCustomization. - Use Snippet factory helpers instead of expression constructors: Literal, TypeOf, New.Instance, Default, and receiver.Invoke(...). Tighten snippet-factory.cop to remove false positives: - KeywordExpression only flags null/this/default (yield/break have no Snippet). - InvokeMethodExpression only flags calls with a non-null instance receiver (unqualified `new InvokeMethodExpression(null, ...)` has no Snippet equivalent). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cop-checks/snippet-factory.cop | 18 ++++++++++++--- .../src/Providers/ClientOptionsProvider.cs | 2 +- .../ClientPipelineExtensionsDefinition.cs | 2 +- .../src/Providers/ClientProvider.cs | 2 +- ...elSerializationExtensionsDefinition.Xml.cs | 2 +- .../MrwSerializationTypeDefinition.cs | 2 +- ...elineRequestHeadersExtensionsDefinition.cs | 4 ++-- .../Providers/PartialMethodCustomization.cs | 23 +++++++++++++++---- 8 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/http-client-csharp/cop-checks/snippet-factory.cop b/packages/http-client-csharp/cop-checks/snippet-factory.cop index bea8ad2f7e5..b42e73d2ced 100644 --- a/packages/http-client-csharp/cop-checks/snippet-factory.cop +++ b/packages/http-client-csharp/cop-checks/snippet-factory.cop @@ -12,11 +12,21 @@ # new LiteralExpression(...) -> Literal(...) # new TypeOfExpression(...) -> TypeOf(...) # new TypeReferenceExpression(...) -> Static(...) -# new InvokeMethodExpression(...) -> .Invoke(...) / Static().Invoke(...) / Nameof(...) # new NewInstanceExpression(...) -> New.Instance(...) -# new KeywordExpression("null"...) -> Null / This / Default +# new InvokeMethodExpression(recv,..) -> recv.Invoke(...) / Static(t).Invoke(...) +# new KeywordExpression("null"|"this"|"default", ...) -> Null / This / Default # new BinaryOperatorExpression("||"|"&&"|"or", ...) -> .Or(...) / .And(...) / .OrPattern(...) # +# Precision notes: +# * KeywordExpression is only flagged for the `null` / `this` / `default` +# keywords, since those are the only ones with Snippet equivalents +# (Null / This / Default). Keywords like `yield` / `break` have no +# equivalent and are not reported. +# * InvokeMethodExpression is only flagged when it has a non-null instance +# receiver (something to hang `.Invoke(...)` off of). An unqualified call +# `new InvokeMethodExpression(null, ...)` has no Snippet equivalent and is +# not reported. +# # The Snippet factory layer (Snippets/) and the expression type definitions # (Expressions/) legitimately use these constructors and are excluded, as are # tests and TestData. @@ -26,7 +36,9 @@ import code predicate constructsViaCtor(Line) => - Line.Text:matches('\bnew\s+(LiteralExpression|TypeOfExpression|TypeReferenceExpression|InvokeMethodExpression|NewInstanceExpression|KeywordExpression)\s*\(') + Line.Text:matches('\bnew\s+(LiteralExpression|TypeOfExpression|TypeReferenceExpression|NewInstanceExpression)\s*\(') + || Line.Text:matches('\bnew\s+KeywordExpression\s*\(\s*"(null|this|default)"') + || Line.Text:matches('\bnew\s+InvokeMethodExpression\s*\(\s*(?!null\b)[A-Za-z_]') || Line.Text:matches('\bnew\s+BinaryOperatorExpression\s*\(\s*"(\|\||&&|or)"') predicate isComment(Line) => Line.Text:matches('^\s*(//|\*|///)') diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs index 7d47ad8191d..24c20fd2c0e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs @@ -342,7 +342,7 @@ protected override ConstructorProvider[] BuildConstructors() // ServiceVersion.Version => "version" switchCases.Add(new SwitchCaseExpression( Static(serviceVersionEnum.Type).Property(serviceVersionMember.Name), - new LiteralExpression(serviceVersionMember.Value))); + Literal(serviceVersionMember.Value))); } switchCases.Add(SwitchCaseExpression.Default(ThrowExpression(New.NotSupportedException(ValueExpression.Empty)))); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientPipelineExtensionsDefinition.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientPipelineExtensionsDefinition.cs index 941cf7a8b34..02864633aca 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientPipelineExtensionsDefinition.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientPipelineExtensionsDefinition.cs @@ -92,7 +92,7 @@ private MethodBodyStatement GetProcessHeadAsBoolMessageBody(HttpResponseApi resp }), new SwitchCaseStatement(Array.Empty(), new MethodBodyStatement[] { - Return(new NewInstanceExpression(ErrorResultSnippets.ErrorResultType.MakeGenericType([typeof(bool)]), [response, new NewInstanceExpression(ScmCodeModelGenerator.Instance.TypeFactory.ClientResponseApi.ClientResponseExceptionType, [response])])) + Return(New.Instance(ErrorResultSnippets.ErrorResultType.MakeGenericType([typeof(bool)]), [response, New.Instance(ScmCodeModelGenerator.Instance.TypeFactory.ClientResponseApi.ClientResponseExceptionType, [response])])) }) ]), }; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs index a0e522fb0ef..75cd23026da 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs @@ -748,7 +748,7 @@ private IEnumerable BuildSettingsConstructors() var propAccess = new MemberExpression(new NullConditionalExpression(settingsParam), propName); // Value types (enums, primitives) need ?? default since null-conditional returns T? ValueExpression arg = param.Type.IsValueType - ? propAccess.NullCoalesce(new KeywordExpression("default", null)) + ? propAccess.NullCoalesce(Default) : propAccess; args.Add(arg); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelSerializationExtensionsDefinition.Xml.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelSerializationExtensionsDefinition.Xml.cs index 5dd058c335f..f328c415daa 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelSerializationExtensionsDefinition.Xml.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelSerializationExtensionsDefinition.Xml.cs @@ -254,7 +254,7 @@ private MethodProvider BuildXmlWriteObjectValueMethodProvider() }); var defaultCase = SwitchCaseStatement.Default( - Throw(New.NotSupportedException(new FormattableStringExpression("Not supported type {0}", [new TypeOfExpression(_t)])))); + Throw(New.NotSupportedException(new FormattableStringExpression("Not supported type {0}", [TypeOf(_t)])))); var body = new SwitchStatement(value, [persistableModelCase, defaultCase]); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/MrwSerializationTypeDefinition.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/MrwSerializationTypeDefinition.cs index 43688790768..6956bfd168e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/MrwSerializationTypeDefinition.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/MrwSerializationTypeDefinition.cs @@ -2382,7 +2382,7 @@ private static bool ValueTypeIsNumber(Type valueType) => { // when `@encode(string)`, the type is serialized as string, so we need to deserialize it from string // sbyte.Parse(element.GetString()) - SerializationFormat.Int_String => new InvokeMethodExpression(type, nameof(int.Parse), [element.GetString()]), + SerializationFormat.Int_String => Static(type).Invoke(nameof(int.Parse), [element.GetString()]), _ => type switch { Type t when t == typeof(long) => element.GetInt64(), diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/PipelineRequestHeadersExtensionsDefinition.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/PipelineRequestHeadersExtensionsDefinition.cs index 1971678b82f..288b7f1af24 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/PipelineRequestHeadersExtensionsDefinition.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/PipelineRequestHeadersExtensionsDefinition.cs @@ -71,7 +71,7 @@ private MethodProvider BuildSetDelimited(bool hasFormat) var body = new[] { Declare("stringValues", value.Select(selector), out var stringValues), - new InvokeMethodExpression(_pipelineRequestHeadersParam, "Set", [nameParameter, StringSnippets.Join(delimiterParameter, stringValues)]).Terminate() + _pipelineRequestHeadersParam.Invoke("Set", [nameParameter, StringSnippets.Join(delimiterParameter, stringValues)]).Terminate() }; return new(signature, body, this); @@ -93,7 +93,7 @@ private MethodProvider BuildAddWithPrefix() var dictionaryExpression = headersToAddParameter.AsDictionary(typeof(string), typeof(string)); var forEachStatement = new ForEachStatement("header", dictionaryExpression, out var header); forEachStatement.Add( - new InvokeMethodExpression(_pipelineRequestHeadersParam, nameof(PipelineRequestHeaders.Add), + _pipelineRequestHeadersParam.Invoke(nameof(PipelineRequestHeaders.Add), [new BinaryOperatorExpression("+", prefixParameter, header.Key), header.Value]).Terminate() ); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs index d86863450c3..549f9e6a283 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs @@ -128,8 +128,16 @@ public static IReadOnlyList RenameAndCloneParameters( IReadOnlyList customParameters, bool removeDefaults) { - if (generatorParameters is null) throw new ArgumentNullException(nameof(generatorParameters)); - if (customParameters is null) throw new ArgumentNullException(nameof(customParameters)); + if (generatorParameters is null) + { + throw new ArgumentNullException(nameof(generatorParameters)); + } + + if (customParameters is null) + { + throw new ArgumentNullException(nameof(customParameters)); + } + if (generatorParameters.Count != customParameters.Count) { throw new ArgumentException( @@ -162,8 +170,15 @@ public static MethodSignature BuildPartialSignature( MethodSignature customSignature, IReadOnlyList implementationParameters) { - if (customSignature is null) throw new ArgumentNullException(nameof(customSignature)); - if (implementationParameters is null) throw new ArgumentNullException(nameof(implementationParameters)); + if (customSignature is null) + { + throw new ArgumentNullException(nameof(customSignature)); + } + + if (implementationParameters is null) + { + throw new ArgumentNullException(nameof(implementationParameters)); + } return new MethodSignature( customSignature.Name, From 08ea6575b731365c169904bb8b1c3ef05c4fe793 Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 14:23:29 -0700 Subject: [PATCH 3/6] ci(http-client-csharp): run cop static-analysis in CI Add Invoke-Cop.ps1 which downloads a pinned Agent Cop release and runs the cop-checks rules against the generator, failing on any violation. Wire it into the unit-test CI job (Test-Packages.ps1) and add an `npm run cop` convenience script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/Invoke-Cop.ps1 | 99 +++++++++++++++++++ .../eng/scripts/Test-Packages.ps1 | 3 + packages/http-client-csharp/package.json | 1 + 3 files changed, 103 insertions(+) create mode 100644 packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 diff --git a/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 new file mode 100644 index 00000000000..1165e9ad4bf --- /dev/null +++ b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 @@ -0,0 +1,99 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS +Runs the Agent Cop (https://github.com/KrzysztofCwalina/cop) static-analysis +rules under cop-checks/ against the C# generator sources. + +.DESCRIPTION +Downloads a pinned cop release for the current platform (cached per-version in +the temp directory) and runs the checks defined in cop-checks/main.cop against +the generator. Exits with cop's exit code, so any rule violation fails the build +(cop returns 1 when violations are found, 0 when clean). + +The checks are executed from a throwaway working directory that contains only +the rule files. This keeps cop targeted at the generator (via -t) and avoids +scanning node_modules or the rest of the repository. + +.PARAMETER Version +The cop release tag to use. Pinned for reproducible CI runs. +#> + +[CmdletBinding()] +param( + [string] $Version = "v2026.06.05j" +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +$packageRoot = (Resolve-Path "$PSScriptRoot/../..").Path.Replace('\', '/') +$copChecks = "$packageRoot/cop-checks" +$generator = "$packageRoot/generator" + +# Map the current platform to a release asset and executable name. +$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +if ($arch -notin @('x64', 'arm64')) { + throw "Unsupported architecture for cop: $arch" +} + +if ($IsWindows) { + $os = 'win' + $exe = 'cop.exe' +} +elseif ($IsLinux) { + $os = 'linux' + $exe = 'cop' +} +elseif ($IsMacOS) { + $os = 'osx' + $exe = 'cop' +} +else { + throw "Unsupported operating system for cop." +} + +$asset = "cop-$os-$arch.zip" +$url = "https://github.com/KrzysztofCwalina/cop/releases/download/$Version/$asset" + +# Cache the downloaded tool per version + platform. +$toolDir = Join-Path ([System.IO.Path]::GetTempPath()) "cop-$Version-$os-$arch" +$copPath = Join-Path $toolDir $exe + +if (-not (Test-Path $copPath)) { + Write-Host "Downloading Agent Cop $Version ($asset)..." -ForegroundColor Cyan + New-Item -ItemType Directory -Force $toolDir | Out-Null + $zipPath = Join-Path $toolDir $asset + Invoke-WebRequest -Uri $url -OutFile $zipPath + Expand-Archive -Path $zipPath -DestinationPath $toolDir -Force + if (-not $IsWindows) { + chmod +x $copPath + } +} + +# Run cop from a clean directory that contains only the rule files so it +# analyzes the generator and nothing else. +$runDir = Join-Path ([System.IO.Path]::GetTempPath()) "cop-run-$([System.Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Force $runDir | Out-Null +try { + Copy-Item "$copChecks/*.cop" $runDir + Push-Location $runDir + try { + Write-Host "Running cop checks against $generator" -ForegroundColor Cyan + & $copPath main.cop -t $generator + $exit = $LASTEXITCODE + } + finally { + Pop-Location + } +} +finally { + Remove-Item -Recurse -Force $runDir -ErrorAction SilentlyContinue +} + +if ($exit -ne 0) { + Write-Error "cop reported violations (exit code $exit). Fix the reported issues before merging." + exit $exit +} + +Write-Host "cop checks passed." -ForegroundColor Green diff --git a/packages/http-client-csharp/eng/scripts/Test-Packages.ps1 b/packages/http-client-csharp/eng/scripts/Test-Packages.ps1 index f859bbd44eb..cf24000fa80 100644 --- a/packages/http-client-csharp/eng/scripts/Test-Packages.ps1 +++ b/packages/http-client-csharp/eng/scripts/Test-Packages.ps1 @@ -21,6 +21,9 @@ try { Invoke-LoggedCommand "npm run build" -GroupOutput Invoke-LoggedCommand "npm run test:emitter" -GroupOutput + # enforce cop static-analysis rules on the generator sources + Invoke-LoggedCommand "./eng/scripts/Invoke-Cop.ps1" -GroupOutput + # test the generator Invoke-LoggedCommand "dotnet test ./generator" -GroupOutput diff --git a/packages/http-client-csharp/package.json b/packages/http-client-csharp/package.json index ee6d4c09580..f91b1e27035 100644 --- a/packages/http-client-csharp/package.json +++ b/packages/http-client-csharp/package.json @@ -46,6 +46,7 @@ "test:ci": "vitest run -c ./emitter/vitest.config.ts --coverage --reporter=junit --reporter=default", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix", + "cop": "pwsh ./eng/scripts/Invoke-Cop.ps1", "format": "pnpm -w format:dir packages/http-client-csharp", "extract-api": "npx api-extractor run --local --verbose", "regen-docs": "npm run build:emitter && tspd doc . --enable-experimental --output-dir ../../website/src/content/docs/docs/emitters/clients/http-client-csharp/reference --skip-js && npx prettier --write docs/ readme.md ../../website/src/content/docs/docs/emitters/clients/http-client-csharp/reference/" From 4790f88ba8cd75fc26453d9b2fead3fda5123aa8 Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 15:11:40 -0700 Subject: [PATCH 4/6] fix(http-client-csharp): retry cop package restore on transient CI failures The cop provider packages (code, csharp) are auto-restored from the GitHub feed on first run, which can fail transiently on CI agents (rate limiting / network blips). Retry the check run with backoff when a restore/provider failure is detected, while still reporting genuine rule violations immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/Invoke-Cop.ps1 | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 index 1165e9ad4bf..e25abd08b3a 100644 --- a/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 +++ b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 @@ -80,8 +80,34 @@ try { Push-Location $runDir try { Write-Host "Running cop checks against $generator" -ForegroundColor Cyan - & $copPath main.cop -t $generator - $exit = $LASTEXITCODE + + # cop auto-restores its provider packages (code, csharp, ...) from the + # GitHub feed on first run. That download can fail transiently on CI + # agents (rate limiting / network blips), surfacing as "not found in any + # configured feed" / "Provider '...' is not loaded" and a non-zero exit. + # Retry the run with backoff when we detect such a restore failure, but + # report genuine rule violations immediately (those don't print a + # restore/provider error). + $maxAttempts = 5 + $restorePattern = 'not found in any configured feed|could not be resolved|is not loaded' + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + $output = & $copPath main.cop -t $generator 2>&1 | Out-String + Write-Host $output + $exit = $LASTEXITCODE + + if ($exit -eq 0 -or $output -notmatch $restorePattern) { + break + } + + if ($attempt -lt $maxAttempts) { + $delay = [Math]::Min(30, [Math]::Pow(2, $attempt)) + Write-Host "Package restore failed (transient). Retrying in $delay s..." -ForegroundColor Yellow + Start-Sleep -Seconds $delay + } + else { + throw "Failed to restore cop provider packages after $maxAttempts attempts." + } + } } finally { Pop-Location From f397817baa34373915153c5209fcf88ccd1af13d Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 16:59:03 -0700 Subject: [PATCH 5/6] fix(http-client-csharp): track latest cop release to match provider feed The cop provider packages (code, csharp) auto-restored from the GitHub feed are version-locked to the cop runtime and always track the latest upstream build. Pinning the binary to an older release tag caused the provider to fail to load (assembly load error) once the feed moved ahead. Default to the latest release (via the releases/latest/download redirect, no API call) so the binary stays in lockstep with the providers, and only retry genuinely transient feed errors -- not version-mismatch load failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/Invoke-Cop.ps1 | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 index e25abd08b3a..e4c3f3039c6 100644 --- a/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 +++ b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 @@ -6,22 +6,30 @@ Runs the Agent Cop (https://github.com/KrzysztofCwalina/cop) static-analysis rules under cop-checks/ against the C# generator sources. .DESCRIPTION -Downloads a pinned cop release for the current platform (cached per-version in -the temp directory) and runs the checks defined in cop-checks/main.cop against -the generator. Exits with cop's exit code, so any rule violation fails the build -(cop returns 1 when violations are found, 0 when clean). +Downloads a cop release for the current platform and runs the checks defined in +cop-checks/main.cop against the generator. Exits with cop's exit code, so any +rule violation fails the build (cop returns 1 when violations are found, 0 when +clean). The checks are executed from a throwaway working directory that contains only the rule files. This keeps cop targeted at the generator (via -t) and avoids scanning node_modules or the rest of the repository. .PARAMETER Version -The cop release tag to use. Pinned for reproducible CI runs. +The cop release tag to use, or "latest" (default) to track the newest release. + +NOTE: cop auto-restores its provider packages (code, csharp) from the GitHub +feed, and the feed always serves the *latest* provider build. Those providers +are version-locked to the cop runtime assembly, so the binary must match the +provider version the feed currently serves -- otherwise the provider fails to +load ("Could not load file or assembly 'cop, Version=...'"). Because the feed +moves with upstream, we default to "latest" so the binary stays in lockstep with +the providers. Pass an explicit tag only to reproduce a historical run. #> [CmdletBinding()] param( - [string] $Version = "v2026.06.05j" + [string] $Version = "latest" ) $ErrorActionPreference = 'Stop' @@ -54,14 +62,24 @@ else { } $asset = "cop-$os-$arch.zip" -$url = "https://github.com/KrzysztofCwalina/cop/releases/download/$Version/$asset" +if ($Version -eq 'latest') { + # The /releases/latest/download/ redirect always resolves to the newest + # release asset without an API call (so no unauthenticated rate-limit risk). + $url = "https://github.com/KrzysztofCwalina/cop/releases/latest/download/$asset" +} +else { + $url = "https://github.com/KrzysztofCwalina/cop/releases/download/$Version/$asset" +} -# Cache the downloaded tool per version + platform. +# Cache the downloaded tool per version + platform. When tracking "latest" the +# tag is not fixed, so always re-download to stay in lockstep with the feed's +# provider packages. $toolDir = Join-Path ([System.IO.Path]::GetTempPath()) "cop-$Version-$os-$arch" $copPath = Join-Path $toolDir $exe -if (-not (Test-Path $copPath)) { +if ($Version -eq 'latest' -or -not (Test-Path $copPath)) { Write-Host "Downloading Agent Cop $Version ($asset)..." -ForegroundColor Cyan + Remove-Item -Recurse -Force $toolDir -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force $toolDir | Out-Null $zipPath = Join-Path $toolDir $asset Invoke-WebRequest -Uri $url -OutFile $zipPath @@ -84,18 +102,19 @@ try { # cop auto-restores its provider packages (code, csharp, ...) from the # GitHub feed on first run. That download can fail transiently on CI # agents (rate limiting / network blips), surfacing as "not found in any - # configured feed" / "Provider '...' is not loaded" and a non-zero exit. - # Retry the run with backoff when we detect such a restore failure, but - # report genuine rule violations immediately (those don't print a - # restore/provider error). + # configured feed" and a non-zero exit. Retry the run with backoff only + # for that transient case. A provider *load* failure ("is not loaded" / + # "Could not load file or assembly") is a version mismatch between the + # cop binary and the feed's provider build -- not transient -- which the + # default "latest" $Version avoids; so fail fast instead of retrying. $maxAttempts = 5 - $restorePattern = 'not found in any configured feed|could not be resolved|is not loaded' + $transientPattern = 'not found in any configured feed' for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { $output = & $copPath main.cop -t $generator 2>&1 | Out-String Write-Host $output $exit = $LASTEXITCODE - if ($exit -eq 0 -or $output -notmatch $restorePattern) { + if ($exit -eq 0 -or $output -notmatch $transientPattern) { break } From a6854aee9c42b63f900f32d07531864f39c54f60 Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 17:33:24 -0700 Subject: [PATCH 6/6] fix(http-client-csharp): vendor cop provider packages to avoid API rate limit cop resolves its feed packages (code, csharp) via the GitHub REST API, which is rate-limited to 60 req/hour for unauthenticated callers. On shared CI agents that budget is exhausted, so auto-restore fails with "not found in any configured feed" and the csharp provider never loads. Fork PRs have no secrets, so a GITHUB_TOKEN cannot be supplied to raise the limit. Seed the two imported packages directly from the cop repo at the tag matching the downloaded binary (read from cop -v) via a sparse, blob-filtered git clone (git protocol, not the REST API; fetches only those two directories). cop then finds them in its cache and never calls the rate-limited feed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/Invoke-Cop.ps1 | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 index e4c3f3039c6..ae33b0fba0e 100644 --- a/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 +++ b/packages/http-client-csharp/eng/scripts/Invoke-Cop.ps1 @@ -89,6 +89,61 @@ if ($Version -eq 'latest' -or -not (Test-Path $copPath)) { } } +# Seed cop's provider package cache from the cop repo instead of relying on its +# auto-restore feed. cop resolves feed packages via the GitHub REST API +# (api.github.com), which is rate-limited to 60 requests/hour for unauthenticated +# callers. On shared CI agents that budget is routinely exhausted, so the restore +# fails with "Package 'code'/'csharp' not found in any configured feed" and the +# csharp provider never loads. Fork PRs have no secrets, so we cannot supply a +# GITHUB_TOKEN to raise the limit. +# +# Instead we vendor the two packages our checks import (code, csharp) directly +# from the cop repo at the tag matching the downloaded binary, using a sparse, +# blob-filtered git clone (git protocol, not the REST API; fetches only those +# two directories). cop is version-locked between its runtime and providers, so +# the package version must match the binary -- we read it from `cop -v`. +$pkgCache = Join-Path $HOME ".cop/packages" +$copVersion = (& $copPath -v) -split '\+' | Select-Object -First 1 +$copTag = "v$copVersion" +# Map: cache package name -> path within the cop repo. +$vendoredPackages = @{ + 'code' = 'packages/code' + 'csharp' = 'packages/dotnet/csharp' +} +$cloneDir = Join-Path ([System.IO.Path]::GetTempPath()) "cop-pkgsrc-$([System.Guid]::NewGuid().ToString('N'))" +try { + Write-Host "Seeding cop provider packages from cop repo @ $copTag..." -ForegroundColor Cyan + git clone --quiet --no-checkout --depth 1 --branch $copTag --filter=blob:none ` + https://github.com/KrzysztofCwalina/cop.git $cloneDir + if ($LASTEXITCODE -ne 0) { + throw "Failed to clone cop repo at tag $copTag for provider packages." + } + Push-Location $cloneDir + try { + git sparse-checkout set --no-cone @($vendoredPackages.Values) | Out-Null + git checkout --quiet $copTag + if ($LASTEXITCODE -ne 0) { + throw "Failed to check out provider package sources at $copTag." + } + } + finally { + Pop-Location + } + New-Item -ItemType Directory -Force $pkgCache | Out-Null + foreach ($name in $vendoredPackages.Keys) { + $src = Join-Path $cloneDir $vendoredPackages[$name] + $dest = Join-Path $pkgCache $name + if (-not (Test-Path $src)) { + throw "Expected package '$name' at '$($vendoredPackages[$name])' in the cop repo, but it was not found." + } + Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue + Copy-Item -Recurse -Force $src $dest + } +} +finally { + Remove-Item -Recurse -Force $cloneDir -ErrorAction SilentlyContinue +} + # Run cop from a clean directory that contains only the rule files so it # analyzes the generator and nothing else. $runDir = Join-Path ([System.IO.Path]::GetTempPath()) "cop-run-$([System.Guid]::NewGuid().ToString('N'))"