Skip to content

t1k:cocos:migration:dep-graph

FieldValue
Modulemigration
Version0.3.0
Effortmedium
Tools

Keywords: cocos, component name, dep graph, export form, migration, registry, require graph

/t1k:cocos:migration:dep-graph

Activate when you need to:

  • Know which files use module.exports = (require-target) vs a bare cc.Class (cc-component).
  • Produce the Export-Form Registry before running the t1k-cocos-migration-js2ts codemod.
  • Get the require-graph map: who requires whom, reverse deps, circular cycles.
  • Decide export = vs export default per file when writing the TS version.

Do NOT use for: editing JS, generating TS, or cutover — this is a read-only scanner.

Node.js CLI script scripts/dep-graph.cjs (auto-installs acorn + acorn-walk on first run; npm install in scripts/ makes them reproducible):

  • Scans every *.js under the target dir (default Client/assets/script/).
  • Parses the AST with acorn (ES5 → latest script → latest module fallback; latest is required to read class fields like x = Date.now() in ES2022 files). Never skips a file because of eval/with — it detects a global eval()/with-statement via AST (accurate, never confused by obj.eval()); only flags scopeUnreliable for the bare-global portion.
  • Classifies the exportForm of each file (see table below).
  • Collects every require('...') + ES import...from, classifying usage (assign/new/member/bare).
  • Resolves per the Cocos 2.x model: relative-path (.js/.ts/index) FIRST, then a global-basename fallback (Cocos registers every script by its unique basename → require("pako"), or a relative path that fails to resolve, still resolves by name). A relative require that resolves to nothing is listed in _meta.danglingRequires (a pre-existing bug in the JS).
  • Reads the uuid from <file>.meta (used to transplant uuids during attach/cutover).
  • Builds the requiredBy reverse map + detects circular requires. Also folds in incoming edges from scripts OUTSIDE assets/script (editor tools, build entry-points) — Cocos compiles every script under assets/, and those can value-require into the set → affecting valueRequired/export-form. Such edges are marked external:true.
  • Analyzes globals: globalsProvided (window.X = ... defined by the file) + globalsUsed (window.X + bare free-globals via scope analysis) → feeds declare generation when converting to TS.
  • Analyzes the component-name graph (4th dependency channel): collects getComponent/addComponent/...("X") string refs (AST), the registered componentName per file (= cc.Class name option, else filename), reverse componentRefBy, _meta.frozenComponentNames (names the codemod MUST preserve), and _meta.brokenComponentRefs (string refs that match no component → dead/orphan).
  • Emits JSON facts to stdout — the skill body / agent reasons over them.
  • Collects the method call-arity map (methodArity: NAME → max args any call passes, whole-program; over-approximated by name) so t1k-cocos-migration-js2ts emits an arity-tolerance overload ONLY on over-called methods.
  • Computes transitive component-ness (isComponent per file): walks each cc.Class extends chain to a cc.* engine component base (ENGINE_COMPONENT_BASES) — extends cc.Component ✓, extends require("./Proto") follows the chain, no-extends ✗. t1k-cocos-migration-js2ts uses it to emit @property ONLY on real components (a non-component → plain fields, else TS1240).
  • Writes the registry (map file → {uuid, exportForm, tsExport, componentName}) + top-level methodArity.

Export-Form → TS export form decision table

Section titled “Export-Form → TS export form decision table”
exportFormDetection conditionTS export formNotes
commonjs-classmodule.exports = cc.Class(...) or = <var assigned cc.Class>export =JS require() from other files keeps working unchanged
commonjs-objectmodule.exports = { ... } or = function(){}export =Keeps require() compatibility
commonjs-namedOnly exports.foo = ... (no module.exports assignment)named (re-export each field)Uncommon, review manually
cc-componentTop-level bare cc.Class({...}) (no module.exports), Cocos auto-registersexport = if valueRequired, otherwise export default⚠️ Cocos auto-export → this file CAN be required as a value
noneNo export at all (side-effect/patch: assigns window.X, or cc.X = cc.Class(...) patches the engine)always export {} (even if valueRequired)none has no value → require() always returns {}; export = would CHANGE require’s return value = behavior change. Internal cc.Class patches are preserved, NO @ccclass
es-defaultAlready an ES module: has export default <expr>export defaultModule system ALREADY migrated; preserve. ES export wins over an in-file cc.Class
es-namedAlready an ES module: export class/const/function, export { a }, export * from (no default)namedPreserve. NEVER export {} (would delete real exports → break import X from)

General principle (every row above follows it): the TS export form must preserve the value require() currently returns for real consumers + keep class-registration/side-effects. valueRequired only changes the form when the file HAS a value to export (cc-component); a none file has no value, so it is always export {}.

CORE gotcha (usage-aware): Cocos 2.x auto-exports the cc.Class() result → a cc-component file CAN still be require()’d as a VALUE (e.g. var X = require("./Avatar"); ...ctor: X). Therefore tsExport is NOT decided by exportForm alone: if ANY file requires it with usage assign/new/member (flag valueRequired=true) → it MUST be export = (keep module.exports = X), otherwise require() returns {default:X} and the untouched JS consumer breaks. Only when valueRequired=false is a cc-component safe as export default.

dep-graph tracks globals as a 2nd dependency channel in parallel with require:

  • globalsProvided: files that assign window.X = ... (provider). Many none files “export” via a global this way (the registry still records exportForm=none, but globalsProvided shows they provide a global).
  • globalsUsed: window.X reads + bare free-globals (via scope analysis) → the list needing declare const X: any / declare global { interface Window {...} } for the TS to compile.
  • _meta.globalProvidedBy: global → defining file. _meta.externalGlobals: bare globals used but defined by NO file → SDK/runtime needing a declare.
{
"version": 1,
"scanned": 434,
"results": [
{
"file": "managers/LevelManager.js", // relative to target dir
"uuid": "3f7a1c29-...", // from .meta, null if absent
"exportForm": "cc-component", // see table above
"requires": [
{ "spec": "./Entity", "line": 3, "internal": true, "usage": "assign" } // assign|new|member|bare
],
"requiredBy": ["managers/GameManager.js"],
"requiredByDetail": [
{ "file": "managers/GameManager.js", "usage": "assign", "line": 42 },
{ "file": "../preloadModule.js", "usage": "assign", "line": 5, "external": true } // outside assets/script
],
"valueRequired": true, // true if any usage is assign/new/member
"circular": [],
"globalsProvided": ["TaskManager"], // window.X = ... defined by this file
"globalsUsed": [ { "name": "user", "kind": "window" }, { "name": "net", "kind": "bare" } ],
"scopeUnreliable": false, // true if a global eval()/with is present → bare-global best-effort
"componentName": "LevelManager", // registered name (= cc.Class name option, else filename); null if not a component
"componentRefs": [ { "name": "Avatar", "line": 88, "method": "getComponent" } ],
"componentRefBy": ["managers/GameSystem.js"] // who calls getComponent("LevelManager") → FROZEN name
}
],
"registry": {
"managers/LevelManager.js": {
"uuid": "3f7a1c29-...",
"exportForm": "cc-component",
"valueRequired": true,
"tsExport": "export =", // usage-aware: valueRequired → export =
"componentName": "LevelManager", // codemod MUST emit @ccclass("LevelManager")
"isComponent": true // transitively extends cc.Component? → @property valid (else plain fields/accessors)
}
},
"methodArity": { "getConfig": 3, "continue": 2, "onExit": 0 }, // NAME → max args any call passes (whole-program); js2ts overload targeting
"_meta": {
"exportFormCounts": { "cc-component": 301, "commonjs-object": 27 },
"circularFiles": [],
"topRequired": [{ "file": "...", "requiredBy": 12 }],
"globalProvidedBy": { "TaskManager": ["managers/TaskManager.js"] },
"externalGlobals": { "sdk": 44, "net": 30 }, // used but defined by no file → SDK/runtime
"danglingRequires": [ { "file": "scripts/TalentDefine.js", "spec": "../components/combat/buffs/BulletOnShot", "line": 1507 } ],
"frozenComponentNames": ["Item", "battleBottomUI", "LocalLabel"], // referenced via getComponent("X") → codemod MUST keep @ccclass name exact
"brokenComponentRefs": [ { "file": "scripts/TeamHead.js", "name": "GunModel", "line": 15 } ],
"externalConsumers": [ { "from": "../preloadModule.js", "target": "managers/EffectManager.js", "usage": "assign" } ]
}
}
Terminal window
# First run (auto-installs acorn, ~5s):
node .claude/skills/t1k-cocos-migration-dep-graph/scripts/dep-graph.cjs
# Human-readable summary:
node .claude/skills/t1k-cocos-migration-dep-graph/scripts/dep-graph.cjs --pretty
# Write the registry to a file:
node .claude/skills/t1k-cocos-migration-dep-graph/scripts/dep-graph.cjs --out tmp/dep-registry.json
# Target a specific folder (STILL full-tree scans for accurate requiredBy, then filters output):
node .claude/skills/t1k-cocos-migration-dep-graph/scripts/dep-graph.cjs <scriptRoot>/effects/visual/
# Detail for ONE file (full-tree scan → globally accurate requiredBy + valueRequired):
node .claude/skills/t1k-cocos-migration-dep-graph/scripts/dep-graph.cjs --pretty <scriptRoot>/components/visual/Avatar.js
  1. Cocos auto-export + value-require: a bare cc.Class({...}) file → cc-component, BUT Cocos auto-exports it so it CAN still be required as a value → in that case tsExport=export = (see the valueRequired flag). Do NOT assume a cc-component is always export default.

  2. Sub-path always full-tree scans: passing a single file or sub-dir still scans all of the script root and only then filters the output — so requiredBy/valueRequired/global stay globally accurate (a folder-only scan gives wrong numbers).

  3. Bare-global = scope analysis, may surface implicit-global leaks: an undeclared free identifier (e.g. e, rnd_name) is reported in globalsUsed — it may be an implicit-global bug, NOT a real global. Review before declare. The scope analysis leans toward over-reporting (safe: better to declare extra than to miss one → TS compile error).

