Skip to content

Never reuse a field number a deletion freed - #610

Merged
Eli Pinkerton (wallstop) merged 15 commits into
mainfrom
dev/wallstop/session-237-issue-sweep
Aug 30, 2026
Merged

Never reuse a field number a deletion freed#610
Eli Pinkerton (wallstop) merged 15 commits into
mainfrom
dev/wallstop/session-237-issue-sweep

Conversation

@wallstop

@wallstop Eli Pinkerton (wallstop) commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Why: A field number freed by a deletion was indistinguishable from one never used, so the next
type or member added could be handed it and read every older save back as the wrong thing.

What:

  • Record a hand-written [WProtoSubtype] number so deleting the subtype retires it, and refuse any
    declaration claiming a retired number.
  • Add [WProtoReserved], so a removed [WProtoMember]'s number and name cannot be taken again;
    it binds subtype tags too, and the exported schema carries proto3 reserved lines.
  • Refuse cross-assembly subtype dispatch permanently, and name composition as the shape that works.
  • Run project validation from one -executeMethod, with a JSON report and a reviewable suppression
    file.
  • Fail any gate that checked nothing, and refuse a docs example naming a namespace or assembly we
    do not have.

Fixes #603
Fixes #604
Fixes #606
Fixes #608

🤖 Generated with Claude Code


Note

Medium Risk
Changes affect save compatibility and subtype/manifest assignment for all WallstopProto users; CI validation batch can fail builds on coverage or unsuppressed findings, but behavior is opt-in via -executeMethod.

Overview
This PR closes the gap where deleting a WallstopProto member or subtype freed a field number with no durable record, so the next assignment could silently break older saves.

WallstopProto wire safety: Adds [WProtoReserved] with build-time WPROTO043 when a live member, include, or subtype reuses a reserved number or schema name; reservations do not change emitted formatters and appear as proto3 reserved lines in schema export. Subtype tag tooling now records hand-written [WProtoSubtype] numbers in the manifest, retires on delete, tracks multiple retirements per pair, treats [WProtoReserved] as spent numbers when auto-assigning, and the generator refuses explicit tags that collide with retired discriminators. Cross-assembly [WProtoSubtype] diagnostics are clarified to explain per-assembly dispatch and recommend composition via [WProtoMember] instead of promising a future registry.

Editor validation in CI: Adds ValidationBatch.ValidateFromCommandLine with JSON reporting (ValidationReport), line-based suppressions (ValidationSuppressions), severity thresholds, folder scoping, and failure when rules throw or the run checked zero rules/assets.

Docs and gates: Documents CI validation and member retirement; fixes broken doc examples (Core.Attributes, runtime assembly name in link.xml); adds lint:doc-identifiers and makes several linters fail when their corpus is empty instead of passing vacuously.

Reviewed by Cursor Bugbot for commit afd46ef. Bugbot is set up for automated code reviews on this repo. Configure here.

check-code-fence-syntax.sh was the one linter left on the missing-red-half
work list: nothing under scripts/tests/ named it, so a green Repo Lint was
evidence about docs/ and no evidence the gate still reports.

Writing its self-test found a second defect. The gate counted issues but
never counted files, so a corpus holding no markdown -- docs/ renamed, a
tree moved, a find that stopped matching -- printed "No code fence syntax
issues found" and exited 0. That is the exact shape test-empty-corpus-
gates.ps1 exists to prevent, and this gate had never been in that family.
It now reports its scan count and fails on an empty walk.

The self-test carries a red half per rule the gate enforces: backtick and
tilde fences, indented fences, fences longer than three backticks, a file
in a subdirectory, a multi-offence count, a missing directory and an empty
corpus. Verified falsifiable against three mutants -- suppressing the issue
counter fails six cases, removing the empty-corpus guard fails one, and
-maxdepth 1 fails the recursion case.

missingRedHalf is empty again; no linter in scripts/ is unfalsifiable.

Fixes #604

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A subtype's field number is a durable wire contract: a payload resolves a
subtype by that number and nothing else. For the explicit forms --
[WProtoSubtype(typeof(Weapon), 100)] and [WProtoInclude(100, typeof(Melee))]
-- the declaration was the only record that 100 had ever been spent, and it
is deleted along with the type it sits on. WPROTO039 fires on two LIVE
claims and has no memory, so a number freed by a deletion was
indistinguishable from one never used: the next subtype added could be
handed 100, and every payload written by an older build read that field
back as the wrong type. No diagnostic, no exception.

The planner now records an explicitly numbered declaration beside the
assigned ones, which is what turns its deletion into a retirement, and the
generator refuses any declaration -- subtype or include -- claiming a number
the assembly has retired, naming the type that held it. The refusal is by
NAME, so re-adding the deleted type still restores its own number.

Two more of the same class, found while writing the tests:

