Skip to content

fix(build-project): the emitted assembly carries the platform's binding identity - #2855

Closed
rbuergi wants to merge 13 commits into
mainfrom
fix/build-project-assembly-version
Closed

rbuergi wants to merge 13 commits into
mainfrom
fix/build-project-assembly-version

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

The defect: a wrong binding identity that nothing turns red

GenerateAssemblyInfo is an MSBuild target, and build-project (#2850) deliberately runs no
targets. So the SDK's [assembly: AssemblyVersion("…")] was never written, and Roslyn emitted its
own default: 0.0.0.0, where the whole fleet binds 3.0.0.0.

Measured first-hand, not inferred — MeshWeaver.Plugins/src/MeshWeaver.Speech.Contract built
through a tester image from feat/cli-build-project:

MeshWeaver.Speech.Contract AssemblyVersion=0.0.0.0

The build was GREEN. That repo's src/Directory.Build.props pins <AssemblyVersion>3.0.0.0
precisely because #143 shipped 1.0.0.0 assemblies into a 3.0.0.0 process and CrashLoopBackOff'd a
migration; its own comment records why the class is nasty — "a target that runs
BeforeTargets="CoreGenerateAssemblyInfo" still builds 1.0.0.0, silently."
The failure lands at
runtime binding, in a different repo, as FileNotFoundException: Could not load file or assembly '…, Version=3.0.0.0'.

🚨 This is a prerequisite for any module adopting "build": "container" in the pack lane
(#2854).
A bundle built without it ships the wrong binding identity, passes every gate it is
subjected to, and fails only at runtime — in a different repo, in a different process, as a
missing-file error naming a version nobody wrote down. Nothing in the pack lane can detect it.

The fix

ProjectFile evaluates the identity; ProjectBuild synthesizes it into the compilation as
one more source document — exactly as the SDK's generated <Project>.AssemblyInfo.cs is one more
Compile item.

🚨 EmitPipeline.CreateCompilationOptions and everything feeding OptionsFingerprint are
untouched; #2850's own CSharpCompilationOptions is where this lives.

The SDK semantics, MEASURED rather than recalled

Every rule below was produced by building the same project with SDK 10.0.400 and reading the
generated *.AssemblyInfo.cs:

$(Version) SDK AssemblyVersion here
1.2.3-beta.4 1.2.3.0 (label dropped) ✅
1.2.3+meta 1.2.3.0 (metadata dropped) ✅
1.2.3.4 1.2.3.4 ✅
4.5 / 1 4.5.0.0 / 1.0.0.0 (padded) ✅
01.2.3 1.2.3.0 (zeros normalise) ✅
(unset) 1.0.0.0 via VersionPrefix ✅
1.2.3.4.5 rejected named refusal

Plus the fallback ORDER, which is the easy thing to get backwards: an explicit AssemblyVersion
wins, FileVersion follows AssemblyVersion (not $(Version)), InformationalVersion follows
$(Version), and Company←Authors←AssemblyName / Product,Title←AssemblyName.

GenerateAssemblyInfo=false synthesizes nothing — the project supplies its own and a second set
is CS0579. Each individual Generate…Attribute switch is honoured.

Three item types that were being dropped

InternalsVisibleTo, AssemblyMetadata and AssemblyAttribute were on this evaluator's "metadata
that does not change which sources compile against what"
list. True of the compile, false of the
assembly: an InternalsVisibleTo dropped here makes the friend project fail to compile in a
later, unrelated run. Sixty-odd projects across these repos declare one.

One consequence worth flagging: now that AssemblyMetadata Value metadata is read, a project
whose value uses a property function outside this evaluator's grammar becomes a named refusal
where it used to be a silent drop. Exactly one project in either repo is affected —
MeshWeaver.Plugins/src/MeshWeaver.AI.Test, whose
<AssemblyMetadata Include="MeshWeaverRoot" Value="$([System.IO.Path]::GetFullPath('$(MeshWeaverRoot)'))" />
now fails the load by name. That is this file's stated doctrine, and no CI lane calls
build-project yet
, so nothing regresses.

Where the SDK cannot be matched, it FAILS BY NAME

An unparseable $(Version); PublishRepositoryUrl=true with no $(RepositoryUrl) (the SDK reads
that off the git remote). No plausible-looking substitute — a wrong binding identity is the entire
defect.

Two divergences, both said out loud

  • $(SourceRevisionId) — the SDK appends +<sha> from git. There is no git here, so every
    build reports the absent suffix by name
    , and the new -p:Name=Value gives exact parity.
  • TargetFrameworkAttribute — a different target (GenerateTargetFrameworkMonikerAttribute)
    with its own switches and its own TargetPlatform/SupportedOSPlatform companions for a
    platform-suffixed TFM this evaluator cannot compute. Emitting half of that set would be worse than
    the gap, so the gap is written down in the code and the README instead.

One bug found on the way

ProjectFile's globalProperties were documented to "win over anything the files set
unconditionally"
and did not — a <PropertyGroup> overwrote them. Fixed (a global property is now
immutable during evaluation, as under MSBuild), because otherwise -p:AssemblyVersion=… would mean
nothing for exactly the property a caller passes it for.

Verified by RUNNING

Unit — 23 new cases in test/MeshWeaver.PluginTester.Test/ProjectAssemblyIdentityTest.cs; suite
198/198 green (175 before, +23). Every assertion reads the emitted file's metadata (PEReader
— not Assembly.LoadFrom, which refuses a second assembly of the same simple name and would turn an
identity suite into a load-order suite).

Mutation proof — with the synthesized tree disabled, the key test fails exactly as the defect
predicts:

Expected value to be 3.0.0.0 because the fleet binds 3.0.0.0 …, but found 0.0.0.0.

End to end, the reproduction that found the defect — tester image built from this branch,
MeshWeaver.Plugins/src/MeshWeaver.Speech.Contract built inside it:

[MeshWeaver.Speech.Contract] assembly identity — AssemblyVersion 3.0.0.0, FileVersion 3.0.0.0,
  InformationalVersion 1.0.0 (no SourceRevisionId: this builder runs no git, so the SDK's +<sha>
  suffix on InformationalVersion is absent — pass --property SourceRevisionId=<sha> to reproduce it)
build-project: GREEN

$ AssemblyName.GetAssemblyName(…/MeshWeaver.Speech.Contract.dll).Version
3.0.0.0

Parity against a real dotnet build of the same project, attribute for attribute — Company,
Configuration, Description, FileVersion, InformationalVersion, Product, Title, AssemblyVersion all
identical; the only difference is TargetFrameworkAttribute (above). With
-p:SourceRevisionId=7e0750c… the InformationalVersion matches the SDK's exactly too:
1.0.0+7e0750c274faa2ecf4a918ed6891ef96e326c8b2.

A recommendation, not a change: --accept targets

build-project refuses to run against MeshWeaver.Plugins/src without --accept targets, because
of <Target Name="VerifyAssemblyVersionMatchesPlatform"> — the guard that exists to catch exactly
this drift. So today that flag is what lets a wrong binding identity through. I have not changed
it.
What I'd suggest instead, for a maintainer call:

ContainerReferenceSet already knows PlatformAssemblyVersion (3.0.0.0, read from the image).
build-project could WARN when the identity it is about to emit differs from the platform's —
naming both numbers. That is the same check the skipped target performs, done from data the builder
already has, and it needs no MSBuild. A warning rather than an error because a standalone tool may
legitimately differ; under the default no-warn policy a module build would still go red. That would
make --accept targets safe again, rather than an accepted blind spot.

Base — read this before reviewing the diff

The branch is cut from integration/core-batch (#2853), because #2850 has not landed on main
yet and the build-project files this changes do not exist there.

The PR nonetheless targets main, not the batch: a PR merged into a feature branch looks merged
while never reaching main, and this has to land on the trunk. The cost is that GitHub currently
shows the batch's files in the diff as well. The five files this PR actually changes are:

  • tools/MeshWeaver.PluginTester/ProjectFile.cs — evaluate the identity
  • tools/MeshWeaver.PluginTester/ProjectBuild.cs — synthesize it into the compilation
  • tools/MeshWeaver.PluginTester/Program.cs — -p:Name=Value
  • tools/MeshWeaver.PluginTester/README.md
  • test/MeshWeaver.PluginTester.Test/ProjectAssemblyIdentityTest.cs (new, 23 cases)

The clean diff is integration/core-batch...fix/build-project-assembly-version.
Once the batch merges, this diff collapses to those five files on its own; no rebase needed.

Not merged, no auto-merge armed, as requested.

🤖 Generated with Claude Code

rbuergi and others added 12 commits August 30, 2026 22:52
Maintainer, 2026-08-30: "also doc safety etc. same standard as with normal
build … see that you tune it to same level. nullability etc … and doc
references and comment references etc … diagnostic must go to activity."

MEASURED FROM THE OUTSIDE FIRST. A deliberate CS0219 — an unused local — added
to an in-mesh source compiled `ok` with "0 warning(s)". Not a clean build: the
ABSENCE OF A REPORT. `emitResult.Diagnostics` is read only when Success is
false, so every warning a green compile produced was discarded.

That is why in-mesh C# was not held to the standard the compiled half is held
to under -warnaserror. No unused-code warnings, and therefore no doc-comment
and no cref ones either — even though CreateParseOptions has ALWAYS asked for
DocumentationMode.Diagnose. The diagnostics were being produced and thrown away.

They now travel to the compile ACTIVITY, which is where a diagnostic belongs: it
is the record of that compile, what a reader opens, what the Overview/Tests
areas render, and what a runner can stream. A warning that only reached a server
log reached nobody.

The route respects the log-once contract rather than working around it.
EmitPipeline deliberately does not log — logging failures there once
double-counted every compile failure in production (~150 ERROR lines/24h were
~72 real failures logged twice, the duplicate arriving first and context-free,
which is what made "your C# does not compile" read like an emit/IO defect). So
the warnings are RETURNED, on EmittedArtifact, and appended by the same single
funnel that reports a failure — as Warning severity, which ActivityLog.Finish
leaves alone, so surfacing them cannot turn a green compile red on its own.

Both emit paths report the same thing. The in-memory path reads
compilation.GetDiagnostics() explicitly: a warning that appeared only when the
disk cache was on would be a warning that depends on a deployment's caching
mode, which is exactly the kind of difference nobody finds.

Deduped, ordered, and CAPPED at 50 with the remainder COUNTED in the last
entry — one bad using-directive produces hundreds of identical diagnostics, and
an activity that is 400 lines of the same warning is one nobody reads. A
truncation a reader cannot see is a lie about how much was wrong.

Four cases pin it, including the two that matter for a cross-repo refactor: an
unresolvable cref (CS1574/CS1584) is reported, and a clean compile reports
nothing — the empty case only means something now that a non-empty one is
possible.

🚨 NOT CHANGED HERE, deliberately: the compilation OPTIONS. Adding a nullable
context is the other half of "the same level", and it moves
EmitPipeline.OptionsFingerprint — which is reflected over the options, so every
cached assembly in the fleet is invalidated and recompiles. That is an
operational event to schedule, not a rider on a diagnostics fix. This change is
fingerprint-neutral: it reads diagnostics that were already being computed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vRaDpW29KDS9NX1gvCjw3
… ours

Maintainer, 2026-08-30: "we are about to remove all nuget dependencies — for
tests. All repos. Check plugins how we do it."

Checked and measured rather than assumed. The Plugins idiom is stricter than
"no xUnit": across all 206 in-mesh test node sets, `using Xunit` occurs ZERO
times, and so does every other assertion library — including our own
Reactive.Assertions. A test is plain C# that throws, with a local four-line
Expect(bool, string) helper owned by the file; a TestsArea enumerates
(name, Action) cases and renders the pass/fail table, registered with a
LITERAL .WithView("Tests", …) because the gate reads that field as text; the
gate executes it on a probe instance and the Tests-area ratchet holds the
population.

So a test compiles against exactly what the image's framework provides plus
its own type's Source: nothing to restore, nothing to version, nothing that
can drift against the framework under test. What retires with the compiled
test projects is the whole test-dependency graph — xUnit v3, FluentAssertions,
the runner, the .trx machinery, and the restore step feeding them — not just
MeshWeaver.Fixture.

Deliberately-given-up list stated so it is a decision, not a discovery:
parametrised theories, runner-process isolation, IDE test explorer, .trx
artifacts. The gate log and the rendered table are the record; the trade has
been paid 206 times in production without a request for any of the four back.

This also RESOLVES the doc's own open question ("deciding what the in-mesh
unit-test surface is" for module tests): the surface exists, it is this, and
it has 206 production instances.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…as optional

`PackageInstaller` waited up to 30 s for a partition's cover grant while the
wait's own doc comment called that grant optional — "a partition whose node
type does not gate never writes it". On the shape the code itself called
*normal* the query can never emit, so every install burned the entire budget
and then reported success, leaving one Information line worded to cover both
outcomes. Measured in `MeshWeaver.PluginCatalog.Test`: 30.25 s / 30.29 s /
30.33 s / 30.24 s per install and 60.28 s for the test that installs twice —
181 s of a 336 s suite. `PackageInstaller.Install` is the production install
path, so a live mesh paid the same 30 s per non-gating package.

Core cannot ask a node type whether it gates (`Store/Plugin`, `PluginContent`
and `AddPluginGating` live in MeshWeaver.Plugins), which is why the grant is a
well-known PATH. It does not have to: `EnsureDeclaredAccess` is the ONE
access-establishment step of an install and already states, per manifest,
whether it published the partition or left it gated. `DeclaredAccessMarker` is
null exactly when it wrote nothing — the commercial branch — and that is the
only shape a gating pass owes a cover grant. `CoverGrantExpected` is that, and
the watch now runs only there.

- `GatingSettleTimeout` (30 s) → `GatingDetectorBudget` (5 s), renamed for what
  it now is. Justified by measurement: when a grant IS owed it is observable in
  44–55 ms (`GatingDetectorTest` measures the real write→observe latency on a
  live mesh), and phase 1 has already paid the activation, so the only
  outstanding work is one access-table write.
- The two outcomes stop looking identical: the expiry is a WARNING naming the
  missing path and the consequence (the partition denies every viewer,
  including on the Subscribe cover that would sell it); "nothing owed" is Debug.
  Warning, not Error — the expiry proves the grant is not there yet, not that
  it never will be, and `VerifyDeclaredAccess` owns the Error verdict for
  access core itself promised to write.
- Still never fatal, and the query is unchanged: `path:{grant}` exact,
  empty-on-absent, never a stream read (#2229) and never a children listing.

`GatingDetectorTest` pins all three outcomes and is mutation-proven: downgrading
the warning, disabling `CoverGrantExpected`, or restoring the 30 s budget each
turns it red.

Suite: 707/707 green, 5 m 36 s → 2 m 49 s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er, no SDK, no NuGet

Maintainer directive, 2026-08-30: "the platform builds dll completely without any external
dotnet kit or nuget."

`memex build project <csproj|dir> --image <image>` compiles a .NET project with NO dotnet SDK
and NO NuGet restore, against the assemblies of a MeshWeaver image. The CLI half is only the
trip into the container — it shares ImageRunner (pull retry, digest pin, process launch) with
`build plugin` rather than carrying a second copy. The work is `mw-plugin-test build-project`,
three types in the tool already in the image:

* ProjectFile — evaluates the .csproj WITHOUT MSBuild: properties, items, the default **/*.cs
  glob minus bin/obj, Compile Include/Remove, ProjectReference, PackageReference, implicit
  usings, the target-framework symbol ladder, the SDK's own default NoWarn (1701;1702), the
  nearest Directory.Build.props/.targets/Directory.Packages.props, and every <Import> whose
  condition holds. Anything it cannot reproduce FAILS the load by name; --accept acknowledges
  one. A silently dropped Nullable or NoWarn is a green build that is not the SDK's build.

* ContainerReferenceSet — the C# port of MeshWeaver.Plugins/scripts/container-refs.py, read
  from /app instead of an extracted image: the image's own .deps.json for versions and the
  binding identity, /app plus the container's shared frameworks for the assemblies. Fails
  closed on an unreadable /app, a missing or ambiguous .deps.json, or MeshWeaver assemblies
  that disagree on their binding identity (MeshWeaver#143, caught in the image rather than at
  run time). A package is matched by the ASSEMBLY FILE on disk, never by its id alone; one the
  container does not supply is an ADDITIONAL library, reported by name and never skipped.

* ProjectBuild — sequences the ProjectReference graph on the existing Cascade (a cycle is
  refused up front and named), compiles with Roslyn, and emits through the platform's own
  EmitPipeline. It builds its OWN CSharpCompilationOptions and never touches
  CreateCompilationOptions, which feeds GeneratedInputIdentity.OptionsFingerprint. Every
  progress line and diagnostic is streamed into an ActivityLog (ActivityCategory.Compilation)
  as it is produced; the console is a rendering of that stream.

Measured against memex-portal-ai@sha256:15c49ee over MeshWeaver.Plugins/src: 12 of 54 non-test
projects green, including MeshWeaver.Import (90 sources, 0 warnings under warnings-as-errors)
with ClosedXML + CsvHelper as --extra-refs. The rest are named limitations: Razor/Blazor (15),
SDK source generators (15), <Protobuf> (3), additional libraries (5), portal hosts (3).

Also here: BuildPluginCommand's docker plumbing moves to ImageRunner (and gains `--init`, the
convention every workflow already uses), and MeshWeaver.Compiler grants InternalsVisibleTo to
mw-plugin-test so the emit is SHARED rather than re-implemented.

51 new tests, executed: csproj evaluation (globs, Remove, Directory.Build.props inheritance,
the condition grammar, every loud refusal), reference resolution (assembly-on-disk matching,
the metapackage case, the additional-library case, all five fail-closed paths), ProjectReference
ordering and cycle detection, and the diagnostic standard — an unresolvable cref and an unused
local are asserted to be REPORTED, and the emitted assembly is LOADED and invoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/MeshWeaver.Cli/BuildPluginCommand.cs
…r kills the whole build

The first real `mw-plugin-test build` run on a dev machine died with exit 70
and ZERO bytes on stdout and stderr. Two defects compounded.

Every early fatal was mute: Print lives at the end of Finish, which only the
successful pipeline reaches — LoadSync throwing, no packages, an unknown
package id, LoadExternalModules refusing, and Run's outer Catch all returned
their Report straight to Program, which turned it into an exit code and
nothing else. Run now guarantees at its one seam that a FatalError report
prints (full exception, stack included) and writes report.json when asked;
Finish never sets FatalError, so nothing prints twice.

With the silence fixed the real cause self-identified: LocalNodeRepo.Sweep
eagerly reads EVERY file under the repo root and died on a dead Playwright
symlink (e2e/.auth/profile/RunningChromeVersion) — gitignored, so invisible
to every CI checkout, fatal for exactly the local no-dotnet loop the verb
exists for. The sweep now skips an unreadable file loudly and continues;
such a file crashed both bake paths before, so the shared swept-content
contract cannot fork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoG4USP5fd8wsxCD7e9q4x
…ng identity

`GenerateAssemblyInfo` is a TARGET, and `build-project` runs no targets — so every
assembly it emitted carried Roslyn's default identity, `AssemblyVersion=0.0.0.0`,
while the whole fleet binds `3.0.0.0`.

Nothing went red. Measured first-hand: building
`MeshWeaver.Plugins/src/MeshWeaver.Speech.Contract` through a tester image from
#2850 produced `AssemblyVersion=0.0.0.0` and a GREEN build, where that repo's
`src/Directory.Build.props` pins `3.0.0.0` precisely because #143 shipped 1.0.0.0
assemblies into a 3.0.0.0 process and CrashLoopBackOff'd a migration. The failure
would have surfaced in another repo, at runtime, as
`FileNotFoundException: … Version=3.0.0.0`.

`ProjectFile` now evaluates the identity and `ProjectBuild` synthesizes it into the
compilation as one more source document, exactly as the SDK's generated
`<Project>.AssemblyInfo.cs` is one more Compile item. Every rule was MEASURED against
SDK 10.0.400 rather than recalled — `Version=1.2.3-beta.4` → `1.2.3.0`, `1` →
`1.0.0.0`, `01.2.3` → `1.2.3.0`, `FileVersion` follows `AssemblyVersion` and never
`$(Version)`.

Also promoted out of this evaluator's "metadata that changes nothing" list, which was
true of the COMPILE and false of the ASSEMBLY: `InternalsVisibleTo` (sixty-odd
projects across these repos), `AssemblyMetadata` and `AssemblyAttribute` items.

`GenerateAssemblyInfo=false` synthesizes nothing (a second set is CS0579); each
`Generate…Attribute` switch is honoured. A property that cannot be derived is a NAMED
failure, never a plausible substitute.

Two divergences, both said out loud rather than hidden: `$(SourceRevisionId)` (the
SDK reads the commit from git; every build here reports the absent `+<sha>` suffix by
name, and the new `-p:Name=Value` gives exact parity) and `TargetFrameworkAttribute`
(a different target, with platform companions this evaluator cannot compute).

`-p:` also fixes a global property that was documented to win and did not: a
`<PropertyGroup>` could overwrite one, so the flag would have meant nothing for
exactly the properties a caller passes it for.

🚨 `CreateCompilationOptions` and `OptionsFingerprint` are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi changed the base branch from integration/core-batch to main August 30, 2026 23:40
@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

792 tests   788 ✅  4m 34s ⏱️
  7 suites    4 💤
  7 files      0 ❌

Results for commit 97b5d9b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 158 tests   1 158 ✅  4m 38s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit 97b5d9b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    8 files      8 suites   4m 3s ⏱️
1 490 tests 1 489 ✅ 1 💤 0 ❌
2 189 runs  2 188 ✅ 1 💤 0 ❌

Results for commit 97b5d9b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 052 tests   1 052 ✅  3m 24s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit 97b5d9b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 057 tests   2 865 ✅  5m 58s ⏱️
    8 suites    192 💤
    8 files        0 ❌

Results for commit 97b5d9b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 142 tests   1 142 ✅  8m 35s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit 97b5d9b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   44 files     44 suites   31m 15s ⏱️
8 691 tests 8 494 ✅ 197 💤 0 ❌
9 390 runs  9 193 ✅ 197 💤 0 ❌

Results for commit 97b5d9b.

♻️ This comment has been updated with latest results.

…ompat red was a BASE artefact

This PR was DIRTY (no CI at all) and additionally red on
"Public surface (binary compatibility)". Both are addressed by merging main.

── the conflict (same as #2850/#2860) ──
One 159-line block in src/MeshWeaver.Cli/BuildPluginCommand.cs holding TWO
unrelated things: PullImage (relocated to ImageRunner.cs by this lane) and main's
new ComposeSealedModules (#2851, #2858). Split at the seam — dropped 39 lines
relocated, kept 120 of main's — then qualified ImageRunner.Capture (now static)
and removed the `/// <summary>` opener the split orphaned.

── the binary-compat failure was measured against the WRONG BASE ──
The gate said:

    Comparing against merge base ... branch integration/core-batch
    ✗ tools/MeshWeaver.PluginTester/ProjectFile.cs
        record Model — arity: primary constructor went from 21 to 22 parameter(s)
    🚨 1 binary-breaking record change(s)

That is a correct verdict about the wrong comparison. `ProjectFile.cs` DOES NOT
EXIST ON MAIN — the Model record is introduced by #2850's lane, and the gate was
comparing against integration/core-batch (#2853), where it existed at arity 21.
Against main the record is NEW, so there is no prior signature to break.

Not suppressed and NOT added to scripts/record-signatures.allow: an allow entry
would assert a deliberate break where there is none, and would then go stale the
moment #2850 lands (the shape that has blocked unrelated PRs before). Re-basing
onto main is the fix; the gate re-runs against main and should now pass on its
own. If it does not, the break is real and belongs in the allow file with a
reason — but that is not what this data shows.

    MeshWeaver.PluginTester / .Test   -c Release -warnaserror, 0 Warning(s) 0 Error(s)
    MeshWeaver.PluginTester.Test      198/198

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 31, 2026 06:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the “build-project” path so assemblies compiled inside a MeshWeaver image carry the correct binding identity (e.g., AssemblyVersion=3.0.0.0 instead of Roslyn’s 0.0.0.0) and so successful compilation warnings are surfaced to the compile activity log (rather than being silently dropped).

Changes:

  • Extend mw-plugin-test build-project to synthesize SDK-equivalent assembly identity attributes into the Roslyn compilation inputs.
  • Add/extend container reference-set reading from /app + *.deps.json, and wire memex build project in the CLI (plus shared docker image runner plumbing).
  • Propagate warnings from successful in-mesh compiles into the activity log; add comprehensive unit + end-to-end tests and documentation updates.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tools/MeshWeaver.PluginTester/README.md Documents build-project behavior, identity rules, and supported/unsupported constructs.
tools/MeshWeaver.PluginTester/ProjectBuild.cs Implements project graph build + Roslyn compilation, including synthesized assembly identity source.
tools/MeshWeaver.PluginTester/Program.cs Adds argv handling for build-project, including -p:Name=Value properties.
tools/MeshWeaver.PluginTester/ContainerReferenceSet.cs Reads /app and *.deps.json to resolve platform references + binding identity.
test/MeshWeaver.PluginTester.Test/ProjectFileTest.cs Expands evaluator contract tests (globs, imports, conditions, constructs).
test/MeshWeaver.PluginTester.Test/ProjectBuildTest.cs End-to-end tests for compiling/emitting/loading a real assembly and streaming diagnostics.
test/MeshWeaver.PluginTester.Test/ProjectAssemblyIdentityTest.cs Verifies emitted-file identity matches SDK semantics via PE metadata inspection.
test/MeshWeaver.PluginTester.Test/ContainerReferenceSetTest.cs Validates fail-closed and file-based package matching rules for the container reference set.
test/MeshWeaver.Graph.Test/EmitPipelineAccess.cs Exposes internal compiler option factories to tests.
test/MeshWeaver.Graph.Test/CompileWarningsReachTheActivityTest.cs Pins warning collection/capping behavior for successful compiles.
src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs Carries compile warnings up so they reach the compile activity record.
src/MeshWeaver.Documentation/Data/Architecture/InMeshBuildAndTest.md Adds documentation for memex build project and its constraints/measurements.
src/MeshWeaver.Compiler/MeshWeaver.Compiler.csproj Adds IVT for mw-plugin-test to reuse the emit pipeline.
src/MeshWeaver.Compiler/EmittedArtifact.cs Extends emitted-artifact record to carry successful-compile warnings.
src/MeshWeaver.Compiler/EmitPipeline.cs Adds warning extraction/capping helper and records warnings in emitted artifacts.
src/MeshWeaver.Cli/README.md Documents memex build project usage and options.
src/MeshWeaver.Cli/Program.cs Wires the memex build project subcommand via BuildProjectCommand.
src/MeshWeaver.Cli/ImageRunner.cs Adds shared docker pull/pin/run plumbing used by build commands.
src/MeshWeaver.Cli/BuildProjectCommand.cs Implements the host-side “trip into the container” for project builds.
src/MeshWeaver.Cli/BuildPluginCommand.cs Refactors to share image-running plumbing with ImageRunner.
Suppressed comments (23)

src/MeshWeaver.Cli/ImageRunner.cs:57

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
                var digest = await Capture("docker",

src/MeshWeaver.Cli/ImageRunner.cs:63

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
                    await output.WriteLineAsync(

src/MeshWeaver.Cli/ImageRunner.cs:72

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
                await error.WriteLineAsync(

src/MeshWeaver.Cli/ImageRunner.cs:74

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
                await Task.Delay(TimeSpan.FromSeconds(10), ct);

src/MeshWeaver.Cli/ImageRunner.cs:77

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
        await error.WriteLineAsync(

src/MeshWeaver.Cli/ImageRunner.cs:123

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
        if ((await Capture("docker", ["image", "inspect", image, "--format", "{{.Id}}"], ct))

src/MeshWeaver.Cli/ImageRunner.cs:126

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
            await error.WriteLineAsync(

src/MeshWeaver.Cli/ImageRunner.cs:146

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
        await output.WriteLineAsync($"$ {file} {string.Join(' ', psi.ArgumentList)}");

src/MeshWeaver.Cli/ImageRunner.cs:131

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
        await output.WriteLineAsync(

src/MeshWeaver.Cli/ImageRunner.cs:153

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
        await p.WaitForExitAsync(ct);

src/MeshWeaver.Cli/ImageRunner.cs:169

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
        var stdout = await p.StandardOutput.ReadToEndAsync(ct);

src/MeshWeaver.Cli/ImageRunner.cs:150

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
            await error.WriteLineAsync($"error: could not start '{file}' — is it installed and on PATH?");

src/MeshWeaver.Cli/ImageRunner.cs:170

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Refactor this to a reactive IObservable<T> flow (and push actual IO through the sanctioned IO boundary), and keep any Task bridge at the outermost CLI boundary only.
        await p.WaitForExitAsync(ct);

src/MeshWeaver.Cli/BuildProjectCommand.cs:61

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
            await error.WriteLineAsync(

src/MeshWeaver.Cli/BuildProjectCommand.cs:96

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
                await error.WriteLineAsync(

src/MeshWeaver.Cli/BuildProjectCommand.cs:110

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
            ? await _runner.UseLocalImage(image, ct)

src/MeshWeaver.Cli/BuildProjectCommand.cs:106

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
            return await _runner.Exec(InImageBuilder, local, ct);

src/MeshWeaver.Cli/BuildProjectCommand.cs:111

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
            : await _runner.PullImage(image, ct);

src/MeshWeaver.Cli/BuildProjectCommand.cs:113

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
        await output.WriteLineAsync($"image: {pinned}");

src/MeshWeaver.Cli/BuildProjectCommand.cs:114

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
        await output.WriteLineAsync($"source root: {root} → /repo; project: /repo/{relative}");

src/MeshWeaver.Cli/BuildProjectCommand.cs:132

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
        var exit = await _runner.RunInImage(pinned, mounts, [], builderArgs, ct);

src/MeshWeaver.Cli/BuildProjectCommand.cs:121

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
                await error.WriteLineAsync(

src/MeshWeaver.Cli/BuildProjectCommand.cs:134

  • Async/await is forbidden in src/ in this repo because it can park turn-based schedulers and/or lose AccessContext across async boundaries. Convert this command implementation to a reactive pipeline and keep any Task bridge at the CLI boundary only.
            await output.WriteLineAsync($"assemblies: {outputDirectory}");

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +222 to +230
if (!doc.RootElement.TryGetProperty("targets", out var targets)
|| targets.ValueKind != JsonValueKind.Object)
throw new UnreadableContainerException(
$"{Path.GetFileName(path)} has no targets section, so no assembly version can be read.");

JsonElement runtimeTarget = default;
foreach (var target in targets.EnumerateObject())
runtimeTarget = target.Value;

Comment on lines +841 to +851
internal ActivityLog Seal(bool green)
{
lock (_gate)
{
_log = green
? _log with { Status = ActivityStatus.Succeeded }
: _log with { Status = ActivityStatus.Failed };
_options.Log?.OnCompleted();
return _log;
}
}
{
for (var attempt = 1; attempt <= PullAttempts; attempt++)
{
if (await Exec("docker", ["pull", image], ct) == 0)
var entry = Path.GetFullPath(options.ProjectPath);
if (!File.Exists(entry) && !Directory.Exists(entry))
{
await error.WriteLineAsync($"error: '{entry}' is neither a project file nor a directory.");
@rbuergi
rbuergi added this pull request to the merge queue Aug 31, 2026
@rbuergi
rbuergi removed this pull request from the merge queue due to a manual request Aug 31, 2026
@rbuergi

rbuergi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Removed from the merge queue — CLEAN here, UNMERGEABLE there, and the difference is real

This PR reads CLEAN on its own page and came back UNMERGEABLE from the queue. That is not a glitch, and re-queueing it will reproduce it exactly.

Why. This branch descends from integration/core-batch (#2853) and still carries its merge commits:

Merge remote-tracking branch 'origin/docs/tests-carry-no-dependencies'  -> #2848  MERGED
Merge remote-tracking branch 'origin/fix/gating-settle-stall'          -> #2849  MERGED
Merge remote-tracking branch 'origin/fix/cascade-build-silent-fatal'   -> #2852  MERGED
Merge remote-tracking branch 'origin/feat/cli-build-project'           -> #2850  in queue

Three of those landed on main as squash merges. So this branch reintroduces their content as separate commits, and the queue's speculative merge — which builds this PR on top of main@now — sees it arriving twice. The PR page cannot show this, because it compares against a base where the duplication does not exist. That gap between the two answers is the whole failure mode, and an ejection outranks the page.

It was also occupying a queue slot and would be ejected each time the stack reached it, costing everyone a cycle — hence removing it rather than leaving it to fail repeatedly.

What it needs, and when

It is genuinely stacked on #2850, which is MERGEABLE in the queue right now. Once that lands:

git fetch origin main
git reset --hard origin/main
git cherry-pick <this PR's own commits only>   # NOT the integration/core-batch merges

That drops the stale batch ancestry entirely and produces a branch whose diff is only what this PR contributes. It cannot be done correctly before #2850 is on main, because this work genuinely builds on it.

I have not done that reset myself: it rewrites your branch, and the "own commits only" selection is a judgement about your work rather than a mechanical merge. Happy to do it once #2850 lands if you would rather — say the word.

(#2853 itself is now closed — three of its four members landed individually, so the batch no longer saves a cycle. The main-merge and conflict resolution already pushed here stay valid; only the stale ancestry needs dropping.)

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
rbuergi added a commit that referenced this pull request Aug 31, 2026
… a 22nd parameter

The binary-compat gate refused this batch — correctly, this time:

    record Model — arity: primary constructor went from 21 to 22 parameter(s)

#2855's original commit passed AssemblyInfo positionally. Its green attested to
a stale merge base (integration/core-batch) where the arity comparison ran
against a tree that already carried the change — the wrong-base shape its own
PR thread documented for a DIFFERENT record the same evening. Against main the
break is real, and the record's own doc block states the house rule the commit
violated:

    "These four are init PROPERTIES, not primary-constructor parameters. Adding
     a parameter — even with a default — REPLACES the record's constructor
     signature, and scripts/check-record-signatures.py refuses that for exactly
     the reason it should."

So AssemblyInfo now follows RazorItems/EmbeddedResources onto the initializer:

  * Model stays at arity 21;
  * `public AssemblyInfo AssemblyInfo { get; init; } = AssemblyInfo.None;`
  * AssemblyInfo.None — the "nothing was stamped" identity — keeps the property
    non-nullable with a valid default;
  * ToModel assigns it in the object initializer beside the other features.

No allow-file entry: the gate found a real break and the fix is to not break,
not to document breaking.

MeshWeaver.PluginTester / .Test  -c Release -warnaserror  0/0
MeshWeaver.PluginTester.Test     265/265
scripts/check-record-signatures.py  ✓ no binary-breaking changes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #2892, now merged as 9fb74fd9. This PR's unique commits are on main byte-for-byte (cherry-picked and union-merged with the Razor work); only this branch's history — the integration/core-batch ancestry that made it permanently UNMERGEABLE in the queue — was dropped. The arity-21 fix the binary-compat gate demanded (AssemblyInfo as an init property rather than a 22nd positional parameter) is included; this PR's own green had attested to a stale base where that break was invisible.

@rbuergi rbuergi closed this Aug 31, 2026
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