Skip to content

JavaScript ignores FieldDeclaration.IsStatic/IsConstant: static class fields become per-instance, and a file-scope field is an undeclared assignment that throws in an ES module #104

Description

@matt-edmondson

What's wrong

JavaScriptGenerator.GenerateFieldDeclaration (Coder/Languages/JavaScriptGenerator.cs:273-287) writes name = value; and nothing else. It never reads IsStatic or IsConstant, and it writes the same text wherever the field stands:

  1. Inside a class, a static or constant field becomes an ordinary instance field. Counter.MAX is undefined, and every instance gets its own copy of what was meant to be shared state. The VariableDeclaration-as-field path in the same file (GenerateField, :391-409) already writes static for a constant, with a comment explaining why. The FieldDeclaration path, which is the node the AST documents for fields, does not.
  2. At file or namespace scope, the declaration has no const/let, so it is a bare assignment to an undeclared name. The generator writes imports as ES module imports (SpellImport at :107 gives import "...";), and modules are always strict. In a module that assignment throws ReferenceError at load. In a sloppy-mode script it silently creates a global instead.

The other targets all honour these flags for the same AST:

Target File-scope constant Static field
C++ inline constexpr int Limit = 10; inline static int created = 0;
C# — public static int created / static readonly
Go const Limit int = 10 var CounterCreated int = 0

Rust's equivalent gap is tracked separately in #81; this issue is JavaScript only.

Reproduction

var file = new SourceFile("Sample");
file.Members.Add(new FieldDeclaration("Limit", "int") { IsConstant = true, InitialValue = new LiteralExpression<int>(10) });
var counter = new ClassDeclaration("Counter");
counter.Members.Add(new FieldDeclaration("created", "int") { IsStatic = true, InitialValue = new LiteralExpression<int>(0) });
counter.Members.Add(new FieldDeclaration("MAX", "int") { IsStatic = true, IsConstant = true, InitialValue = new LiteralExpression<int>(99) });
file.Members.Add(counter);
Console.WriteLine(new JavaScriptGenerator().Generate(file));

Actual output:

Limit = 10;

class Counter {
    created = 0;
    MAX = 99;
}

Run as an ES module (node gen.mjs), node 22 fails on line 1:

Limit = 10;
      ^
ReferenceError: Limit is not defined

Run as a classic script, it loads, but console.log(Counter.MAX, globalThis.Limit) prints undefined 10. The static field is missing from the class, and the file-scope constant leaked onto the global object.

Expected:

const Limit = 10;

class Counter {
    static created = 0;
    static MAX = 99;
}

Why it matters

Constant tables and shared counters are among the main things the AST is documented to generate (see FieldDeclaration.IsStatic/IsConstant in CLAUDE.md). In JavaScript they either crash the module at load, or quietly change from one shared value to one value per instance.

Suggested fix / acceptance criteria

  • Inside a class body, write static when IsStatic || IsConstant, matching GenerateField for VariableDeclaration.
  • Outside a class, meaning directly in a SourceFile or NamespaceDeclaration, write const for IsConstant and let otherwise, as GenerateVariableDeclaration already does for locals.
  • Add tests covering: a static field in a class, a constant field in a class, and a constant field at file scope. Ideally run the output with node when it is available and check Counter.MAX === 99.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions