Arming the in-mesh warning ratchets in this repo
The maintainer's report, 2026-09-16:
baking, as executed in the plugins pipeline, is very verbose and does NOT surface warnings as errors ⇒ we have lots of missing xml comments
Core built the mechanism in MeshWeaver#4473 — read The In-Mesh Warning Standard for what the two ratchets ARE, why the runtime compile must stay lenient, and which diagnostic codes are the platform's to fix rather than a repo's to record. This page is the other half: what it took to arm them here, and what they measured.
Why nothing was enforced until this change
The tester's default is observe-only and exits 0. A bake with no --warning-baseline measures
every warning, prints the folded inventory, and says by name that it enforced nothing — deliberately,
so that landing the mechanism could not red the whole fleet in one commit. A satellite stays in that
state until it names a baseline file on the shared lanes — this repo's adoption is that step.
(Measured 2026-09-16, org-wide code search for warning-baseline-file: three hits, all in
core — the two lanes and core's own page. No satellite had adopted.)
That is the shape the report describes exactly: verbose (an inventory), and not an error (exit 0).
This repo bakes its content THREE times. Two of them can be armed.
Measured on .github/workflows/ci.yml and on the lanes themselves, not assumed —
warning-baseline-file: is declared by exactly two platform lanes, and this repo's seven lane calls
include both:
| Bake | Where | Verb | Armed |
|---|---|---|---|
| the PR gate's compile | node-repo-gate.yml (ci.yml → test-repos) |
mw-plugin-test compile |
yes — warning-baseline-file: plugin-gate-warnings.allow |
| the merge's publication bake | node-repo-publish-bake.yml (ci.yml → publish-bake) |
mw-plugin-test compile |
yes — the SAME file |
| the in-mesh lane | scripts/mesh-test.py --no-tests (ci.yml → compile-check-lanes) |
mw-plugin-test build |
no — see below |
Both shared lanes take the same file, and that is not tidiness. A baseline on one lane only
holds a pull request and its own publication to different standards: the PR would be judged and the
publication would not, or the reverse — and the reverse is how a green PR produces a red main.
The third bake is the one this repo owns. mw-plugin-test build accepts --warning-baseline too,
but mesh-test.py does not pass it, and its own no-warn policy cannot see the new report. Measured
against that script's own WARN_RE/ERROR_RE: every shape the report emits — the inventory, the
per-code table, both verdict lines, a site line and a NEW line — matches NEITHER, while the
classic Foo.cs(3,5): warning CS1591: … shape they were written for matches. So its long-standing
caveat —
⚠ zero warnings is NOT evidence of a clean build
— is still true of that lane, and is now false of the other two.
🚨 And it could not share this file even if it were plumbed. Measured on the same run, same 98
of 98 types, the two verbs disagree — not over the modules (both composed 4) but over the shape of
the reference set itself. compile builds it from the PORTAL HOST's /app (218 assemblies) plus
its shared frameworks (313); build runs in-process and uses its OWN application directory's
TRUSTED_PLATFORM_ASSEMBLIES. Different references, different diagnostics:
compile (the two armed lanes) |
build (the in-mesh lane) |
|
|---|---|---|
| raw occurrences | 3,248 | 4,686 |
| distinct sites | 1,523 | 1,572 |
warnings pairs to baseline |
236 | 250 |
CS0436 (type conflicts with an imported type) |
— | 1,446× over 45 sites / 14 types |
CS0436 appears only in the build lane, and it is an artefact of THAT lane's composition, not of
the content. A baseline harvested from one verb is therefore STALE and NEW at once against the
other, which is exactly why this change arms the two lanes that bake identically and leaves the
third alone. Nothing is unguarded by that: the in-mesh lane compiles the same content the two armed
bakes do, so it is redundant coverage rather than a hole. Giving it its own baseline is a separate
piece of work with its own measurement.
🚨 The baseline was MEASURED, and an empty baseline is the instrument
Core's adoption path is "run one bake with MW_LOG_LEVEL=Information and paste the
warnings: baseline … lines". That path is not available from inside the shared lanes: the lane
starts the tester itself — in container mode with a fixed env (-e HOME=/tmp), so MW_LOG_LEVEL
never reaches it — and a caller cannot set env: on a reusable workflow's job in either mode.
Hand-writing the file, or copying core's, would have recorded debt nobody measured, under this
repo's NodeType names.
So the baseline was harvested from an armed bake with an EMPTY baseline file, in two pushes on the same pull request:
- Push 1 — both lanes name
plugin-gate-warnings.allow; the file carries its header and no entries. An empty baseline tolerates nothing, so every observed pair is NEW and the bake goes RED, printing for each one the exact line to record:warnings: <ratchet> NEW <type> <code> — not in the baseline. Fix it, or record the debt by adding the line '<type> <code>'. - Push 2 — those lines, sorted and de-duplicated, ARE the file. The same bake then reports
ENFORCED … 0 NEW, 0 staleand the gate is green.
That red is not a mishap on the way to the change; it is the positive control. It proves the ratchets are armed and CAN fail before anything is recorded — which is the one thing a green run on a green tree can never prove, and the reason this repo does not accept a gate whose evidence is "the command returned".
…and it cannot be silently switched off again
The input's default is EMPTY, empty is observe-only, and observe-only exits 0. So a future edit
that deletes warning-baseline-file: from one lane — or from both — fails nothing and changes
nothing a reader would notice: the wall stays green and the repo is back where the report found it.
Core guards its own two trees with a postcondition inside bake-then-gate.sh (it asserts both
ENFORCED lines on a green bake); a satellite that calls the shared lanes has no equivalent, so
this repo grew its own.
scripts/check-shared-lanes.py — the checker that already reads every platform lane call, with the
calling job's block travelling with the call so a call answers for ITSELF rather than for a
neighbour — gained a fifth arm:
- every
node-repo-gate.ymlandnode-repo-publish-bake.ymlcall must NAME a baseline; - both must name the same one;
- the file must EXIST (a named-but-absent path is a hard refusal inside the tester) and PARSE (a malformed line tolerates nothing while reading like a record of debt).
It runs in Repo policy gates beside its own --self-test, which drives the arm from both
directions — including the case that matters, a lane call with the input deleted.
What the two ratchets measured
One bake of this repo's whole content. First measured observe-only on platform set
3.0.0-ci.8721 (core ea35bbe1a) and then reproduced byte for byte by the armed run that
produced the baseline, on 3.0.0-ci.8736 (core f0ea2ceb3) — same 3,248 occurrences, same 1,523
sites, same 15 codes. The denominator first, because a count without one is not a measurement:
| NodeTypes discovered, and compiled | 98 of 98 — none failed, so no baseline entry is unverifiable |
| NodeTypes producing at least one warning | 98 of 98 — there is no warning-free type in this repo |
| bundles | 37, from 1,237 node files over 71 packages |
| reference set | the portal host's /app (218 assemblies) + its shared frameworks (313) + the gate's 4 composed module bundles |
| raw warning occurrences | 3,248 |
| folded to distinct sites | 1,523 (a site is one (id, message); a shared Source/*.cs reports one defect once, not once per consumer) |
| distinct diagnostic codes | 15 |
Independently confirmed: the repo carries exactly 98 files declaring "nodeType": "NodeType", so
the bake compiled every one of them — the sweep's denominator is the tree's.
Per code, and who owns it
| code | occurrences | sites | types | ratchet | whose |
|---|---|---|---|---|---|
CS1591 missing XML doc comment |
2,378 | 1,386 | 85 | doc-comments | this repo's content — the maintainer's report, counted |
CS1573 a <param> with no matching parameter |
247 | 61 | 30 | warnings | this repo's content — a doc comment that IS wrong |
CS0419 ambiguous cref |
194 | 26 | 29 | warnings | this repo's content |
CS1574 cref resolves to nothing |
188 | 25 | 31 | warnings | this repo's content |
CS1701 assembly-reference version skew |
114 | 2 | 95 | warnings | the reference set — no author can fix it |
CS1734 paramref naming no parameter |
59 | 7 | 23 | warnings | this repo's content |
CS1572 a <param> tag for no such parameter |
18 | 4 | 8 | warnings | this repo's content |
CS8602 possible null dereference |
15 | 1 | 4 | warnings | a latent bug — but 12 of the 15 are the generator's; see below |
CS8604 possible null argument |
14 | 4 | 6 | warnings | a latent bug |
CS8601 possible null assignment |
12 | 1 | 1 | warnings | |
CS1570 badly formed XML |
3 | 2 | 3 | warnings | this repo's content |
CS1571 duplicate <param> |
3 | 1 | 3 | warnings | this repo's content |
CS0105 using directive appeared previously |
1 | 1 | 1 | warnings | |
CS1587 XML comment not on a valid element |
1 | 1 | 1 | warnings | this repo's content |
CS8625 null literal to a non-nullable |
1 | 1 | 1 | warnings | a latent bug |
The nullable four — CS8602/CS8604/CS8601/CS8625, 42 occurrences over 7 distinct sites — are
the debt worth paying first: real possible-null defects in source that ASKED for the analysis (571
of this repo's in-mesh Source/ and Test/ .cs files carry #nullable enable), and few enough
to clear. CS1701 is the one class nobody here can pay — System.Reactive compiled against .NET
8's System.Linq.Expressions and running on .NET 10 — so the bake RECORDS it.
The two armed lanes were checked against each other FIRST
The gate composes 4 module bundles and publish-bake composes 8, so "both lanes take the same file"
is a claim that had to be measured rather than assumed — a code that only the 8-module composition
produces would be NEW on main the moment the PR that introduced the baseline merged.
On the same commit, observe-only: publish-bake's (narrowed) bake compiled 38 of 38 types with its 8
modules and produced 11 diagnostic codes, every one of them inside the gate's 15 — CS1591,
CS1573, CS1574, CS0419, CS1701, CS1734, CS1572, CS8604, CS1570, CS1587, CS8602.
No code appears that the 4-module composition does not see, and in particular no CS0436. That is
what makes one file honest for both lanes.
It is a strong check rather than a proof: a narrowed bake cannot enumerate the pairs of types it did
not compile. The residue is bounded and loud — should the merge lane ever see a pair the gate did
not, it fails on main naming the exact line to add, which is the ratchet behaving correctly rather
than a silent drift.
The baseline, and what it enforces
plugin-gate-warnings.allow records 321 (type, code) pairs over 98 NodeTypes, every one of
them a line the armed bake printed for itself:
| ratchet | entries | codes | what it refuses from now on |
|---|---|---|---|
warnings |
236 | 14 | a NEW real warning on ANY of the 98 types — including a new CODE on a type already carrying another — and a recorded one that has since been fixed (STALE) |
doc-comments |
85 | 1 (CS1591) |
a type acquiring its FIRST undocumented public member, and a type that has become fully documented (STALE) |
The key is (type, code), not (type): the 85 types already carrying CS1591 cannot pick up a
broken cref or a possible-null dereference in silence, because that pair is still NEW.
The harvest is internally consistent, which is the check worth stating: the 321 lines split 236/85
exactly as the two ENFORCED verdicts counted them, and the per-code line counts reproduce the
inventory's own type(s) column for all 15 codes (CS1701 95, CS1591 85, CS1574 31, CS1573 30,
CS0419 29, CS1734 23, CS1572 8, CS8604 6, CS8602 4, CS1570 3, CS1571 3, CS0105 / CS1587 / CS8601 /
CS8625 one each).
The CS0105 row was misattributed — and the check that would have caught it
The harvest originally closed with "Not one site names a GENERATED member — core fixed those three
codes at the root before any satellite could record them under the content's name." That sentence
was false, and the CS0105 row is the counter-example. Core did fix the skeleton generator's
un-deduped usings; this repo's own scripts/gen-scope-proxies.py then produced a duplicate by a
different route, and the baseline recorded it as BusinessRules/Scope's debt.
The mechanism, measured at the byte:
The scope-proxy generator is a .NET program. Its output is written with
Encoding.UTF8, which always emits a byte-order mark.gen-scope-proxies.pyread that output with Python's plainutf-8codec, which decodes the BOM to a literal U+FEFF instead of consuming it (onlyutf-8-sigconsumes it), then concatenated it after the// <auto-generated>header. The BOM therefore landed mid-file, immediately beforeusing System;, in all seven committed proxies:00000110: 656e 6572 6174 6564 3e0a efbb bf75 7369 enerated>....usi 00000120: 6e67 2053 7973 7465 6d3b ng System;Roslyn's lexer treats U+FEFF as whitespace, so the compiler read an ordinary
using System;. Every string-keyed dedup upstream did not: core's skeleton generator keys the hoisted usings ondirective.Trim()underStringComparer.Ordinal, and .NET'sstring.Trim()does not strip U+FEFF — it is a format character, not whitespace. So"using System;" != "using System;", both directives were emitted, and the in-mesh compile reported CS0105 exactly once, on one site, on one type — which is the row above.
Fixed at the generator (utf-8-sig on every read of .NET-written text) and the proxies regenerated;
the baseline line is deleted, and the STALE check is what proves the warning is gone rather than
merely tolerated.
What made it survive review. Nothing renders a mid-file BOM: not an editor, not git diff, not
the code review. The generator's own --check compared the committed bytes against the same wrong
bytes and printed ✓ 7 proxy file(s) match. The ratchet was the only instrument that saw anything
at all, and what it saw it attributed to the wrong owner. The check that separates the two cases
is one command — if a code the platform is supposed to have fixed reappears, look for the site
before believing the attribution:
grep -c $'\xef\xbb\xbf' <the module's committed .cs files> # 0 everywhere, or the BOM is yours
This is the fleet's utf-8 / utf-8-sig trap in its quiet form. The known shape crashes
(UnicodeDecodeError / a stray in a parsed key); this one emits a duplicate and keeps
going. Any Python in this repo that reads text a .NET program wrote uses utf-8-sig.
…and the same generator owned the CS8601 row, and 12 of the 14 CS8602 occurrences
Having found one row misattributed, the arithmetic was worth redoing for the neighbours. CS8601
is 12 occurrences over one type, BusinessRules/Scope. The seven committed proxies contain
exactly 12 GetProperty calls, and each one is this line:
private static readonly System.Reflection.MethodInfo __G___X = typeof(X).GetProperty(nameof(X.Y)).GetMethod;
Type.GetProperty returns PropertyInfo? and PropertyInfo.GetMethod is MethodInfo?, so that
single line is two warnings: a dereference of a possibly-null reference (CS8602) and a
possibly-null assignment to a non-nullable field (CS8601). Measured on a standalone project
configured exactly as the mesh compiles — <Nullable>annotations</Nullable>, one compilation unit,
an authored #nullable enable ahead of the generated text — two such lines produced 2 × CS8602
- 2 ×
CS8601, and the null-safe form produced 0 Warning(s).
The counts close with nothing left over, on both trees:
| tree | CS8602 total |
types | ⇒ BusinessRules/Scope |
|---|---|---|---|
main |
15 | Scope, Collaboration/Review, Edu/CourseCatalog, Store/Plugin | 12 (the other three ≥ 1 each) |
| the warn-as-error branch, authored sites already fixed | 14 | Scope, Edu/CourseCatalog, Store/Plugin | 12 (the other two ≥ 1 each) |
So BusinessRules/Scope carried no authored CS8601 or CS8602 at all — 24 of the repo's
recorded warning occurrences were one emit in one generator, filed under the content's name.
Why nobody could see it where the proxy lives. The mesh compiles a NodeType's sources as ONE
concatenated compilation unit under NullableContextOptions.Annotations, so nullable analysis runs
only in text that opted in — and an authored #nullable enable earlier in the concatenation
(BusinessRules/Scope's Test files all carry one) is still in force when the generated text is
reached. The pre-push gate, scripts/compile-check.py, feeds each file as its own <Compile> item,
where the directive cannot reach across, and reports nothing. Both halves measured: the gate green,
the bake naming 24 occurrences.
The fix is at the generator, and it is a fix rather than a silencing — ?.GetMethod ?? throw new InvalidOperationException(…). The getter is guaranteed by the symbol the line was emitted from, so
a null there is a metadata mismatch; naming it at type initialisation beats a
NullReferenceException raised later inside Evaluate. No !, no #pragma.
The instrument: scripts/concat-probe.py
Both defects were invisible to the pre-push gate and named only in aggregate by the bake, which is
an unpleasant place to debug from: CS8602 14× / 1 site(s) / 3 type(s) says a code and a count, not
a file and a line. scripts/concat-probe.py closes that gap — it builds the MESH's shape for ONE
NodeType (usings hoisted the way ExtractUsingStatements hoists them, everything concatenated into
one unit, <Nullable>annotations</Nullable>, documentation file on, analyzers off) and prints every
diagnostic mapped back to the contributing file and line:
python3 scripts/concat-probe.py BusinessRules/Scope
# before the generator fix: 24 diagnostic(s) — 12 CS8601 + 12 CS8602, each on a
# `GetProperty(...).GetMethod` line in a Test/Generated/*Proxy.cs
# after: 0 diagnostic(s)
🚨 It is a diagnostic, not a gate, and it exits 0 whatever it finds. Its reference set is a
source build, so it can differ from the bake over anything the platform image's implementation
assemblies decide (CS1701 above all), and it is not sharded, baselined or ratcheted. Use it to
FIND a site the bake has already named — the bake stays the verdict.
Reading a red
| The line says | What happened | What to do |
|---|---|---|
NEW <type> <code> |
this (type, code) pair is not in the baseline | fix the warning. Record it only if it is genuinely not yours to fix — and then say why in a comment beside the line |
STALE baseline entry |
the type compiled CLEAN of that code | delete the line. The debt was paid; the ratchet refuses to keep pretending otherwise |
unverifiable baseline entry |
the type did not compile in this run, or this run never baked it | nothing. It is a warning, never a failure — a type that failed emitted no warnings, and reading that silence as "the debt is paid" would delete a record on a measurement nobody took |
MW_LOG_LEVEL=Information on a local bake restores every distinct site behind a line, so a baseline
entry can always be expanded back into the members it covers.
🚨 A #pragma warning disable in a NodeType's source is not a fix, it is the same evasion
wearing content's clothes. And a code the PLATFORM emits into content it does not own is fixed in
core — three whole codes were, rather than being recorded in every repo of the fleet under the
content's name (see the core page).
🚨 The ratchet lives in the IMAGE, not in the lane
--warning-baseline is parsed by mw-plugin-test, which comes from the resolved platform set —
not from @main of the workflow. Measured on the two images themselves:
| tester image | compile --help |
--warning-baseline /tmp/nope |
|---|---|---|
3.0.0-ci.8721 (@sha256:17a621ff…) |
lists [--warning-baseline <file>] and explains both ratchets |
accepted |
3.0.0-ci.8716 (@sha256:eb3ac423…) |
no such option | Unknown argument '--warning-baseline'. Try --help. — exit 2 |
So a repo that arms the input while its runs still resolve an older sealed set does not get a lenient bake; it gets a dead one, with an error that names the tester's argv rather than the pin.
This is why arming waited for main to pass on a set carrying it: pull requests here follow the
newest sealed set this repo's main has passed (scripts/resolve-platform.py), so a repo cannot
adopt a core capability before its own main has vouched for the set that carries it. A satellite
that copies this change without checking its resolved set will red every PR at once, and the error
will name the tester's argv rather than the pin.
🚨 And "main has passed on it" is read from an ANNOTATION that a re-run erases. Measured while
adopting this, 2026-09-16: main's run resolved 3.0.0-ci.8721 and died on an artifact-service 403
(FinalizeArtifact; the tests themselves reported failed: 0). rerun-failed-jobs re-ran the two
legs and the run concluded SUCCESS — but GitHub re-creates job records for the whole new attempt,
and the carried-over Resolve the released platform job came back with zero annotations
(attempt 1 had it; attempt 2 did not). main_passed_ceiling() reads the latest attempt, found
nothing, skipped the run, and every pull request in this repo went on resolving 3.0.0-ci.8716 —
under a sentence that is false: "main has not passed on it yet". It had, six minutes earlier.
This change was blocked on exactly that for three hours, and the way out was a FULL re-run of a
later main run: it re-runs the resolver, so the annotation is re-published (measured — attempt 2's
own job record carries it), where a partial re-run would have erased it again.
It is not a one-off: enumerating this repo's newest successful main push runs and reading each
one's annotation directly, every run at attempt 1 carries it and both runs that were re-run have
none (35073843357 today, 35027485210 the previous evening). The loss correlates exactly with
run_attempt > 1 — which is the ordinary outcome of retry-known-transients.yml doing its job.
The only honest ways out are a NEW push to main or a FULL re-run; re-running the resolver job
alone would re-resolve a NEWER set and publish an annotation claiming main passed on a set it
never tested. Filed as MeshWeaver#4491 — the resolver should read the annotation across attempts,
and the vendored copy (check-resolver-copy.py) means the fix lands in core first.
A consequence worth knowing: the reference set is part of the measurement. A diagnostic like
CS1701 (assembly-reference version skew) is a property of the set, so a platform bump can add or
remove a pair with no content change at all. That is loud by design — the entry is added or deleted
with the bump, and the ratchet never silently absorbs it.
Related
- The In-Mesh Warning Standard — the mechanism, the two ratchets, and why the runtime compile stays lenient.
- NodeType Compilation — what a NodeType compile is, and what parking one costs.
- CI Content Bake — where each bake runs and which lane publishes.
- PlatformResolution — how this repo picks the set the tester
comes from, and why a core capability cannot be adopted before
mainhas passed on it. - BuildAndReleaseProcess — when this repo runs each lane.