- Deleting the number from an attribute made the pair look brand new, so it
  was handed the smallest free number instead of keeping the one it wrote.
  Editing a number in place left no trace at all. Both now record the new
  number and retire what they left.
- A pair that had retired two numbers -- what a hand-edited number leaves
  behind -- lost one of them on the next run, because re-emission was keyed
  by pair rather than by pair and number. A dropped retirement is a number
  that is free again a run later.

Nine new plan and generator tests, red before the change. The three existing
tests that asserted the old behaviour are updated in place: the "no manifest
at all" guard now rests on FreshlyAssigned, which is the condition the
unattended pass actually reads, so adopting the package still writes nothing
into a project that invents no numbers.

689 generator tests pass against protobuf-net 3.2.56 and 688 against 2.4.9.
The shipped analyzer is rebuilt and byte-identical to its sources.

Fixes #606

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#603 asked for a decision: build a runtime registry so a consumer can derive
from a package base, or keep refusing and explain why the refusal is not
temporary. PLAN.md names refusal the safe default, and it is: every failure
mode of a registry is silent data corruption rather than a build error.
Unity's registrars run unordered, so a serialize before the last registrar
writes under the wrong number or none; two packages picking one number on a
shared base is undetectable at build time and type-confusing at read time;
and the lookup has to survive managed stripping under IL2CPP. A build error
you can see beats a player that writes an unreadable save.

WPROTO040's message said the base's chain "was generated when its own
assembly was compiled" and left the reader to guess whether that was a limit
or a gap. It now states that this is how per-assembly generation works
rather than a gap waiting to be filled, and names composition -- your own
[WProtoContract] holding the base as a [WProtoMember] -- as the shape that
does work.

A diagnostic that names a fix has to name one that works, so a second test
compiles that alternative against an upstream assembly and asserts it
generates clean. The docs carry both shapes side by side, and say plainly
what composition costs: a List<Weapon> cannot hold your type.

Fixes #603

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The validation engine shipped without a way for anything but a person to run
it, which is the difference between "you can write project checks" and "your
project is checked". This is the headless half.

ValidationBatch.ValidateFromCommandLine finds every IValidationRule through
TypeCache, builds it, runs the lot, writes a JSON report and exits non-zero
when anything at or above -validationFailOn stands unsuppressed. A rule that
threw fails the run whatever the threshold: it produced no answer for that
asset, and passing on it would report coverage the run does not have. A rule
that cannot be constructed is reported and skipped rather than ending the
run, because one broken rule hiding every other rule's findings is worse.

ValidationSuppressions is one finding identity per line, so a diff shows
exactly which check somebody switched off. It matches on rule, asset GUID and
discriminator -- never the path, never the message -- so moving an asset or
rewording a rule does not silently un-suppress it. Render() writes the path
and message above each entry as a comment, because a rule name and a GUID
tell a reviewer nothing.

UnusedIn reports entries that matched nothing. A suppression that outlives
the finding it silenced reads as a considered decision and is really a line
nobody has looked at -- the same shape as a linter that cannot report. Its
doc says plainly that only a whole-project run may be trusted for it.

The report keeps suppressed findings and marks them, rather than dropping
them: a report that omitted them would make a suppression file
indistinguishable from a project with nothing wrong.

Verified: compiles against Unity reference assemblies through typecheck:editor
and typecheck:tests, changed-file preflight and the five relevant repo-lint
gates pass. The 18 EditMode fixtures run in CI -- no Unity license or MCP
bridge was reachable this session, so their execution is unverified locally.
The JSON assertions round-trip through JsonUtility rather than matching
pretty-printed spacing, so they test content rather than indentation.

Addresses #288 (results window and automatic re-runs still open)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A member's field number is a durable wire contract, and the declaration
that spends one is deleted along with the member it sits on. WPROTO002
refuses two members claiming one number at the same TIME, so it cannot see
a number a deletion freed: delete Health at 3, add a string at 3, and every
payload written by an older build reads that field back as a string. No
diagnostic, no exception -- the same defect as #606, one level up, reaching
far more code because every contract has members.

[WProtoReserved(3)] records the removal, and [WProtoReserved("Health")]
records the name. Names matter for the reason protobuf reserves both: a
re-added Health at a DIFFERENT number still breaks anything matching by
name -- a JSON projection, a generated .proto consumer, a schema registry --
while carrying data that means something else.

WPROTO043 refuses a member that takes either. It is deliberately one
diagnostic, not two: "a new member took a dead number" and "a reservation
contradicts a live member" are the same state seen from two sides, nothing
in the compiler can tell which is wrong, and a second code could never fire
alongside the first. The message names both fixes instead.

The schema exporter emits the matching proto3 reserved lines. Without them
the exported schema permits, in the consumer's own toolchain, exactly the
reuse this refuses. A reserved number outside proto3's range is dropped with
a diagnostic naming it, because a schema nobody can parse is worse than one
missing a reservation.

