Skip to content

Modernize ESI.NET: netstandard2.0/net8.0/net10.0, ESI compatibility-date versioning, full endpoint coverage - #85

Merged
seraphx2 merged 70 commits into
masterfrom
dev
Sep 12, 2026
Merged

seraphx2 merged 70 commits into
masterfrom
dev

Conversation

@seraphx2

Copy link
Copy Markdown
Owner

First release since 2023.12.12. Full details are in CHANGELOG.md and the breaking-change migration steps are in MIGRATION.md — every consumer needs code changes, all mechanical.

Highlights:

  • Targets netstandard2.0;net8.0;net10.0 (was the old net462...net7.0 sprawl)
  • ESI compatibility-date versioning (X-Compatibility-Date/X-Tenant), current through 2026-08-18
  • Every ESI endpoint added since 2020 now wrapped (233/233)
  • Per-call EsiCallOptions (character, cancellation, ETag, paging) replacing the old mutable-state setters
  • Transparent access-token refresh with a DI-friendly persistence hook
  • Two real security fixes (JWT audience validation, cryptographic PKCE verifier generation) found during an analyzer sweep
  • Full NetAnalyzers rule set enabled repo-wide; 0 warnings in the library

Verified before this PR: 56/56 unit tests on both net8.0 and net10.0, 23/23 live integration tests against the real ESI/SSO API.

🤖 Generated with Claude Code

thelocalsim and others added 30 commits August 28, 2026 09:42
Constructing an EsiClient on Blazor WebAssembly threw
"One or more errors occurred. (Operation is not supported on this platform.)"
before any request was made.

The default handler set HttpClientHandler.AutomaticDecompression
unconditionally. On the browser runtime that property is annotated
[UnsupportedOSPlatform("browser")] and the underlying BrowserHttpHandler
throws PlatformNotSupportedException from both the getter and the setter,
because the fetch API performs content decoding itself and exposes no way
to configure it.

Guard the assignment with HttpClientHandler.SupportsAutomaticDecompression,
which the browser handler defines as a compile-time constant false and which
returns true on every other supported platform. The property is a plain
bool getter that never throws, and it is present in every target framework
this project builds for (netstandard2.0, net462 through net48, netcoreapp3.1,
net6.0 and net7.0), so no additional conditional compilation is needed.

Behaviour is unchanged everywhere decompression is supported: the existing
#if NET split between DecompressionMethods.All and GZip|Deflate is preserved.
On Blazor WebAssembly the client now constructs successfully and the browser
handles gzip/deflate/brotli transparently.

The handler construction moves into a small private factory so that a
handler is still only allocated when no HttpClient is supplied by the caller.

Fixes #77

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors the versioning/CI system from the dev-prompt project, adapted for a
NuGet library.

Branch model:
  - dev is the long-lived integration branch; everything targets it.
  - master is the release branch. A dev->master merge cuts exactly one release.

Versioning (scripts/compute-version.sh):
  - CalVer YYYY.(MM*100+DD).BUILD, e.g. 2026.909.1.
  - Computed at release time from today's date + existing git tags (build =
    highest same-day tag + 1, else 1). Nothing is hand-incremented.
  - Tags are un-prefixed to match this repo's existing history; the version is
    passed to `dotnet pack -p:Version=` so no file is mutated or committed.
  - csproj <Version> is now a 0.0.0-dev placeholder.

Workflows:
  - ci.yml        PR gate on master + dev; aggregating `check` job for branch
                  protection. Replaces build-check.yml.
  - ci-dev.yml    per-commit build on push to dev.
  - release.yml   push to master (or manual dispatch) -> compute version, pack,
                  push to NuGet, GitHub Release (creates the tag), Discord.
                  Skips on [skip release] / doc- and CI-only changes. A manual
                  draft run packs without pushing to NuGet. Replaces deploy.yml.
  - .github/actions/ci-dotnet  shared restore + all-TFM Release build.

dependabot.yml: grouped weekly nuget + github-actions updates targeting dev;
semver-majors ignored (taken by hand).

Removed GetBuildVersion.psm1 (dead Azure-DevOps-era snippet) and the SAK
source-control junk PropertyGroup. .gitattributes keeps *.sh / *.yml LF-only
for the Linux runners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Clears the Node 20 deprecation warning on the runners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ci: CalVer release automation + dev/master branch flow
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `5` | `7` |
| [actions/setup-dotnet](https://github.com/actions/setup-dotnet) | `5` | `6` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2` | `3` |
| [Ilshidur/action-discord](https://github.com/ilshidur/action-discord) | `0.3.2` | `0.4.0` |


Updates `actions/checkout` from 5 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v5...v7)

Updates `actions/setup-dotnet` from 5 to 6
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](actions/setup-dotnet@v5...v6)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v4...v7)

Updates `softprops/action-gh-release` from 2 to 3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](softprops/action-gh-release@v2...v3)

