Skip to content

Class members leave the lexical chain (§13.17, §13.18, §13.22) - #179

Merged
kaidesu merged 3 commits into
1.0from
claude/ghost-items-prioritize-f4ghko
Sep 6, 2026
Merged

kaidesu merged 3 commits into
1.0from
claude/ghost-items-prioritize-f4ghko

Conversation

@kaidesu

@kaidesu kaidesu commented Sep 4, 2026

Copy link
Copy Markdown
Member

Overview

§15's #2, #3 and #4. It started as #2 and #4 — one diagnostic on the member-declaration path — and a review question reframed it:

a method named math is reached as this.math(), so why should it collide with a top-level math import at all?

It shouldn't. Rejecting the declaration made an author work around a collision that has no reason to exist. So the second commit replaces that diagnostic with a scoping change, in the one direction §13.17 was already forcing — which is why #2 and #3 turned out to be the same question, not two.

A method is a member, not a name in scope. A method body — and a field initializer — now resolves through the scope its class was declared in, rather than the class body's scope. Members still live in class.Environment, which LookupMember reads for this.x() and super.x(); that table is simply no longer a lexical scope.

import "ghost:math" as math

class Theme {
    load() { return math.floor(3.7) }   // 3 — the module
    math(role) { return role }          // reached as this.math()
}

Both work. §13.22 dissolves rather than being guarded.

What each callout gets

§13.22 — no diagnostic. Gone with it: shadowedModuleError, isImportedModule, Environment.GetEnclosing and the object.Map.Module flag — along with the question they existed to answer, is this binding "really" an imported module? That had no honest answer for a Ghost source module bound as a plain Map, and a rule that has to classify what it shadows is doing the wrong job.

§13.17 — closed by the same change. A bare sibling call is now a plain Name fault at the call site, and undefined recognises a name that is a member of the running class:

show() { return describe() }
// before: name error at describe()'s `this`, in correct code, in another method
// after:  name error here, at `describe`
//         help: `describe` is a member of `W`; reach it through `this.describe`

§13.18 — untouched, still a Syntax fault at the second declaration. A field and a method sharing a name is a genuine collision; it is unrelated to lexical scoping.

Field initializers needed the same treatment — the other path into a class body, evaluated per instance, which reproduced §13.22 exactly (size = math.floor(2.7) beside a method named math) and would have survived the diagnostic entirely.

A bonus

§14 decision 9 recorded, as a live cost of walking assignment, that "a local named scale would rebind a sibling method named scale". Members are out of the chain now, so that collision is gone:

class A {
    scale() { return 1 }
    run() { scale = 5 return this.scale() }
}
// before: type error - `A.scale` is a number, which cannot be called
// after:  1

§13.13 and decision 9 are updated to say so, with a test pinning it.

Breaking change

A bare sibling call to a method that never touches this used to work — the narrowest possible version of the feature, and precisely why the defect survived to 1.0 — and now raises. One test pinned it (TestClassMemberResolution, updated to this.).

All 41 programs in examples/ and all 132 cases of Studio's suite are unchanged; neither codebase used the bare form, and Studio's own CLAUDE.md already mandates this.method(). mud.gs differs only in frame count inside its timeout, as in the previous PRs.

§8.8 now states the JavaScript rule the rest of Ghost's class syntax already follows — new, extends, constructor, keyword-less methods — where bare sibling calls don't exist either. The bare form was a Ruby-ism inside a JS-shaped class.

Verification

  • evaluator/class_members_test.go: the field/method collision both ways and in a trait; a method sharing a name with a scheme import, a Ghost source module, a trait, and a field initializer, with the method still reachable; bare sibling calls for a callee that touches this and one that doesn't; the help text; the local-vs-sibling-method case; and the eight shapes that must not be rejected.
  • go build ./..., go vet ./..., gofmt -l ., go test ./... all clean.

§14 decision 12 records the call and its cost. Next on §15 is #5 (§13.16), the one open design decision (§14 decision 10).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EGsNMRiKwWmvoizxD619zA

§15's #2 and #4, taken together because they wanted the same check in the
same place: a class or trait member colliding with a name that already
means something, reported where the collision is written rather than left
to surface somewhere else, or not at all.

§13.22 shipped a crash. A method body resolves through the class
environment before it reaches the file's imports, so a method named `font`
hides `import "lumen:font"` from *every* method in the class, and the
failure lands as a property error at whatever call site first wanted the
module, with nothing at the import or the method to connect it back.

    import "ghost:math" as math
    class Theme {
      load() { return math.floor(3.7) }   // was: property error here,
      math(role) { return role }          // function has no method `floor`
    }                                     // now: syntax error at `math`

§13.18 was silent. A field and a method could share a name, a property read
answering with the field and a call with the method, because the two lookup
paths never meet.

    class Thing {
      thing = 7
      thing() { return "method" }   // now: syntax error, already a field
    }

checkMethodDeclaration (evaluator/function.go) makes both checks on the
declaration path. Either declaration can come first, so the field/method
collision is also checked from the other side in evaluateAssign - methods
being the only members that live in the body's own environment. Neither
lookup path changed: both are correct on their own, and it is the pair of
declarations that is the mistake. HasField and DeclaredName join
object.FieldDeclarer so the checks read the same on a class and a trait.

Deciding what counts as a module needed an honest answer rather than type
sniffing, since a `scheme:name` import binds a LibraryModule but a Ghost
source module binds a plain Map, which is also what a map literal is.
object.Map gains a Module flag set only by moduleValue, so shadowing an
import is rejected while shadowing a map, a function or a variable stays
ordinary lexical shadowing. Environment.GetEnclosing is what asks the
question, skipping the class's own members to see what the name meant
outside.

Global modules are deliberately not covered, and a test pins why: a method
named `console` does not hide the global `console` from its siblings,
because globals resolve through the library registry rather than the
environment chain. There is nothing there to warn about.

Tested in evaluator/class_members_test.go, fifteen cases across both
declaration orders, class and trait bodies, a Ghost source module written
to a temp directory, and - the half that matters for a check like this -
the shapes that must not be rejected: overriding an inherited field or
method, a field and method merely sitting beside each other, a method named
like an outer function, variable or plain map, and the aliased-import form
the fix recommends. §8.8 documents both rules. All 41 programs in examples/
are unchanged, and Studio's 132-case suite passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGsNMRiKwWmvoizxD619zA
Replaces this branch's §13.22 diagnostic, which treated a symptom. The
review question that prompted it: a method named `math` is reached as
`this.math()`, so why should it collide with a top-level `math` import at
all? It should not. Rejecting the declaration made an author work around a
collision that has no reason to exist.

So resolution order changed instead, in the one way §13.17 was already
forcing. A method now closes over the scope its class was *declared* in
rather than the class body's scope, and initializeField does the same for
field initializers - the other path into a class body, which reproduced the
bug exactly and would have survived the diagnostic. Members stay in
class.Environment, which LookupMember reads for `this.x()` and `super.x()`;
that table is simply no longer a lexical scope.

    import "ghost:math" as math
    class Theme {
      load() { return math.floor(3.7) }   // 3 - the module
      math(role) { return role }          // reached as this.math()
    }

This closes §13.17 in the same change, since it is the same question: a
bare sibling call is now a plain name error at the call site, and undefined
recognises a name that is a member of the running class and answers
"`describe` is a member of `W`; reach it through `this.describe`". The old
report pointed at the callee's `this`, in correct code, in another method.

Gone with the diagnostic: shadowedModuleError, isImportedModule,
Environment.GetEnclosing and the object.Map.Module flag - along with the
question they existed to answer, which was whether a binding was "really"
an imported module. That had no honest answer for a Ghost source module
bound as a plain Map, and a rule that has to classify what it shadows is
doing the wrong job. §13.18's field/method check is untouched.

A bonus worth recording: §14 decision 9 listed "a method's local named like
a sibling method rebinds that method" as a live cost of walking assignment.
Members are out of the chain now, so that collision is gone; §13.13 and
decision 9 are updated, and a test pins it.

This is a breaking change. A bare sibling call to a method that never
touches `this` used to work - the narrowest version of the feature, which
is why the defect survived to 1.0 - and now raises. One test pinned it. All
41 programs in examples/ and all 132 cases of Studio's suite are unchanged,
neither having used the bare form. §8.8 now states the JavaScript rule the
rest of Ghost's class syntax already follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGsNMRiKwWmvoizxD619zA
@kaidesu kaidesu changed the title Reject a member that collides with a field or an import (§13.18, §13.22) Class members leave the lexical chain (§13.17, §13.18, §13.22) Sep 5, 2026
… line

The note claimed confining the field/method check to one body was deliberate
"where a name appearing twice is overriding rather than colliding". That
reasoning holds for field-over-field and method-over-method, not for
field-over-method, which is the collision §13.18 is about. A class field
against an inherited or trait-supplied method still answers the field for a
read and the method for a call, exactly as the callout describes.

Records it as a residual with the shape of the fix, rather than as a
justified boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGsNMRiKwWmvoizxD619zA
@kaidesu
kaidesu marked this pull request as ready for review September 6, 2026 04:17
@kaidesu
kaidesu merged commit 66fb4aa into 1.0 Sep 6, 2026
2 checks passed
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.

2 participants