9b. Builtin set is DATA-DRIVEN (not hardcoded): the builtin/ambient set used to filter globalsUsed is DERIVED at runtime: (1) Object.getOwnPropertyNames(globalThis) → JS/ECMAScript globals; (2) parsing declare in creator.d.ts → engine globals (cc, sp, dragonBones, CC_*, Editor); (3) a small stable DOM/Web residual + language-context names (require, module, exports, arguments, self). Mini-game/native platform globals (wx/tt/qq/swan/jsb/…) are deliberately NOT filtered — they’re in no TS lib AND absent from creator.d.ts, so they genuinely need a declare; leaving them unfiltered surfaces them in externalGlobals so the js2ts globals.d.ts generator declares them (SSOT, no duplicated allowlist). The final authority on “what needs a declare” is tsc at the js2ts step.

  1. eval/with no longer skips the whole file: AST distinguishes a global eval(/with-statement (scope-dangerous → scopeUnreliable flag) from a method obj.eval()/arr.with() (harmless). A scope-unreliable file still has full require/exportForm/window data; only bare-globals are best-effort.

  2. Aliased require + new: var $x = require("./X"); new $x() → the script detects this pattern and marks the usage as "new". Ensures export = for file X.

  3. Incoming edges from OUTSIDE assets/script (editor + build entry-points) — must be folded or export form is wrong: Cocos compiles EVERY script under assets/ (not only assets/script). Scanning only assets/script would under-report requiredBy → a cc-component value-required ONLY by an external script → wrongly classified export default instead of export =require() at the entry-point breaks. Fix: scan .js outside the tree (parent of scanRoot), fold VALUE edges into requiredBy/valueRequired, mark external:true + _meta.externalConsumers. Only .js is scanned (acorn can’t parse TS types; out-of-tree .ts don’t import into the set).

  4. cc.find("path") / getChildByName("X") are NOT a dep-graph channel (negative result): they are NODE names in the scene hierarchy, NOT script/class names. Node names live in .fire/.prefab (scene graph), do not map to a .js file → no script↔script edge. The codemod doesn’t change string literals → paths are invariant. Contrast with gotcha #12: getComponent("X") = class-name (script-dep, FROZEN) >< cc.find/getChildByName = node-name (orthogonal). Do NOT add this channel (YAGNI).

  5. properties: { x: { type: T } } is NOT a new dependency channel (verified, negative result): if type: Foo then Foo MUST already be require("./Foo")/imported in scope → the require-graph ALREADY has that edge; a type-ref is merely a “usage” of the require, not a new edge. Deciding which identifier emits @property({type: X}) is a transformation of the t1k-cocos-migration-js2ts codemod — NOT dep-graph’s job. Do NOT add a type-ref channel (YAGNI).

  6. getComponent(“X”) = 4th dependency channel, component name FROZEN: Cocos references a component by its string class-name via getComponent/addComponent/...("X"). The registered name = cc.Class name option if present, else = filename. Codemod consequence (integrity): when converting it must emit @ccclass("<componentName>") with the EXACT original name + keep the file basename, or every getComponent("X") returns null → crash. _meta.frozenComponentNames = names that must not change; _meta.brokenComponentRefs = refs pointing at a non-existent component (pre-existing dead/orphan, do NOT fix). Note: this channel does NOT affect tsExport.

  7. Cocos 2.x require by BASENAME (not only path): Cocos Creator 2.x registers every script in a flat namespace keyed by basename (script names MUST be unique). So require("pako") (no ./) AND a relative require("./Attribute") whose path doesn’t resolve → all resolve via the global basename. Resolver: relative-path (.js/.ts/index) first → basename fallback (unique index). NOT modeling this = missing requiredBy edges → may wrongly assign export default to a cc-component that should be export =. Safety condition: unique basenames (0 collisions) — on a collision the index drops that name (no guessing). _meta.danglingRequires lists relative requires that fail to resolve (a pre-existing JS bug, do NOT fix per integrity).

  8. Circular timing: static cycle detection over the graph — not runtime circular detection. A circular require in CommonJS JS still runs (Cocos accepts it); just track it when writing TS to avoid TDZ.

  9. UUID from .meta: read from <file>.js.meta. Null if the file was never imported into the Cocos editor (an orphan file). Check for null before transplanting a UUID.

  10. Strictly read-only: the script NEVER writes/edits .js files. If edits are needed — that’s a different skill (t1k-cocos-migration-js2ts).

  11. Parser fallback: ES5 → latest script → latest module. Uses ecmaVersion: 'latest' because some files are ES2022 with class fields. ES modules are NOT rare: many files already use import/export (hybrid: ES import + a cc.Class body, or export default). dep-graph collects import ... from, export ... from re-exports, and dynamic import('...') as dependency edges — just like require(). exportForm recognizes ES exports → es-default/es-named, never misclassifying them as none/export {}.

  12. Excluded dirs: node_modules, temp, library, build, dist, quick-scripts, BackupAssets, .git — skipped automatically.

  • Scan: Bash runs node .claude/skills/t1k-cocos-migration-dep-graph/scripts/dep-graph.cjs [options] [dir]
  • Registry analysis: the skill body reads the JSON output and reasons about exportForm, requiredBy, circular
  • Do NOT use Grep to detect exports — too slow and inaccurate with aliased patterns
  • t1k-cocos-migration-js2ts — codemod that consumes this skill’s Export-Form Registry as input.
  • t1k-cocos-migration-migrate — orchestrator; uses results[].extendsAbs/requires to topo-sort the closure.