Updates `Ilshidur/action-discord` from 0.3.2 to 0.4.0
- [Release notes](https://github.com/ilshidur/action-discord/releases)
- [Commits](Ilshidur/action-discord@0.3.2...0.4.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: Ilshidur/action-discord
  dependency-version: 0.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Drop the stored NUGET_API_KEY. The release job now requests a GitHub OIDC
token (id-token: write) and exchanges it via NuGet/login@v1 for a ~1-hour
API key, gated by a trusted-publishing policy on nuget.org bound to
seraphx2/ESI.NET -> release.yml. nuget.org account: robmburke.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ompression

Only set AutomaticDecompression when the platform supports it
- TFMs: drop the EOL targets (netcoreapp3.1, net6.0, net7.0) and the five
  explicit .NET Framework entries -> netstandard2.0;net8.0. netstandard2.0
  keeps .NET Framework 4.6.1+ consumers working; the library has no
  framework-specific #if, so those targets built byte-identical assemblies.
- Microsoft.IdentityModel.Tokens / System.IdentityModel.Tokens.Jwt: 6.14.1 -> 8.22.0
- Microsoft.Extensions.*: 2.0.0 -> 8.0.x (LTS floor; consumers float up)
- Newtonsoft.Json: 13.0.2 -> 13.0.4
- Drop the explicit System.Net.Http 4.3.4 reference (a known-advisory package);
  it is in-box on both targets now.
- Microsoft.CSharp / System.Collections.Immutable: netstandard2.0-only now,
  in-box in the net8.0 shared framework.
- OpportunitiesLogic: rename the `opportunities` using-alias to `Opportunities`
  (fixes CS8981, lower-cased type name).

Local: dotnet build -c Release -> both TFMs, 0 errors, 0 warnings.
dotnet pack -> clean nupkg, correct per-TFM dependency groups, no System.Net.Http.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Proves the Microsoft.IdentityModel 6.x -> 8.x bump is behaviour-neutral for
the access-token validation path, offline (no EVE credentials, no network).

- _SSOLogic: extract SsoLogic.ValidateAccessToken(token, ssoUrl, jwksJson) from
  Verify() as an internal seam. Same TokenValidationParameters, same raw claim
  reads, same field projection. Verify() keeps the JWKS fetch + affiliation
  lookup and is unchanged on the success path.
  Also: the JWKS fetch used `.GetAsync(url).Result.Content` — a blocking .Result
  inside an async method (sync-context deadlock risk) — now fully awaited; and
  the `jwtksUrl` typo is fixed.
- ESI.NET.Tests: new xUnit project (net8.0), added to the solution. 7 tests —
  valid token projects claims, claim types are read raw (not remapped, the
  likeliest 6->8 silent break), wrong issuer / wrong signing key / expired
  beyond skew are rejected, expired within the 2s skew still validates, and a
  pinned snapshot of the real login.eveonline.com/oauth/jwks parses under
  IdentityModel 8.22.0 with Keys.First() being the RS256 signing key.
- InternalsVisibleTo ESI.NET.Tests.
- CI: ci-dotnet composite runs `dotnet test`; release.yml gates on it before pack.

Local: restore -> build --no-restore -> test --no-build => 7/7 passed, 0 warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ecords

/dogma/dynamic/items/{type_id}/{item_id}/ was typed EsiResponse<Effect> and
returned an all-empty object; DynamicItem itself was a non-public class.

- Dogma.Attribute is now the { attribute_id, value } pair as it appears on an
  item; Dogma.Effect is now { effect_id, is_default }. Both are what
  /universe/types/{id}/ and the dynamic-items endpoint actually return inline.
- New Dogma.AttributeInfo / Dogma.EffectInfo are the full definitions from
  /dogma/attributes/{id}/ and /dogma/effects/{id}/ (flat, no inheritance).
  Modifier moves to EffectInfo.cs.
- DynamicItem is public; DogmaAttributes/DogmaEffects are List<Attribute> /
  List<Effect>.
- Universe.Type drops its private duplicate Attribute/Effect classes and binds
  to the Dogma types (via a `using Dogma =` alias so bare `Attribute` can't
  collide with System.Attribute). Its value field goes float -> double.
- DogmaLogic: Attribute() -> EsiResponse<AttributeInfo>,
  Effect() -> EsiResponse<EffectInfo>, DynamicItem() -> EsiResponse<DynamicItem>.
- ESI.NET.Tests/DogmaModelTests: 7 checks (the two *Info maps, DynamicItem,
  Universe.Type, and a theory locking each endpoint's payload type). The JSON
  is spec-shaped but illustrative; pinning real ESI response bodies as fixtures
  is a follow-up.

Shapes verified against esi.evetech.net/meta/openapi.json.
Local: build 0 warnings, 14/14 tests.

BREAKING: DogmaLogic.Attribute()/Effect() return types; the meaning of
Dogma.Attribute/Effect; removal of Universe.Attribute/Effect.

Co-Authored-By: lsawin <mesa.lemur@gmail.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EsiResponse<T> was constructed synchronously and read the body twice with
.Result inside the constructor (deadlock risk under a sync context, blocks a
pool thread).

- New internal static EsiResponse<T>.CreateAsync(response, path, ct): reads the
  body once, awaited (with the CancellationToken overload on net8; netstandard2.0
  has no token overload for ReadAsStringAsync). The constructor is now private,
  takes the already-read body, and does only the synchronous header parsing.
  response.Dispose() moves to the factory's finally.
- _noContentMessage was a readonly *instance* field, so the ~25-entry
  ImmutableDictionary was rebuilt on every response -> now static.
- _noContentMessage[path] raw indexer -> TryGetValue(...) ? msg : "No Content".
  A 204 from an endpoint not in the table used to throw KeyNotFoundException into
  the catch and leave Message null.
- Call sites (EsiRequest.Execute, SsoLogic.Verify) updated; both already async.
- ESI.NET.Tests/EsiResponseTests: 10 in-memory cases (json object/array, non-json
  body, known/unknown 204, 304, error string, header parse, captured
  deserialization failure, response disposal).

No caller-facing change beyond removing the public constructor. The ct parameter
on CreateAsync is dormant until EsiCallOptions threads it.

Local: build 0 warnings both TFMs, 24/24 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tic ETag

The client held the authorized character (SetCharacterData re-instantiated ~22
Logic objects) and the If-None-Match ETag in a *static* field shared across the
whole process. Both are now per-call state on a new EsiCallOptions.

EsiCallOptions { Character, CancellationToken, IfNoneMatch, Page }

- EsiRequest.Execute: `string token` param -> `EsiCallOptions options` (required,
  positioned after `endpoint`). Threads CancellationToken into SendAsync and the
  response read, Page -> `&page=`, IfNoneMatch -> header (tolerating quotes).
  The static EsiRequest.ETag field is deleted.
- Every Logic class ctor is now (HttpClient, EsiConfig); the _data /
  character_id / corporation_id / alliance_id fields are gone.
- Every endpoint method takes EsiCallOptions:
    * required (no default) on authenticated endpoints -> forgetting the
      character is now a compile error, not a runtime ArgumentException
    * `= null` on public endpoints (unchanged call sites)
- `int page = 1` parameters folded into EsiCallOptions.Page and removed from 13
  methods.
- EsiClient.SetCharacterData and SetIfNoneMatchHeader removed (class + IEsiClient).
- Stripped the now-unused `using ESI.NET.Models.SSO;` from 24 Logic files.

Tests: ESI.NET.Tests/EsiRequestTests - 8 cases via a capturing HttpMessageHandler
(url/datasource, path replacements, auth-guard throw, bearer token, &page=,
If-None-Match quoting, cancellation propagation, null-options tolerance).

BREAKING:
- Auth is per call: `client.Clones.List(new() { Character = data })` instead of
  `client.SetCharacterData(data); client.Clones.List();`
- ETag: `options.IfNoneMatch` instead of `client.SetIfNoneMatchHeader(...)`
- Pagination: `new() { Page = 2 }` instead of a `page` argument.

Local: build 0 warnings both TFMs, 32/32 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…andlers

- AddEsi now registers IEsiClient as a typed HttpClient
  (AddHttpClient<IEsiClient, EsiClient>) and returns IHttpClientBuilder so
  callers can chain .AddStandardResilienceHandler() after referencing
  Microsoft.Extensions.Http.Resilience. Polly stays out of core.
  New AddEsi(Action<EsiConfig>) overload.
- Http/EsiHeadersHandler: sets X-User-Agent (from EsiConfig.UserAgent) and
  Accept: application/json per request, skipping either if already present.
- Http/EsiErrorLimitHandler + EsiErrorLimitState: reads
  X-Esi-Error-Limit-Remain/-Reset; once the budget is spent (or a 420 is
  returned) it blocks further sends on that client until the window resets,
  and throws EsiErrorLimitException on 420.
- EsiClient ctor: a supplied HttpClient (DI pipeline or caller) is used as-is;
  headers/handler are only configured on a client the ctor creates itself.
  CreateDefaultHandler is now internal.
- csproj: + Microsoft.Extensions.Http 8.0.1 (netstandard2.0-compatible).
- ESI.NET.Tests/EsiHandlerTests: 7 cases (header add / no-duplicate /
  missing-UA throw; error-limit pass-through / 420 throw / blocks next send;
  AddEsi end-to-end through a stubbed primary handler).

BREAKING:
- AddEsi returns IHttpClientBuilder, not IServiceCollection; IEsiClient
  lifetime is now the typed-client default, not AddScoped.
- A caller-supplied bare HttpClient no longer gets X-User-Agent / Accept added
  automatically.
- The manual Accept-Encoding gzip/deflate request headers are gone;
  decompression is the primary handler's AutomaticDecompression.

Local: build 0 warnings both TFMs, 39/39 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tions-c0f53b236f

ci: bump the actions group with 5 updates
- SsoLogic.Verify: reuse the injected HttpClient instead of `new HttpClient()`
  per call; dispose the JWKS response; on validation failure throw
  InvalidOperationException instead of returning a blank AuthorizedCharacterData;
  affiliation lookup is now explicitly best-effort. XML docs note the
  CharacterOwnerHash re-login check.
- CHANGELOG.md: full breaking-change list since 2023.12.12 with a before/after
  migration table (EsiCallOptions, SetCharacterData/SetIfNoneMatchHeader
  removal, AddEsi -> IHttpClientBuilder, Dogma Attribute/AttributeInfo split,
  TFM + dependency changes).
- README.md: rewrote the DI / AddEsi / per-call-options / SSO sections for the
  new API; fixed the dead Azure DevOps build badge and swagger link.
- ci-dotnet composite action: setup-dotnet@v5 -> v6 (consistency with #81).
- Test: Verify throws InvalidOperationException on a bad token.

Local: build 0 warnings both TFMs, 40/40 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nRefreshed

Set OnTokenRefreshed and an authenticated call whose Character access token is
within a minute of expiry is refreshed with its refresh token before the request
goes out; Character is updated in place (Token / RefreshToken / ExpiresOn) and
the callback is invoked with it so the caller can persist the rotated refresh
token. No callback -> unchanged behaviour, no SSO calls made implicitly.

- SsoLogic: internal static SsoHost(DataSource), RequestTokenAsync (HTTP Basic
  when SecretKey is set, else client_id in the body for a PKCE client), and
  RefreshAccessTokenAsync (mutates the character; ExpiresOn = now + expires_in).
- EsiRequest.Execute: RefreshIfNeededAsync runs before the bearer token is
  attached, so the request (and the DI pipeline handlers) use the fresh token.
- Refresh reuses the call's HttpClient.
- Tests: expired -> refreshed + callback + new bearer on the real call;
  still-valid -> untouched; no callback -> untouched.
- CHANGELOG / README updated.

Local: build 0 warnings both TFMs, 43/43 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…siTokenRefreshSink

Refresh is now a pipeline concern, not something EsiRequest.Execute does inline,
so a persist hook can be registered once at DI time instead of on every call.

- EsiTokenRefreshHandler (DelegatingHandler): reads the character + optional
  per-call callback off the request, and if the access token is within a minute
  of expiry, exchanges the refresh token (routed through the rest of the
  pipeline via base.SendAsync), updates the character in place, swaps the bearer
  header, then invokes the per-call callback and — when a DI scope is available —
  a registered IEsiTokenRefreshSink (resolved in a fresh scope, so a scoped
  DbContext is fine).
- IEsiTokenRefreshSink: register one
  (services.AddScoped<IEsiTokenRefreshSink, T>()) and it covers every
  authenticated call. EsiCallOptions.OnTokenRefreshed stays as the per-call /
  non-DI hook; both fire.
- EsiRequest.Execute: stashes the character/callback on HttpRequestMessage
  (request.Options on net8, request.Properties on netstandard2.0) via
  EsiRequestState; no longer refreshes itself.
- SsoLogic.RequestTokenAsync / RefreshAccessTokenAsync take a send delegate
  instead of an HttpClient.
- AddEsi wires EsiTokenRefreshHandler outermost; non-DI EsiClient wraps its
  self-made handler with it (per-call callback only, no sink).
- csproj: <LangVersion>latest</LangVersion>.
- Tests: 5 cases incl. AddEsi + one sink registration -> every authenticated
  call refreshes and the sink fires. CHANGELOG / README updated (sink-first).

Local: build 0 warnings both TFMs, 45/45 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A local, run-once console that walks the EVE SSO authorization-code flow in
the browser, catches the redirect on http://localhost:8080/callback with an
HttpListener, exchanges the code, calls Verify() to confirm the character and
exercise the JWKS path, and prints a long-lived refresh token for the live
auth probe / pre-release check.

Optionally writes the token straight to a GitHub Actions secret via `gh secret
set` (value over stdin, never argv). The repo has several GitHub remotes, so
--repo is auto-resolved from `git remote get-url origin`.

Not packed, not shipped. Added to the solution under a tools/ folder so it
keeps building against the library API.
Compares the live ESI OpenAPI 3.1 document against the endpoint set the
wrapper actually implements. The route, HTTP method and security live inside
each Logic method body, so they are read from a Roslyn syntax walk of every
Execute<T>(...) call; the response model T comes from reflecting the built
ESI.NET assembly and is joined on (class, method). No hand-maintained
manifest.

Tier 1 diff:
- orphaned (implemented, absent from spec) -> error (latent 404)
- missing  (in spec, not implemented)      -> warning (error under --strict)
- parameter-name drift                     -> warning

Current run: 195/197 covered; 2 missing (GET /meta/changelog,
/meta/compatibility-dates); 12 orphaned - the esi-bookmarks and
esi-opportunities removals plus chat_channels, GET /characters/names and the
public /search branch.

Not packed. Added to the solution under tools/.
Flattens each covered endpoint's model (reflection) and its resolved 200
schema into a shared Node tree and walks them together.

- schema-shape / schema-type            -> error
- schema-int-width, missing/extra prop,
  enum drift                            -> warning (error under --strict)
- enum-unmodelled, date-as-string,
  oneOf/anyOf/dictionary                -> info

First run over 175 endpoints: 6 errors (2 array/object shape mismatches -
CorporationLogic.Standings, UniverseLogic.AsteroidBelt; 4 number-typed-as
int/string), plus warnings: 492 ids typed int32 vs the spec's int64, 7 enum
typos ("loan ", "cleamup", "vulnerable "), 12 unbound (often required)
properties, 89 stale properties.
Bullet and emphasis style, blank lines around headings and fenced blocks.
Drops one parenthetical from the Discord line.
Runs tools/SpecCheck against the live ESI OpenAPI document. Schedule +
workflow_dispatch only - not a PR/push gate; a failed run emails the
maintainer as the nudge to address drift. workflow_dispatch takes a `strict`
input to fail on warnings too.

Currently red: 12 orphaned endpoints + 6 schema shape/type mismatches.
tests/ESI.NET.IntegrationTests - NOT in ESI.NET.sln, so the normal
`dotnet test ESI.NET.sln` path never runs it. Runs only from
.github/workflows/integration.yml (weekly + workflow_dispatch, the manual
pre-release check).

- PublicSmokeTests (collection "live"): ~25 unauthenticated GETs across ~12
  tags, asserting 200 + bound Data + no exception. LiveFixture resolves entity
  ids by name and retries on transport errors / 5xx.
- AuthProbeTests (collection "live-auth", [SkippableFact]): refresh-token
  exchange -> Verify (JWKS) -> a bearer call -> transparent refresh against
  live SSO. Skips unless ESI_CLIENT_ID + ESI_SECRET_KEY + ESI_REFRESH_TOKEN
  are set.

First run flagged two real wrapper bugs (backlog): EsiResponse's JSON sniff
doesn't trim, so endpoints whose body has a trailing newline return null Data;
Bloodline.ship_type_id is non-nullable but ESI returns null.
Groups it with tests/ESI.NET.IntegrationTests, mirroring the tools/ folder.
ProjectReference and the solution entry updated; InternalsVisibleTo is by
assembly name so it is unaffected. Build + 45 tests green from the new path.
Replaces the plain one-line webhook with the embed format carried over from
the old Azure DevOps pipeline (title links to the release, "Psianna Archeia"
author), built with jq + curl so nothing needs hand-escaping. Still last step,
still skips drafts, still test-webhook for dispatch / real-webhook for a
dev->master release.
…alars

The 200/201 branch keyed on body.StartsWith("{")/EndsWith("}"), so any
endpoint whose body ends in a newline (several do) fell through to
Message and left Data null - a 200 with no exception and no data. It also
never bound bare-scalar bodies (a wallet balance, a CSPA cost).

Now: trim, then deserialize when the first non-space char can start a JSON
value ({ [ " - digit t f n). A genuinely non-JSON 200 body still goes to
Message with no exception; the outer catch still captures real
deserialization failures.

+3 tests (trailing newline, surrounding whitespace, bare decimal). 48/48.
Fixes 3 of the 4 integration smoke failures.
workflow_dispatch now defaults to a prerelease: packs <version>-<label>.<run#>
(label input, default beta), pushes it to nuget.org + GitHub Packages, and
stops - no tag, no GitHub Release, no Discord. Consumers opt in with
`dotnet add package ESI.NET --prerelease`; same feed, no repo switch. Set
prerelease=false on the dispatch to cut a full release by hand.

push to master is unchanged (full release) and now also mirrors the package
to GitHub Packages. permissions gains packages: write.
README is now first-time-implementer only: Install -> Setup (DI, with a non-DI
fallback) -> Making a request -> Authenticated (SSO) as numbered steps, incl. a
desktop/GUI note pointing at tools/MintToken -> Token refresh -> Resilience. No
before/after, no "removed", no history. Fixes stale bits (Universe.Names takes
List<int> not List<long>; DataSource lists Singularity; trimmed the duplicated
user-agent prose and the EsiConfig sample).

MIGRATION.md (new) holds every breaking change and the before/after tables,
linked from the top of the README and from the changelog.

CHANGELOG: new "Tooling & tests" block (test suites, the weekly SpecCheck
spec-drift tool, CI/release automation, MintToken); the EsiResponse trim fix
under Fixed; the Migration table replaced by a pointer to MIGRATION.md.
- assets/icon.svg + a GDI+ render script -> assets/icon.png (128x128), packed
  as PackageIcon
- README.md packed as PackageReadmeFile (renders on nuget.org)
- PackageTags, Copyright (matches LICENSE.txt), PackageReleaseNotes

dotnet pack is warning-clean; the .nupkg carries icon, readme, license, and a
<repository> commit entry.
seraphx2 and others added 29 commits September 10, 2026 15:54
New _esi.MilitaryCampaigns accessor. Campaign and objective listings are
public; the character's own objective progress needs esi.activity.char:read.

- MilitaryCampaigns.All()                              -> MilitaryCampaignList
- MilitaryCampaigns.Get(campaign_id)                   -> MilitaryCampaign
- MilitaryCampaigns.Objectives(campaign_id, ...)       -> MilitaryObjectiveList
- MilitaryCampaigns.Objective(campaign_id, obj_id)     -> MilitaryObjective
- MilitaryCampaigns.CharacterObjectives(...)           -> CharacterMilitaryObjectiveList
- MilitaryCampaigns.CharacterObjective(objective_id)   -> CharacterMilitaryObjective

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…endpoints)

New _esi.Structures accessor for the 2026 structure types.
esi-structures.read_corporation.v1 / esi-structures.read_character.v1;
/skyhooks/raidable is public.

- Structures.Skyhooks() / Skyhook(id)                 -> SkyhookList / Skyhook
- Structures.SovereigntyHubs() / SovereigntyHub(id)   -> SovereigntyHubList / SovereigntyHub
- Structures.MercenaryDens() / MercenaryDen(id)       -> MercenaryDenList / MercenaryDen
- Structures.RaidableSkyhooks()                       -> RaidableSkyhookList

Sovereignty-hub workforce_transport (import/export/transit oneOf) kept loose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New _esi.Cosmetics accessor. Global Paragon listings and a design lookup
are public; the rest need esi.cosmetic.char:read.

- Cosmetics.ParagonListings(...)                       -> ParagonListingPage
- Cosmetics.Paragon{Alliance,Character,Corporation}Listings(id, ...)
- Cosmetics.MyParagonListings(...)
- Cosmetics.Skinr(skinr_id)                            -> SkinrDesign
- Cosmetics.MyLicenses()                               -> SkinrLicenses
- Cosmetics.MyComponents()                             -> SkinrComponents

price / target / runs / slot-configuration oneOf fields kept loose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ndpoints)

Coverage is now 233/233 - every endpoint in the 2026-08-18 snapshot.

Character (esi-access.read_lists.v1 / esi-activities.read_character.v1):
- Character.AccessLists() / AccessList(id)
- Character.MercenaryTacticalOperations() / MercenaryTacticalOperation(id)

New _esi.Meta accessor (all public):
- Meta.Changelog() / CompatibilityDates() / Name() / Status()

SpecCheck allowlist is now empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- MIGRATION.md sections 12-13: compatibility-date versioning, the Route /
  Sovereignty / Character / Corporation changes, and the new endpoint groups
  with their scopes
- CHANGELOG: compatibility-date block + "every endpoint since 2020 is wrapped"
- README: DataSource note (X-Tenant), a line on the pinned compatibility date
- Integration smoke tests: fix Route_between_two_hubs and Sovereignty_systems
  for the new return shapes; add Meta / MilitaryCampaigns / FreelanceJobs /
  raidable-skyhooks round-trips

Coverage 233/233. SpecCheck allowlist empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SpecCheck now reads /meta/compatibility-dates, takes the newest, and checks
the wrapper against that snapshot - not against the date the wrapper pins
(EsiVersion.CompatibilityDate). When CCP publishes a new date the run goes
red with the diff, which is the cue for a catch-up release: wrap/fix the
changes, bump EsiVersion.CompatibilityDate, green again. When the two dates
match the wrapper is current (printed on every run).

Falls back to the pinned date if /meta is unreachable, and leaves an
explicit --spec <file> alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live ESI returns ship_type_id: null for bloodlines with no starter ship,
even though the spec marks it required and non-null. Surfaced by the
integration smoke test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The spec almost never declares nullability (0 nullable:true in 2026-08-18),
so a field ESI actually returns as null - like Bloodline.ship_type_id -
throws for every caller and SchemaCheck can't see it. --probe fetches every
public GET (path params from a fixture set) and deserializes the real body
into the wrapper's model; a JsonException is a failure.

Current state: 75 endpoints, 0 failures (2 skipped - military-campaigns
needs a live campaign uuid).

Also: FromClr now tracks nullability, and CompareScalar flags the rare case
where the spec *does* declare null against a non-nullable model
(schema-nullable, Error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When ESI_CLIENT_ID / ESI_SECRET_KEY / ESI_REFRESH_TOKEN are set, --probe
exchanges the refresh token, resolves character/corp/alliance from the
access token, and probes every authenticated GET. Sub-ids (contract_id,
mail_id, planet_id, project_id, killmail, ...) are pulled from the matching
list endpoint; anything unresolvable or non-200 is skipped and reported.

New workflow_dispatch-only probe.yml runs it with the SSO secrets. Not
scheduled: it rotates ESI_REFRESH_TOKEN, which would break the scheduled
integration workflow.

Public probe unchanged: 75 ok, 0 failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fetches the full scope list from the spec's securitySchemes so the probe
token can reach the whole authenticated surface. Anything else is still a
literal space/comma list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Directory.Build.props: AnalysisLevel=latest-all + EnforceCodeStyleInBuild,
applied to every project via the SDK's built-in Microsoft.CodeAnalysis.NetAnalyzers.
No external service, no token, nothing to go stale - the free replacement for
SonarCloud (whose Automatic Analysis doesn't support C# anyway, which is most
likely why it's been failing).

Warnings only, build still green (2225 across the repo on a clean rebuild -
see follow-up discussion on what's real vs. DTO-shape noise).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The one CA2007 category worth acting on from the analyzer sweep: 233
'await Execute<T>(...)' call sites across every *Logic.cs class, plus 4
awaits in SsoLogic.GetToken/RevokeToken that predated the refresh-handler
work and never got the same treatment.

This is a library, not an app - a caller running under a sync context
(classic ASP.NET, some WPF/desktop code) that blocks on one of these
calls (.Result / .Wait()) can deadlock without it. Every await in the
request/response layer now opts out of capturing the caller's context.

Left alone (deliberately): the CA2007/CA1305/etc. still open in tools/
and tests/ - console processes have no sync context, so the same risk
doesn't apply there.

Build: 0 errors. CA2007 in ESI.NET/ (the shipped library): 233 -> 0.
Full unit suite: 48/48 passing, unchanged (ConfigureAwait doesn't alter
observable behavior in a single-threaded test host).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Symbols: IncludeSymbols/SymbolPackageFormat=snupkg + Microsoft.SourceLink.GitHub
so a consumer can step into ESI.NET's actual source while debugging.
release.yml's GitHub Packages push gets --no-symbols - that registry has no
symbol server (confirmed against GitHub's own NuGet registry docs, which never
mention .snupkg), and dotnet nuget push auto-pushes a matching snupkg by
default. nuget.org's push is untouched; it supports symbol packages natively.

License: PackageLicenseExpression=MIT (SPDX id, nuget.org's own recommendation
for a standard license) replaces the packed LICENSE.txt/PackageLicenseFile.
The repository's LICENSE.txt file is untouched - this only changes what goes
into the .nupkg.

Verified locally with a throwaway   Determining projects to restore...
  Restored D:\git\ESI.NET\tools\SpecCheck\SpecCheck.csproj (in 290 ms).
  Restored D:\git\ESI.NET\tools\MintToken\MintToken.csproj (in 290 ms).
  Restored D:\git\ESI.NET\ESI.NET\ESI.NET.csproj (in 290 ms).
  Restored D:\git\ESI.NET\tests\ESI.NET.Tests\ESI.NET.Tests.csproj (in 290 ms).
  Successfully created package 'D:\git\ESI.NET\ESI.NET\bin\Release\ESI.NET.9.9.9-local.nupkg'.
  Successfully created package 'D:\git\ESI.NET\ESI.NET\bin\Release\ESI.NET.9.9.9-local.snupkg'. (no
push anywhere): nuspec shows <license type="expression">MIT</license> and
licenseUrl -> licenses.nuget.org/MIT, LICENSE.txt is no longer packed, and the
.snupkg's PDB embeds a real Source Link URL to this repo/commit. Build clean,
48/48 unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_noContentMessage mapped ~19 specific METHOD|path pairs to friendly strings
('Fleet invitation sent', 'Mail deleted', ...) for 204 responses. It never
affected correctness - the body is never read for a 204 regardless - it was
purely cosmetic, and it was already stale: none of the 39 endpoints added
earlier in this modernization pass were ever added to it.

EsiResponse<T>.Message on a 204 is now always "No Content", same pattern
already used for 304 ("Not Modified"). StatusCode + Endpoint already tell a
consumer everything the dictionary was decorating; this needs no maintenance
and can't go stale.

Consolidated the two tests this touched into one. Build clean, 47/47 unit
tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CA1305 findings that were real, not cosmetic - int.Parse/DateTime.Parse
default to CurrentCulture:

- Expires / LastModified: dropped the manual GetValues().First() +
  DateTime.Parse(headerString) entirely. HttpContentHeaders already exposes
  these as pre-parsed DateTimeOffset? (RFC 1123-aware, correct regardless of
  host locale) - read those instead. A raw DateTime.Parse on an HTTP date can
  misparse or throw under a non-default culture; this removes that class of
  bug rather than just widening the parse call.
- Pages / ErrorLimitRemain / ErrorLimitReset: int.Parse(..., CultureInfo.InvariantCulture).
  Lower real-world risk (plain ASCII digit headers) but the same principle.

Build clean, 47/47 unit tests passing (Headers_are_parsed_onto_the_response
already covered Expires/LastModified/Pages/ErrorLimit*, unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sal, documented CA1031 suppression

A closer pass over EsiRequest/EsiResponse/Extensions, prompted by the earlier
analyzer sweep rather than a full formal code review:

- Extensions.ToEsiValue(): replaced hand-rolled reflection over [EnumMember]
  (plus a dead Enum.Parse(...).GetType() call that did nothing) with
  resolution through the same StringEnumConverter every ESI.NET enum already
  declares - it can no longer drift from what real JSON serialization of the
  same enum would produce. Flags enums still decompose and rejoin with a bare
  comma (verified against actual Newtonsoft output: its own flags
  serialization inserts ", " with a space, which is not what ESI's
  comma-separated query params expect).
- DataSource and GrantType get the StringEnumConverter they were missing
  (SearchCategory already needed it for the above and now has it too) -
  without it, a direct JSON serialization of either would have emitted the
  underlying int, not the ESI string. Confirmed no model exposes any of the
  three as a serialized property, so this only changes ToEsiValue's own
  behavior, not accidentally something else.
- EsiRequest.Execute<T>: using-var request - CA2000, low severity in
  practice (HttpRequestMessage holds no real OS resource) but free to fix
  since request isn't touched after both awaits complete.
- EsiResponse's constructor catch(Exception): documented CA1031 suppression
  instead of narrowing it. This is the boundary between untrusted ESI bytes
  and a typed object - the whole point is that an unanticipated parsing
  failure lands on Exception/Message instead of throwing out of Execute<T>.
  Enumerating specific exception types would defeat that: the next ESI quirk
  nobody's hit yet would throw straight through instead of being caught.

New ExtensionsTests.cs (6 tests): single-value resolution, the two
previously-unconverted enums, one flag, two flags (asserts no space), three
flags in a different combination order. Build clean (0 warnings), 53/53 unit
tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…L segments

222 long.ToString() calls across Logic/*.cs building {placeholder} URL path
segments (e.g. alliance_id.ToString()) now pass CultureInfo.InvariantCulture
explicitly. Real-world risk was always low - a bare integer ToString() only
diverges from ASCII digits under an unusual locale configuration - but it's
a free fix with zero downside, so fixed rather than suppressed (unlike
CA1707/CA1724 elsewhere, where the analyzer's suggestion would itself be the
regression).

The one instance in this sweep that was NOT just cosmetic - a genuine
long.Parse(string) on a JWT claim in _SSOLogic.cs - is fixed in the next
commit alongside the other _SSOLogic.cs changes.

Build clean, 53/53 unit tests passing (pre-existing suite; _SSOLogic.cs's
own test additions land in the next commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…le noise

CA5394 - GenerateChallengeCode() (the PKCE code_verifier, RFC 7636) used
System.Random, which is seeded and predictable. PKCE's entire protection
against authorization-code interception depends on the verifier being
unpredictable. Replaced with RandomNumberGenerator, with reject-and-retry
(not a plain modulo) so every character is exactly equally likely rather
than slightly biased toward the first few.

CA5404 - ValidateAccessToken() had ValidateAudience = false. Checked CCP's
own published JWT validation docs (docs.esi.evetech.net/docs/sso/validating_eve_jwt.html):
validating the aud claim is one of four steps they document as required,
alongside signature/issuer/expiry (all three of which this code already
does). A real EVE SSO JWT's aud is [clientId, "EVE Online"], and skipping
this check means a well-formed, correctly-signed token issued for a
*different* registered application would be accepted here - client
confusion / cross-app token replay. Fixed with a custom AudienceValidator
requiring both values; ValidateAccessToken now takes clientId and Verify()
passes _config.ClientId through.

Also fixed in this file: the one genuine CA1305 in the earlier sweep -
long.Parse on the JWT sub claim now takes CultureInfo.InvariantCulture,
same treatment as the header-parsing fix in EsiResponse.cs.

Verification, because guessing wrong here breaks every login:
- SsoTokenValidationTests.cs: DefaultClaims() now includes a correct aud
  claim (every existing test exercises the happy path against it), plus two
  new tests - a token audienced for a different client_id is rejected, and
  a token missing the "EVE Online" audience is rejected.
- Live: nothing in CI previously exercised SsoLogic.Verify()/ValidateAccessToken
  against a real token - the probe's token exchange talks to the SSO endpoint
  directly and never called into SsoLogic. Added exactly that call to
  Probe.cs's ProbeAuthAsync (calls Verify() on the real access token, exactly
  as a consumer would) so this is checked against reality, not just a
  synthetic fixture built from reading CCP's docs. Dispatching probe.yml to
  confirm before considering this done.

One residual unknown, noted rather than guessed at: CCP's docs describe
Tranquility's JWT shape. I could not verify whether Serenity (login.evepc.163.com)
uses the identical aud format - the same SSO software almost certainly
backs both, but this is unverified for that path specifically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…y covered

AuthProbeTests.cs's AuthFixture.InitializeAsync() already exchanges the
refresh token and calls Client.SSO.Verify(Token) against a real token, on
integration.yml's weekly schedule - I should have checked for that before
adding a second, redundant live-verification path in Probe.cs. No harm done
(it reused the access token already in scope, no extra token exchange), but
no reason to keep two things doing the same job. Dispatching integration.yml
to confirm the audience-validation fix against reality through the path that
already existed for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same encoding-loss bug as the ConfigureAwait sweep, caught then and
reproduced here because the CA1305 script used the same utf-8-sig-read/
utf-8-write pattern - missed re-checking for it this time before
committing. No functional effect (BOM doesn't affect compilation), just
inconsistent with the rest of the codebase's file encoding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ring ESI)

Every method parameter that copied ESI's own snake_case field name
(alliance_id, character_id, max_war_id, ...) is renamed to camelCase
(allianceId, characterId, maxWarId). These were hand-typed against ESI's docs
while coding ~230 endpoints by hand - understandable at the time, not
idiomatic C#, and now worth fixing for real rather than suppressing (unlike
CA1724/CA1002/CA1819 elsewhere, where the analyzer's own suggestion would be
the regression).

Done with a small Roslyn-based tool (not regex) specifically because a
plain text rename is unsafe here: every one of these methods also has a
same-named STRING LITERAL - {"alliance_id", alliance_id.ToString()} - the
quoted key must stay snake_case (it matches the {alliance_id} placeholder
in the endpoint's URL template and, for POST bodies like Routes.Map, the
literal JSON field name ESI expects), while only the bare identifier should
change. Roslyn's syntax tree makes this trivial to get right by
construction: IdentifierNameSyntax nodes are never string-literal tokens,
so a search scoped to "real identifier references inside this one method"
can't touch the quoted key next to it. 283 identifier renames + 106
<param name="..."> doc-comment fixes across 25 files, each one verified by
inspecting a sample diff (plain params, array params, Dictionary<string,object>
POST bodies) before trusting the full run - confirmed the quoted keys never
moved.

EsiClient.cs handled by hand instead: its params were _config/_client
specifically because the fields were named config/client (no underscore) -
the reverse of the _client/_config-as-fields convention every Logic class
already uses. Fixed properly: fields renamed to _config/_client, params to
config/client, matching the rest of the codebase instead of just moving the
naming inconsistency around.

Breaking change, documented in CHANGELOG.md and MIGRATION.md §14: only
affects callers using named arguments; positional calls (the common case)
need no change.

Build clean, 0 warnings for CA1707, 55/55 unit tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CA2227 (settable collection property) / CA1002 (List<T> in public API) /
CA1819 (array-returning property) assume a hand-authored public API surface
where a mutable collection is a design smell - a caller reaching in and
mutating what should be encapsulated state. Every type under Models/ is a
plain JSON-deserialization target instead: Newtonsoft needs a settable
property to populate it, there's no encapsulation to violate, and switching
to Collection<T>/ReadOnlyCollection<T> would be pure ceremony around a bag
of fields with zero behavioral benefit. Scoped via Models/.editorconfig -
confirmed CA2227/CA1819 were 100% confined to Models/, so this is a full,
correct fix for those two. CA1002 also has 8 real hits on Logic/*.cs METHOD
PARAMETERS (List<T> instead of IEnumerable<T> as an input type) - a
different, still-valid concern this justification doesn't cover, so those
are deliberately left alone (deferred to the long-tail pass).

CA1724 (type name collides with its own namespace - Alliance, Corporation,
Market, Wallet, ...): every one of these mirrors ESI's own terminology,
and renaming any of them is a breaking public API change for a cosmetic
rule. Same Models/.editorconfig covers the 9 Models/ cases; Extensions.cs
(the one hit outside Models/, colliding with the unrelated Microsoft.Extensions.*
family) gets a documented [SuppressMessage] on the class instead, same
pattern as EsiResponse's CA1031 suppression.

Build clean, 584 warnings remaining (was 1014). 55/55 unit tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…red params

The 15 of 138 CA1062 hits that were NOT the deliberately-optional
EsiCallOptions parameter (that one's handled separately - see next commit):
config on EsiClient/EsiHeadersHandler/EsiTokenRefreshHandler/SsoLogic's
constructors, request on both DelegatingHandler.SendAsync overrides,
SsoLogic.Verify's token, Extensions.ToEsiValue's e, AssetsLogic's four
itemIds parameters, KillmailsLogic.Information's killmailHash, and
UniverseLogic's anyIds/names. Every one of these is a required input a
method can't do anything sensible without - unlike EsiCallOptions, null
here really is a caller mistake, and CA1062's suggested fix (throw
ArgumentNullException naming the parameter) is the correct, idiomatic
improvement over whatever NullReferenceException would happen downstream.

Most visible behavior change: SsoLogic.Verify(null) now throws
ArgumentNullException instead of being silently caught inside Verify's own
try/catch and re-wrapped as "SSO access-token verification failed" -
misleading for what's actually a caller bug, not a token/network problem.
New regression test locks this in.

Expression-bodied methods (AssetsLogic, KillmailsLogic, UniverseLogic) use
the `x ?? throw new ArgumentNullException(nameof(x))` inline pattern rather
than converting to block bodies. UniverseLogic.cs specifically can't `using
System;` (collides with its own ESI.NET.Models.Universe.Type), so those two
throws are fully qualified System.ArgumentNullException instead.

Build clean, 56/56 unit tests passing (was 55 - one new regression test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tions param

The remaining 123 of 138 CA1062 hits, all the same case: the trailing
EsiCallOptions options = null parameter every endpoint method has.
Unlike the 15 fixed in the previous commit, null here isn't a caller
mistake - it's the correct, most common value (most calls need no special
options at all), and the null case is already handled centrally in
EsiRequest.Execute<T>. Throwing ArgumentNullException, which is CA1062's
literal suggested fix, would turn the library's single most common call
shape into a guaranteed crash - a regression, not an improvement.

Added via the same kind of small Roslyn-based script as the CA1707 rename
(123 identical [SuppressMessage] insertions across 24 files) rather than
by hand. One wrinkle worth noting: CA1062's diagnostic column for these
points at the *use site* (options: options, the argument passed into
Execute<T>) rather than the parameter declaration - different from
CA1707/CA1305, whose columns pointed at the declaration/call being
flagged. Confirmed empirically before trusting the run, same as always.

Build clean, CA1062 fully eliminated (138 -> 0). 56/56 unit tests passing.
Warning count for the whole session's cleanup: 1292 -> 318.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ons.cs

Moves all 125 attribute-based suppressions - the 123 CA1062 "options" ones,
plus CA1031 on EsiResponse's constructor and CA1724 on Extensions - out of
the source files and into one GlobalSuppressions.cs using assembly-level
[assembly: SuppressMessage(..., Scope = "member"/"type", Target = "~...")].
No reason to have 24 Logic files each carrying an identical 2-line
attribute+justification block when the reasoning is exactly the same
every time; the source files go back to reading exactly as they did
before tonight's CA1062 pass, and every suppression + its justification
now lives in one place that's easy to scan or audit as a whole.

Target strings are real compiler-verified DocumentationCommentIds (via
MSBuildWorkspace + ISymbol.GetDocumentationCommentId()), not hand-typed -
SuppressMessage's Target format is strict and a wrong string just silently
fails to suppress anything, so this was resolved through the actual
semantic model rather than guessed from source text.

Worth recording since it cost real time tonight: the first attempt at
removing the inline attributes used Roslyn's AttributeListSyntax.FullSpan,
which pulled in each method's XML doc comment as leading trivia (nothing
else was claiming it) - deleted every /// <summary> in the process. Caught
before committing, reverted clean, redone with a plain, exact-text regex
match instead (validated against every file's expected count before
writing anything, not after) - doc comments and blank lines confirmed
intact this time.

Build clean, 318 warnings (no change from before this move - purely a
reorganization). 56/56 unit tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Clears every warning left in the library after tonight's CA1305/CA1707/
CA1062 passes - 16 rules, verified via a clean rm -rf bin/obj rebuild and
deduped by stripping the trailing [project::TFM] tag (the first pass had
each warning appearing up to 4x from stacked incremental builds).

Real fixes:
- EsiErrorLimitException gains the 3 standard exception constructors
  (parameterless, (message), (message, innerException)) - CA1032.
- 5 undisposed HttpRequestMessage/StringContent objects in _SSOLogic.cs
  and EsiClient's manually-wired handler are now `using` - CA2000.
- RequestTokenAsync forwards cancellationToken to ReadAsStringAsync on
  net8.0 instead of dropping it, matching EsiResponse.cs's existing
  #if NET pattern - CA2016.
- PKCE hashing uses SHA256.HashData on net8.0 instead of
  SHA256.Create().ComputeHash(...) - CA1850. GenerateChallengeCode is
  static - CA1822. RevokeToken/Verify call PostAsync/GetAsync with a Uri
  instead of a raw string - CA2234.
- New internal Guard.NotNull() replaces 8 duplicated
  `if (x == null) throw new ArgumentNullException(nameof(x))` guards
  (from tonight's own CA1062 pass), resolving to
  ArgumentNullException.ThrowIfNull on net8.0 - CA1510.
- AssetsLogic's 4 itemIds params, UniverseLogic.Names/.IDs, and
  CreateAuthenticationUrl's scope widened List<T> -> IReadOnlyList<T> -
  CA1002. Non-breaking: a caller passing List<T> is unaffected.
- EsiClient implements IDisposable, disposing _client only when it
  created it itself (a new _ownsClient flag) - never a caller- or
  AddEsi-IHttpClientFactory-supplied one, which isn't this instance's
  to dispose - CA1001.
- RoutesFlag renamed RoutePreference - CA1711. Breaking, but it wasn't
  actually a [Flags] enum and mirrored no ESI concept name, so there was
  no reason to keep one colliding with the reserved Flag suffix.

Documented in GlobalSuppressions.cs rather than fixed (15 more hits,
7 rules): Event/Module/Structure(x2)/Dogma.Attribute keep names that
mirror ESI's own concepts rather than dodge a reserved-keyword/-suffix
collision (CA1716/CA1711, same reasoning as Extensions's CA1724);
EsiConfig.EsiUrl/.CallbackUrl, Corporation.Url, and
CreateAuthenticationUrl's return type stay string rather than
System.Uri - config/DTO surface manipulated as strings throughout,
not worth the blast radius for a parser that can throw where a lenient
string never would (CA1056/CA1055); four .Replace/.IndexOf calls skip
an explicit StringComparison/.Contains(char) because those overloads
don't exist on netstandard2.0 - verified by actually compiling against
it, not assumed (CA1307/CA2249); Verify's best-effort affiliation-lookup
catch{} joins EsiResponse<T>'s constructor under the existing
"must not invalidate an otherwise-good token" CA1031 justification.

Caught along the way: widening those 4 AssetsLogic parameter types
changed their compiler-computed signature, which silently orphaned
their existing CA1062 suppression Targets in GlobalSuppressions.cs (a
stale Target fails to suppress with no error - it isn't line-based, it's
a DocumentationCommentId keyed to the exact parameter-type list). Caught
by a rebuild showing them reappear as "new" warnings; fixed by updating
the 4 Target strings and re-verifying via another rebuild that they
actually suppress again.

Build clean on both TFMs, 0 warnings in the library, 56/56 tests
passing. MIGRATION.md / CHANGELOG.md updated for the RoutesFlag rename
and the rest of this pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A beta.3 reviewer's app is net10.0 and it resolved the netstandard2.0
asset instead of net8.0 - net8.0 should already be forward-compatible
with a net10.0 consumer via NuGet's resolution, but there's no reason
to depend on that working as expected when an explicit net10.0 build
removes the ambiguity entirely and lets net10.0 consumers get whatever
newer-BCL code paths (ArgumentNullException.ThrowIfNull, SHA256.HashData,
...) are already conditionally compiled in via #if NET.

net8.0 stays alongside it - it's supported through November 2026, and
dropping it now would just be another breaking move with no upside.
net11.0 (STS, ships alongside .NET's usual November release) won't get
the same treatment when it lands: the pattern here is floor
(netstandard2.0, permanent) + whichever LTS release(s) are currently in
support, not "every version" - non-LTS releases are already served
correctly by the netstandard2.0 fallback and don't stay in production
long enough to be worth a dedicated build. net8.0 drops the same way
net7.0 already did, once it actually reaches EOL.

- ESI.NET.csproj: TargetFrameworks netstandard2.0;net8.0 ->
  netstandard2.0;net8.0;net10.0.
- ESI.NET.Tests.csproj: TargetFramework net8.0 -> TargetFrameworks
  net8.0;net10.0, so CI actually runs the suite on both runtimes
  instead of only compiling for one of them.
- .github/actions/ci-dotnet, .github/workflows/release.yml: setup-dotnet
  bumped 8.0.x -> 10.0.x. Caught before it shipped: an 8.0.x SDK doesn't
  understand a net10.0 TargetFramework at all (unlike the reverse - a
  newer SDK builds older TFMs fine), so this would have broken every PR
  check and the release pipeline the moment the csproj change landed.
  integration.yml/probe.yml stay on 8.0.x; they only ever build
  single-TFM net8.0 projects and never touch the multi-targeted library
  directly.

Verified locally: clean build and 0 analyzer warnings on all three TFMs,
56/56 tests passing on both net8.0 and net10.0, `dotnet pack` produces
all three lib/ folders in the .nupkg, and the exact ci-dotnet
restore/build/test sequence run by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@seraphx2
seraphx2 merged commit 196aa02 into master Sep 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants