From 67d1a8b9978e1f5229fe168f6813836fd5e230b8 Mon Sep 17 00:00:00 2001 From: antonsynd <2648730+antonsynd@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:42:11 -0400 Subject: [PATCH 1/4] docs(spec): the abstract-property and abstract-event examples declare their class @abstract and every name they read, so both blocks compile and leave the spec-block allowlist (call-31 band; refs #1952) The two blocks predated #1307's rule that an @abstract member needs an @abstract class, and each carried a second defect behind the first error the allowlist recorded: - properties.md "Inheritance and Interfaces" (was properties.md::2cad2b350f, recorded SPY0203 first): SPY0493 "@abstract property 'area' in non-abstract class 'Shape'" + SPY0203 "Type 'Circle' has no member 'radius'". Now `@abstract class Shape`, and `Circle` declares `radius` with an `__init__`. - events.md "Abstract Events" (was events.md::7e16c0b9bc, recorded SPY0202 first): SPY0493 "@abstract event 'on_update' in non-abstract class 'BasePublisher'" + SPY0202 "Type 'UpdateEventArgs' not found". Now `@abstract class BasePublisher`, and the block declares `UpdateEventArgs(EventArgs)` (the file's prelude imports `EventArgs`). Guard (the spec-block sweep): the old texts fail as measured with the built sharpyc @ 6a0b507f1 (both diagnostics above, `emit diagnostics` as-is); the new texts compile as-is (no errors). The two allowlist rows are deleted, so a regression fails the sweep as a non-allowlisted block. `bash build_tools/check_spec_blocks.sh --jobs 8`: 1227 blocks, 0 failing, allowlisted 476 -> 474, EXIT=0 (measured). Not touched: events.md's two `ref[T]` parameter blocks (511f0d2735, f6007db8e3) - posted on #2268, P59 decides the spelling; their rows stay on #1952. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MpT1WWr9aKoBrYhJCpTWuR --- build_tools/spec_blocks_allowlist.txt | 2 -- docs/language_specification/events.md | 4 ++++ docs/language_specification/properties.md | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/build_tools/spec_blocks_allowlist.txt b/build_tools/spec_blocks_allowlist.txt index 86bb039ee..dfe743e6f 100644 --- a/build_tools/spec_blocks_allowlist.txt +++ b/build_tools/spec_blocks_allowlist.txt @@ -103,7 +103,6 @@ events.md::511f0d2735 # SPY0200 first #1952 events.md::5d33d34876 # SPY0420 first #1952 events.md::6c35a3c709 # SPY0200 first #1952 events.md::77bbdcdca8 # SPY0200 first #1952 -events.md::7e16c0b9bc # SPY0202 first #1952 events.md::a692edaeaf # SPY0202 first #1952 events.md::b337a4b650 # SPY0200 first #1952 events.md::b83e0c5310 # SPY0420 first #1952 @@ -346,7 +345,6 @@ primitive_types.md::412c2923ce # SPY0346 first #1952 program_entry_point.md::08fda2fe5d # SPY0200 first #1952 program_entry_point.md::3e9510358b # SPY0200 first #1952 program_entry_point.md::daa5d09e86 # SPY0200 first #1952 -properties.md::2cad2b350f # SPY0203 first #1952 properties_function_style.md::4538adfdac # SPY0407 first #1952 properties_function_style.md::86bb0df588 # SPY0496 first #1952 properties_function_style.md::b43ba33646 # SPY0406 first #1952 diff --git a/docs/language_specification/events.md b/docs/language_specification/events.md index 952ad11cb..27aca8b38 100644 --- a/docs/language_specification/events.md +++ b/docs/language_specification/events.md @@ -304,6 +304,10 @@ class CustomControl(BaseControl): ### Abstract Events ```python +class UpdateEventArgs(EventArgs): + pass + +@abstract class BasePublisher: @abstract event on_update: EventHandler[UpdateEventArgs] diff --git a/docs/language_specification/properties.md b/docs/language_specification/properties.md index 9bc17195d..62ca2053b 100644 --- a/docs/language_specification/properties.md +++ b/docs/language_specification/properties.md @@ -145,6 +145,7 @@ For complete details on function-style properties, static properties, mixed acce Properties participate in inheritance using `@virtual`, `@abstract`, `@override`, and `@final` decorators: ```python +@abstract class Shape: @abstract property get area(self) -> float: @@ -155,6 +156,11 @@ class Shape: return "Shape" class Circle(Shape): + radius: float + + def __init__(self, radius: float): + self.radius = radius + @override property get area(self) -> float: return 3.14159 * self.radius ** 2 From a78e3aa71159e9f31ff521867da4ffc91ff518bf Mon Sep 17 00:00:00 2001 From: antonsynd <2648730+antonsynd@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:08:25 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(semantic):=20a=20property's=20type=20is?= =?UTF-8?q?=20its=20declaring=20annotation=20=E2=80=94=20a=20setter's=20va?= =?UTF-8?q?lue=20parameter,=20never=20its=20return=20annotation=20?= =?UTF-8?q?=E2=80=94=20at=20all=20six=20readers;=20a=20setter=20annotated?= =?UTF-8?q?=20other=20than=20None=20is=20SPY0496=20(call-31=20band,=20R-JD?= =?UTF-8?q?;=20refs=20#2067=20#2370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract (R-JD): a set-only property is typed by its setter's VALUE parameter; a setter's return annotation is the accessor's return — `-> None` or absent — on every host {class, struct, interface, module}, and anything else is refused, never ignored. Class: six parallel readers of "which annotation declares this property's type" disagreed on the setter arm. CheckModuleProperty read the value parameter; ResolvePropertyTypes took the FIRST PropertyDef of the name and, for a setter, its return annotation; CheckClassProperty's fallback did the same; the emitter's single-accessor path (FunctionStylePropertyType) read ReturnType first; the combined-accessor path preferred the getter but fell back to `first.ReturnType`; hover read Type ?? ReturnType. Cure: ONE syntax fact, `StatementExtensions.DeclaredTypeAnnotation` (auto -> Type, getter -> return annotation, set/init -> the value parameter's annotation) plus its multi-accessor overload (getter/auto first, then a setter's value parameter, whatever the written order), read by all six. The emitter still decides nothing: it maps the annotation the user wrote, as before. The checker resolves a setter's return annotation in a return position (ResolveWriteAccessorReturnAnnotation, also for body-less interface/abstract accessors), and PropertyValidator rule 9 refuses a resolved type other than void. Diagnostic: SPY0496 REUSED (AccessorParameterNotExpressible; comment + explanation widened to "accessor signature"): its rule is "an accessor's signature is fixed by C#" — the parameter list was its only face so far, a setter's return is the other. Not a new code: one rule, one explanation, one fix-it family. Direction record (measured: the new 36-cell matrix run against the unfixed compiler in a worktree @ 67d1a8b99 — 20 red / 48 green — and the built sharpyc @ 6a0b507f1): - set-only `-> None`, class / struct / interface, with a store: SPY0220 "Cannot assign type 'int32' to 'None'" -> runs; with no store (any host): SPY0908 CS0547 "property or indexer cannot have void type" -> runs; module: SPY0908 CS0547 + CS1503 -> runs. - set-then-get pair with `-> None` on the setter (class, struct; interface behind #2067): SPY0260 at the getter's return + SPY0220 at the store -> runs (a FALSE refusal of an ordinary program, found here; the get-then-set order and the module pair already ran). - `-> int` on a setter: RAN with the annotation silently ignored (class, struct, module × 3 shapes; interface set-only) -> SPY0496 at each annotated setter. Worked -> refused, inside R-JD's text ("anything else refused"); the charter's "nothing that runs changes" did not hold for these cells — the `-> None` cells are the ones that never ran. - getter/setter type mismatch (getter `-> int`, setter `x: str`), class: the setter-first order typed the property `str`; both orders now type it `int` and refuse the `str` store (SPY0220). Guard: NoneAnnotationPositionMatrixTests gains the closed-axis theory shape {set-only, get-then-set, set-then-get} × host {class, struct, interface, module} × setter annotation {none, `-> None`, `-> int`} = 36 cells (run + printed value, or exactly one SPY0496 per annotated setter at its annotation, never SPY0908), the written-order pair, and a literal-anchored count (36). The four interface × pair × {none, `-> None`} cells are #2067's open "abstract get+set pair on an interface -> CS0102 + CS0229" cell (red with a BARE setter too, so not this arm): pinned by fingerprint (`new KnownRedInterfacePair("#2067", "CS0102")`, visible to the issue-state gate) and failing when P30 fixes them (drain on fix). HoverServiceTests gains class and module cells. Mutations (each applied alone, filter NoneAnnotationPositionMatrixTests, restored by copy + touch, restored run 68/0/0): - M1 the setter's return annotation as the type (DeclaredTypeAnnotation's set arm skipped when ReturnType != null): 6 red — the `-> None` cells. - M2 written order decides (the overload's OrderBy dropped): 1 red — order: set_then_get. - M3 the module setter's return annotation never resolved: 3 red — module × `-> int`. - M4 the emitter alone reads ReturnType first (checker fixed): 4 red — set-only `-> None` (CS0547). - Hover: `Type ?? ReturnType` restored: both hover cells red ("(property) level: None"); restored 2/0. Spec (properties_function_style.md; R-BG drained the file's 7 allowlist rows FIRST, all #1952): §Accessor Parameter Lists states the setter's return face with a runnable output block (`-> None` set-only property, prints 5) and adds the `-> int` case to the SPY0496 error block (now marked `error SPY0496`). The other six: `error SPY0406` marker (intentional method/property clash); `fragment` on the cross-module `os` usage snippet (needs os.spy as a second module); real example defects fixed — the Temperature block declared the `_celsius` field its own prose forbids (SPY0407), the mixed auto+custom snippet was written at module level with `self` (SPY0232; now a class), AppConfig read instance fields through the type (SPY0290; now `@static`) and its usage ran at module level (now `main`, output-asserted, R-FY), Counter's usage likewise (now output-asserted). Counter's "ERROR: setter is private" line is SPY0908 CS0272 under `run` — a missing refusal of the access class (posted on #2074); under the emit-only sweep it would pass silently, so it stays as a one-line `fragment` beside the runnable block. Sweep: 1229 blocks, 0 failing, allowlisted 474 -> 467, output-asserted 9 -> 12, error-asserted 95 -> 97, EXIT=0 (measured). Edit loop (measured on this tree): Compiler.Tests filter (property/accessor/observer classes, the None-annotation matrix, DiagnosticExplanations + codes, DispatchSiteInventory, StaticStateConformance, EmitterCarrierOnly, SymbolFactMirror, SinkPushingTotality, LayoutFactConsumerScan, TypeDefinitionOwnSymbolMatrixTests, FileBasedIntegrationTests) 3879/0/0; Lsp.Tests Hover + FrontEndParity 104/0/0; `dotnet format whitespace sharpy.sln --verify-no-changes` EXIT=0. No spy-sourced stdlib setter carries a return annotation (no regen). The band rides P21g's gate. Out of this arm, posted on #2067: a class body accepts a getter/setter TYPE mismatch with no store (runs) where the module host refuses it (SPY0220 "accessor type 'str' does not match"). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MpT1WWr9aKoBrYhJCpTWuR --- build_tools/spec_blocks_allowlist.txt | 7 - .../properties_function_style.md | 68 ++++++--- .../NoneAnnotationPositionMatrixTests.cs | 140 ++++++++++++++++++ .../RoslynEmitter.ClassMembers.Properties.cs | 49 ++---- .../Diagnostics/DiagnosticCodes.cs | 6 +- .../DiagnosticExplanations.Validation.cs | 14 +- .../Parser/Ast/StatementExtensions.cs | 42 ++++++ .../Semantic/TypeChecker.Definitions.cs | 119 ++++++--------- .../Semantic/Validation/PropertyValidator.cs | 34 ++++- src/Sharpy.Lsp.Tests/HoverServiceTests.cs | 15 ++ src/Sharpy.Lsp/HoverService.cs | 12 +- 11 files changed, 358 insertions(+), 148 deletions(-) diff --git a/build_tools/spec_blocks_allowlist.txt b/build_tools/spec_blocks_allowlist.txt index dfe743e6f..e5c78939a 100644 --- a/build_tools/spec_blocks_allowlist.txt +++ b/build_tools/spec_blocks_allowlist.txt @@ -345,13 +345,6 @@ primitive_types.md::412c2923ce # SPY0346 first #1952 program_entry_point.md::08fda2fe5d # SPY0200 first #1952 program_entry_point.md::3e9510358b # SPY0200 first #1952 program_entry_point.md::daa5d09e86 # SPY0200 first #1952 -properties_function_style.md::4538adfdac # SPY0407 first #1952 -properties_function_style.md::86bb0df588 # SPY0496 first #1952 -properties_function_style.md::b43ba33646 # SPY0406 first #1952 -properties_function_style.md::c8231a5042 # SPY0203 first #1952 -properties_function_style.md::d61225a7b3 # SPY0232 first #1952 -properties_function_style.md::ec45a38dac # SPY0290 first #1952 -properties_function_style.md::f31d92c964 # SPY0202 first #1952 properties_inheritance.md::7553d3f5f3 # SPY0203 first #1952 properties_inheritance.md::7695a1ea67 # SPY0493 first #1952 properties_inheritance.md::b20a3d0647 # SPY0202 first #1952 diff --git a/docs/language_specification/properties_function_style.md b/docs/language_specification/properties_function_style.md index 917a76130..06e8ffc11 100644 --- a/docs/language_specification/properties_function_style.md +++ b/docs/language_specification/properties_function_style.md @@ -7,11 +7,12 @@ For properties requiring custom logic (validation, transformation, computation), You can combine an auto-property declaration with a custom getter, setter, or both. The auto-property declaration defines the type and a backing field (`_name`), while the custom accessor provides the logic: ```python -# Auto-getter generated, custom setter validates -property name: str -property set name(self, value: str): - if len(value) > 0: - self._name = value +class User: + # Auto-getter generated, custom setter validates + property name: str = "" + property set name(self, value: str): + if len(value) > 0: + self._name = value ``` **Semantics:** @@ -74,15 +75,13 @@ public float Diagonal { ```python class Temperature: - _celsius: float = 0.0 - # Function-style setter with validation property set celsius(self, value: float): if value < -273.15: raise ValueError("Temperature below absolute zero") self._celsius = value - # The auto half supplies the storage — do NOT also declare `_celsius` + # The auto half supplies the storage (`_celsius`) — do NOT also declare it property celsius: float = 0.0 ``` @@ -117,8 +116,25 @@ class Box: self._value = v ``` -Two shapes leave that constraint, and both are refused with **SPY0496**: +A setter also returns nothing to C#, so its return annotation, when written, is `None` — the +accessor's own return, never the property's type. The property's type is always the value +parameter's, so this set-only property is an `int` property: + + +```python +class Gauge: + # `-> None` is the setter's own return; the property's type is `v`'s: int + property set level(self, v: int) -> None: + print(v) + +def main() -> None: + g = Gauge() + g.level = 5 +``` + +Three shapes leave that constraint, and each is refused with **SPY0496**: + ```python class Broken: # ❌ A getter receives no value, so `factor` has nothing to denote — @@ -129,6 +145,10 @@ class Broken: # ❌ An accessor has no argument list to vary property set samples(self, *values: int): print(len(values)) + + # ❌ A setter returns nothing to C# — its return annotation is `None` or nothing + property set level(self, v: int) -> int: + print(v) ``` The rule applies wherever a property is declared: in a class, a struct, an interface, a nested type, @@ -140,6 +160,7 @@ See [Function Variadic Arguments](function_variadic_arguments.md#not-in-a-proper Getters and setters can have different visibility: + ```python class Counter: _value: int = 0 @@ -160,11 +181,18 @@ class Counter: def reset(self): self.value = 0 -# Usage -c = Counter() -print(c.value) # OK: public getter -c.increment() # OK: internal modification via public method -c.value = 10 # ERROR: setter is private +def main() -> None: + c = Counter() + print(c.value) # 0 + c.increment() # the public method modifies it through the private setter + print(c.value) # 1 +``` + +Outside the class the setter is not accessible: + + +```python +c.value = 10 # ❌ ERROR: the setter of 'value' is private to Counter ``` **With Auto-Properties:** @@ -201,8 +229,10 @@ public int Value { Use `@static` decorator for class-level properties. Static properties take no `self` parameter: + ```python class AppConfig: + @static _debug_mode: bool = False _instance_count: int = 0 @@ -227,10 +257,10 @@ class AppConfig: property get debug_mode() -> bool: return AppConfig._debug_mode -# Usage -print(AppConfig.version) # "1.0.0" -AppConfig.debug_mode = True -print(AppConfig.is_debug_enabled) # True +def main() -> None: + print(AppConfig.version) # 1.0.0 + AppConfig.debug_mode = True + print(AppConfig.is_debug_enabled) # True ``` **Static Property Rules:** @@ -275,6 +305,7 @@ property set debug_mode(value: bool): property default_timeout: float = 30.0 ``` + ```python # Usage from another module import os @@ -319,6 +350,7 @@ public static partial class OsModule A property and a method cannot share the same name within a class: + ```python class Example: _value: int = 0 diff --git a/src/Sharpy.Compiler.Tests/Semantic/NoneAnnotationPositionMatrixTests.cs b/src/Sharpy.Compiler.Tests/Semantic/NoneAnnotationPositionMatrixTests.cs index 7c11c2a74..89eef8879 100644 --- a/src/Sharpy.Compiler.Tests/Semantic/NoneAnnotationPositionMatrixTests.cs +++ b/src/Sharpy.Compiler.Tests/Semantic/NoneAnnotationPositionMatrixTests.cs @@ -82,6 +82,145 @@ public NoneAnnotationPositionMatrixTests(ITestOutputHelper output) : base(output + " c = C()\n c.v = 6\n print(c.v)\n", "6"), }; + // ---- #2370 (R-JD): a setter's return annotation is the accessor's return, never the property's type ---- + // Closed axes: accessor shape {set-only, get-then-set, set-then-get} × host {class, struct, + // interface, module} × the setter's return annotation {none, `-> None`, `-> int`} = 36 cells. Every + // cell types the property by the setter's VALUE parameter (`v: int`): `-> None` and none run, and + // `-> int` is SPY0496 at each annotated setter (the interface declares and implements one, so two). + // Before the fix a set-only `-> None` was SPY0220 "Cannot assign type 'int32' to 'None'" (class, + // struct, interface) or SPY0908 CS0547 (module; any host with no store), a set-then-get pair with + // `-> None` was SPY0260 + SPY0220, and `-> int` was silently ignored and ran. + public static readonly string[] SetterShapes = { "set_only", "get_then_set", "set_then_get" }; + public static readonly string[] SetterHosts = { "class", "struct", "interface", "module" }; + public static readonly string[] SetterAnnotations = { "none", "none_annotated", "int_annotated" }; + + private static string SetterAnnotationText(string annotation) => annotation switch + { + "none" => "", + "none_annotated" => " -> None", + "int_annotated" => " -> int", + _ => throw new ArgumentOutOfRangeException(nameof(annotation), annotation, null), + }; + + /// The cell's program: a setter printing its value and, for the pair shapes, a getter + /// returning 7; main stores 5 and, for the pairs, prints the property. + private static string SetterCellSource(string shape, string host, string annotation) + { + bool module = host == "module"; + string self = module ? "" : "self, "; + string selfOnly = module ? "" : "self"; + string ann = SetterAnnotationText(annotation); + string setter = $"property set fresh({self}v: int){ann}:\n print(v)\n"; + string getter = $"property get fresh({selfOnly}) -> int:\n return 7\n"; + string members = shape switch + { + "set_only" => setter, + "get_then_set" => getter + "\n" + setter, + "set_then_get" => setter + "\n" + getter, + _ => throw new ArgumentOutOfRangeException(nameof(shape), shape, null), + }; + bool pair = shape != "set_only"; + static string Indent(string s) => string.Join("\n", s.Split('\n').Select(l => l.Length == 0 ? l : " " + l)); + string read = pair ? (module ? " print(fresh)\n" : " print(h.fresh)\n") : ""; + switch (host) + { + case "module": + return members + "\ndef main() -> None:\n fresh = 5\n" + read; + case "interface": + // The interface declares the same accessors body-less; the class implements them. + string declared = shape switch + { + "set_only" => $"property set fresh(self, v: int){ann}\n", + "get_then_set" => $"property get fresh(self) -> int\nproperty set fresh(self, v: int){ann}\n", + _ => $"property set fresh(self, v: int){ann}\nproperty get fresh(self) -> int\n", + }; + return "interface IHost:\n" + Indent(declared) + "\nclass Host(IHost):\n" + Indent(members) + + "\ndef main() -> None:\n h: IHost = Host()\n h.fresh = 5\n" + read; + default: + return $"{host} Host:\n tag: int = 0\n\n" + Indent(members) + + "\ndef main() -> None:\n h = Host()\n h.fresh = 5\n" + read; + } + } + + /// A pinned red cell of another class, by fingerprint; it fails when the cell turns green + /// (drain on fix). The construction is what check_allowlist_issue_state.sh reads as a cite. + private sealed record KnownRedInterfacePair(string Issue, string Fingerprint); + + // #2067 (P30): an INTERFACE's body-less get+set pair is emitted as two C# properties — CS0102 + + // CS0229 behind SPY0908 — whatever the setter's annotation (the bare setter too; measured + // @ 67d1a8b99). Not this arm's defect; the cells stay in the matrix, pinned. + private static readonly KnownRedInterfacePair InterfacePairPin = new KnownRedInterfacePair("#2067", "CS0102"); + + public static IEnumerable SetterCells() => + from shape in SetterShapes + from host in SetterHosts + from annotation in SetterAnnotations + select new object[] { shape, host, annotation }; + + [Theory] + [MemberData(nameof(SetterCells))] + public void SetterReturnAnnotation_IsTheAccessorsReturn_NeverThePropertysType(string shape, string host, string annotation) + { + var cell = $"{shape} × {host} × {annotation}"; + var source = SetterCellSource(shape, host, annotation); + var result = CompileAndExecute(source); + var all = string.Join(" | ", result.RawDiagnostics.Select(d => $"{d.Code} {d.Line}:{d.Column} {d.Message}")); + var errors = result.RawDiagnostics.Where(d => d.Severity == CompilerDiagnosticSeverity.Error).ToList(); + + if (host == "interface" && shape != "set_only" && annotation != "int_annotated") + { + result.Success.Should().BeFalse($"[{cell}] the {InterfacePairPin.Issue} pin drains — delete it: {all}"); + errors.Should().OnlyContain(d => d.Code == DiagnosticCodes.Infrastructure.GeneratedCodeCompilationError, $"[{cell}] {all}"); + errors.Should().Contain(d => d.Message.Contains(InterfacePairPin.Fingerprint), $"[{cell}] {all}"); + return; + } + + errors.Select(d => d.Code).Should().NotContain(DiagnosticCodes.Infrastructure.GeneratedCodeCompilationError, $"[{cell}] never SPY0908"); + + if (annotation == "int_annotated") + { + result.Success.Should().BeFalse($"[{cell}] a setter's `-> int` is refused, never ignored"); + // Exactly one SPY0496 per annotated setter, at its annotation, and nothing else (#2075). + var expectedPositions = source.Split('\n') + .Select((line, index) => (line, index)) + .Where(x => x.line.TrimStart().StartsWith("property set ", StringComparison.Ordinal) && x.line.Contains("-> int")) + .Select(x => (Line: x.index + 1, Column: x.line.IndexOf("-> int", StringComparison.Ordinal) + 4)) + .ToList(); + expectedPositions.Should().HaveCount(host == "interface" ? 2 : 1, $"[{cell}] the cell's own setter count"); + errors.Should().OnlyContain(d => d.Code == DiagnosticCodes.Validation.AccessorParameterNotExpressible, $"[{cell}] {all}"); + errors.Select(d => (d.Line, d.Column)).Should().BeEquivalentTo(expectedPositions, $"[{cell}] {all}"); + } + else + { + result.Success.Should().BeTrue($"[{cell}] {all}"); + result.StandardOutput.Trim().Should().Be(shape == "set_only" ? "5" : "5\n7", $"[{cell}] the setter ran with the stored value"); + } + } + + /// + /// The accessors' written order never decides the property's type (#2370, R-JD): with a getter + /// typed int and a setter typed str, both orders type the property int (the + /// getter's annotation first) and refuse the store of a str. Taking the first-written + /// accessor typed the set-then-get order str. + /// + [Theory] + [InlineData("get_then_set")] + [InlineData("set_then_get")] + public void AccessorWrittenOrder_DoesNotDecideThePropertyType(string order) + { + const string getter = " property get v(self) -> int:\n return 7\n"; + const string setter = " property set v(self, x: str):\n print(x)\n"; + var source = "class C:\n" + (order == "get_then_set" ? getter + "\n" + setter : setter + "\n" + getter) + + "\ndef main() -> None:\n c = C()\n c.v = \"s\"\n print(c.v)\n"; + var result = CompileAndExecute(source); + + var errors = result.RawDiagnostics.Where(d => d.Severity == CompilerDiagnosticSeverity.Error).ToList(); + errors.Should().ContainSingle($"[{order}] {string.Join(" | ", result.RawDiagnostics.Select(d => d.Code + " " + d.Message))}"); + errors[0].Code.Should().Be(DiagnosticCodes.Semantic.TypeMismatch); + errors[0].Message.Should().Be("Cannot assign type 'str' to 'int32'"); + errors[0].Line.Should().Be(source.Split('\n').ToList().FindIndex(l => l.Contains("c.v = \"s\"")) + 1, $"[{order}] at the store"); + } + public static IEnumerable Refused() => RefusedCells.Keys.Select(k => new object[] { k }); public static IEnumerable Controls() => ControlCells.Keys.Select(k => new object[] { k }); public static IEnumerable WrappedReturns() => WrappedReturnCells.Keys.Select(k => new object[] { k }); @@ -143,5 +282,6 @@ public void Matrix_IsTotal() RefusedCells.Should().HaveCount(13); WrappedReturnCells.Should().HaveCount(8); ControlCells.Should().HaveCount(8); + SetterCells().Should().HaveCount(36, "3 shapes × 4 hosts × 3 setter annotations (#2370)"); } } diff --git a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.ClassMembers.Properties.cs b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.ClassMembers.Properties.cs index cd48227c7..4b0d3c16b 100644 --- a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.ClassMembers.Properties.cs +++ b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.ClassMembers.Properties.cs @@ -447,29 +447,14 @@ private PropertyDeclarationSyntax GenerateCombinedFunctionStyleProperty(List None` is never it (#2370, R-JD). var getterProp = propGroup.FirstOrDefault(p => p.Accessor == PropertyAccessor.Get); - var setterProp = propGroup.FirstOrDefault(p => p.Accessor == PropertyAccessor.Set || p.Accessor == PropertyAccessor.Init); - - if (getterProp?.ReturnType != null) - { - propertyType = _typeMapper.MapType(getterProp.ReturnType); - } - else if (setterProp != null) - { - // Infer type from setter's non-self parameter type - var valueParam = setterProp.Parameters - .FirstOrDefault(p => !string.Equals(p.Name, PythonNames.Self, StringComparison.OrdinalIgnoreCase)); - if (valueParam?.Type != null) - { - propertyType = _typeMapper.MapType(valueParam.Type); - } - } - else if (first.ReturnType != null) - { - propertyType = _typeMapper.MapType(first.ReturnType); - } + var declaredAnnotation = propGroup.DeclaredTypeAnnotation(); + TypeSyntax propertyType = declaredAnnotation != null + ? _typeMapper.MapType(declaredAnnotation) + : PredefinedType(Token(SyntaxKind.ObjectKeyword)); // Determine property-level modifiers from the getter (or first property) var modifierSource = getterProp ?? first; @@ -865,7 +850,9 @@ private BlockSyntax GenerateObserverBody(PropertyObserver observer, string targe /// /// The C# type of a single-accessor function-style property, read from the annotation the user - /// WROTE: a getter's return type, else a setter's value-parameter type. + /// WROTE that declares it: a getter's return type, a setter's value-parameter type — never a + /// setter's return annotation, which made property set p(self, v: int) -> None a C# + /// void property (CS0547; #2370, R-JD). /// /// The setter arm is what was missing (#1405): consulting only ReturnType then /// Type — both null for a setter — fell through to object, so @@ -876,19 +863,9 @@ private BlockSyntax GenerateObserverBody(PropertyObserver observer, string targe /// type parameters (it is what the combined-accessor generator has always used). /// private TypeSyntax FunctionStylePropertyType(PropertyDef propDef) - { - if (propDef.ReturnType != null) - return _typeMapper.MapType(propDef.ReturnType); - if (propDef.Type != null) - return _typeMapper.MapType(propDef.Type); - - var valueParam = propDef.Parameters.FirstOrDefault(p => - !string.Equals(p.Name, PythonNames.Self, StringComparison.OrdinalIgnoreCase)); - if (valueParam?.Type != null) - return _typeMapper.MapType(valueParam.Type); - - return PredefinedType(Token(SyntaxKind.ObjectKeyword)); - } + => propDef.DeclaredTypeAnnotation() is { } declaredAnnotation + ? _typeMapper.MapType(declaredAnnotation) + : PredefinedType(Token(SyntaxKind.ObjectKeyword)); /// /// Generates a C# property with a function-style body (custom getter/setter). diff --git a/src/Sharpy.Compiler/Diagnostics/DiagnosticCodes.cs b/src/Sharpy.Compiler/Diagnostics/DiagnosticCodes.cs index 29d09d5ff..3ff0b6678 100644 --- a/src/Sharpy.Compiler/Diagnostics/DiagnosticCodes.cs +++ b/src/Sharpy.Compiler/Diagnostics/DiagnosticCodes.cs @@ -819,8 +819,10 @@ public static class Validation // One code, because it is one rule: a property or event accessor receives exactly the // value C# hands it — a setter/init gets the implicit `value`, a getter gets nothing — // so `self` plus at most one value parameter is the entire expressible shape. A variadic - // and a getter's extra parameter are the two ways to leave it. - public const string AccessorParameterNotExpressible = "SPY0496"; // Active (#1406, #1405) + // and a getter's extra parameter are the two ways to leave it through the parameters; a + // setter's return annotation other than `None` is the way through the return (#2370, + // R-JD) — a setter returns nothing to C#, and the property's type is its value parameter's. + public const string AccessorParameterNotExpressible = "SPY0496"; // Active (#1406, #1405, #2370) // `Self` in a VARIANT position of an interface member — nested in a generic argument, // tuple element, or wrapped optional/nullable (`list[Self]`, `Self?`) — has no sound diff --git a/src/Sharpy.Compiler/Diagnostics/DiagnosticExplanations.Validation.cs b/src/Sharpy.Compiler/Diagnostics/DiagnosticExplanations.Validation.cs index 130c89aa7..9596fd391 100644 --- a/src/Sharpy.Compiler/Diagnostics/DiagnosticExplanations.Validation.cs +++ b/src/Sharpy.Compiler/Diagnostics/DiagnosticExplanations.Validation.cs @@ -900,7 +900,7 @@ private static void AddValidationEntries(Dictionary int' on a setter " + + "names a value that is never returned, and is refused rather than ignored.", "class Reading:\n" + " property set samples(self, *values: int): # SPY0496 — no variadic accessor exists\n" + " print(len(values))\n\n" + " property get scale(self, factor: int) -> int: # SPY0496 — a getter takes only self\n" - + " return factor", + + " return factor\n\n" + + " property set level(self, v: int) -> int: # SPY0496 — a setter returns nothing\n" + + " print(v)", "Take a single value and let the caller pass a collection:\n" + "property set samples(self, values: list[int]):\n" + " print(len(values))\n\n" + + "A setter's return annotation is 'None' or nothing; its value parameter types the property:\n" + + "property set level(self, v: int) -> None:\n" + + " print(v)\n\n" + "For a getter that needs an argument, write a method instead — that is what a method is:\n" + "def scaled(self, factor: int) -> int:\n" + " return factor\n\n" diff --git a/src/Sharpy.Compiler/Parser/Ast/StatementExtensions.cs b/src/Sharpy.Compiler/Parser/Ast/StatementExtensions.cs index 8fc5c6c8a..3651f379b 100644 --- a/src/Sharpy.Compiler/Parser/Ast/StatementExtensions.cs +++ b/src/Sharpy.Compiler/Parser/Ast/StatementExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using Sharpy.Compiler.Shared; namespace Sharpy.Compiler.Parser.Ast; @@ -66,6 +67,47 @@ public static class StatementExtensions public static Statement UnwrapDecorated(this Statement stmt) => stmt is DecoratedStatement decorated ? decorated.Statement : stmt; + /// + /// The annotation that declares 's TYPE: an auto-property's + /// Type, a getter's return annotation, a setter's or init accessor's VALUE parameter. + /// Null when that slot is unannotated. The one authority every reader of a property's type + /// consults — the checker's class and module arms, the emitter, hover (#2370, R-JD) — so a + /// setter's return annotation can never stand in for the property's type: it is the accessor's + /// return, -> None or nothing (SPY0496 refuses anything else). Before this, three of + /// six readers took a setter's -> None as the type, and a set-only property became + /// C# void (CS0547) or a None-typed property (SPY0220 at every store). + /// + public static TypeAnnotation? DeclaredTypeAnnotation(this PropertyDef prop) + { + if (!prop.IsFunctionStyle) + return prop.Type; + + if (prop.Accessor is PropertyAccessor.Set or PropertyAccessor.Init) + { + // The value parameter is the last one that is not the receiver; a module-level setter + // has no receiver at all. + return prop.Parameters.LastOrDefault(p => + !string.Equals(p.Name, PythonNames.Self, StringComparison.OrdinalIgnoreCase) + && !string.Equals(p.Name, PythonNames.Cls, StringComparison.OrdinalIgnoreCase))?.Type; + } + + return prop.ReturnType ?? prop.Type; + } + + /// + /// The declaring annotation of ONE property spelled by several s + /// (an auto-property beside custom accessors, a split getter and setter): the getter's or the + /// auto-property's annotation first, then a setter's or init accessor's value parameter — + /// whatever order the accessors were written in, so a setter declared before its getter types + /// the property exactly as the reverse order does (#2370, R-JD). The checker types the + /// property and the emitter spells it through this one rule. + /// + public static TypeAnnotation? DeclaredTypeAnnotation(this IEnumerable accessors) + => accessors + .OrderBy(p => p.IsFunctionStyle && p.Accessor is PropertyAccessor.Set or PropertyAccessor.Init ? 1 : 0) + .Select(p => p.DeclaredTypeAnnotation()) + .FirstOrDefault(a => a != null); + /// /// Classifies as one of the seven type-declaring statement kinds — the /// single authority a nesting-aware caller (name resolution, module loading, the nested-type diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs index 29207e688..c73322391 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs @@ -2253,6 +2253,22 @@ private void CheckEvent(EventDef eventDef) _symbolTable.ExitScope(); } + /// + /// Resolves a setter's or init accessor's return annotation in a RETURN position, so it is + /// recorded (None → void) for PropertyValidator's rule 9, which refuses anything but + /// None there (SPY0496). It is the accessor's return and never the property's type — + /// is (#2370, R-JD). + /// Resolved for body-less (abstract, interface) accessors too, which no body check reaches. + /// + private void ResolveWriteAccessorReturnAnnotation(PropertyDef propDef) + { + if (propDef.IsFunctionStyle && propDef.ReturnType != null + && propDef.Accessor is PropertyAccessor.Set or PropertyAccessor.Init) + { + _typeResolver.ResolveTypeAnnotation(propDef.ReturnType, AnnotationPosition.Return); + } + } + /// /// Type-checks a module-level property declaration (#844). /// Resolves the property type onto the VariableSymbol registered by @@ -2267,32 +2283,17 @@ private void CheckModuleProperty(PropertyDef propDef) bool isSetter = propDef.Accessor == PropertyAccessor.Set; - // Resolve the declared property type from the accessor signature, - // mirroring class-level ResolvePropertyTypes. - SemanticType propertyType; - if (propDef.IsFunctionStyle) - { - if (propDef.ReturnType != null && !isSetter) - { - // Getter: type comes from the return annotation - // A getter's return annotation IS the property's type — a value position (#2004). - propertyType = _typeResolver.ResolveTypeAnnotation(propDef.ReturnType, AnnotationPosition.Value); - } - else if (isSetter && propDef.Parameters.Length > 0) - { - // Setter: type comes from the value parameter (last parameter) - propertyType = _typeResolver.ResolveTypeAnnotation(propDef.Parameters[^1].Type, AnnotationPosition.Value); - } - else - { - propertyType = SemanticType.Unknown; - } - } - else - { - // Auto-property: type comes from the type annotation - propertyType = _typeResolver.ResolveTypeAnnotation(propDef.Type, AnnotationPosition.Value); - } + ResolveWriteAccessorReturnAnnotation(propDef); + + // The property's type comes from its declaring annotation — the auto-property's type, a + // getter's return, a setter's value parameter — the one syntax fact the class arm and the + // emitter read too (#2370, R-JD). A getter's return annotation IS the property's type — a + // value position (#2004). An unannotated slot is Unknown (an auto-property then infers + // from its initializer below). + var declaredAnnotation = propDef.DeclaredTypeAnnotation(); + SemanticType propertyType = declaredAnnotation != null + ? _typeResolver.ResolveTypeAnnotation(declaredAnnotation, AnnotationPosition.Value) + : SemanticType.Unknown; // Auto-property: check the default value against the declared type, // inferring the type from the initializer when no annotation is given @@ -2439,26 +2440,17 @@ private void CheckClassProperty(PropertyDef propDef) bool isSetter = propDef.Accessor == PropertyAccessor.Set || propDef.Accessor == PropertyAccessor.Init; + ResolveWriteAccessorReturnAnnotation(propDef); + // The declared property type was resolved by ResolvePropertyTypes var propSymbol = _currentClass.Properties.FirstOrDefault(p => p.Name == propDef.Name); var propertyType = propSymbol?.Type ?? SemanticType.Unknown; - // Fallback: re-resolve from this accessor's annotation if the merged - // symbol type is still unknown (e.g. resolution from another accessor failed) - if (propertyType is UnknownType) - { - if (propDef.IsFunctionStyle && propDef.ReturnType != null) - { - // A getter's return annotation is the property's type (a value position); a setter's - // or init accessor's `-> None` is a true return (#2004). - propertyType = _typeResolver.ResolveTypeAnnotation(propDef.ReturnType, - isSetter ? AnnotationPosition.Return : AnnotationPosition.Value); - } - else if (!propDef.IsFunctionStyle && propDef.Type != null) - { - propertyType = _typeResolver.ResolveTypeAnnotation(propDef.Type, AnnotationPosition.Value); - } - } + // Fallback: re-resolve from this accessor's declaring annotation if the merged symbol type + // is still unknown (e.g. resolution from another accessor failed). A setter's return + // annotation is never that annotation (#2370, R-JD). + if (propertyType is UnknownType && propDef.DeclaredTypeAnnotation() is { } declaredAnnotation) + propertyType = _typeResolver.ResolveTypeAnnotation(declaredAnnotation, AnnotationPosition.Value); // Auto-property: check the default value against the declared type if (!propDef.IsFunctionStyle) @@ -2676,42 +2668,19 @@ private void ResolvePropertyTypes(TypeSymbol typeSymbol, System.Collections.Immu var propSymbol = typeSymbol.Properties[i]; if (propSymbol.Type is UnknownType) { - // Find the corresponding PropertyDef in the AST - var propDef = body + // The declaring annotation across every accessor of the name — the getter's or the + // auto-property's first, then a setter's value parameter — so the accessors' written + // order does not decide the type, and a setter's `-> None` is never the type + // (#2370, R-JD; it was taken from whichever PropertyDef came first, and a setter's + // return annotation stood in for the property's type). + var declaredAnnotation = body .OfType() - .FirstOrDefault(p => p.Name == propSymbol.Name); + .Where(p => p.Name == propSymbol.Name) + .DeclaredTypeAnnotation(); - if (propDef != null) + if (declaredAnnotation != null) { - SemanticType resolvedType; - if (propDef.IsFunctionStyle) - { - // For function-style properties, type comes from ReturnType (getter) or parameter type (setter) - if (propDef.ReturnType != null) - { - // A getter's return annotation is the property's type (a value position); - // a setter's or init accessor's `-> None` is a true return (#2004). - resolvedType = _typeResolver.ResolveTypeAnnotation(propDef.ReturnType, - propDef.Accessor is PropertyAccessor.Set or PropertyAccessor.Init - ? AnnotationPosition.Return - : AnnotationPosition.Value); - } - else if (propDef.Parameters.Length > 1) - { - // Setter: second parameter is the value (first is self) - resolvedType = _typeResolver.ResolveTypeAnnotation(propDef.Parameters[^1].Type, AnnotationPosition.Value); - } - else - { - resolvedType = SemanticType.Unknown; - } - } - else - { - // Auto-property: type comes from type annotation - resolvedType = _typeResolver.ResolveTypeAnnotation(propDef.Type, AnnotationPosition.Value); - } - + var resolvedType = _typeResolver.ResolveTypeAnnotation(declaredAnnotation, AnnotationPosition.Value); if (resolvedType != SemanticType.Unknown) { typeSymbol.Properties[i] = propSymbol with { Type = resolvedType }; diff --git a/src/Sharpy.Compiler/Semantic/Validation/PropertyValidator.cs b/src/Sharpy.Compiler/Semantic/Validation/PropertyValidator.cs index 5dffcd824..60995b1a3 100644 --- a/src/Sharpy.Compiler/Semantic/Validation/PropertyValidator.cs +++ b/src/Sharpy.Compiler/Semantic/Validation/PropertyValidator.cs @@ -22,7 +22,8 @@ namespace Sharpy.Compiler.Semantic.Validation; /// 6. @final cannot be combined with @abstract or @virtual (SPY0410) /// 7. @override property must have matching virtual/abstract base property (SPY0411) /// 8. Init properties without defaults must be assigned in every constructor (SPY0426) -/// 9. An accessor's parameter list must be expressible as a C# accessor's (SPY0496) +/// 9. An accessor's signature must be expressible as a C# accessor's (SPY0496): its parameter +/// list, and a setter's return annotation — None or nothing (#2370, R-JD) /// internal class PropertyValidator : SemanticValidatorBase { @@ -51,7 +52,10 @@ public override void Validate(Module module, SemanticContext context) // PropertyDef is never visited at all, and the interface arm runs only the observer rule // (#1406). foreach (var propDef in EnumerateAllProperties(module.Body)) + { ValidateAccessorParameterShape(propDef); + ValidateWriteAccessorReturn(propDef); + } } /// @@ -185,6 +189,34 @@ private void ValidateAccessorParameterShape(PropertyDef propDef) // guessed at. } + /// + /// Rule 9's return face (#2370, R-JD): a setter returns nothing to C#, so its return annotation + /// is None or absent. The property's type is the value parameter's + /// (), so any other + /// annotation is a type the accessor can never return — it was silently ignored. Reads the + /// annotation as the checker resolved it in a return position, so an alias of None is + /// None; an annotation that failed to resolve was already reported and is not reported + /// again. (A function-style init accessor is SPY0408 and never reaches here meaningfully.) + /// + private void ValidateWriteAccessorReturn(PropertyDef propDef) + { + if (!propDef.IsFunctionStyle || propDef.Accessor != PropertyAccessor.Set || propDef.ReturnType is not { } annotation) + return; + + var resolved = _context.SemanticInfo.GetTypeAnnotation(annotation); + if (resolved is null or UnknownType or VoidType || _context.SemanticInfo.IsAnnotationErrorRecovery(annotation)) + return; + + AddError(_context, + $"The 'set' accessor of property '{propDef.Name}' returns nothing to C#, so its return " + + $"annotation can only be 'None'; '{resolved.GetDisplayName()}' is never returned. The " + + "property's type is the value parameter's — drop the return annotation, or write a " + + "method if the operation must produce a value.", + annotation.LineStart, annotation.ColumnStart, + code: DiagnosticCodes.Validation.AccessorParameterNotExpressible, + span: annotation.Span); + } + private void ValidateTypeBody(Statement declaration, string typeName, IReadOnlyList body) { // Collect fields, methods, and properties from the body diff --git a/src/Sharpy.Lsp.Tests/HoverServiceTests.cs b/src/Sharpy.Lsp.Tests/HoverServiceTests.cs index bbcb3dc24..e4a1832cb 100644 --- a/src/Sharpy.Lsp.Tests/HoverServiceTests.cs +++ b/src/Sharpy.Lsp.Tests/HoverServiceTests.cs @@ -667,6 +667,21 @@ public void TypePattern_TypeAnnotationIsInnermost_HoversTheType() _hoverService.GetHoverMarkdown(result, 6, 14).Should().NotBeNull().And.Contain("Point"); } + /// + /// #2370 (R-JD): a setter's -> None is the accessor's return, never the property's type — + /// hovering a set-only property's declared name shows its value parameter's type. Hover read + /// the return annotation and showed None. + /// + [Theory] + [InlineData("class Gauge:\n property set level(self, v: int) -> None:\n print(v)\n\ndef main() -> None:\n g = Gauge()\n g.level = 5", 2, 18)] + [InlineData("property set level(v: int) -> None:\n print(v)\n\ndef main() -> None:\n level = 5", 1, 14)] + public void SetOnlyProperty_AnnotatedNone_HoversTheValueParameterType(string source, int line, int column) + { + var result = _api.Analyze(source); + var markdown = _hoverService.GetHoverMarkdown(result, line, column); + markdown.Should().NotBeNull().And.Contain("level: int").And.NotContain("None"); + } + [Fact] public void PropertyPattern_TypeHead_HoversTheClass() { diff --git a/src/Sharpy.Lsp/HoverService.cs b/src/Sharpy.Lsp/HoverService.cs index 57fed4e90..a8cbad5c4 100644 --- a/src/Sharpy.Lsp/HoverService.cs +++ b/src/Sharpy.Lsp/HoverService.cs @@ -439,12 +439,12 @@ private static bool IsInsideComment(IReadOnlyList spans, int line, return SymbolFormatter.FormatParameterWithDocs(param.Name, param.Type != null ? query.GetTypeAnnotation(param.Type) : null); - // Property type info - var typeStr = propDef.Type != null - ? query.GetTypeAnnotation(propDef.Type)?.GetDisplayName() ?? "unknown" - : propDef.ReturnType != null - ? query.GetTypeAnnotation(propDef.ReturnType)?.GetDisplayName() ?? "unknown" - : "unknown"; + // Property type info: the declaring annotation — a setter's value parameter, + // never its `-> None` (#2370, R-JD) + var declaredAnnotation = propDef.DeclaredTypeAnnotation(); + var typeStr = declaredAnnotation != null + ? query.GetTypeAnnotation(declaredAnnotation)?.GetDisplayName() ?? "unknown" + : "unknown"; return $"```sharpy\n(property) {propDef.Name}: {typeStr}\n```"; } From 23889b79eccc63da5e1e697a7e230804f956ff09 Mon Sep 17 00:00:00 2001 From: antonsynd <2648730+antonsynd@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:14:35 -0400 Subject: [PATCH 3/4] test(compiler): the spec-code census claims SPY0406, which properties_function_style.md now names in its `error SPY0406` marker (follow-up to a78e3aa71; refs #2287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a78e3aa71 marked the method/property name-clash example ``, so the file names SPY0406 and TypedGeneratorRosterTests.SpecCodes_EveryNamedCodeIsClaimedByARow went red: "properties_function_style.md names SPY0406 and no roster row claims it" (measured on the tree after a78e3aa71). The row cites #2287 like the file's siblings SPY0405 / SPY0407 / SPY0496 (#2287 owns the lane for a file no typed generator emits). The census is its own falsifier: without the row it is red (the run above); with it the roster, census, spec-code, JudgeSource and verdict-conformance classes are 373/0/1 (the skip is a pre-existing Skip=). a78e3aa71's edit-loop filter did not include the roster classes — the recurring pre-commit filter gap; chore 3's runs include them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MpT1WWr9aKoBrYhJCpTWuR --- .../Properties/Semantic/TypedGeneratorRosterTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Sharpy.Compiler.Tests/Properties/Semantic/TypedGeneratorRosterTests.cs b/src/Sharpy.Compiler.Tests/Properties/Semantic/TypedGeneratorRosterTests.cs index 941ab1f19..6d506c143 100644 --- a/src/Sharpy.Compiler.Tests/Properties/Semantic/TypedGeneratorRosterTests.cs +++ b/src/Sharpy.Compiler.Tests/Properties/Semantic/TypedGeneratorRosterTests.cs @@ -1765,6 +1765,7 @@ public sealed record ProseCrossReference(string File, string ExpectedCode, strin new KnownRedSpecCode("#2287", "properties.md", "SPY0148"), new KnownRedSpecCode("#2287", "properties.md", "SPY0708"), new KnownRedSpecCode("#2287", "properties_function_style.md", "SPY0405"), + new KnownRedSpecCode("#2287", "properties_function_style.md", "SPY0406"), new KnownRedSpecCode("#2287", "properties_function_style.md", "SPY0407"), new KnownRedSpecCode("#2287", "properties_function_style.md", "SPY0496"), new KnownRedSpecCode("#2287", "question_mark_operator.md", "SPY0211"), From 8d6883cd21d9eaef6f4cfcc10c3c30d584d21254 Mon Sep 17 00:00:00 2001 From: antonsynd <2648730+antonsynd@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:37:54 -0400 Subject: [PATCH 4/4] =?UTF-8?q?test(compiler):=20the=20undefined-parameter?= =?UTF-8?q?=20injection=20lane=20pins=20#2075's=20abs(Unknown)=20cascade?= =?UTF-8?q?=20by=20shape=20=E2=80=94=20a=20KnownRedCascade=20beside=20Know?= =?UTF-8?q?nRedRefusal=20tolerates=20only=20that=20cell's=20measured=20fol?= =?UTF-8?q?low-on=20codes,=20and=20a=20deterministic=20fact=20per=20face?= =?UTF-8?q?=20fails=20when=20it=20drains=20(call-31=20band,=20R-JE;=20refs?= =?UTF-8?q?=20#2075)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The red: ErrorCompletenessPropertyTests.UndefinedTypeAtParameter_IsRejected drew SPY0202 + SPY0220 on ~1 seed in 80 (the P21f gate @ 6a0b507f1, identical @ 17c3d397d): `def compute(arg0: Nope_xyz)` / `if abs(arg0):` — abs of an Unknown argument is typed by abs's FIRST overload (int8), which the condition refuses ("has no falsy case"). The sentinel (P17 phase 1, R-AZ) is the cure; R-JE pins it. Mechanism: - ErrorInjector.KnownRedCascade(Issue, Tolerated[], Observed), carried on InjectionResult.KnownCascade. JudgeDiagnostics' error arm accepts the expected code alone, or the expected code plus codes from Tolerated — never any other code. A cascade pin on a KnownRed-refusal or warning-arm injection throws (it only rides an error-arm injection with an expected code). The record joins PropertyOracleConformanceTests' CodeSinks; `new KnownRedCascade("#2075", ...)` is read by the issue-state gate's KNOWNRED_CELL_RE. - The lane attaches ErrorInjector.AbsOfUnknownCascade ONLY when a function of the mutated module passes a parameter annotated with the injected undefined type to `abs`; every other sample is judged exactly as before. RunInjectionTest reports "KnownRedCascade #2075 — k/n on the pinned shape". Shape and faces, measured with the built sharpyc @ a78e3aa71: over 17 builtins called on an undefined-typed argument in an `if` and a `while` test, only `abs` cascades with SPY0220 (`sum` draws SPY0354, a shape this lane never generates — posted on #2075). A 2000-sample run with the arm removed went red on a SECOND face of the same cell: `arg0 = abs(arg0)` then `90 if False else arg0` — SPY0227 "no best common type" (int8 vs int). DEVIATION from R-JE's literal text ("tolerated: SPY0220"): the pin tolerates both measured faces [SPY0220, SPY0227] of the one root, because pinning one face leaves the lane seed-flaky on the other — the condition R-JE exists to end. Still keyed on the abs shape, still refusing any unrelated code. Guards (JudgeSourceTests, literal programs): each face's program, unpinned, still draws exactly [SPY0202, face] (the drain signal: it fails the first time a face stops cascading); each is accepted under the pin; a third unrelated code (SPY0200) is refused under the pin; a pin tolerating another code (SPY0354) does not accept the cascade; the lane attaches the pin on the abs shape only (a comparison and a `len(str(...))` base get none). Mutations (filter JudgeSourceTests; restored by copy + touch; restored run 26/0/0): - C1 the cascade arm dropped: AcceptsTheExpectedCodePlusAToleratedFace red (both faces). - C2 any follow-on accepted: RefusesAThirdCode + ToleratesOnlyItsOwnCode red. - C3 the SPY0227 face dropped from the pin: EachFaceStillCascades + Accepts...(SPY0227) red. - C4 the pin attached on every shape: IsAttachedOnlyOnTheAbsOfTheMutatedParameterShape red (2 rows). - C5 the drain simulated (the face-1 program no longer cascades): EachFaceStillCascades red. Lane evidence, SHARPY_PROPERTY_ITER=2000 (20× the in-suite count), fresh seeds: with the pin green three times (44/1695, 41/1700, 35/1662 samples on the pinned shape; the generic-function lane 2000/2000 with none on it); with the arm removed red, 3/1660 misjudged (the SPY0227 face first). Edit loop: JudgeSource, both undefined-parameter lanes, PropertyOracleConformance, TypedGeneratorRoster + every *Roster*/*Census*/*SpecCode*/*SpecBlock* class: 375/0/1 (the skip is a pre-existing Skip=); `dotnet format whitespace sharpy.sln --verify-no-changes` EXIT=0. Tests only; no compiler change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MpT1WWr9aKoBrYhJCpTWuR --- .../Typed/ErrorInjector.Mutations.cs | 39 ++++++- .../Generators/Typed/ErrorInjector.cs | 48 +++++++-- .../ErrorCompletenessPropertyTests.cs | 10 ++ .../Properties/Semantic/JudgeSourceTests.cs | 100 ++++++++++++++++++ .../PropertyOracleConformanceTests.cs | 2 +- 5 files changed, 188 insertions(+), 11 deletions(-) diff --git a/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.Mutations.cs b/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.Mutations.cs index 177ab3e8f..d09533a63 100644 --- a/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.Mutations.cs +++ b/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.Mutations.cs @@ -114,7 +114,44 @@ internal static partial class ErrorInjector // ---- annotations ------------------------------------------------------------------------------ public static InjectionResult? InjectUndefinedTypeAtParameter(Module module) => - Lane(MutationLanes.UndefinedTypeAtParameter(module), "SPY0202"); + WithAbsOfUnknownCascadePin(Lane(MutationLanes.UndefinedTypeAtParameter(module), "SPY0202")); + + /// + /// #2075 (R-JE): an undefined parameter type leaves the parameter Unknown, and abs(p) on it + /// is typed by abs's FIRST overload, int8 — never Unknown. Two measured faces of that one cell + /// (built sharpyc @ a78e3aa71): in a condition, SPY0220 "If condition must be boolean, got 'int8' — + /// has no falsy case"; stored back into the parameter and later met by an int in a + /// conditional expression, SPY0227 "no best common type". Over 17 builtins called on an + /// undefined-typed argument in an if and a while test only abs cascades with + /// SPY0220 (sum draws SPY0354, a shape this lane never generates). Before the pin the lane + /// was red on ~1 seed in 80 (the SPY0220 face, the P21f gate @ 6a0b507f1) and the SPY0227 face + /// surfaced at 2000 samples. + /// + internal static readonly KnownRedCascade AbsOfUnknownCascade = new KnownRedCascade("#2075", new[] { "SPY0220", "SPY0227" }, + "abs() is typed int8: refused as a condition, or with no best common type beside an int"); + + /// Attaches only when a function of the mutated module + /// passes a parameter annotated with the injected undefined type to abs. + private static InjectionResult? WithAbsOfUnknownCascadePin(InjectionResult? result) + { + if (result is null) + return null; + + bool shape = Sharpy.Compiler.Tests.Infrastructure.AstDescendants.SelfAndDescendants(result.Mutated) + .OfType() + .Any(function => + { + var unknown = function.Parameters + .Where(p => p.Type?.Name == PositionMutations.UndefinedType) + .Select(p => p.Name) + .ToHashSet(StringComparer.Ordinal); + return unknown.Count > 0 + && Sharpy.Compiler.Tests.Infrastructure.AstDescendants.SelfAndDescendants(function).Any(n => + n is FunctionCall { Function: Identifier { Name: "abs" } } call + && call.Arguments.Any(a => a is Identifier id && unknown.Contains(id.Name))); + }); + return shape ? result with { KnownCascade = AbsOfUnknownCascade } : result; + } public static InjectionResult? InjectUndefinedTypeAtBase(Module module) => Lane(MutationLanes.UndefinedTypeAtBase(module), "SPY0202"); diff --git a/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.cs b/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.cs index 42251d200..8477afb84 100644 --- a/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.cs +++ b/src/Sharpy.Compiler.Tests/Properties/Generators/Typed/ErrorInjector.cs @@ -16,18 +16,32 @@ internal static partial class ErrorInjector /// to still analyse clean, so the first refusal fails the row and the pin drains with its issue. /// is null only on a pin whose refusal has no code yet. /// is the severity the code is drawn at — Error unless the one - /// broken fact is a warning (SPY0463); see . + /// broken fact is a warning (SPY0463); see . + /// pins a follow-on code the compiler draws today beside the expected one, on one measured shape. /// public record InjectionResult( Module Mutated, string? ExpectedDiagnosticCode, KnownRedRefusal? KnownRed = null, - CompilerDiagnosticSeverity Severity = CompilerDiagnosticSeverity.Error); + CompilerDiagnosticSeverity Severity = CompilerDiagnosticSeverity.Error, + KnownRedCascade? KnownCascade = null); /// A refusal the compiler does not make today: tracks it, and /// says what happens instead. public sealed record KnownRedRefusal(string Issue, string Observed); + /// + /// A cascade the compiler draws today (#2075, R-JE): beside the injection's one code, follow-on + /// codes from — the MEASURED faces of one known cell of + /// ("one mistake, one diagnostic" broken by the compiler, not by the + /// injection breaking a second fact). Attached by a lane only on the measured shape that draws it, + /// so every other sample of the lane is still judged exactly; under it the verdict accepts the + /// expected code alone, or the expected code plus tolerated codes, and refuses any other code. It + /// drains with the error sentinel (P17 phase 1, R-AZ); the deterministic facts on each face's + /// program fail the first time it stops cascading. + /// + public sealed record KnownRedCascade(string Issue, string[] Tolerated, string Observed); + /// /// Unparses the injected program and judges it through at the /// injection's — this overload adds nothing but the @@ -36,7 +50,8 @@ public sealed record KnownRedRefusal(string Issue, string Observed); public static string? Judge(InjectionResult injection, string label) { var source = Sharpy.Compiler.Pretty.Unparser.Unparse(injection.Mutated); - return JudgeSource(source, injection.ExpectedDiagnosticCode, injection.Severity, injection.KnownRed, label); + return JudgeSource(source, injection.ExpectedDiagnosticCode, injection.Severity, injection.KnownRed, label, + injection.KnownCascade); } /// @@ -61,8 +76,9 @@ public sealed record KnownRedRefusal(string Issue, string Observed); string? expectedCode, CompilerDiagnosticSeverity severity, KnownRedRefusal? pin, - string label) => - JudgeDiagnostics(() => AnalyseSingleFile(source), source, expectedCode, severity, pin, label); + string label, + KnownRedCascade? cascade = null) => + JudgeDiagnostics(() => AnalyseSingleFile(source), source, expectedCode, severity, pin, label, cascade); /// /// The multi-file front-end of the same verdict: compiles (relative @@ -92,11 +108,16 @@ public sealed record KnownRedRefusal(string Issue, string Observed); string? expectedCode, CompilerDiagnosticSeverity severity, KnownRedRefusal? pin, - string label) + string label, + KnownRedCascade? cascade = null) { if (severity is not (CompilerDiagnosticSeverity.Error or CompilerDiagnosticSeverity.Warning)) throw new ArgumentOutOfRangeException(nameof(severity), severity, "A refusal lane is judged at Error or Warning severity"); + if (cascade is not null && (pin is not null || severity != CompilerDiagnosticSeverity.Error)) + throw new ArgumentException( + "A cascade pin rides an error-arm injection that has an expected code, never a KnownRed refusal pin", + nameof(cascade)); var source = sourceForMessage; List errors; @@ -145,9 +166,18 @@ public sealed record KnownRedRefusal(string Issue, string Observed); : $"Injected {label} must draw exactly one warning, {expected}, and no error, drew {drewAll}:\n{source}"; } - return errors is [var only] && only == expected - ? null - : $"Injected {label} must draw exactly {expected}, drew {drewAll}:\n{source}"; + if (errors is [var only] && only == expected) + return null; + + // The pinned cascade (#2075, R-JE): the expected code plus follow-ons from the tolerated faces only. + if (cascade is not null && errors.Contains(expected) + && errors.All(c => c == expected || cascade.Tolerated.Contains(c))) + return null; + + var allowed = cascade is null + ? expected + : $"{expected} (or {expected} + [{string.Join(", ", cascade.Tolerated)}], pinned on {cascade.Issue})"; + return $"Injected {label} must draw exactly {allowed}, drew {drewAll}:\n{source}"; } /// diff --git a/src/Sharpy.Compiler.Tests/Properties/Semantic/ErrorCompletenessPropertyTests.cs b/src/Sharpy.Compiler.Tests/Properties/Semantic/ErrorCompletenessPropertyTests.cs index a58b89fc9..69b352cdc 100644 --- a/src/Sharpy.Compiler.Tests/Properties/Semantic/ErrorCompletenessPropertyTests.cs +++ b/src/Sharpy.Compiler.Tests/Properties/Semantic/ErrorCompletenessPropertyTests.cs @@ -633,6 +633,8 @@ private void RunInjectionTest( var failures = new List(); var sources = new HashSet(StringComparer.Ordinal); ErrorInjector.KnownRedRefusal? pin = null; + ErrorInjector.KnownRedCascade? cascade = null; + int onCascadeShape = 0; var baseGen = overrideGen ?? Gen.OneOfConst("int", "str", "bool").SelectMany(type => GenTyped.TypedProgram(TypeEnv.Default, type, fuel: 2)); @@ -649,6 +651,11 @@ private void RunInjectionTest( Interlocked.Increment(ref tested); pin = result.KnownRed; + if (result.KnownCascade is { } known) + { + cascade = known; + Interlocked.Increment(ref onCascadeShape); + } var mutatedSource = Sharpy.Compiler.Pretty.Unparser.Unparse(result.Mutated); lock (sources) sources.Add(mutatedSource); @@ -669,6 +676,9 @@ private void RunInjectionTest( _output.WriteLine(pin is null ? $"Error completeness ({label}): {tested - failures.Count}/{tested} drew the expected code ({sources.Count} distinct mutated sources)" : $"Error completeness ({label}): KnownRed {pin.Issue} — {tested - failures.Count}/{tested} still unrefused ({pin.Observed}; {sources.Count} distinct mutated sources)"); + if (cascade is not null) + _output.WriteLine($"Error completeness ({label}): KnownRedCascade {cascade.Issue} — {onCascadeShape}/{tested} on the pinned shape, " + + $"judged with [{string.Join(", ", cascade.Tolerated)}] tolerated ({cascade.Observed})"); Assert.True(tested > 0, $"No {label} injections were applicable"); Assert.True(failures.Count == 0, $"{failures.Count}/{tested} {label} injections misjudged; the first:\n{failures.FirstOrDefault()}"); diff --git a/src/Sharpy.Compiler.Tests/Properties/Semantic/JudgeSourceTests.cs b/src/Sharpy.Compiler.Tests/Properties/Semantic/JudgeSourceTests.cs index adf32c0e1..243a9c239 100644 --- a/src/Sharpy.Compiler.Tests/Properties/Semantic/JudgeSourceTests.cs +++ b/src/Sharpy.Compiler.Tests/Properties/Semantic/JudgeSourceTests.cs @@ -1,6 +1,9 @@ using Sharpy.Compiler.Diagnostics; +using Sharpy.Compiler.Parser.Ast; using Sharpy.Compiler.Tests.Properties.Generators.Typed; using Xunit; +using LexerNs = Sharpy.Compiler.Lexer; +using ParserNs = Sharpy.Compiler.Parser; namespace Sharpy.Compiler.Tests.Properties.Semantic; @@ -92,6 +95,103 @@ def main(): private static readonly ErrorInjector.KnownRedRefusal Pin = new("probe-pin", "probe pin for the JudgeSource arms — never a real cell, so it cites no issue"); + // ---- the #2075 cascade pin (R-JE) ------------------------------------------------------------ + // The issue's own program, measured with the built sharpyc `emit diagnostics` @ a78e3aa71: + // SPY0202 at the annotation + SPY0220 "If condition must be boolean, got 'int8'" at the condition. + private const string AbsOfUnknownInCondition = """ + def compute(arg0: Nope_xyz) -> bool: + if abs(arg0): + arg0 = 15 + return False + + def main(): + print(1) + """; + + // The second face of the same cell: abs(Unknown) stored back as int8, then met by an int in a + // conditional expression — SPY0202 + SPY0227 (measured @ a78e3aa71; first seen at 2000 samples). + private const string AbsOfUnknownBesideAnInt = """ + def compute(arg0: Nope_xyz) -> int: + arg0 = abs(arg0) + arg0 = 90 if False else arg0 + return 187 + + def main(): + print(1) + """; + + // The same cascade beside an unrelated third error (SPY0200): the pin tolerates only its own faces. + private const string AbsOfUnknownInConditionAndUndefinedName = """ + def compute(arg0: Nope_xyz) -> bool: + if abs(arg0): + arg0 = 15 + return False + + def main(): + print(undefined_name) + """; + + // The drain signal, per face: the first time a face stops cascading (the sentinel, P17 phase 1), this + // fails and that face — or AbsOfUnknownCascade with its lane attachment — is dropped. + [Theory] + [InlineData(nameof(AbsOfUnknownInCondition), "SPY0220")] + [InlineData(nameof(AbsOfUnknownBesideAnInt), "SPY0227")] + public void CascadePin_EachFaceStillCascades(string program, string face) + { + var source = program == nameof(AbsOfUnknownInCondition) ? AbsOfUnknownInCondition : AbsOfUnknownBesideAnInt; + Assert.Contains(face, ErrorInjector.AbsOfUnknownCascade.Tolerated); + var unpinned = ErrorInjector.JudgeSource( + source, "SPY0202", CompilerDiagnosticSeverity.Error, null, "undefined parameter type"); + Assert.True(unpinned is not null && unpinned.Contains($"drew [SPY0202, {face}]", StringComparison.Ordinal), + $"{ErrorInjector.AbsOfUnknownCascade.Issue}'s abs(Unknown) {face} face no longer fires — drop it from " + + $"ErrorInjector.AbsOfUnknownCascade (the pin itself when no face is left). Unpinned verdict: {unpinned ?? "accepted"}"); + } + + [Theory] + [InlineData(nameof(AbsOfUnknownInCondition))] + [InlineData(nameof(AbsOfUnknownBesideAnInt))] + public void CascadePin_AcceptsTheExpectedCodePlusAToleratedFace(string program) + { + var source = program == nameof(AbsOfUnknownInCondition) ? AbsOfUnknownInCondition : AbsOfUnknownBesideAnInt; + Assert.Null(ErrorInjector.JudgeSource( + source, "SPY0202", CompilerDiagnosticSeverity.Error, null, "undefined parameter type", + ErrorInjector.AbsOfUnknownCascade)); + } + + [Fact] + public void CascadePin_RefusesAThirdCode() + { + var failure = ErrorInjector.JudgeSource( + AbsOfUnknownInConditionAndUndefinedName, "SPY0202", CompilerDiagnosticSeverity.Error, null, + "undefined parameter type", ErrorInjector.AbsOfUnknownCascade); + Assert.NotNull(failure); + Assert.Contains("drew [SPY0200, SPY0202, SPY0220]", failure); + } + + // Positive control: the arm is keyed on the pinned code, not on "any second code". + [Fact] + public void CascadePin_ToleratesOnlyItsOwnCode() + { + var otherCode = new ErrorInjector.KnownRedCascade("probe-pin", new[] { "SPY0354" }, "probe — never a real cell"); + Assert.NotNull(ErrorInjector.JudgeSource( + AbsOfUnknownInCondition, "SPY0202", CompilerDiagnosticSeverity.Error, null, "undefined parameter type", otherCode)); + } + + // The lane attaches the pin on the measured shape only — abs of the mutated parameter — and every + // other sample of the lane is judged exactly. + [Theory] + [InlineData("def compute(arg0: int) -> bool:\n if abs(arg0):\n arg0 = 15\n return False\n", true)] + [InlineData("def compute(arg0: int) -> bool:\n if arg0 > 0:\n return True\n return False\n", false)] + [InlineData("def compute(arg0: int) -> bool:\n if len(str(arg0)) > 0:\n return True\n return False\n", false)] + public void CascadePin_IsAttachedOnlyOnTheAbsOfTheMutatedParameterShape(string baseSource, bool pinned) + { + var tokens = new LexerNs.Lexer(baseSource).TokenizeAll(); + var module = new ParserNs.Parser(tokens).ParseModule(); + var result = ErrorInjector.InjectUndefinedTypeAtParameter(module); + Assert.NotNull(result); + Assert.Equal(pinned, result!.KnownCascade is not null); + } + // (a) the warning arm accepts exactly one warning of the code with no error. [Fact] public void WarningArm_SoleExpectedWarning_IsAccepted() diff --git a/src/Sharpy.Compiler.Tests/Properties/Semantic/PropertyOracleConformanceTests.cs b/src/Sharpy.Compiler.Tests/Properties/Semantic/PropertyOracleConformanceTests.cs index 6cf333e64..1106bec64 100644 --- a/src/Sharpy.Compiler.Tests/Properties/Semantic/PropertyOracleConformanceTests.cs +++ b/src/Sharpy.Compiler.Tests/Properties/Semantic/PropertyOracleConformanceTests.cs @@ -78,7 +78,7 @@ public class PropertyOracleConformanceTests /// expected code into it (the roster's lanes and pins). private static readonly HashSet CodeSinks = new(StringComparer.Ordinal) { - "JudgeSource", "JudgeProject", "InjectionResult", "ProjectInjectionResult", "KnownRedRefusal", "KnownRedRow", + "JudgeSource", "JudgeProject", "InjectionResult", "ProjectInjectionResult", "KnownRedRefusal", "KnownRedCascade", "KnownRedRow", "YInjected", "KnownRedSpecCode", "ProseCrossReference", };