Reservations are per contract. A base's does not bind its subtypes: their
numbers live in a different space, so inheriting one would refuse a member
for a collision that cannot happen.

Verified: 700 generator tests against protobuf-net 3.2.56 and 699 against
2.4.9. The schema fixtures live in Tests/Runtime, which is excluded from the
dotnet harness, so their exact expected text was produced by compiling the
real WProtoSchemaText against the same fixtures in a scratch project rather
than guessed. The shipped analyzer is rebuilt and byte-identical.

Fixes #608

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread Editor/Tools/WProtoSubtypeTagPlan.cs
Comment thread Editor/Validation/Continuous/ValidationBatch.cs
lint:code-samples extracts 3,061 C# blocks out of docs/ and validates none
of them, so an example naming something moved or renamed reads as correct
forever. Session 236 found invented APIs in three pages by hand; nothing
stops the next one.

Two rules, chosen because they have no false positives. A general "does
this API exist" check needs a real parser -- a first attempt keyed on
Type.Member reported Serializer.ProtoDeserialize as missing, because a regex
over declarations cannot see a generic method, and a gate that cries wolf is
one people stop reading.

  1. A `using WallstopStudios.UnityHelpers...;` must name a namespace some
     .cs file declares. A using is unambiguous: it is a namespace, spelled
     in full, or it does not compile.
  2. An `<assembly fullname="WallstopStudios...">` in a link.xml example
     must name an assembly some .asmdef declares.

Both found a real defect on the run that introduced them, and both shipped
in 3.5.1:

- The enum display-name example imported Core.Attribute, not Core.Attributes.
  A reader copying it gets a compile error.
- The link.xml example preserved `WallstopStudios.UnityHelpers.Runtime`. The
  runtime assembly is `WallstopStudios.UnityHelpers`, and the linker reports
  nothing for a name it cannot match -- so that one surfaced as a stripped
  player rather than as an error.

Ten self-test cases, red halves included, and the run-repo-lint meta-check
was verified to catch this linter when its self-test was unregistered.

Addresses #441

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of the headless runner added an hour earlier found it
carrying the exact defect the code-fence gate was fixed for this morning: a
run with no rules, or no assets, exited 0 and reported "validation passed"
having measured nothing.

Both shapes are reachable with nothing looking wrong at the call site.
ValidationTargets.Enumerate skips a folder that does not exist rather than
reporting it -- deliberately, because Unity logs a warning per missing
folder -- so one typo in -validationFolder yields zero targets and a green
build. A project that has not written a rule yet yields zero rules.

CoverageProblems is a pure function of the two counts and the folder list,
separated from Run so it can be asserted without an asset database. Both
reasons are reported rather than the first, so fixing one does not hide the
other, and the empty-assets message names the folders it was given -- the
reader cannot otherwise tell "the project is empty" from "I typed the path
wrong".

Addresses #288

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread scripts/lint-doc-identifiers.js
Two real defects Cursor Bugbot found in the first draft, both silent data
corruption.

Restore dropped sibling retired numbers. `restoredRetirements` was keyed by
subtype/base pair, and the final loop then dropped EVERY retirement for that
pair. A pair can hold more than one -- a hand-edited number leaves one and a
later deletion leaves another -- so re-adding the type under the first freed
the second, which is the exact reuse the record exists to forbid. It is
keyed by pair AND number now. Verified against a mutant: three new cases and
two existing ones go red when the key is coarsened back.

Reserved numbers skipped subtype tags. WPROTO043 inspected only
[WProtoMember], while a base's includes are numbered against its members --
one space -- so a rule binding half of it was one an author steps around by
writing the number on the other half. [WProtoInclude] and [WProtoSubtype]
are both refused now, at their own diagnostics so each names what the author
actually wrote.

That has a second half Bugbot named: the assignment tool has to assign
around reserved numbers as well, or it hands out a number the next compile
rejects -- a deadlock, which is the thing that tool exists to remove.
AddReserved now feeds [WProtoReserved] numbers in beside the members and
includes.

Bugbot's third finding, a validation run over an empty folder exiting 0, was
already fixed in 21f0a34.

707 generator tests against protobuf-net 3.2.56, 706 against 2.4.9.

Addresses #606, #608

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Same shape as the reservation-skips-subtypes defect a review just found:
the rule read one half of the space. [WProtoMember(9, Name = "Health")]
presents itself as Health to a generated schema, a payload dump and anything
matching by name, whatever the C# member is called -- so a reservation that
inspected only the identifier was one an author steps around by renaming.

Both names are checked now, and the diagnostic names whichever one was
taken. The other direction stays legal, because it is the point of the
override: a C# member called Health that presents itself as something else
takes nothing back.

