Skip to content

fix(generate): reject modules whose declarations and references disagree - #175

Open
marc0olo wants to merge 1 commit into
fix/actor-class-namefrom
fix/module-consistency-gate
Open

marc0olo wants to merge 1 commit into
fix/actor-class-namefrom
fix/module-consistency-gate

Conversation

@marc0olo

@marc0olo marc0olo commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #157.

Behaviour

The one PR in the chain that makes generation fail where it previously succeeded. A .did whose generated module contradicts itself is rejected with a diagnostic instead of written out:

generated wrapper uses the name `Variant_a_b` twice in type space (as enum and as class).
Candid type names, the .did basename, variant tags, the conversion functions and the imports
the generated module needs all feed into this namespace; two of them collided here. TypeScript
would merge them silently rather than reject them, producing a type that claims members the
value does not have. Rename one of them in the .did file; where a side is the class or the
interface named after the file, renaming the file works too. Generating only the declarations
(`output.actor.disabled`) is unaffected.

The class named after the file already steps aside from a candid type of that name (#174), so the checks are the backstop for what cannot be escaped up front: an enum named after an inline variant's tags, or a conversion function, is named while the module is built. collide_class.did therefore generates (Collide_class_), and variant_a_b.did is the fixture that still refuses. The names taken from the file are not in the set the locals step past either, so _Foo.did beside type Foo — where the class and the local for Foo’s shape both want _Foo — is refused here rather than named around.

Everything the PRs below this one fix would land in that category, which is why they come first — nothing legal is rejected by the time this lands, with one deliberate exception: two methods whose keys escape to one name (class and class_) are refused until #180 stops escaping method keys. Existing output is unchanged; no snapshot moves.

Two inputs that generated broken output now generate correctly: a .did named after its own service type (backend.did with type backend = service { … }) got an interface extending itself, and a candid type named _Foo collided with the local under which the wrapper imports Foo's candid shape. The locals are now allocated together, once per module, each stepping past the names the module binds itself, every candid type’s identifier and every local before it, so no two coincide (Foo, Foo_, _Foo, _Foo__Foo__, _Foo___, __Foo, __Foo_).

What is checked

  • No two top-level bindings collide. Candid type ids, the .did basename, joined variant tags, the conversion functions named after the types they convert, and the module's imports all feed into one namespace, and nothing reconciled them.
  • Every referenced type resolves to a type the module declares or imports — a function or a value import of that name does not count — so a declaration that was interned away cannot leave a type consumers are unable to import. A reference to a type parameter resolves to the parameter, so those are tracked by the scope that binds them — the preamble's Option<T> does not excuse an unrelated T elsewhere.
  • No object type declares a member twice — type literals, interfaces and the class alike: the __kind__ discriminant beside a candid tag of that name, or two methods escaping to one key.
  • No candid name is __proto__, checked ahead of both generators and whichever files are wanted. JavaScript treats it as the object's prototype in every position it would appear — record key, enum member, method — so the field, tag or method is missing at runtime, and IDL.Record({ '__proto__': … }) in the declarations loses it too. Verified on main: such a field never round-tripped, so refusing costs no capability.

compile returns Result, surfaced through generate() alongside the existing candid parse errors. The module checks describe the actor files, so they do not run when only declarations/ is wanted, and their diagnostics say so; the __proto__ refusal is the one that applies to the declarations as well.

Reviewer note. The reason to reject is not that TypeScript refuses these — it mostly does not, verified with tsc --strict. What comes out is a type claiming members the runtime value lacks: a wrapper class that silently gains a candid record's fields, or a string enum accepting members its variant never declared, which the conversions then put on the wire.

A declaration occupies TypeScript's type space, its value space, or both, and two collide only where they overlap: a function beside a same-named interface is the merge TypeScript intends and is left alone, while a function beside a same-named enum is two bindings of one name. Import locals take part in the same namespace, and heritage clauses (interface X extends Y) are checked as references — both are names the AST holds differently from an ordinary type reference. Syntactic validity is not checked here: export enum 'Variant_my-tag' is self-consistent and still does not parse, which #180 closes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The review identified a failing success-path test and four unresolved validation gaps.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds validation to reject inconsistent generated TypeScript modules and propagates generation errors.

Changes:

  • Validates names, declarations, members, and type references.
  • Adds collision and invalid-name fixtures.
  • Integrates validation and actor-disabled generation handling.
File summaries
File Summary
tests/validation.test.ts Adds validation tests; success-path toThrow assertion needs correction.
tests/assets/unrepresentable_tag.did Adds invalid __proto__ tag fixture.
tests/assets/unrepresentable_field.did Adds invalid __proto__ field fixture.
tests/assets/duplicate_member.did Adds duplicate-member fixture.
tests/assets/collide_escaped_names.did Adds escaped-name collision fixture.
tests/assets/collide_class.did Adds class/type collision fixture.
src/core/generate/rs/src/lib.rs Propagates compilation errors and actor-disabled handling.
src/core/generate/rs/src/bindings/typescript_native/validate.rs Adds module checks; unresolved type-parameter scope, duplicate imports, generated-function collisions, and service method keys remain insufficiently validated.
src/core/generate/rs/src/bindings/typescript_native/mod.rs Registers validation integration.
src/core/generate/rs/src/bindings/typescript_native/compile.rs Returns compilation errors.
src/core/generate/rs/src/bindings/typescript_native/compile_wrapper.rs Applies validation to wrapper output.
src/core/generate/rs/src/bindings/typescript_native/compile_interface.rs Applies validation to interface output.
src/core/generate/rs/Cargo.toml Adds SWC visitor/parser features.
src/core/generate/index.ts Forwards actor-disabled configuration.
Cargo.lock Updates locked dependencies.
Review details

Suppressed comments (4)

src/core/generate/rs/src/bindings/typescript_native/validate.rs:121

  • Collecting type parameters module-wide lets an unresolved reference to T pass as if it were declared. For example, type T = record { nat }; service : () -> { f : (T) -> () } uses the tuple type T, which add_type_definitions intentionally omits, but the generated preamble's Some<T>/Option<T> causes references.type_params to contain T, so this validator returns Ok and still emits a wrapper with an out-of-scope T. Track type-parameter scopes (or otherwise exclude preamble-local T from module-level bindings) and add this regression fixture.
    // Type parameters are collected module-wide rather than per-scope. The generated module is
    // flat and its only generics come from the fixed preamble (`Option<T>`, `Some<T>`), so
    // scope tracking would buy nothing; the cost is that a reference is not flagged if some
    // unrelated declaration happens to bind the same name as a type parameter.
    known.extend(references.type_params);

src/core/generate/rs/src/bindings/typescript_native/validate.rs:81

  • This loop silently overwrites an existing entry when two imports use the same local name, so the validator does not actually enforce the namespace invariant for imports. For example, a named type SERVICE that needs a wrapper conversion adds SERVICE as _SERVICE through OriginalTypescriptTypes, while the fixed preamble already imports _SERVICE; the emitted module then has duplicate local import bindings but this check proceeds. Reject duplicate import locals here (or otherwise check the previous insert result) before scanning declarations.
    for name in imported_names(module) {
        seen.insert(name, "import");

src/core/generate/rs/src/bindings/typescript_native/validate.rs:85

  • type_decl filters out Decl::Fn, so a generated conversion function can still collide with a value-bearing enum (or class) declaration. For example, a payload variant Foo causes to_candid_Foo_n1 to be emitted; an all-null Candid variant named to_candid_Foo_n1 emits an enum with that same identifier. This check accepts the module even though the wrapper now has two conflicting value bindings. Keep the intentional function/interface compatibility, but also compare generated functions with enum/class names (or reserve those generated names).
    for (name, kind) in module.body.iter().filter_map(as_decl).filter_map(type_decl) {
        if let Some(previous) = seen.insert(name.clone(), kind) {

src/core/generate/rs/src/bindings/typescript_native/validate.rs:230

  • Service methods are emitted as TsMethodSignature nodes, whose keys are Expr values; they do not pass through visit_prop_name or visit_ts_property_signature. Thus a named nested service such as type S = service { "__proto__" : () -> () }; passes this check and emits an unusable __proto__ method in SInterface. Add a visitor for TsMethodSignature keys so method names are validated too.
    fn visit_prop_name(&mut self, node: &PropName) {
        match node {
            PropName::Ident(ident) => self.names.push(ident.sym.to_string()),
            PropName::Str(s) => self.names.push(s.value.to_string()),
            PropName::Computed(_) | PropName::Num(_) | PropName::BigInt(_) => {}
        }
        node.visit_children_with(self);
  • Files reviewed: 14/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/validation.test.ts Outdated
@marc0olo
marc0olo force-pushed the fix/module-consistency-gate branch 2 times, most recently from c2f290c to d812bd9 Compare September 16, 2026 13:54
@marc0olo
marc0olo force-pushed the fix/module-consistency-gate branch from d812bd9 to 3810ea4 Compare September 16, 2026 14:07
@marc0olo

Copy link
Copy Markdown
Member Author

The four suppressed comments, in order.

Duplicate import locals not rejected — correct, and reproduced: a candid type named SERVICE had its shape imported as _SERVICE, the local the service type already binds, so ActorSubclass<_SERVICE> silently meant the user's type. Fixed on both sides — #173 steps the alias aside (_SERVICE_), and the check here now treats import locals as part of the same namespace, with duplicate_import_locals_are_reported covering it.

Generated function vs. enum/class — correct, and reproduced: type foo plus type to_candid_foo_n1 emitted export enum to_candid_foo_n1 beside function to_candid_foo_n1. The check now models both of TypeScript's namespaces and reports a collision only where they overlap, so a function beside a same-named interface still merges as TypeScript intends while a function beside an enum is rejected. collide_conversion_function.did and function_colliding_with_an_enum_is_reported cover it.

TsMethodSignature keys not visited — correct for a nested service type, and fixed in #180 where the method-name refusals live: type S = service { "__proto__" : () -> () } emitted __proto__(): Promise<void> unchecked, and now fails generation. unrepresentable_nested_method.did pins it. The top-level service was already covered, because its methods reach the class as well as the interface.

Type parameters collected module-wide — did not reproduce. The premise is that the tuple type is omitted from the declarations; on the stack tip type T = record { nat } emits export type T = [bigint], so the reference resolves to a real declaration and nothing relies on the preamble's T. With every named candid type now declared (#173), a reference masked by a preamble type parameter needs a type that is referenced and undeclared, which is the case the other check closes. The comment in the code stays as the record of the limitation.

@marc0olo
marc0olo force-pushed the fix/module-consistency-gate branch from abef9cc to 3215e37 Compare September 18, 2026 10:55
@marc0olo
marc0olo force-pushed the fix/module-consistency-gate branch from 3215e37 to f5f7a5d Compare September 18, 2026 12:20
@marc0olo
marc0olo requested a lite review from Copilot September 18, 2026 13:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

All reviewed changes have no unresolved blocking issues.

Review details
  • Files reviewed: 32/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Import-local allocation can still produce duplicate locals for certain valid type-name combinations.

Review details

Suppressed comments (1)

src/core/generate/rs/src/bindings/typescript_native/utils.rs:604

  • This collision check is not sufficient to make import locals unique. For a module containing candid types Foo, _Foo, and Foo_, the Foo shape starts at _Foo and steps to _Foo_ because _Foo is a type, while the Foo_ shape independently uses _Foo_; both imports therefore bind the same local and check_unique_declarations rejects this otherwise representable input. Derive/allocate locals against the other generated import locals as well (or include this case in the collision search) so legal names are not refused.
    let mut local = format!("_{id}");
    while taken(&local) {
        local.push('_');
  • Files reviewed: 32/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@marc0olo
marc0olo force-pushed the fix/module-consistency-gate branch from afc01ba to b95eedf Compare September 18, 2026 14:16
@marc0olo

Copy link
Copy Markdown
Member Author

Re the suppressed comment on candid_import_local: agreed, stepping aside per type in isolation could land two locals on one name — Foo beside _Foo stepped to _Foo_, which is also the plain local for Foo_. The locals are now allocated together, in the sorted order of the candid ids, each stepping past the names the module occupies, every type's identifier and every local allocated before it, so no two can coincide. import_local_collision.did now holds Foo, Foo_, _Foo and _Foo_ together (locals _Foo__, _Foo___), and a unit test pins the four distinct results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

No unresolved review issues remain, and validation is covered by tests and fixtures.

Review details
  • Files reviewed: 32/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@marc0olo
marc0olo force-pushed the fix/module-consistency-gate branch from b95eedf to 3d5d56a Compare September 18, 2026 15:39
@marc0olo
marc0olo force-pushed the fix/module-consistency-gate branch from 3d5d56a to f3139e2 Compare September 21, 2026 09:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Unresolved import-local and class-member collision findings require changes, and the documentation needs clarification.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)

Comment thread src/core/generate/rs/src/bindings/typescript_native/validate.rs
Comment thread src/core/generate/rs/src/bindings/typescript_native/utils.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Import-local allocation must use deterministic sorted traversal to prevent non-reproducible generated output.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)

The generator emits TypeScript it never re-reads, and both generated files carry
`@ts-nocheck`, so an inconsistent module reaches the user's build instead of failing
at generation.

Two checks run before a module is rendered. No two of the module's top-level bindings
may collide — candid type ids, the .did basename, joined variant tags, the conversion
functions named after the types they convert, and the imports the module needs all
feed into one namespace, and nothing reconciles them. And every referenced type must
resolve to a type the module declares or imports — a function or a value import of that
name does not count — so a declaration that was interned away cannot leave a type
consumers are unable to import.

The reason to reject is not that TypeScript refuses these. It mostly does not: an
interface merges with a same-named interface, enum or class without complaint. What
comes out is a type claiming members the runtime value does not have — a wrapper
class that silently gains a candid record's fields, or a string enum accepting
members its variant never declared, which the conversion functions then put on the
wire.

A declaration occupies TypeScript's type space, its value space, or both, and two of
them collide only where they overlap. A function and a same-named interface merge
without inventing members, which is what TypeScript intends and is left alone; a
function and a same-named `enum` are two bindings of one name, which is not.

Syntactic validity is not checked here: `export enum 'Variant_my-tag'` is
self-consistent and still does not parse. The module docstring says so rather than
implying wider coverage.

`compile` now returns `Result`, surfaced through `generate()` alongside the existing
candid parse errors.

A candid name of `__proto__` is refused ahead of both generators, whichever files are
wanted. JavaScript treats it as the object's prototype in every position it would
occupy: `{ __proto__: v }` sets the prototype rather than creating a property, and so
does the quoted form, so `IDL.Record({ '__proto__': … })` in the declarations loses the
field from the schema; an enum member of that name lowers to a bracket assignment that
hits the same setter and is simply absent.

A computed key would rescue a record field on its own, but the generated type would
still read `__proto__?: T`, so a caller writing the obvious spelling would lose the
value with no error. Refusing costs nothing: such a field was already dropped from
both the IDL schema and the conversions, so it has never round-tripped.

The module checks describe the actor files, so they only run when those files are
wanted, and their diagnostics say so. The wasm compiles them unconditionally otherwise,
and a caller asking for `declarations/` alone would lose that output because of a
restriction that does not apply to it.

No object type — type literal, interface or class — may declare the same member twice.
Field names come from the candid interface, but the generator injects `__kind__`
alongside them as the discriminant for a variant carrying payloads, and two candid
names can escape to one member key; the later member silently wins.

A `.did` named after its own service type — `backend.did` holding `type backend =
service { … }` — already declares the actor's interface under that name. A second
interface extending it would extend itself, so none is added.

The local under which the module imports a type's candid shape, `_Foo`, steps aside
when a candid type is itself named `_Foo`, and from every other local: the locals are
allocated together, once per module, so no two can coincide.
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.

Bug: a Candid type whose name matches the generated actor class collides in the wrapper

2 participants