t1k:cocos:migration:js2ts
| Field | Value |
|---|---|
| Module | migration |
| Version | 0.3.0 |
| Effort | high |
| Tools | — |
Keywords: ccclass, cocos, codemod, export form, js to ts, migration, ts-morph
How to invoke
Section titled “How to invoke”/t1k:cocos:migration:js2tsWhen to use this skill
Section titled “When to use this skill”Activate to convert a Cocos 2.4.x .js component to a dormant .cv.ts during the JS→TS migration, AFTER t1k-cocos-migration-dep-graph has produced the Export-Form Registry. This is the js2ts step in the pipeline dep-graph → js2ts → tsc-validate → cutover → uuid-verify.
Mission (inherited, absolute): convert SYNTAX only, keep 100% behavior, app runs identically. The codemod copies method bodies verbatim and only adds type-level declarations needed to compile (behavior-preserving). It does NOT fix bugs, scope-bugs, or logic.
Do NOT use for: deleting/renaming the .js (that is t1k-cocos-migration-migrate’s cutover.cjs), uuid transplant, or scene/prefab edits.
What the skill does
Section titled “What the skill does”Node.js CLI scripts/js2ts.cjs (ts-morph; run npm install in scripts/ once for ts-morph):
- Reads the registry entry for the target file (
--registry dep-registry.json):uuid,exportForm,valueRequired,tsExport,componentName. - Parses the
.jsas an AST (ts-morph), locates the singlecc.Class({...})call and classifies every top-level statement. - Emits a decorator class matching the project idiom (
const { ccclass, property } = cc._decorator;→@ccclass('<name>')→ class). - Surgical type-resolve pass: writes the file, runs the TS checker with
creator.d.ts, collectsTS2339/TS2551(Property 'X' does not exist/ same with a “did you mean” hint) errors whose access is onthis, and declares each asX: any;(no initializer → no runtime emit at target es5). Base-provided members (cc.Component.node,getComponent, …) never raise the error, so they are never shadowed. - Writes
<file>.cv.ts(with--write) or prints to stdout (dry-run).
The source .js is read-only — never written.
Export-form decision (from the registry)
Section titled “Export-form decision (from the registry)”The TS export form is taken from registry[file].tsExport (computed usage-aware by dep-graph):
| exportForm | tsExport | Mode | Emitted shape | Status |
|---|---|---|---|---|
| cc-component | export default | A | @ccclass('X_cv') export default class X_cv extends Base {} | ✅ tsc |
| cc-component / commonjs-class | export = | A | @ccclass('X_cv') class X_cv extends Base {} export = X_cv; | ✅ tsc |
| commonjs-object | export = | B | body verbatim; module.exports = E → export = E | ✅ tsc |
| none (side-effect) | export {} | B | body verbatim; requires→import; append export {}; + globals.d.ts + cc-ext.d.ts | ✅ tsc |
| es-default / es-named | export default / named | C | body verbatim; relative import/export … from → .cv | ✅ tsc |
Three modes — routed by exportForm (the registry SSOT), NOT by cc.Class presence. Mode A (cc-component / commonjs-class) rebuilds a decorator class. Mode B (commonjs-object / none / commonjs-named) keeps the ENTIRE file verbatim (incl. comments) and only rewrites top-level var X = require() → import X = require(), module.exports = E → export = E, and appends export {}; for a none file. Mode C (es-default / es-named) is for a file that is ALREADY an ES module (import/export, often ES2022 class fields) — keep the body VERBATIM and only rewrite RELATIVE import/export … from specifiers to the .cv coexistence sibling (if it exists). No export conversion (ES exports ARE TS exports), no export {} append. Simplest of the three. Maximum integrity — no body restructuring. A none file may still CONTAIN a cc.Class — an engine PATCH like cc.Update = cc.Class({ name: "cc.Update", ... }) — which must stay VERBATIM (Mode B), NOT be rebuilt as a component. Routing by cc.Class-presence (the old bug) sent such files to Mode A → threw on the name/other options.
Mode A preserves side-effect top-level statements. A cc.Class file often has other top-level statements beside the class (window.SOME_ENUM = {...}, var X;, …). These are kept VERBATIM in source order (before/after the class) — they run at module load exactly as before; the bare globals they assign rely on globals.d.ts. Two recurring holdout patterns and their resolutions, NOT by touching dep-graph: a file with wrapped properties + computed-key (properties: ((a={…}),(a[CONST+"Icon"]=v),…,a)) → unwrap the sequence + resolve CONST+"Icon" via constantsFile; a file dep-graph misclassifies as a component but which is really a side-effect engine-patch module (anonymous cc.X = cc.Class(...) with no name/extends/scene-ref) → exportFormOverride forces none = Mode B verbatim.
cc.Class options (Mode A). Handled: extends → heritage clause (or none); properties → @property fields only when the class is a true component (registry[file].isComponent — transitively extends cc.Component; a no-extends cc.Class base OR one extending a non-component → plain fields/accessors, else TS1240 — see Gotcha 12), and computed {get,set} entries → TS accessors (see Property transform); statics: {X:v, fn(){}, get g(){}, set s(v){}} → static X = v; static fn(){} static get g(){} static set s(v){} (get/set accessor support; setter param KHÔNG optionalize, TS1051 cấm set x(v?)); editor: {...} → class decorators; ctor: function(){} → constructor(){ [super();] ... }; name: "X" → reserved CLASS-NAME key, DROPPED (@ccclass(componentName) từ registry SSOT đã mang tên); top-level NON-function data field (ngoài properties, vd data: null, itemList: void 0) → plain instance default name: any = value; (KHÔNG @property — behavior-preserving). mixins → throws (add support before converting such a file). Destructuring require (const { default: X } = require("Y")) → giữ verbatim const {…} = require("Y.cv") (import=require KHÔNG destructure được; require runtime Cocos thật, type residual any-safe). Wrapped properties + computed-key → unwrap sequence + resolve CONST+"Icon" thành tên literal qua constantsFile map → build synthetic object literal → convertProperties SẴN CÓ. Alias rename: var i = cc.Class({...}) — obfuscated class-var i renamed (scope-correct) to TS class name.
Config override (cocos-migrate.json). exportFormOverride: { "<file>": "none" } — ép exportForm cho file dep-graph phân loại SAI, KHÔNG đụng dep-graph algorithm (rủi ro toàn bộ registry). Vd một file mà dep-graph thấy cc.X = cc.Class(...) (engine-patch) → cc-component, nhưng thực ra là side-effect module (bare anonymous cc.Class không name/extends/scene-ref) → ép none = Mode B verbatim. constantsFile: "<path>" — file global string-const (IDENT="lit" hoặc window.IDENT="lit") để resolve computed-key properties.
Coexistence naming (.cv marker)
Section titled “Coexistence naming (.cv marker)”To let the dormant TS validate in parallel with the live JS:
- File:
Foo.js→Foo.cv.ts(basenameFoo.cv, so it never collides with the liveFoo.js). - Class +
@ccclass:@ccclass('Foo_cv')+class Foo_cv— a temp registered name so Cocos sees no duplicate of the liveFoo. The class IDENTIFIER is sanitized (e.g.map-scroll-view→map_scroll_view_cv); the@ccclassSTRING keeps the original chars +_cv. - Internal imports point at
.cv: a relativerequire("./Dep")becomesimport Dep = require("./Dep.cv")iffDep.cv.tsalready exists on disk. This makes the converted set a self-consistent, type-checkable module graph (all realexport =/export defaultmodules) while the live.jsset runs the game. At cutover the.cvsuffix is stripped (→Foo.ts,@ccclass('Foo'),require('./Dep')).
Convert in dependency order (base/dep before dependents) so the .cv sibling exists when a dependent is converted; otherwise the import stays ./Dep and won’t type-check against the bare-cc.Class .js (see Gotcha 2).
Property transform
Section titled “Property transform”properties: { ... } entries → @property fields:
| JS form | TS emitted |
|---|---|
content: cc.Sprite (shorthand type) | @property(cc.Sprite)content: cc.Sprite = null; |
list: [cc.Node] (array shorthand) | @property([cc.Node])list: cc.Node[] = []; |
{ type: cc.Integer, default: 5, tooltip: 't' } | @property({ type: cc.Integer, tooltip: 't' })name: number = 5; |
count: 5 / flag: false / label: "" (literal default) | @propertycount: number = 5; (type inferred) |
x: { get: function(){…}, set: function(e){…} } (computed) | get x() {…}set x(e) {…} (TS accessor, body VERBATIM — NOT @property) |
cc.Integer/cc.Float→number, cc.String→string, cc.Boolean→boolean, cc.X/sp.X/require’d-class→same, else any.
Computed (get/set) properties → TS accessors, not @property. A cc.Class { get, set } property IS an accessor; emitting @property({ get, set }) is invalid (TS2353 “‘get’ does not exist in the descriptor type”). The codemod emits a real get name(){…} / set name(e){…} (works on any class, component or not; behavior-preserving — accessor defined on the prototype exactly as cc.Class did). The @property vs plain-field/accessor decision is gated by isComponent (Gotcha 12).
Bare window-globals — generated globals.d.ts
Section titled “Bare window-globals — generated globals.d.ts”Every project global is a runtime window.X = ... (e.g. window.SOME_ENUM = {...}) accessed bare (SOME_ENUM.x). Once a file becomes a TS module, a bare name needs an ambient declare var X: any; — a interface Window augmentation only types window.X, NOT bare X.
scripts/gen-globals-dts.cjs generates an ambient globals.d.ts (SSOT = the dep-graph registry):
- declares every
_meta.globalProvidedByname + non-leak_meta.externalGlobals(SDK/UPPER_CASE/camelCase, including mini-game platform globalstt/wx/… that dep-graph no longer filters) +declare function require. (No hardcoded platform allowlist — they arrive viaexternalGlobalsfrom the registry, SSOT.) - drops leak-like names (single lowercase letter
e/a/i/n/…) so they surface astscerrors for a later scope fix. 2-char names are KEPT (many are real SDKs:qg/ks/uc/bl).
node .claude/skills/t1k-cocos-migration-js2ts/scripts/gen-globals-dts.cjs --registry tmp/dep-registry.json --out <scriptRoot>/types/globals.d.tsRegenerate after any dep-graph re-run. Include globals.d.ts in the validation tsconfig.
Engine patches — generated cc-ext.d.ts
Section titled “Engine patches — generated cc-ext.d.ts”The game patches the cc namespace at runtime (cc.recycle = ...) AND uses cc members absent from a partial creator.d.ts (cc.vec4(...), cc.js, cc.gfx, cc.RotateBy …) → TS2339. scripts/gen-cc-ext-dts.cjs scans the source for every cc.<id> member access (assignments + read/call usage), filters out members already declared in creator.d.ts (avoids duplicate-identifier; legit cc.Node/cc.Sprite/cc.log never re-declared), and emits a declare namespace cc { let <id>: any; } augmentation (type-only, 0 runtime emit → behavior-preserving).
node .claude/skills/t1k-cocos-migration-js2ts/scripts/gen-cc-ext-dts.cjs --root <scriptRoot> --types <creatorDts> --out <scriptRoot>/types/cc-ext.d.tslet (not const/function) so both the patch assignment cc.recycle = fn and every consumer cc.recycle() type-check. Caveat: a file that reassigns an engine-owned member (cc.update = ..., which creator.d.ts declares as a function) still raises an assignment error — that is a genuine override the codemod surfaces, not something cc-ext.d.ts masks.
Custom node members — generated node-ext.d.ts
Section titled “Custom node members — generated node-ext.d.ts”The game stores custom props on node INSTANCES (this.node.onTouch = fn, then var e = this.node.onTouch). this.node is cc.Node, which has no onTouch → TS2339 (4th dynamic category, distinct from bare globals / cc-namespace patches / this.X). scripts/gen-node-ext-dts.cjs scans the source for .node.<id> accesses, filters out every real cc.Node member (incl. inherited from _BaseNode, read via the ts-morph type checker — avoids the interface/class merge error on active/x/on/…), and emits declare namespace cc { interface Node { <id>: any; } } (interface-merges with the engine’s cc.Node class; type-only, 0 runtime emit).
node .claude/skills/t1k-cocos-migration-js2ts/scripts/gen-node-ext-dts.cjs --root <scriptRoot> --types <creatorDts> --out <scriptRoot>/types/node-ext.d.tsScope: only the .node.<id> receiver is detected; custom members reached via an aliased node var or event.target.<id> are not, and surface as TS2339 for a follow-up.
Nested / prototype engine patches — generated engine-ext.d.ts
Section titled “Nested / prototype engine patches — generated engine-ext.d.ts”cc-ext (cc.<id>) and node-ext (this.node.<id>) each own ONE receiver shape. An engine-bootstrap file patches engine internals at OTHER receivers that neither covers: cc.sys.<id> (custom langs), cc.director.<id> (_scheduler), cc.Node/Component/Action.prototype.<id> (incl. var h = cc.Node.prototype; h.<id> = …), and foreign-namespace prototype patches (<lib>.<Class>.prototype.<id> / <lib>.<Class>.<staticId>). Each maps to a different augmentation (namespace member vs class interface-merge). scripts/gen-engine-ext-dts.cjs is config-driven — the RECEIVERS table maps each { receiver, ns, target, kind: namespace|interface, alias }:
- scans each receiver (direct
<recv>.<id>+ aliasvar h = <recv>; h.<id> = …for prototype patches); - filters against creator.d.ts — namespace vars/functions/enums + class STATIC and INSTANCE members incl. inherited (so real members are never re-declared → no duplicate-identifier). A namespace declared across MULTIPLE
declare namespace <ns> { … }blocks is searched across ALL blocks (getModules().filter), else members are missed; - filters against
node-ext.d.ts(so cc.Node members aren’t double-declared); - emits grouped by namespace.
let(notconst) for namespace members — the game ASSIGNS them (cc.sys.LANGUAGE_X = "x"→ aconstraises TS2540).
node .claude/skills/t1k-cocos-migration-js2ts/scripts/gen-engine-ext-dts.cjs --root <scriptRoot> --types <creatorDts> --out <scriptRoot>/types/engine-ext.d.ts --node-ext <scriptRoot>/types/node-ext.d.tsResidual NOT receiver-typing: function-object property patches (var f = …; f.<id> = … — the receiver is a function value; intractable) + verbatim obfuscated-code quirks (!1 & bool TS2447, arity TS2554, non-callable TS2349, … — the same accepted-residual category, behavior-preserving). Include engine-ext.d.ts in the validation tsconfig alongside the other three.
Config — .claude/cocos-migrate.json (SSOT, KHÔNG hardcode)
Section titled “Config — .claude/cocos-migrate.json (SSOT, KHÔNG hardcode)”--registry (key registry) và --types (key creatorDts) mặc định lấy từ .claude/cocos-migrate.json (dùng chung với uuid-verify/tsc-validate/migrate). Có config → gọi js2ts KHÔNG cần truyền 2 flag đó. Thứ tự: CLI flag > config > fallback (registry=null bắt buộc, types=auto walk-up). Xem t1k-cocos-migration-migrate § Config.
# Có .claude/cocos-migrate.json → không cần --registry/--types:node .claude/skills/t1k-cocos-migration-js2ts/scripts/js2ts.cjs <file.js> # dry → stdoutnode .claude/skills/t1k-cocos-migration-js2ts/scripts/js2ts.cjs <file.js> --write # ghi <file>.cv.ts
# Dry-run (prints TS to stdout; surgical pass uses a temp sibling, then deletes it):node .claude/skills/t1k-cocos-migration-js2ts/scripts/js2ts.cjs <file.js> --registry tmp/dep-registry.json
# Write <file>.cv.ts:node .claude/skills/t1k-cocos-migration-js2ts/scripts/js2ts.cjs <file.js> --registry tmp/dep-registry.json --write
# Override creator.d.ts location (else auto-located by walking up):... --types <creatorDts>
# Skip the surgical type-resolve pass (faster; may leave TS2339):... --no-resolveValidate the produced .cv.ts set with tsc --noEmit against creator.d.ts (module commonjs, target es5, experimentalDecorators, skipLibCheck, allowJs, noImplicitAny off — matches the real Cocos 2.4.x project tsconfig.json, which sets NO strict/noImplicitAny → defaults to off).
noImplicitAny: false is REQUIRED, not optional. Params are emitted as bare e? (optional, type implicitly any); the codemod does NOT write : any (it would be redundant under the project config). Under noImplicitAny: true / strict (an IDE-only setting — the project tsconfig does NOT enable it), every un-annotated param raises TS7006. On a large obfuscated file this produces dozens of TS7006, many on params of top-level helpers / nested callbacks inside VERBATIM bodies, unfixable without editing the body (Gotcha 4). So strict-clean is unreachable for obfuscated code; the migration targets noImplicitAny: false. If an IDE shows TS7006, point it at the project tsconfig.json rather than annotating.
Gotchas
Section titled “Gotchas”-
Dynamic
this.X(base→subclass method, prototype patch) needs a declaration, NOT a body edit. Example: a base class callsthis.enterTransition()which only a sibling subclass defines — a valid polymorphic pattern, not a bug. The surgical pass declaresenterTransition: any;on the base class. Chosen over a blanket[key:string]: anyindex signature (keeps type-checking everywhere else) and over// @ts-nocheck(which kills all checking). A bareX: any;(no initializer) emits no runtime code at es5 → behavior-preserving. -
A bare-cc.Class
.jsis “not a module” to TS (TS2306). A cc-component.jshas nomodule.exports(Cocos auto-registers it), soimport X = require("./Dep")against the liveDep.jsfails type-check. Fix = the.cvcoexistence import (point at the convertedDep.cv.ts, a real module). Consequence: convert in dependency order, and a not-yet-converted relative dep will not type-check until it is converted. -
this.nodefalsely declared if the typed base is lost. If the import resolves to a non-module/anybase, everythis.memberraises 2339 and gets a spuriousanydecl. Always resolve the import to the typed.cvbase so inherited members (node, etc.) stay typed and are not redeclared. -
Method bodies are copied VERBATIM (
getBody().getText()), only re-indented as a block. No statement is rewritten — integrity. Obfuscated single-letter params (e,t,i) are preserved untyped (tsconfig hasnoImplicitAnyoff → compiles). -
Loud-fail on unhandled cc.Class options.
mixins, multiplecc.Class()calls, and unrecognized top-level statements throw rather than convert lossily. Add explicit support before converting such files (integrity > silent drop). -
export =+ decorator class compiles undermodule: commonjs+experimentalDecorators(verified). The class is declared thenexport = X;(decorator on the non-exported declaration). -
cc.Xengine patches need adeclare namespace ccaugmentation — done via the generatedcc-ext.d.ts(see section above).globals.d.ts(bare globals) does NOT cover these (they’recc.members, not bare). -
this._super→super.<methodName>transform (DONE, on by default). cc.Class injectsthis._superto call the same-named superclass method; an ESextendsclass has no_super. The codemod replaces everythis._superPropertyAccess withsuper.<enclosing-method-name>, which handles all call shapes uniformly (this._super(a)→super.foo(a),this._super.apply(this,arguments)→super.foo.apply(this,arguments)). This is the ONE body-editing transform — a behavior-PRESERVING syntax conversion (without itthis._superis undefined → runtime crash). Disable with--no-super-rewrite(then_superstays verbatim + gets declared — compiles but runtime-wrong). Caveat:super.foo()requires the typed base (.cv) to declarefoo; convert in dependency order. Athis._superinside a nested non-arrowfunction(){}(wherethisis rebound — a pre-existing bug) would become an invalidsuper— rare; tsc flags it.
9b. No-extends cc.Class must NOT default to extends cc.Component. A bare cc.Class({...}) with no extends key is a plain base class, NOT a component — the codemod emits class X {} with no heritage clause.
-
Dynamic members on
cc.Nodeinstances — done via the generatednode-ext.d.ts(see section above). The game stores custom props on nodes (this.node.onTouch);cc.Nodehas noonTouch→ TS2339.node-ext.d.tsinterface-merges the custom members ontocc.Node. Remaining gap: custom members reached via an aliased node var orevent.target.<id>(not.node.<id>) are still uncovered. -
Obfuscated comma-wrapper arg — handled.
cc.Class( ((i = {OBJ}).onHitWall = fn, i.remove = fn, i.statics = {…}, i) ): the config is assigned to a locali(the basei = {OBJ}is often NESTED in the first attachment’s LHS(i = {OBJ}).key), members are attached asi.<key> = <val>(functions → instance methods,statics→ static block), andiis returned (often via a result varvar a = cc.Class(...)). The codemod: unwraps the comma-sequence (unwrapWrapperArg), finds the nested base object, collects attachments, renames BOTH the config aliasiAND the result vara→ the TS class, and dedupes an attachment method that overrides an inline method of the same name (i.remove = fnwins → no TS2393 duplicate). -
@propertyonly on a TRUE component (transitiveisComponent).@propertyis valid ONLY when the class transitively extendscc.Component(proven by isolation test:@propertyon a class extendingcc.Componentcompiles; on a plain class → TS1240 “Unable to resolve signature of property decorator”). The codemod readsregistry[file].isComponent(dep-graph computes it by walking theextendschain to acc.*engine component base inENGINE_COMPONENT_BASES); non-components emitpropertiesas plain fields / accessors. This REPLACES the old “has a base → @property” heuristic, which was wrong: a class extending a no-extendsplain base is NOT a component, but the old heuristic decorated it → TS1240. A real component (extends cc.Component/cc.Sprite/…) still gets@property. -
Method arity variance — handled via a TARGETED type-only OVERLOAD (NOT a rest param). Obfuscated JS treats every parameter as optional and ignores extra args; TS enforces arity → over-call (TS2554, called with MORE args than declared) and under-call / derived-adds-param (TS2554/TS2416). Under-call & derived-adds-param are resolved by
optionalizeParam(each simple param → optional). For over-call, the codemod emits a type-only overload signaturename(...args: any[]): any;immediately above the method/static (overloadLine); it accepts any argument count, is ERASED at emit (0 runtime code), and the impl keeps its original params. Targeting (full call-graph): the overload is emitted ONLY when the method is actually over-called somewhere —dep-graphships a whole-programmethodAritymap (NAME → max args any call passes; keyed by name, a safe over-approximation) andoverloadLineemits the overload iffmethodArity[name] > declaredParamCount. Constructors are always overloaded (called via import-aliasednew Alias(...), so arity-by-name is unreliable). Safety: a missed over-call (e.g. via dynamicobj[x](...)dispatch the map can’t see) degrades to a visible TS2554 residual at validation — NEVER a runtime change — because the overload is purely a type-level aid. Why not a rest param on the impl: at target es5 a rest param emits anarguments-gather loop (var args = []; for (var _i …)) EVEN WHEN UNUSED — a real per-call array allocation, NOT behavior-preserving. Residual NOT covered (by design): a LOCAL function-expression inside a verbatim method body (var n = function (t, i){…}thenn(x)) — its arity mismatch surfaces as TS2554, but fixing it would require editing the verbatim body (forbidden by Gotcha 4), so it is left as a classified residual (behavior-irrelevant: the source JS ignored the arg-count too). Also residual: a fewTS2353(object-literal excess-property, narrowly-inferred param shape). -
Surgical pass over-declares when the base dep is unresolved. If
extends Baseresolves to a not-yet-converted bare-cc.Class.js(anany/non-module base), EVERYthis.memberraises 2339 and gets a spuriousanyfield. Convert in dependency order so the typed.cvbase exists first; then only genuinely-undeclared members are declared.
Scope / not-yet-implemented
Section titled “Scope / not-yet-implemented”Verified-converting categories: cc-component (export default + export =), commonjs-object, none/side-effect (modulo cc.X patches), commonjs-class (Mode A), already-ES modules (Mode C: verbatim + .cv import rewrite). Transforms proven: _super→super, no-extends fix, editor→decorators, ctor→constructor, statics+alias-rename, @property-only-on-component, uniform type-only overload for arity tolerance.
Not yet built:
TS2353object-literal excess-property (narrowly-inferred param shape) — minor.- Aliased-node /
event.target.<id>custom members (Gotcha 10 residual — only.node.<id>is covered bynode-ext.d.ts). mixinscc.Class option (throws if seen).commonjs-named(exports.foo = ...) — Mode B throws if seen.- Basename (Cocos-global) require coexistence rewrite (only relative
.//../handled). .cv.ts.metageneration (new uuid) —t1k-cocos-migration-uuid-verifyconcern.
See also
Section titled “See also”t1k-cocos-migration-dep-graph— produces the Export-Form Registry this codemod consumes (run it first).t1k-cocos-migration-tsc-validate— validates the produced.cv.tsset.t1k-cocos-migration-uuid-verify— strips.cv, transplants uuid, attaches to prefab/scene.