The schema exporter already renders the override, so the two halves now
agree -- a project that compiles can no longer produce a message carrying
`reserved "Health";` beside `int32 Health = 9;`.

709 generator tests against protobuf-net 3.2.56.

Addresses #608

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0c4279c. Configure here.

Comment thread Generator~/WallstopStudios.UnityHelpers.Proto.Generator/WProtoGenerator.cs Outdated
A reservation documents a wire contract, so the one outcome it must not have
is changing one. Asserted by comparing the emitted formatter source with and
without [WProtoReserved], which is stronger than comparing bytes for a
handful of values: if the emitted code is the same code, there is no payload
the two could disagree about.

710 generator tests against protobuf-net 3.2.56.

Addresses #608

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inserting the helper above AsksForZigZag's signature put it between that
method's doc comment and the method, so AsksForZigZag lost its
documentation and SchemaNameOf inherited a <summary> about ZigZag.

Caught by lint-xml-doc-summaries, which exists for exactly this -- a
member's documentation outliving the member (#591). Recorded rather than
quietly fixed, because the miss was mine twice over: an anchor chosen on the
signature rather than the doc block, and a push that ran the targeted gates
for the docs I changed but not the one for the C# I added.

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The empty-corpus rule was enforced by test-empty-corpus-gates.ps1 over six
PowerShell linters and had never reached the JavaScript ones. Measured by
pointing each at a directory holding nothing, through the scan-root override
its own self-test already uses:

  lint-comparison-direction   exit=0
  lint-nested-type-placement  exit=1
  lint-xml-doc-summaries      exit=0
  lint-doc-identifiers        exit=0

Three of four reported a clean run over nothing, including the linter this
branch added an hour earlier -- the session's own theme, in the session's own
new code, for the third time.

All three fail an empty walk now and name what was empty. lint-doc-identifiers
checks both halves, because either going empty makes every answer vacuous: no
documents means nothing was read, and no namespaces means every using would
resolve to nothing. It reports its document count on success too, so a corpus
quietly shrinking is visible before it reaches zero.

A red half per gate, in each gate's own self-test rather than in a central
list, so the case sits beside the rules it guards.

Addresses #556's rule for the JavaScript family

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WGroupEndAttribute already exposes IReadOnlyList<string> for the same shape,
so an attribute handing back its own mutable array was the odd one out. The
sets are constructed in the constructor and never change; the type now says
so.

Verified the schema exporter still renders identically -- reserved numbers
and names in the same order, and the three out-of-range diagnostics
unchanged -- by re-running the real WProtoSchemaText against the same
fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 30, 2026 02:45
@wallstop Eli Pinkerton (wallstop) changed the title Record wire numbers a deletion frees, and run validation headlessly Never reuse a field number a deletion freed Aug 30, 2026

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The rule read the C# identifier alongside the schema name, so a member
presenting a free name through [WProtoMember(Name = ...)] was refused when
its identifier happened to be reserved. That contradicted this feature's own
documentation, which already promised "a C# Health presenting itself as
something else is not" refused, and the commit that introduced it, whose
subject was reserving the name a consumer sees.

The test could not have caught it: it named the member Vitality AND set
Name = "Vitality", so it passed whichever of the two identities the rule
happened to read. It now reserves "Health", names the member Health, and
presents it as Vitality -- the only arrangement that tells them apart.
Verified by mutating the rule back: that case is the one that goes red.

Reported by Cursor Bugbot.

710 generator tests against protobuf-net 3.2.56.

Addresses #608

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…l be

WPROTO040's message claimed the limit was "how per-assembly generation works
rather than a gap waiting to be filled". The first half is true; the second
overreached, and three measurements taken after that wording shipped show a
mechanism that could lift it:

- A consumer's compilation can already read the package base's
  [WProtoInclude] tags and every [WProtoMember] tag out of metadata, so
  collision detection needs no new information.
- WProtoFormatterProvider already documents that a later registration
  replaces an earlier one, deliberately, so a consumer can supply a base's
  formatter.
- The emitted chain is ordinary static code; nothing requires the assembly
  that declared the base to be the one that emits it.

So the extending assembly could emit the base's whole chain itself. That is
not the runtime registry #603 refused -- no lookup, no MakeGenericType, no
unordered-registrar hazard -- and it is now tracked on #612.

The message states the mechanism and the working alternative and stops
predicting the future in either direction: it does not promise a release
either, because the refusal is real today whatever #612 decides. The test
that pinned the permanence claim now pins the mechanism and the fix instead.

710 generator tests against protobuf-net 3.2.56.

Refs #603, #612

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@wallstop
Eli Pinkerton (wallstop) merged commit 19a336b into main Aug 30, 2026
34 of 35 checks passed
@wallstop
Eli Pinkerton (wallstop) deleted the dev/wallstop/session-237-issue-sweep branch August 30, 2026 06:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants