Skip to content

audit: named returns must be locally provable; org default outranks file-local style - #65

Merged
thedavidmeister merged 5 commits into
mainfrom
2026-07-30-issue-64-named-returns
Jul 30, 2026
Merged

audit: named returns must be locally provable; org default outranks file-local style#65
thedavidmeister merged 5 commits into
mainfrom
2026-07-30-issue-64-named-returns

Conversation

@thedavidmeister

Copy link
Copy Markdown
Contributor

Closes #64

Two independent edits to skills/audit/SKILL.md.

1. New Shared rule: Solidity named returns must be locally provable

Added to the Domain rules beside the other Solidity naming conventions, because that is precisely the gap they leave: the storage-class rule and the short-names rule reach declarations, locals and params, but neither reaches a return declaration. A named return is where Solidity silently supplies a zero/empty default on a path the unnamed form would refuse to compile.

What it flags

The finding is only the subset whose assignment cannot be proved from the declaration's own function:

case severity
Assigned only inside a loop or a branch, so correctness rests on a guard elsewhere in the function LOW
Never assigned on a reachable path — a silent zero/empty return MEDIUM
Shadowed on every path by an explicit return — dead declaration, pure reader tax LOW
Last-iteration-wins — assigned once per loop iteration, returns only the final one LOW, MEDIUM when iterations can diverge
Mixed forms introduced by one diff — authoring-time only, added code only INFO

The first four stand regardless of when the code was written. The mixed-forms case is new-code only, where the fix is free.

What it explicitly does NOT do

It is not a ban and not a conversion mandate. A named return sometimes genuinely simplifies the source — a value accumulated across branches, a slot written from assembly, a multi-value tuple whose component names are the documentation. And converting an existing named return to unnamed means adding an explicit return to every path of a function whose paths you must first prove you enumerated: a semantic-risk edit bought for a style win, worst in exactly the audited deploy code where that trade is least affordable.

So the proposed fix for the loop/branch case is never "unname it" — it is to make the assignment locally provable (assign a default at the top, or hoist the guard's invariant into the declaration), a strictly smaller and safer change than the refactor.

Five non-findings are stated explicitly so a reviewer does not reach for them: a tuple component documented by its name; a named return referenced from an assembly block; one assigned unconditionally on entry or on every branch of a total if/else; and a file whose local convention is named returns, absent one of the four cases — INFO at most, never a sweep.

Why the restraint is there, in the org's own words

The skill already carries this exact shape twice, and the new rule reuses their voice rather than inventing a third:

  • pragma convention: "An 'inconsistent pragma' finding is answered by applying this rule per file kind, NEVER by mass-pinning everything to one pragma."
  • stale soldeer deps: "Default INFO/LOW — a review prompt, NOT a mandate to bump."

Why this is correctness, not style

Two Shared rules already reach the same conclusions by a different route, and the new rule cross-references both:

  • Fail-closed on input — the never-assigned case is a fail-open default where the permissive value is the zero.
  • Explicit handling of ambiguous sets — last-iteration-wins is order-dependent resolution of an ambiguous set, reached through a return declaration instead of a .find.

2. Dimension 4 #1 no longer inverts the ruling on an outlier file

#1 measured "style consistency" against the surrounding file. Where the file is the outlier that inverts the finding: the deviating majority inside that one file reads as the standard, and the conventional minority beside it gets flagged as the deviation. Applied to rain.deploy's LibRainDeploy.sol (5 named return components to 1 unnamed) a reviewer blesses the named returns and flags the unnamed sibling — exactly backwards.

#1 now requires the convention to be established from the widest first-party scope observable (org, else repo, else directory) before judging any one file, and states that when a file-local convention contradicts a wider default, the wider default is the reference and file-local consistency is the weaker signal.

The fix is deliberately general — it holds for every convention where a single file can be the outlier (import style, error style, test structure, naming), not just returns. A whole-file deviation with no other defect is capped at INFO, with pointers to the pragma and named-return rules as the two cases where the sweep would itself cause harm.

Prevalence, counted independently

Counted with a comment/string-stripping parser over each repo's default branch src/, generated files excluded, per return component (which is the unit the issue's table uses — a returns (bool, bytes32) counts 2):

repo (src/, generated excluded) named unnamed % unnamed
rain.erc4626.words 4 44 91.7%
rain.math.float 19 125 86.8%
rain.interpreter 59 445 88.3%
rain.deploy 5 1 16.7%
total 87 615 87.6%

Three of the four reproduce the issue's table exactly. rain.interpreter differs slightly — mine is 59/445, the issue says 58/450 — a parser-nuance delta that does not move the conclusion (~12% named either way). Per the reporting instruction, my counts stand.

Discrepancies found while verifying (details in the QA comment)

  • The commit the worked example was verified at, fb137c6, is the head of rain.deploy#21, not main. The issue's line numbers are that branch's.
  • Consequently the 5 named / 1 unnamed figure for rain.deploy is the main count; at fb137c6 it is 6/1, because zoltuAddress is one of the functions audit: add events-belong-on-the-interface check (Code quality #12) #21 adds.

Both are cosmetic to the rule, and the rule text deliberately cites the function and shape rather than line numbers, which would rot.

Versions

plugin.json and marketplace.json 0.20.0 → 0.21.0 (lockstep, as version-hygiene requires). The SKILL.md frontmatter had drifted — it still declared 0.19.0 while plugin.json said 0.20.0 (the 0.20.0 bump came in via a merge-conflict resolution) — so it is realigned to 0.21.0 with the other two.

QA

  • Discriminating tests: n/a — this repo ships no test suite over SKILL.md (git ls-files is 6 files: the skill, README.md, install.sh, the two .claude-plugin manifests and one workflow); there are no structure/ordering assertions to extend and no prose linter configured. The only automated gate is .github/workflows/version-hygiene.yaml, and both of its jobs were replicated locally against origin/main before pushing — job 1 (plugin.json 0.21.0 == marketplace.json 0.21.0) PASS, job 2 (skills/ changed ⇒ plugin.json bumped, 0.20.0 → 0.21.0) PASS. Evidence transcribed in the QA comment.
  • Mutations applied: n/a — the diff is prose in a skill document plus three version-string bumps. There is no executable branch to mutate; the analogue performed instead was falsifying the rule's motivating example against real source (rain.deploy LibRainDeploy.sol), where a wrong claim would have invalidated the rule. All four load-bearing claims were checked line by line and held; see the QA comment.
  • Oracle: the issue's own specification for the rule's content (four flaggable cases, five non-findings, severities), and — independently of it — first-party Solidity source read directly for every factual claim: rainlanguage/rain.deploy src/lib/LibRainDeploy.sol at both origin/main and fb137c6 for the worked example, and the src/ trees of rain.erc4626.words, rain.math.float, rain.interpreter and rain.deploy for the prevalence table. The prevalence counts come from a parser written for this task, not from the issue's table; where the two disagree (rain.interpreter) the independently measured figure is the one shipped.
  • Category check: issue asks (a) a named-return rule in the Shared rules' Domain rules list, (b) covering the four flaggable cases at the stated severities, (c) covering the fifth authoring-time-only mixed-forms case at INFO, (d) explicitly not a ban and not a refactor mandate, in the voice of the pragma and stale-soldeer rules, (e) the five non-findings stated, (f) cross-references to Fail-closed on input and Explicit handling of ambiguous sets, (g) the loop/branch fix being "make it locally provable", never "unname it", and (h) a general fix to dimension 4 feat: marketplace + consolidate to one ultracode-native audit skill #1 so a file-local convention contradicting an org-wide default cannot read as the standard. Covered (a)–(h). The issue's "Check" section is satisfied clause by clause: the rule flags an unprovable assignment, does not flag a named return that reads better, treats a deployToNetworks-shaped loop-only assignment as a finding with a local fix, forbids the mass edit, and feat: marketplace + consolidate to one ultracode-native audit skill #1 can no longer produce the inverted ruling.

🤖 Generated with Claude Code

…ile-local style (0.21.0)

Adds a Solidity named-return rule to the Shared rules' Domain rules, beside
the other Solidity naming conventions those rules leave a gap in: they reach
declarations, locals and params but not return declarations.

The rule flags only the subset whose assignment cannot be proved from the
declaration's own function — loop/branch-only assignment, never-assigned on a
reachable path, shadowed on every path by an explicit return, and
last-iteration-wins — plus mixed forms inside a single diff at INFO. It is
explicitly not a ban and not a conversion mandate, matching the restraint the
pragma convention and the stale-soldeer-deps rule already carry.

Cross-references Fail-closed on input (the zero value is the permissive
default) and Explicit handling of ambiguous sets (last-iteration-wins reached
through a return declaration rather than a .find), which is why this is a
correctness rule and not a style preference.

Separately fixes dimension 4 #1: style consistency measured against the
surrounding file inverts the ruling when the file is itself the outlier. The
convention is now established from the org-wide default, with file-local
consistency as the weaker signal. Kept general — it holds for every convention
where one file can be the outlier.

Realigns the SKILL.md frontmatter version with plugin.json/marketplace.json,
which had drifted to 0.20.0 while the skill still declared 0.19.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b6b1238c-a2a4-4983-8b8f-b170dccc833d

📥 Commits

Reviewing files that changed from the base of the PR and between b94e7eb and 5371cbf.

📒 Files selected for processing (3)
  • .claude-plugin/marketplace.json
  • .claude-plugin/plugin.json
  • skills/audit/SKILL.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-07-30-issue-64-named-returns

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The pre-loop derived-address check exists only on rain.deploy#21's branch, not
on main. State instead what holds on both: every check is either before the
loop or inside it, and there is none after it, so the zero escapes either way.

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

Copy link
Copy Markdown
Contributor Author

QA evidence

Head SHA at time of writing: 9d001d6ed3d3e2a4232f707f83bc76f931fc18fb

Checks run

This repo ships no test suite and no linter config. git ls-files is six files:

.claude-plugin/marketplace.json
.claude-plugin/plugin.json
.github/workflows/version-hygiene.yaml
README.md
install.sh
skills/audit/SKILL.md

There is no CLAUDE.md, no flake.nix, no .pre-commit-config.yaml, no prettier/deno/markdownlint config, and no test asserting SKILL.md structure or section ordering — so there is nothing to extend on that axis and no prose reflow gate to trip. The sole automated check is .github/workflows/version-hygiene.yaml. Both of its jobs were replicated locally, verbatim from the workflow's own shell and with real jq (nix shell nixpkgs#jq), before the first push:

########## version-hygiene job 1: plugin.json and marketplace.json versions agree ##########
versions agree: 0.21.0

########## version-hygiene job 2: a change under skills/ must bump plugin.json version ##########
BASE=b94e7eb6c1d688f854e47a7c1a79dba61a3c97a0
HEAD=d55437a472e65cbe70a2469170bac9639412f7cb
skills/ changed and version bumped: 0.20.0 -> 0.21.0

Manifest and frontmatter well-formedness:

plugin.json: valid JSON
marketplace.json: valid JSON
frontmatter block found: True
  name -> present
  description -> present
  version -> present
no stray tabs in file: True
trailing newline present: True

And on CI, at head d55437a:

version-hygiene   pass   5s

Note the pre-existing drift this PR closes: SKILL.md declared 0.19.0 while plugin.json/marketplace.json were at 0.20.0. The workflow only gates plugin-vs-marketplace, so the skill's own frontmatter had gone unchecked since the 0.20.0 bump arrived through a merge-conflict resolution (ebddd92). All three now read 0.21.0.

Verification of the deployToNetworks worked example

Read top to bottom at rainlanguage/rain.deploy fb137c6654d6a35e7642701f99934b0215daa7ba, src/lib/LibRainDeploy.sol (341 lines). Every claim the rule rests on:

1. deployedAddress is assigned only inside the for loop — CONFIRMED.

Every assignment to that identifier in the whole file:

178:            deployedAddress := mload(0)          <- deployZoltu's OWN named return (different function)
278:                deployedAddress = deployZoltu(creationCode);
290:                deployedAddress = expectedAddress;
337:        deployedAddress = deployToNetworks(      <- deployAndBroadcast's own return (different function)

Within deployToNetworks (declared :228:237, body :237:305) there are exactly two: :278 and :290. The for opens at :251 and closes at :304; the function closes at :305. So both assignments are inside the loop, they are the two arms of the if (expectedAddress.code.length == 0) / else at :259/:283, and there is no statement between the loop's closing brace and the function's. If the loop body never runs, address(0) is returned.

2. The NoNetworks() revert is what makes it safe — CONFIRMED.

) internal returns (address deployedAddress) {   // :237
    if (networks.length == 0) {                  // :238
        revert NoNetworks();                     // :239
    }

First statement in the function, before anything else. It is the sole guarantor of at least one iteration — the loop bound is networks.length itself, so with the guard removed a zero-length array compiles and silently returns the zero address.

3. The expectedAddress check precedes the loop — CONFIRMED.

address derivedAddress = zoltuAddress(creationCode);      // :247
if (derivedAddress != expectedAddress) {                  // :248
    revert UnexpectedDeployedAddress(expectedAddress, derivedAddress);
}
for (uint256 i = 0; i < networks.length; i++) {           // :251

:248 vs loop at :251. It validates an input against another input and never observes the return value, so it cannot catch the zero. The one check that does look at deployedAddressif (expectedCodeHash != deployedAddress.codehash) at :294 — is inside the loop, so it is skipped along with it.

Conclusion: the issue's motivating example is correct as written. Every one of the four claims holds.

Caveat found (and why the shipped text avoids it). fb137c6 is the head of rainlanguage/rain.deploy#21, not main, so the issue's line numbers are that branch's. The loop-only-assignment shape is on main too — function at :208, assignments at :248/:260 inside the for at :221, NoNetworks at :219 — but the pre-loop derived-address check does not exist there; it is what #21 adds. The zero escapes on both revisions, for the same reason. The rule text therefore cites the function and the shape, not line numbers, and states the zero-escape in a form true on both revisions (9d001d6 tightened exactly this).

The non-findings were also checked against real code in the same file rather than asserted: deployZoltu (:168) is a named return written from an assembly block (:178); isStartBlock (:79), findDeployBlock (:106), zoltuAddress (:156) and deployAndBroadcast (:329) all assign unconditionally. All five are correctly non-findings under the rule as written — the rule does not fire on the file's other named returns, which is the discriminating property.

Verification of case 5 (mixed forms in one diff)

git diff origin/main fb137c6 over all files, functions added:

+    function zoltuAddress(bytes memory creationCode) internal pure returns (address derivedAddress) {
+    function mockDeployableAddress() internal pure returns (address) {
+    function mockDeployableCodeHash() internal pure returns (bytes32) {
+    function mockDeployableV2Address() internal pure returns (address) {

zoltuAddress assigns implicitly with no return; all three mockDeployable* use an unnamed return with an explicit return. Both forms, one diff — CONFIRMED.

One nuance: they are not adjacent. zoltuAddress lands in src/lib/LibRainDeploy.sol, the three helpers in test/src/lib/LibRainDeploy.t.sol. The rule text says "in the same diff", which is the load-bearing and accurate part, and drops "adjacent functions".

Verification of the prevalence table

Counted with a purpose-written parser that strips comments and string literals, walks each function header to its matching returns (...) clause, splits the clause on top-level commas, and classifies each component as named when ≥2 meaningful tokens remain after dropping memory/calldata/storage/payable. Run over each repo's default-branch src/, generated files excluded.

Two calibration steps before trusting it:

  • Hand-checked against a full manual read. rain.deploy src/ is one file; the parser's per-clause output was compared line by line against reading all 341 lines. Agreement on all 7 clauses.
  • A real bug found and fixed. The first version pruned any directory named lib, which silently swallowed src/lib/ — first-party source — and reported 0/0 for rain.deploy. A counter that returns zero for the one repo whose count matters is the kind of failure that would have shipped a fabricated table. Fixed to prune only vendored trees outside src/, then re-run.

Result:

repo (src/, generated excluded) named unnamed % unnamed issue's table agrees
rain.erc4626.words @ e02d60e 4 44 91.7% 4 / 44 exact
rain.math.float @ d3fb611 19 125 86.8% 19 / 125 exact
rain.interpreter @ a6b7ad7 59 445 88.3% 58 / 450 differs
rain.deploy @ origin/main 5 1 16.7% 5 / 1 exact
total 87 615 87.6%

Three of four reproduce exactly, which is what establishes that the table's unit is the return component, not the clause (returns (bool, bytes32) counts 2). By clause the same trees give rain.erc4626.words 3/32, rain.math.float 10/100, rain.interpreter 50/318, rain.deploy 5/1 — which matches nothing in the table, so the component reading is confirmed rather than assumed.

Discrepancy: rain.interpreter is 59/445 by my count, not 58/450. A parser-nuance delta (one named component more, five unnamed fewer, out of 504). It does not move the conclusion — ~11.7% named either way — and the shipped figure is the independently measured one. Excluding/including the six src/generated/*.pointers.sol files changes nothing: they declare no functions with returns.

rain.deploy's 5 / 1 is the main count. At fb137c6 it is 6 / 1, because zoltuAddress is one of the functions #21 adds. The table row and the worked example's line numbers therefore come from different revisions of that repo — cosmetic, but worth recording since both figures appear in the issue.

Scope note

git diff --stat origin/main HEAD is skills/audit/SKILL.md plus the two version manifests — no other file touched, and nothing was written outside this branch's own clone.

claude added 3 commits July 30, 2026 10:37
The named-return rule turned on a term it never defined — "cannot be proved
from the declaration's own function" — leaving a reader to guess at try/catch,
at a branch whose complement reverts, and at a total if/else nested in a loop.
Two sessions could reach opposite findings on the rule's own worked example,
whose shape (an if/else inside a for loop) is indistinguishable from the
"total if/else" non-finding as it was written. Provability is now a stated
test over paths reaching the function's end, and nesting is judged by that
test rather than by the syntax.

Cases 2 and 4 carried flat severities while every neighbouring rule scales by
what the value gates, so a never-assigned `returns (bool blocked)` capped at
MEDIUM where Fail-closed on input rates the same fail-open default
CRITICAL/HIGH. Both now escalate on the scale of the rule they cross-
reference, and file once rather than twice.

The non-findings mixed two kinds without saying so: some justify the named
form (assembly, tuple documentation), some establish provability itself. Only
the second kind discharges cases 1/2/4, and case 3 does not reach a name that
is the documentation.

The mixed-forms example cited rain.deploy#21 adding `zoltuAddress` with a
named return. That PR's head is now 528a7c7, "style(deploy): drop the named
return from zoltuAddress" — the citation is falsified by going and looking,
so the case states the shape instead.

Dimension 4 #1 mandated an org-wide measurement an audit cannot make: the
skill's file discovery globs one repo. It now prefers a Domain rule (which is
where the org-wide defaults actually live), then the repo's own documented
conventions, then the widest observable scope — and makes no finding at all
where nothing wider than the file is observable. A raw majority count no
longer overrides a per-file-kind rule, which would have inverted the pragma
convention exactly as #1 inverted named returns. The LibRainDeploy count is
pinned to `main` and the load-bearing claim made rot-tolerant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule's central premise was that a named return supplies a zero "on a path
the unnamed form would refuse to compile", and case 2 called the unnamed form
"a compile error". Both are false. Checked against solc 0.8.25: the unnamed
form compiles and emits a WARNING —

  Unnamed return variable can remain unassigned. Add an explicit return with
  value to all non-reverting code paths or name the variable.

— exit 0. Every unnamed shape with a non-reverting path to the function end
warns (empty body, no return statement, return only inside a loop, return in
if but not else); a named return draws nothing on any of them; the sole
unnamed exception is a completely empty body.

That makes the true motivation stronger than the false one. The compiler
already detects this class, and it offers "or name the variable" as one of two
remedies — so naming the return suppresses the diagnostic on the compiler's
own suggestion. Since dimension 4 #4 rates a build warning LOW or higher, the
rule is the audit standing in for a signal the name removed, rather than a
claim about what compiles.

The same probe falsified a sentence added in the previous commit: a partially
named tuple does NOT force an explicit return. `returns (bool ok, bytes32)`
with no return statement compiles, warning on the unnamed component only. The
case-3 carve-out now rests on the name being the component's documentation,
which is the real reason, and notes which component solc actually warns about.

Prevalence re-measured independently rather than carried over: 86 named /
561 unnamed = 86.7% unnamed across the four repos' src/ function declarations,
generated files excluded. "~88%" is reachable only by also counting the
`returns` of function TYPES (87.8%), which are types and not declarations, so
the figure is now ~87% with its unit stated and the other convention named.
The 5-named-to-1-unnamed count for LibRainDeploy.sol on main is confirmed
exact (it is 5/2 on rain.deploy#21's head, which is why the pin matters).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The warning behaviour is identical on 0.8.25 and 0.8.35 — same two diagnostics,
exit 0 both — so the claim is pinned to the range rather than one version. The
worked example now says why dropping the guard is silent: the name is the thing
that suppresses the warning.

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

Copy link
Copy Markdown
Contributor Author

Independent adversarial review — 5371cbf

CodeRabbit's check on this PR read Review rate limited: a green check with no review behind it, so the "0 unresolved threads" was vacuous. This stands in for it. Every factual claim in the added text was falsified against real source; two premise-level claims did not survive. Fixes are pushed to this branch.

Verified and correct — no change needed

The load-bearing zero-escape claim holds on both revisions. I read LibRainDeploy.deployToNetworks top to bottom on main (4422e29) and on rain.deploy#21's head (528a7c7). The text's strongest assertion — every check is either before the loop or inside it, including the codehash check on deployedAddress itself, with none after — is true on both:

main #21 head 528a7c7
NoNetworks guard, top of function L218 L238
pre-loop derived-address check (inputs only) L247–250
assignments (both if/else arms, inside loop) L248, L260 L278, L290
codehash check on deployedAddress L264 — inside loop L294 — inside loop
any check after the loop none none

The two revisions do differ exactly as expected — the pre-loop derived-address check exists only on the branch — but since both of its pre-loop checks test inputs, the shipped wording is true either way. The 9d001d6 commit that made this revision-independent was the right call.

5 named / 1 unnamed for LibRainDeploy.sol is exact — on main. Confirmed by independent parse: 5/1 at 4422e29, 5/2 at 528a7c7. The text did not say which, so I pinned it to main and made the load-bearing claim ("predominantly named returns") survive #21 landing. Incidentally the lone unnamed one on main is supportedNetworks(), whose docstring already says @return networks — the name exists in the doc but not the code.

Case 1 flagging currently-correct code is consistent with "findings are PROBLEMS". I checked this against the rule two levels above it. It survives on the same footing as the ERC-165 rule's "a finding even when it happens to be correct today" and Hazard cat. 3: the finding is the non-local proof, not the behaviour. Not a defect.

No overlap with the storage-class naming rule beside it. It opens by delineating precisely — declarations/locals/params vs return declarations. Clean.

Defects found and fixed

1. The rule's central premise was false. (the big one) The text claimed a named return supplies a zero "on a path the unnamed form would refuse to compile", and case 2 called the unnamed form "a compile error". Neither is true. Compiled probes on solc 0.8.25 and 0.8.35, identical results, exit 0 both:

Warning: Unnamed return variable can remain unassigned. Add an explicit return
with value to all non-reverting code paths or name the variable.

It is a warning, never an error. Every unnamed shape with a non-reverting path to the function end warns (empty body, no return at all, return only inside a loop, return in if but not else); a named return draws nothing on any of them; the sole unnamed exception is a completely empty body.

This makes the true motivation stronger than the false one, so the rule now rests on it: the compiler already detects this class, and it offers "or name the variable" as one of its two suggested remedies — so naming the return suppresses the diagnostic on the compiler's own advice. Since dimension 4 #4 rates a build warning LOW or higher, the rule is now framed as the audit standing in for a signal the name removed, rather than as a claim about what compiles.

2. The mixed-forms worked example is falsified by going and looking. The text cited rain.deploy#21 as adding zoltuAddress(...) returns (address derivedAddress). That PR's head commit is literally titled style(deploy): drop the named return from zoltuAddress — at 528a7c7 it is returns (address) with an explicit return, i.e. the same form as the three sibling helpers. The one cited instance of the INFO case no longer exists. (The PR body verified it at fb137c6, one commit earlier — accurate when written, rotted since.) A reader who checks the only example and finds the opposite will distrust the whole rule, so the case now states the shape and cites no live PR.

3. cannot be proved from the declaration's own function was never defined — the rule's central term. Applying dimension 0 to the added text as the process document it is, this is the "missing defaults or undefined terms" defect, and it left every boundary case open. Worse, the rule's own worked example (an if/else inside a for) is syntactically indistinguishable from its own non-finding ("on every branch of a total if/else") — two sessions could reach opposite findings on deployToNetworks itself. Now defined: on every path reaching the function's end, the name is either assigned or the path reverts, judged from that body alone — and nesting is judged by that test, not by syntax, so a total if/else or a both-arms try/catch inside a loop is explicitly not total.

That one definition resolves the boundary cases I constructed: try/catch (catch is a path), a branch whose complement reverts (the path reverts ⇒ provable), a total if/else with no trailing code (provable), and loop nesting.

4. Cases 1 and 2 had no stated discriminator. The same try-assigns / catch-doesn't code could be read as case 1 (LOW) or case 2 (MEDIUM). Now explicit: case 1 = a guard does make it total (hazard is the distance to the proof); case 2 = no guard does. Each cross-references the other.

5. Severities were flat where every neighbouring rule scales by what the value gates. Case 2 cross-references Fail-closed on input, which rates a fail-open default "CRITICAL/HIGH if it bypasses auth, lifts a spend/write bound, or destroys data" — but capped itself at MEDIUM; case 4 cross-references Explicit handling of ambiguous sets ("HIGH if it drives a verdict / permission / value / destructive action") and capped at MEDIUM. Both now escalate on the scale of the rule they cite. I also added the sense check, because a zero is only permissive in one direction: returns (bool valid) falling to false fails closed, but returns (bool blocked) / (bool paused) falling to false reads as "proceed", and returns (address recipient) falling to address(0) sends funds nowhere.

Relatedly, the cross-references left ownership unstated — file it once, here, taking the highest applicable severity.

6. The non-findings list silently mixed two kinds. Assembly and tuple documentation justify the named form; unconditional on entry and total if/else establish provability. Only the second kind discharges cases 1/2/4. This is the collision you asked about: a named return written from assembly and only inside a loop hit both clauses with no stated precedence. Resolved — the naming justifications do not discharge provability, and the fix there is the local proof, never unnaming it (which the rule already forbids). Conversely case 3 must not reach a name that is the documentation, so it now excludes the partially-named tuple.

7. My own first fix was wrong, and the compiler caught it. I initially wrote that a partially-named tuple "forces an explicit return on every path". It does not — returns (bool ok, bytes32) with no return statement compiles, warning on the unnamed component only. Rewritten to rest on the real reason (the name is the component's documentation) and to note that it is the unnamed sibling solc warns about.

8. Dimension 4 #1 over-reached into something unactionable. "Establish the convention from the ORG-WIDE default" cannot be done by this skill: its own file discovery globs one repo, so a reviewer has nothing org-wide to measure. It also contradicted its own fallback ladder's last rung ("else the directory"), and said nothing about what to do when no wider scope is observable. Reordered to what is actually actionable: a Domain rule that states the convention (which is where the org-wide defaults actually live, carried in the doc precisely because one repo can't measure the org), else the repo's own CLAUDE.md/AGENTS.md, else the widest observable scope.

Two limits added so "general" did not become "unbounded":

  • Where nothing wider than the file is observable there is no style-consistency finding — do not manufacture one from a two-file sample.
  • Where a Domain rule sets the convention per file kind, a raw majority count is not the reference. As written, feat: marketplace + consolidate to one ultracode-native audit skill #1 would have inverted the pragma convention in exactly the way it was rewritten to stop inverting named returns: most files float ^, so a deliberately pinned = on a concrete contract would read as the deviation. It cross-referenced the pragma rule only as a don't-mass-edit caveat, not as "the majority is the wrong reference here".

And on your question about licensing a flag on a deliberate per-repo convention — yes, it did. CLAUDE.md/AGENTS.md is now the repo's reference, matching how Shared rules already opens ("First read CLAUDE.md / AGENTS.md for project structure and conventions") and how the stale-soldeer rule respects an explicit freeze marker.

9. The prevalence figure was not reproducible as stated. Measured independently with a comment/string-stripping parser over all four repos' src/ trees (8 *.pointers.sol files excluded), attribution-audited so every returns token maps to exactly one owner, zero unclassifiable:

repo named unnamed % unnamed
rain.erc4626.words 4 30 88.2%
rain.math.float 19 125 86.8%
rain.interpreter 58 405 87.5%
rain.deploy 5 1 16.7%
total 86 561 86.7%

86.7%, not ~88%. The gap is entirely one counting decision: returns clauses of function types (function(ParseState memory, uint256, uint256) view returns (uint256, bytes32) used as a param/array-element type — 57 extra components, all unnamed). Include them and it is 87.8%, which rounds to 88. That also reconciles all three prior counts: this PR's rain.erc4626.words 4/44 is exactly my 4/30 plus 14 function-type components. So no measurement was careless — the unit was just never stated, and a reader re-measuring declarations gets a different number. Now ~87% with its unit spelled out and the other convention named. The conclusion is untouched at any of these figures.

Not a defect, recorded so it isn't re-raised

  • Length/compression risk. The rule is long, and dimension 0 flags instructions "fragile under context compression". I judged it acceptable rather than restructuring: the restraint ("NOT a ban and NOT a mandate") is bolded and front-loaded. I did split the non-findings and precedence into their own block so the stance and the mechanics are separately legible, which is how the Temporal and Deploy-pin rules are already shaped.
  • Overlap with Fail-closed / ambiguous sets is real but intended, and the file has precedent for a rule reachable from several routes (Derived constants spans Domain rules, dimension 2 and Hazard cat. 3). Fixed by stating ownership, not by removing the cross-references.
  • The ~88% aggregate says nothing about rain.deploy (6 of 647 components, 0.9%) — but the rule never uses it that way, and dimension 4 feat: marketplace + consolidate to one ultracode-native audit skill #1 handles the outlier case directly. Not a defect.

Out of scope — needs its own issue

The version-hygiene workflow cannot see the drift this PR just fixed. Confirmed by reading .github/workflows/version-hygiene.yaml: job 1 compares plugin.jsonmarketplace.json; job 2 compares plugin.json base ↔ head when skills/ changed. Neither job ever reads skills/audit/SKILL.mdgrep -rn "SKILL.md" .github/workflows/ returns nothing. So the skill's own frontmatter version: is ungated and can drift straight back. It already had: on main, SKILL.md declared 0.19.0 while plugin.json/marketplace.json said 0.20.0 — two minors adrift, invisible to CI, which is exactly why this PR had to realign three files instead of two. The fix is a third assertion (frontmatter version == plugin.json version), deliberately not made here.

Verification

This repo has no CLAUDE.md, no flake, no pre-commit and no test over SKILL.md structure, so version-hygiene is the only automated gate and I replicated both jobs by hand with real jq (1.8.2) against origin/main before each push:

JOB1 PASS: versions agree: 0.21.0
JOB2 PASS: bumped 0.20.0 -> 0.21.0
SKILL.md frontmatter: version: 0.21.0

All three declarations agree at 0.21.0. No further bump: 0.21.0 is unreleased, so this content belongs to it.

Solidity claims were checked by compiling probe contracts, not by reading — which is how findings 1 and 7 surfaced.

🤖 Generated with Claude Code

@thedavidmeister

Copy link
Copy Markdown
Contributor Author

Reviewed 5371cbf: ready — named-return rule rests on the measured Warning 6321 mechanism, dimension 4 #1 derives conventions Domain-rule-first, both hygiene jobs replicated by hand, adversarially audited in place of CodeRabbit's rate-limited green.

@thedavidmeister
thedavidmeister merged commit c8faf40 into main Jul 30, 2026
2 checks passed
thedavidmeister added a commit that referenced this pull request Jul 30, 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.

Named returns unflagged, and dimension 4 #1 inverts the ruling on an outlier file

2 participants