Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/StaticWebAssetsSdk/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,112 @@ These invariants must hold at all times. Violating any of them is a bug.
- `AssetKind` → `All` (or `Build` when `CopyToPublishDirectory` is `Never`).
- `AssetMode` → `All`; `AssetRole` → `Primary`.

## Triage Heuristic: "Sequence contains more than one element" (and other aggregation crashes)

> **The crash site is never the fix.** A manifest/endpoint task that throws on a collision is the
> *symptom*, not the cause. Work out **why two assets reached the same target-path + `AssetKind` slot**, then
> fix the layer that *should have stopped the second asset from existing*. The most durable fix **prevents the
> surplus asset from being emitted at all**, at its producer — so exactly one asset ever lands on the route —
> rather than letting two land and de-duplicating them downstream. It is **never** the throwing task; but it
> is **also not** automatically "the SDK resolution." Localize where the second asset is *born*, then decide
> whether it should be born conditionally (best) or de-duplicated later (a defensible-but-weaker compensation).

**Symptom.** A Static Web Assets task throws
`InvalidOperationException: Sequence contains more than one element`, or otherwise finds more
than one asset where it expects at most one. The canonical site is
`ChooseNearestAssetKind(group, kind).SingleOrDefault()` in `GenerateStaticWebAssetEndpointsManifest`
(and the identical pattern in `GenerateStaticWebAssetsDevelopmentManifest`).

**What it actually means.** It is a violation of the **target-path uniqueness invariant** documented
above: *at a given target path, at most one asset per `AssetKind` slot (`Build`/`Publish`/`All`)*.
`ChooseNearestAssetKind` is documented to **assume the manifest is already correct** — it deliberately
does not validate or de-duplicate, and yields multiple assets on malformed input precisely so the error
surfaces. So when this throws, **upstream input is malformed**: some producer emitted two assets that
resolve to the same target path + kind.

**The traps (what NOT to do).** Two reflexes both fail here:

1. **Symptom-site softening.** Do not "fix" it at the throw site by replacing `SingleOrDefault()` with
`First()`, by collapsing/de-duplicating the colliding assets in the consuming task, or by relaxing the
uniqueness invariant. That masks the real defect and contradicts the task's own contract.
2. **The *unconditional* package remodel.** Do not retype the package's fallback (e.g. model
`blazor.modules.json` as a `Framework` asset) while still emitting it **unconditionally**. That still
leaves **two** assets on the route — it just relabels one — so the collision survives.

