Conversation
b6d456d to
864131c
Compare
1bc3337 to
951376a
Compare
951376a to
b4c017d
Compare
9a78930 to
e3af88f
Compare
There was a problem hiding this comment.
🟡 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
Tpass as if it were declared. For example,type T = record { nat }; service : () -> { f : (T) -> () }uses the tuple typeT, whichadd_type_definitionsintentionally omits, but the generated preamble'sSome<T>/Option<T>causesreferences.type_paramsto containT, so this validator returnsOkand still emits a wrapper with an out-of-scopeT. Track type-parameter scopes (or otherwise exclude preamble-localTfrom 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
SERVICEthat needs a wrapper conversion addsSERVICE as _SERVICEthroughOriginalTypescriptTypes, 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 previousinsertresult) 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_declfilters outDecl::Fn, so a generated conversion function can still collide with a value-bearing enum (or class) declaration. For example, a payload variantFoocausesto_candid_Foo_n1to be emitted; an all-null Candid variant namedto_candid_Foo_n1emits 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
TsMethodSignaturenodes, whose keys areExprvalues; they do not pass throughvisit_prop_nameorvisit_ts_property_signature. Thus a named nested service such astype S = service { "__proto__" : () -> () };passes this check and emits an unusable__proto__method inSInterface. Add a visitor forTsMethodSignaturekeys 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.
c2f290c to
d812bd9
Compare
d812bd9 to
3810ea4
Compare
|
The four suppressed comments, in order. Duplicate import locals not rejected — correct, and reproduced: a candid type named Generated function vs. enum/class — correct, and reproduced:
Type parameters collected module-wide — did not reproduce. The premise is that the tuple type is omitted from the declarations; on the stack tip |
abef9cc to
3215e37
Compare
3215e37 to
f5f7a5d
Compare
f5f7a5d to
afc01ba
Compare
There was a problem hiding this comment.
🔵 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, andFoo_, theFooshape starts at_Fooand steps to_Foo_because_Foois a type, while theFoo_shape independently uses_Foo_; both imports therefore bind the same local andcheck_unique_declarationsrejects 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
afc01ba to
b95eedf
Compare
|
Re the suppressed comment on |
b95eedf to
3d5d56a
Compare
3d5d56a to
f3139e2
Compare
There was a problem hiding this comment.
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
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.


Closes #157.
Behaviour
The one PR in the chain that makes generation fail where it previously succeeded. A
.didwhose generated module contradicts itself is rejected with a diagnostic instead of written out: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.didtherefore generates (Collide_class_), andvariant_a_b.didis the fixture that still refuses. The names taken from the file are not in the set the locals step past either, so_Foo.didbesidetype Foo— where the class and the local forFoo’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 (
classandclass_) 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
.didnamed after its own service type (backend.didwithtype backend = service { … }) got an interface extending itself, and a candid type named_Foocollided with the local under which the wrapper importsFoo'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
.didbasename, 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.Option<T>does not excuse an unrelatedTelsewhere.__kind__discriminant beside a candid tag of that name, or two methods escaping to one key.__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, andIDL.Record({ '__proto__': … })in the declarations loses it too. Verified onmain: such a field never round-tripped, so refusing costs no capability.compilereturnsResult, surfaced throughgenerate()alongside the existing candid parse errors. The module checks describe the actor files, so they do not run when onlydeclarations/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
enumis 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.