Skip to content

Gate discovery must equal the baker's, and the PR lane bakes with the compiler (#2063, #2064 part 1) - #2250

Merged
rbuergi merged 7 commits into
mainfrom
fix/2063-gate-discovery-equals-bake
Aug 25, 2026
Merged

rbuergi merged 7 commits into
mainfrom
fix/2063-gate-discovery-equals-bake

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes #2063. Advances #2064 (part 1 — the PR lane; the satellite lane stays open, see below).

#2063 — the gate never checked a NodeType whose .json carries a BOM

PluginGateRunner.DiscoverNodeTypes parsed package bytes with a raw JsonDocument.Parse while the
installer strips the UTF-8 BOM (FileFormatParserRegistry.WithoutBom, #1767). Package content
arrives as bytes and is decoded with Encoding.UTF8.GetString, which preserves U+FEFF — so a
BOM'd file threw here and the catch (JsonException) { continue; } dropped it, under a comment
("malformed json is surfaced by the install itself") that had quietly stopped being true.

samples/Graph/Data/PensionFund ships 5 BOM'd NodeTypes. Measured before and after, locally:

before:  [PASS] PensionFund (72 node(s), 0 type(s))     ← a green verdict over nothing
after:   [DEBT] PensionFund (72 node(s), 5 type(s))     ← run is GREEN; both failures ratcheted

A second divergence of the same shape

Found while fixing the first: discovery mapped file→path with NodeFileMapper.FromRelativePath,
which is only half of PackageInstaller.NodePathForFile — the other half excludes README.md,
manifest.lock and content/** assets. A NodeType-shaped .json under content/** was therefore
discovered by the gate and installed by nobody, so the gate would wait out its full compile
budget and die with a TimeoutException that reads as harness noise.

Both file-shaped decisions now come from the installer itself.

The real deliverable: the drift-proof test

GateDiscoveryEqualsBakeDiscoveryTest runs PluginGateRunner.DiscoverNodeTypes and the
compiler-driven bake's discovery (TreeNodeLoader.Load(...).Where(Content is NodeTypeDefinition))
over ONE fixture tree carrying every shape the two halves could disagree about — a plain NodeType, a
BOM'd one, a nested BOM'd one, a NodeType-shaped .json under content/**, a non-NodeType node,
README.md, and malformed JSON — and asserts the type-path sets are equal. It also pins what
that set is, so the equality cannot be satisfied by both halves regressing to empty together.

I watched it fail on each defect independently (reverting one fix at a time):

reverted bake discovered gate discovered result
BOM strip Bommed, Nested/Deep, Thing Thing RED — plus Assert.Single() Failure: The collection was empty, the 0 type(s) pass in miniature
path mapping Bommed, Nested/Deep, Thing … , content/Asset RED

Discovery only — no mesh, no Roslyn. 3 ms, so it can be exhaustive about file shapes in a way
BakeEquivalenceTest (which compiles both sides) is not.

Newly-revealed failures, ratcheted individually

Both are pre-existing debt made visible, not caused by this change. Diagnoses are inline in
.github/samples-gate.allow.

1. PensionFund/BalanceSheet compile — NuGet.Protocol.FatalProtocolException: The local source '<repo>/dist/packages' doesn't exist.
BalanceSheetScopes.cs:20 declares #r "nuget:MeshWeaver.BusinessRules.Generator".
GeneratorPipeline.StripBuiltInScopeGeneratorRef removes that from the NuGet resolve set only when
BuiltInGeneratorPaths is non-empty
, i.e. when MeshWeaver.BusinessRules.Generator.dll sits beside
the app. BusinessRules left the platform (f7a8c08 — it ships in the MeshWeaver.Plugins/BusinessRules
plugin), so that list is EMPTY on the gate host and the strip is a no-op. The directive then reaches
NuGet, where NuGet.config's packageSourceMapping pins MeshWeaver.* exclusively to the
mesh-local source dist/packages — a feed #395 stopped producing, which dotnet-test.yml:372-377
documents CI deliberately no longer packs. Deterministic, not a flake: verified no such dll in the
tester's output and no dist/packages on any runner. (No CS code — it faults before Roslyn.) The real
fix is content-side and needs the plugin's generator reachable from the gate host — a separate change.

2. PensionFund idempotence — the second install rewrites Currency/{CHF,EUR,USD} (3 nodes,
expected 0). Not the BOM, and not all instances: Year/2024 and every Position/* round-trip clean.
The three that churn are exactly the three whose content is default-valued —
Currency.DecimalPlaces carries = 2 and all three files author "decimalPlaces": 2. Once the
Currency NodeType compiles its type is registered, so the installer materialises the instance as a
typed Currency instead of an untyped JsonElement, re-serialises it, and default-suppression drops
the property. The stored node then differs from the authored file and the unchanged-skip misses. This
is the deploy-flicker the idempotence pin exists to catch.

#2064 part 1 — the PR lane bakes with the compiler

doc-gate and plugin-gate ran the fused shape: one mw-plugin-test <stage> --bake-output <dir>
that stood up an in-process mesh and let the mesh compile every NodeType — the very thing #1763
removed from CD, still running in the platform's own PR gate. They now run the same two steps
main-cd's publish-bake does, held in .github/scripts/bake-then-gate.sh so the two lanes cannot
drift: compile <stage> --output <dir>, then the gate with --seed <dir>.

No --bake-output invocation survives in dotnet-test.yml (only historical prose in comments).
One bake directory per tree (bake/doc, bake/samples) — a shared one would let each gate's
--seed see the other's bundles and satisfy the postcondition with bundles it never installed.

The assertions, and why they are not decoration

Adoption is invisible in a gate verdict by construction: an adopted type renders and tests exactly
like a compiled one, so the consuming half could stop working with every run green.
assert-bake-consumption.sh greps both logs for the compile-fallback family (no prebuilt bundle,
carried no assemblies, could resolve NO artifact, no prebuilt assemblies adopted) and reads the
gate's own seed: adopted N of M line.

  • M > 0 is load-bearing: BakeSeed.Shortfall() returns a PASS over an empty bake, because
    "adopted everything declared" is vacuously true of nothing.
  • N >= M, not N == M — the tester's own contract (if (Adopted >= expected.Count) return null),
    not a loosened one. N counts adoption events and the gate installs every package twice for
    the idempotence pin. My first draft used -ne and it went red on a healthy run at adopted 32 of 28;
    a gate that cries wolf is one that gets switched off.

Every negative case was run and watched go red (assert-bake-consumption.sh against synthetic logs):

OK exit=0 | healthy baseline                | adopted=8 declared=8
OK exit=0 | double-install adopted>declared | adopted=32 declared=28
OK exit=1 | NEG vacuous empty bake 0of0     | adopted=0 declared=0
OK exit=1 | NEG shortfall 3of8              | adopted=3 declared=8
OK exit=1 | NEG gate never seeded           | printed no 'seed:' line
OK exit=1 | NEG compile-fallback phrase     | "carried no assemblies"
OK exit=1 | NEG bake produced no bundles    | no *.zip under the bake dir
OK exit=1 | NEG bake wrote empty identity   | framework-mvid.txt empty

And end-to-end on the real samples tree: compile: 28/31 NodeType(s) compiled, 8 bundle(s) →
gate adopted 32/28 baked assembly(ies) → exit 0.

🚧 What #2064 still needs — and a correction to the plan

Stage 2 (node-repo-publish-bake.yml: the capability floor probe, the two-step, and the
"published nothing must go RED" job) is not in this PR, and part of it cannot work as
specified
:

An if: always() job added inside node-repo-publish-bake.yml can never fire for the case it
targets. The reusable workflow is invoked as a job in the caller; when the caller's job is
skipped by its own needs: (Education needs: [validate, batteries, mesh-gate], Plugins
needs: [preflight, validate, compile-check, test-repos], Reinsurance + demo-data), every job
inside the called workflow is skipped with it
. The "published nothing" guard therefore has to
live in each satellite's caller — which this PR is scoped out of.

Also worth recording: MeshWeaver.Education does not call the reusable workflow at all. Its
publish-bake (ci.yml:785) is an inline job that vendored publish-bake-bundles.sh (ci.yml:939), so
Education#211's live symptom cannot be fixed from this repo's node-repo-publish-bake.yml under any
shape. Plugins and Reinsurance/SocialMedia do call it.

Verification

  • dotnet build -c Release -warnaserror clean (0 Error(s), 0 Warning(s)): MeshWeaver.PluginTester,
    MeshWeaver.PluginTester.Test, MeshWeaver.Documentation.Test.
  • MeshWeaver.PluginTester.Test: 79/79 pass, incl. BakeEquivalenceTest and BakeGateSplitTest.
  • MeshWeaver.Documentation.Test link-integrity + PlatformBakeLaneGuard + UpstreamBuildGateGuard
    • WhatsNew: 15/15 pass.
  • The full samples gate run locally, green with the ratchet: GREEN — 4 known-debt failure(s) allowed.
  • dotnet-test.yml re-parsed as YAML; both new scripts bash -n clean.

No satellite repo is touched; node-repo-publish-bake.yml is unchanged, so the pinned callers
(Plugins @731620dc, Reinsurance/SocialMedia @d193dd20) are unaffected.

🤖 Generated with Claude Code

… compiler

#2063 — the gate never checked a NodeType whose .json carries a BOM.

PluginGateRunner.DiscoverNodeTypes parsed package bytes with a raw
JsonDocument.Parse while the installer strips the UTF-8 BOM
(FileFormatParserRegistry.WithoutBom, #1767). Package content arrives as BYTES
and is decoded with Encoding.UTF8.GetString, which PRESERVES U+FEFF — so a
BOM'd file threw here and the `catch (JsonException) { continue; }` dropped it
under a comment ("malformed json is surfaced by the install itself") that had
stopped being true. samples/Graph/Data/PensionFund ships 5 BOM'd NodeTypes and
CI printed `[PASS] PensionFund (72 node(s), 0 type(s))` — a green verdict over
nothing. It now reads `(72 node(s), 5 type(s))`.

A SECOND divergence, same shape, found while fixing the first: discovery mapped
file->path with NodeFileMapper.FromRelativePath, only half of
PackageInstaller.NodePathForFile, which also excludes README.md, manifest.lock
and content/** assets. A NodeType-shaped .json under content/** was therefore
discovered by the gate and installed by nobody, so the gate would wait out its
whole compile budget and die with a TimeoutException reading as harness noise.

Both file-shaped decisions now come from the installer itself, and
GateDiscoveryEqualsBakeDiscoveryTest pins the gate's discovered type-path set
EQUAL to the compiler-driven bake's over one fixture tree carrying every shape
the two halves could disagree about. The drift, not either symptom, is the bug;
verified to fail independently against each defect (gate 1 vs bake 3; gate 4 vs
bake 3) and to reject two empty sets.

Two pre-existing PensionFund failures this makes visible are ratcheted into
.github/samples-gate.allow, each diagnosed in place, neither bulk-added.

#2064 (part 1) — the PR lane still baked through a mesh.

dotnet-test.yml's doc-gate and plugin-gate ran the FUSED shape: one
`mw-plugin-test <stage> --bake-output <dir>` that stood up an in-process mesh
and let the MESH compile every NodeType — the very thing #1763 removed from CD,
still running in the platform's own PR gate. They now run the same two steps
main-cd's publish-bake does, held in .github/scripts/bake-then-gate.sh so the
two lanes cannot drift: `compile <stage> --output <dir>`, then the gate with
`--seed <dir>`. No `--bake-output` invocation survives in dotnet-test.yml.

The split is also a stronger gate: what renders and what runs the `Tests` areas
is now the assembly the bake produced, not a private recompile nothing ships.

assert-bake-consumption.sh makes that claim checkable, because adoption is
invisible in a gate verdict by construction — an adopted type renders exactly
like a compiled one, so the consuming half could stop working with every run
green. It greps both logs for the compile-fallback family and reads the gate's
own `seed: adopted N of M` line, requiring M > 0 as well as N >= M. The M > 0 is
load-bearing: BakeSeed.Shortfall() returns a PASS over an EMPTY bake, since
"adopted everything declared" is vacuously true of nothing.

N >= M rather than N == M is the tester's own contract, not a loosened one: N
counts adoption events and the gate installs every package twice for the
idempotence pin, so `adopted 32 of 28` is healthy — measured on
samples/Graph/Data. An equality test would be red on every green run.

All six negative cases were run and watched go red: vacuous 0-of-0, shortfall,
no seed line, a fallback phrase, no bundles, and an empty identity file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 25, 2026 18:37
…hub/scripts entry

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

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 MeshWeaver’s content-gating pipeline by ensuring NodeType discovery in the gate matches the installer/baker’s discovery (fixing BOM/path-rule drift) and by updating the PR CI lane to follow the same compiler-driven bake + seeded gate split used in CD.

Changes:

  • Align PluginGateRunner.DiscoverNodeTypes with installer rules (BOM handling + canonical node path mapping) and expose a seam for testing.
  • Add GateDiscoveryEqualsBakeDiscoveryTest to pin gate discovery == bake discovery over a fixture that exercises the known drift cases.
  • Update the PR workflow to bake via compile --output and gate via --seed, with postconditions asserting that the bake was actually consumed (no silent fallback to local compilation), plus documentation/ratchet updates.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tools/MeshWeaver.PluginTester/PluginGateRunner.cs Gate-side NodeType discovery updated to use installer path mapping and BOM stripping; discovery made internal for test pinning.
tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj Grants test assembly access to internals to enable drift-proof discovery tests.
test/MeshWeaver.PluginTester.Test/GateDiscoveryEqualsBakeDiscoveryTest.cs New fixture-based test asserting gate discovery equals bake discovery (and pins the expected discovered set).
.github/workflows/dotnet-test.yml PR lane updated to run bake+gate as two steps via shared scripts; summaries updated; bake identity cross-check added.
.github/scripts/bake-then-gate.sh New helper to run compiler-driven bake then seeded gate, preserving the existing “verdict line” behavior.
.github/scripts/assert-bake-consumption.sh New postcondition checks ensuring the gate consumed baked bundles and did not fall back to local compilation.
.github/samples-gate.allow Ratchets newly revealed PensionFund failures as known debt (explicitly diagnosed).
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-25-content-checks-stop-skipping-what-they-claim-to-check.md WhatsNew entry documenting the behavioral fix and its impact.
src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md Documentation updated to reflect the two-step bake+gate model and its postconditions.

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

… the BOM

Copilot review on #2063, and it is the same defect one layer further in.

DiscoverNodeTypes parsed with JsonDocument.Parse's DEFAULT JsonDocumentOptions,
which REJECT comments and trailing commas. The installer (JsonFileParser) and
the bake (TreeNodeLoader.ContentJsonDocument) both opt into
JsonCommentHandling.Skip + AllowTrailingCommas — copied off the hub's own
options — so a node file carrying either parses for both of them and threw
here, landing in the same `catch (JsonException)` that swallowed the BOM. That
is #2063 exactly: discovered by one half, installed and shipped by the other,
green either way.

JsonFileParser already carries the warning verbatim ("JsonDocumentOptions
defaults REJECT comments and trailing commas… taking the default here would
have made a node file that parsed yesterday start failing today"). Discovery
now shares those tolerances.

The drift test did NOT catch this — its fixture had no comment and no trailing
comma, which is why the review found it and the test did not. The fixture now
carries a node file with both, and the assertion was verified to fail without
the fix: bake discovered 4 types, gate discovered 3 (Widget/Tolerant dropped).

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

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

    9 files  ±0      9 suites  ±0   6m 24s ⏱️ -14s
1 823 tests ±0  1 819 ✅ ±0  4 💤 ±0  0 ❌ ±0 
2 354 runs  +1  2 350 ✅ +1  4 💤 ±0  0 ❌ ±0 

Results for commit 527c44c. ± Comparison against base commit e6a1945.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

   10 files  ±0     10 suites  ±0   6m 37s ⏱️ -18s
1 301 tests ±0  1 300 ✅ ±0  1 💤 ±0  0 ❌ ±0 
1 302 runs  ±0  1 301 ✅ ±0  1 💤 ±0  0 ❌ ±0 

Results for commit 527c44c. ± Comparison against base commit e6a1945.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

1 201 tests  ±0   1 201 ✅ ±0   17m 16s ⏱️ +45s
    9 suites ±0       0 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit 527c44c. ± Comparison against base commit e6a1945.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 106 tests  ±0   1 806 ✅ ±0   9m 15s ⏱️ + 1m 12s
    9 suites ±0     300 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit 527c44c. ± Comparison against base commit e6a1945.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 305 tests  ±0   3 305 ✅ ±0   9m 52s ⏱️ +37s
    8 suites ±0       0 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit 527c44c. ± Comparison against base commit e6a1945.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 882 tests  +2   1 882 ✅ +2   7m 48s ⏱️ -8s
   10 suites ±0       0 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit 527c44c. ± Comparison against base commit e6a1945.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    55 files  ±0      55 suites  ±0   57m 15s ⏱️ + 1m 55s
11 618 tests +2  11 313 ✅ +2  305 💤 ±0  0 ❌ ±0 
12 150 runs  +3  11 845 ✅ +3  305 💤 ±0  0 ❌ ±0 

Results for commit 527c44c. ± Comparison against base commit e6a1945.

♻️ This comment has been updated with latest results.

rbuergi and others added 3 commits August 25, 2026 21:48
The gate reports it STALE ("now passing — remove it") and the ratchet may
only shrink, so leaving it fails the gate. Confirmed still stale on
82f3c16, i.e. after the parser-tolerance change as well.

It passes for the right reason, verified from the run rather than assumed:

  [DEBT] PensionFund (72 node(s), 5 type(s))     <- was 0 type(s)
  STALE allow entry (now passing — remove it): PensionFund idempotence

Discovery now finds PensionFund's 5 NodeTypes where it previously found 0,
so the idempotence check executes against compiled content types for the
first time rather than being carried as debt for a check that never ran.

(Re-applied: an earlier commit of this change was dropped by a force-push.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I removed this earlier on the gate's own report that it was STALE ("now
passing — remove it"). The very next run reported it as a NEW failure.

Both readings are correct: the check is nondeterministic. Whether it fires
depends on whether the Currency NodeType has compiled yet, which is exactly
what the BOM discovery fix in this PR changed. So the entry is not stale —
it is intermittently visible debt, and a single green run is not evidence
that it is gone.

The underlying defect is real and unfixed (default-suppression drops a
property whose value equals the type's default, so the stored node differs
from the authored file and the unchanged-skip misses). Documented at the
entry and tracked separately rather than papered over here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi merged commit 096edb3 into main Aug 25, 2026
25 checks passed
@rbuergi
rbuergi deleted the fix/2063-gate-discovery-equals-bake branch August 28, 2026 08:56
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.

The gate never checks a NodeType whose .json carries a BOM — it installs, compiles, and is silently ungated

2 participants