Both traps were hit on dotnet/sdk#54779 (a symptom-site task "fix" *and* an unconditional `Framework`-asset
remodel) and **both were rejected in review**. **Crucially, the fix that ultimately landed *was* a package
change** (dotnet/aspnetcore#67375, merged) — but a **conditional** one: it makes the package emit its
fallback only when the consumer has no asset of its own, so the second asset never exists. "Change the
package" is not the trap; **changing it *unconditionally*** is. The discriminator is always: *does exactly
one asset end up on the route, or do two?*

**Playbook (do this instead).**

1. **Enumerate every colliding asset** at the offending target path with full provenance —
`Identity`, `SourceId`, `SourceType`, `AssetKind`, `AssetMode`. Get this from the failing build's
binlog (search the target path, e.g. `_framework/blazor.modules.json`) or by temporarily logging the
group contents. You usually have the provenance already; the gap is interpreting it.
2. **Identify the producer** of each colliding asset — which referenced project, NuGet package, or target
generated it (`SourceType=Package`/`Project`/`Computed`/`Framework` tells you where to look).
3. **Localize the *resolution gap*, not just the producer.** Ask two questions, in order:
1. **Is the input genuinely malformed?** Is there a duplicate that should *never* exist under any correct
resolution — e.g. two unrelated sources each emitting a primary asset at one target path with no
grouping relationship between them? If so, the producer of the spurious asset is at fault.
2. **Or is the input legitimately grouped, but the surplus variant was emitted anyway?** Assets that
*intentionally* share a target path do so via **asset groups** (advanced extensibility): a package ships
a fallback that must be **dropped** once the consumer supplies its own asset for the same path.
Historically this was modeled with a **deferred** group resolved at **build**, while the build manifest
deliberately **retained all variants** so transitive consumers could re-resolve against their own graph.
**Key signal:** if the project **builds** cleanly and only **publish** throws, the build-time resolution
wasn't reflected at publish — so the variant that *should* have been dropped survives. Two layers can own
this: the **package** that emits the fallback (it can decline to emit it when the consumer already has an
asset), and the **SDK** that reloads the build manifest at publish (it can re-apply the build-time
resolution). The durable fix is the former.
4. **Fix the layer that can stop the second asset from existing.**
- **A grouped/fallback collision that only manifests at publish** → **preferred: make the package's
fallback conditional** so exactly one asset is ever produced. The package's build targets run *inside the
consumer's build* and can see whether the consumer already contributes an asset for the path (e.g.
`@(_ExistingBuildJSModules) == ''`); gate the fallback on that, and the surplus variant is never emitted —
no deferred group, no downstream de-duplication, no SDK change. This is what landed for #54779
(dotnet/aspnetcore#67375). **Acceptable alternative (weaker): carry the build resolution into publish in
the SDK** — persist the *resolved* (no-longer-`Deferred`) groups into the build manifest and re-apply that
**unscoped** decision when the manifest is reloaded at publish (dotnet/sdk#54941). It is a real, working
fix, but it *de-duplicates two assets after the fact* rather than preventing the second one, and it was
**superseded** by the package fix. Whichever layer you choose, do **NOT** retype the package's asset as a
`Framework` asset while still emitting it **unconditionally** (it leaves two assets and a redundant
endpoint), and do **NOT** add a consumer-scoped `Remove`/`SourceId != $(PackageId)` guard in the publish
targets — at publish the group filter is consumer-scoped (`Source="$(PackageId)"`) and *structurally
cannot* filter a group owned by a referenced project or package, so a scoped patch is both wrong and
incomplete.
- **A genuine duplicate from a referenced project** (no legitimate grouping) → fix that project.
- **A genuine duplicate emitted by the SDK's own targets** → fix the emitting target.
In every case, **preserve** the target-path uniqueness invariant and never soften the throwing task.

**Why "blame the producer" is half-right.** Provenance tells you *which* asset collided and *where it came
from*; it does **not** by itself tell you where the fix belongs. The fix belongs where the surplus asset can
be **prevented**. For #54779 that turned out to be the producing package after all — but not because "the
producer is always at fault," and not via an unconditional remodel. The package's targets execute in the
consumer's build, so the package *can* see whether the consumer already owns the asset and decline to emit its
fallback. The earlier belief that "only the SDK can resolve this, because the package can't see the consumer's
graph" was the false premise: at build time, inside the consumer, the package's own targets see exactly the
signal they need (`_ExistingBuildJSModules`). Preventing the asset at its source beats carrying a resolution
forward to delete it later.

**Worked example.** dotnet/sdk#54779 — the `Microsoft.AspNetCore.Components.WebView` package shipped a
fallback `_framework/blazor.modules.json` *unconditionally* (originally via a **deferred** static-web-asset
group). The app *built* fine but failed to *publish* with `Sequence contains more than one element`, because
the fallback survived alongside the app's own modules manifest — two assets on one route. **The fix that
landed is in the package** (dotnet/aspnetcore#67375, merged; backport dotnet/aspnetcore#67401): it removes the
deferred-group machinery and instead materializes the empty (`[]`) fallback as the consumer's own asset
**only when the consumer has no JS modules of its own**, so exactly one asset ever lands on the route and the
collision cannot form — with **no SDK change**. An SDK-side carry-forward (dotnet/sdk#54941) was built and
*empirically verified to work*, but it compensates downstream and was **not merged** (superseded). The earlier
attempts to soften the throwing task and to remodel the package's asset as a `Framework` asset *unconditionally*
were both rejected.

See [Architecture.md](Architecture.md) §"Deferred Groups and `SkipDeferred` Filtering" for the mechanism. A
regression eval for this exact reasoning failure lives in [`evals/`](evals/README.md).

## Development Workflow

For the full development workflow (inner loop, patching, testing, validation), use the **Static Web Assets** agent defined in `.github/agents/static-web-assets-agent.agent.md`. It has the step-by-step process, commands, and test strategy.
Expand Down
43 changes: 43 additions & 0 deletions src/StaticWebAssetsSdk/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,49 @@ When a project targets multiple frameworks (`TargetFrameworks`), each inner buil

Groups are keyed by `(SourceId, Name)` and stored in a `Dictionary<(string SourceId, string Name), StaticWebAssetGroup>`. They travel alongside assets in manifests and allow a consuming project to read provider-specific settings without coupling to the provider's implementation. An asset's `AssetGroups` property (semicolon-separated `Name=Value` pairs) can reference these groups.

#### Deferred Groups and `SkipDeferred` Filtering

A group can be marked **`Deferred`**. Deferred groups model assets whose final resolution depends on the
*consumer* — most often a **fallback** asset that a package ships but that must be **excluded** once the
consuming project (or one of its references) supplies a more specific asset for the same target path.

`FilterStaticWebAssetGroups` (`Tasks/FilterStaticWebAssetGroups.cs`) applies group declarations and runs
in two modes, controlled by its `SkipDeferred` parameter:

- **`SkipDeferred="true"`** (pre-filter pass): groups marked `Deferred` are **skipped** — their exclusions
are *not* applied yet. The **publish** path runs in this mode
(`Microsoft.NET.Sdk.StaticWebAssets.Publish.targets`).
- **`SkipDeferred="false"`** (final pass): all groups must be concrete; an error is raised if any group is
still `Deferred`.

> **Failure mode (target-path collision) — and where it is actually fixed.** The build manifest
> (`staticwebassets.build.json`) intentionally **retains every variant** of a grouped asset so that
> transitive consumers can re-resolve the group against their own asset graph. At **build**, the unscoped
> `FilterStaticWebAssetGroups` pass resolves the deferred group and drops the excluded fallback. At
> **publish**, the SDK reloads that build manifest but historically **never re-resolved the deferred group**:
> there is no publish-side resolution pass, and the publish `FilterStaticWebAssetGroups` is *consumer-scoped*
> (`Source="$(PackageId)"`), so it structurally **cannot** filter a group owned by a referenced project or
> package. The fallback therefore survives and reaches `GenerateStaticWebAssetEndpointsManifest` *alongside*
> the consumer's own asset — two assets at the same target path + `AssetKind` slot — and
> `ChooseNearestAssetKind(...).SingleOrDefault()` throws `Sequence contains more than one element`. **Key
> signal: the project builds fine; only publish fails.**
> **Where it was actually fixed (and the precision point).** The durable fix does **not** carry the resolution
> forward — it stops the second asset from being emitted at all. The producing package's build targets run
> *inside the consumer's build*, so they can gate the fallback on whether the consumer already contributes an
> asset (e.g. `@(_ExistingBuildJSModules) == ''`) and materialize it **only when there is none**. Then exactly
> one asset ever lands on the route and there is nothing to collide — no deferred group, no SDK change. This is
> what landed for dotnet/sdk#54779: dotnet/aspnetcore#67375 (merged; backport dotnet/aspnetcore#67401) removes
> the deferred-group machinery from the `Microsoft.AspNetCore.Components.WebView` package and makes the
> `blazor.modules.json` fallback **conditional**. A *different* SDK-side fix that re-applies the resolved
> groups at publish (dotnet/sdk#54941) was built and **empirically verified to work**, but it de-duplicates two
> assets after the fact rather than preventing the second one, and was **not merged** (superseded). Two further
> approaches were tried and **rejected in review**: softening the throwing task, and retyping the package's
> asset as a **`Framework`** asset while still emitting it **unconditionally** (that just relabels one of two
> assets — the collision survives). The distinction that matters: a *conditional* package fallback (one asset,
> correct) versus an *unconditional* package remodel (two assets, still broken). See the triage heuristic in
> [AGENTS.md](AGENTS.md).


### Discovery Patterns

`StaticWebAssetsDiscoveryPattern` (`Tasks/Data/StaticWebAssetsDiscoveryPattern.cs`) describes a glob pattern for discovering files dynamically at runtime (for the dev server).
Expand Down
10 changes: 10 additions & 0 deletions src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,16 @@ public static StaticWebAsset FromTaskItem(ITaskItem item, TaskEnvironment env, b
// than one type of asset we will just return all of them.
// One exception to this is the `All` kind of assets, where we will just return the first two we find. The reason for it is
// to avoid having to allocate a buffer to collect all the `All` assets.
// CONTRACT: callers must treat "more than one asset returned" as malformed input for this pipeline — a
// violation of the target-path uniqueness invariant (at most one asset per AssetKind slot per target
// path). Surface the error; do NOT silently collapse the duplicates here. The cause is upstream: either
// a genuine producer duplicate, or (commonly) a package fallback that was emitted even though the consumer
// already supplied its own asset for the same path. The durable fix removes the surplus asset at its
// source — make the package's fallback conditional so exactly one asset is ever produced
// (dotnet/aspnetcore#67375, the fix that landed for dotnet/sdk#54779). An SDK-side carry-forward that
// re-applies the build-time group resolution at publish (dotnet/sdk#54941) also works but compensates
// downstream and was superseded. Either way, do NOT soften this call. See
// StaticWebAssetsSdk/AGENTS.md "Triage Heuristic".
internal static IEnumerable<StaticWebAsset> ChooseNearestAssetKind(IEnumerable<StaticWebAsset> group, string assetKind)
{
StaticWebAsset allKindAssetCandidate = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@ private IEnumerable<TargetPathAssetPair> ComputeManifestAssets(IEnumerable<Stati

foreach (var group in assetsByTargetPath)
{
// ChooseNearestAssetKind assumes the manifest is correct and yields more than one asset only
// when the input is malformed (two assets share a target path + kind slot). SingleOrDefault()
// therefore throws "Sequence contains more than one element" on a real upstream defect. The most
// common cause is a package fallback (e.g. blazor.modules.json) that was emitted even though the
// consumer already supplied its own asset for the same path. The durable fix removes the surplus
// asset at its source — make the package's fallback conditional so only one asset is produced
// (dotnet/aspnetcore#67375, the fix that landed for dotnet/sdk#54779). An SDK-side carry-forward
// that re-applies the build-time group resolution at publish (dotnet/sdk#54941) also works but
// compensates downstream and was superseded. Either way, do NOT soften this call. See
// StaticWebAssetsSdk/AGENTS.md "Triage Heuristic" and dotnet/sdk#54779.
var asset = StaticWebAsset.ChooseNearestAssetKind(group, kind).SingleOrDefault();

if (asset == null)
Expand Down
Loading
Loading