From a2558fec3926b7150de00f05a90037e5c39e8229 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 22 Aug 2026 22:07:29 +0200 Subject: [PATCH] feat: add Phaser 4 rule pack detected from the phaser dependency Ten AST rules for scene shutdown ownership, global emitter leaks, no Phaser factories in update/tick, branded scene/texture keys, and no ignoreDestroy. Analogous to the three.js pack. --- eslint.config.js | 1 + packages/core/ARCHITECTURE.md | 6 +- packages/core/RULES.md | 10 + .../core/src/loop/feedback/pack-rule-docs.ts | 51 ++ .../loop/feedback/rule-docs.generated.json | 50 ++ packages/core/src/rule-packs/index.ts | 2 + packages/core/src/rule-packs/phaser/index.ts | 47 ++ .../phaser/rules/no-global-phaser.ts | 64 ++ .../phaser/rules/no-ignore-destroy.ts | 79 ++ .../phaser/rules/no-loader-in-update.ts | 57 ++ .../phaser/rules/no-phaser-alloc-in-update.ts | 81 ++ .../rules/no-phaser-import-in-pure-layers.ts | 107 +++ .../rules/no-physics-collider-in-update.ts | 57 ++ .../phaser/rules/no-raw-scene-key-literal.ts | 118 +++ .../rules/no-raw-texture-key-literal.ts | 59 ++ .../rules/no-unmanaged-global-listeners.ts | 58 ++ .../rules/require-scene-shutdown-hook.ts | 59 ++ packages/core/src/rule-packs/phaser/utils.ts | 753 ++++++++++++++++++ packages/core/src/rule-packs/rule-metadata.ts | 16 + packages/core/src/stack-detection/packs.ts | 11 + .../phaser-starter/src/domain/score.ts | 3 + .../src/runtime/phaser/scenes/BootScene.ts | 13 + .../runtime/phaser/scenes/WorldScene.setup.ts | 19 + .../src/runtime/phaser/scenes/WorldScene.ts | 24 + .../src/runtime/phaser/scenes/keys.ts | 2 + packages/core/tests/rule-pack-phaser.test.ts | 570 +++++++++++++ packages/core/tests/rule-packs.test.ts | 3 +- packages/core/tests/stack-detection.test.ts | 23 + packages/core/tsconfig.json | 2 +- 29 files changed, 2340 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/rule-packs/phaser/index.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-global-phaser.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-ignore-destroy.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-loader-in-update.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-phaser-alloc-in-update.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-phaser-import-in-pure-layers.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-physics-collider-in-update.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-raw-scene-key-literal.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-raw-texture-key-literal.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/no-unmanaged-global-listeners.ts create mode 100644 packages/core/src/rule-packs/phaser/rules/require-scene-shutdown-hook.ts create mode 100644 packages/core/src/rule-packs/phaser/utils.ts create mode 100644 packages/core/tests/fixtures/phaser-starter/src/domain/score.ts create mode 100644 packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/BootScene.ts create mode 100644 packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.setup.ts create mode 100644 packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.ts create mode 100644 packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/keys.ts create mode 100644 packages/core/tests/rule-pack-phaser.test.ts diff --git a/eslint.config.js b/eslint.config.js index ac59598d..14b62604 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,6 +26,7 @@ export default tseslint.config( // test itself lints them with `--no-ignore` to override this. SCOPED to the exact test location // (not a bare `**/…` glob) so it can't shadow real code that happens to share the sentinel name. "packages/core/src/loop/**/__adapter_boundary_*/**", + "packages/core/tests/fixtures/phaser-starter/**", ], }, { diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index ab03f102..8cd1b0f9 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **30 subsystems**, **651 files**, **129k lines**, **143 cross-subsystem edges**. +Derived from `packages/core/src`: **30 subsystems**, **663 files**, **131k lines**, **143 cross-subsystem edges**. This page is the exhaustive record: every subsystem, every cross-subsystem edge, and @@ -14,7 +14,7 @@ inventory, see the hand-drawn map on [Internals](/internals/). | Subsystem | Purpose | Tier | Files | Lines | Fan-in | Fan-out | | --- | --- | --- | --- | --- | --- | --- | | `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 155 | 43k | 8 | 22 | -| `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 179 | 22k | 3 | 3 | +| `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 191 | 23k | 3 | 3 | | `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 45 | 11k | 7 | 7 | | `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 22 | 8k | 2 | 18 | | `agent` | Tool schemas, the model-as-agent wrapper, and the malformed-tool-call repair ladder | core | 11 | 4k | 8 | 9 | @@ -65,7 +65,7 @@ buries the ones someone can actually go and break. | `agent` ↔ `policy` | `agent/agent-runner.ts:20` → `../policy` | `policy/classify.ts:1` → `../agent` | | `agent` ↔ `spec` | `agent/agent.types.ts:1` → `../spec` | `spec/generate-tests.ts:5` → `../agent` | | `cli` ↔ `render` | `cli/banner.ts:9` → `../render` | `render/command-menu.ts:2` → `../cli/commands` | -| `config` ↔ `rule-packs` | `config/external-plugins.ts:5` → `../rule-packs` | `rule-packs/index.ts:133` → `../config/plugin-fingerprint` | +| `config` ↔ `rule-packs` | `config/external-plugins.ts:5` → `../rule-packs` | `rule-packs/index.ts:135` → `../config/plugin-fingerprint` | | `editor` ↔ `render` | `editor/completion.ts:8` → `../render/file-menu` | `render/frame/input-seq.ts:1` → `../../editor/segments` | | `eval` ↔ `loop` | `eval/failure-class.ts:1` → `../loop/loop.types` | `loop/loop.types.ts:7` → `../eval/failure-class` | | `inference` ↔ `loop` | `inference/wire.ts:10` → `../loop/context-hygiene` | `loop/assistant-message.ts:1` → `../inference` | diff --git a/packages/core/RULES.md b/packages/core/RULES.md index 43ba235b..a49ccfdd 100644 --- a/packages/core/RULES.md +++ b/packages/core/RULES.md @@ -19,6 +19,9 @@ Rules are grouped by **adoption tier**. Use `profile` in `tsforge.config.json` t - **jwt-cookies/auth-cookie-must-set-samesite** [ERROR]: Auth-cookie writes must set `sameSite` (`strict` or `lax`) — missing SameSite allows cross-site cookie delivery. - **jwt-cookies/jwt-must-verify-not-decode** [ERROR]: Disallow `jwt.decode` / `decodeJwt` — decoding without verification accepts forged tokens. Use `jwt.verify` or `jwtVerify` instead. - **nextjs/no-secret-props-to-client** [WARN]: Warn when Server Components pass secret-looking props to JSX — values may cross the client boundary. +- **phaser/no-ignore-destroy** [ERROR]: Do not set GameObject.ignoreDestroy. Scene/Group destroy will skip the object and you own the reference forever. +- **phaser/no-phaser-import-in-pure-layers** [ERROR]: Do not import phaser from domain, content, shared, or features layers. Keep engine code in runtime, app, game, or scenes. +- **phaser/no-unmanaged-global-listeners** [ERROR]: Do not attach window/document/Game/Registry/Scale/Animation/Texture listeners from a Phaser.Scene. Scene-owned emitters are cleaned on shutdown; game-lifetime emitters belong in app bootstrap. - **runtime-boundaries/no-prototype-polluting-merge** [ERROR]: Disallow merging request body/query/params into objects — enables prototype pollution. - **runtime-boundaries/no-user-controlled-fetch-url** [ERROR]: Disallow fetch/axios requests whose ORIGIN is not fixed at author time — a runtime-controlled host enables SSRF. - **runtime-boundaries/no-user-controlled-redirect** [ERROR]: Disallow redirects to non-literal URLs — user-controlled redirects enable open redirects. @@ -97,6 +100,11 @@ Rules are grouped by **adoption tier**. Use `profile` in `tsforge.config.json` t - **oauth-security/pkce-required-for-oidc** [ERROR]: OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`. - **oauth-security/state-must-be-redis-backed** [ERROR]: OAuth state must be persisted to Redis and not stuffed into a cookie. Cookie-backed state lets attackers replay forged state across sessions. - **oauth-security/state-ttl-bounded** [ERROR]: OAuth state writes to Redis must use a short TTL — long-lived state widens the replay window. +- **phaser/no-global-phaser** [WARN]: Do not rely on a script-tag global `Phaser` identifier or `require('phaser')`. Import from the `phaser` package so the runtime is one module graph. +- **phaser/no-loader-in-update** [ERROR]: Do not call the Phaser Loader from update/tick/preUpdate. Queue assets in preload or a declared runtime-load path. +- **phaser/no-phaser-alloc-in-update** [WARN]: Do not construct Phaser GameObjects, Geom, or Math objects inside update/tick/preUpdate. Preallocate or pool; setText in the tick re-uploads a GPU texture. +- **phaser/no-physics-collider-in-update** [ERROR]: Do not register Arcade overlap/collider handlers inside update/tick/preUpdate. Create them once in setup/create. +- **phaser/require-scene-shutdown-hook** [ERROR]: A Phaser.Scene that binds persistent listeners must register a SHUTDOWN (or DESTROY) handler so restarts do not leak callbacks. - **react-component-architecture/dangerous-html-requires-sanitize** [ERROR]: dangerouslySetInnerHTML requires a sanitization library (DOMPurify or equivalent) imported in the same file. - **react-component-architecture/forwardref-display-name** [ERROR]: forwardRef components must have displayName set - **react-component-architecture/index-must-reexport-default** [ERROR]: index.ts in component folders must re-export the component default export and types @@ -139,6 +147,8 @@ Rules are grouped by **adoption tier**. Use `profile` in `tsforge.config.json` t - **comment-hygiene/no-historical-comments** [ERROR]: Disallow comments that frame code relative to what it used to do or to a past incident ('Codex flagged X', 'before the fix', 'after the refactor', 'we used to', 'no longer'). Source comments must describe the current invariant; history belongs in the commit message or PR description, where it doesn't rot when the code changes again. - **comment-hygiene/no-narration-comments** [ERROR]: Disallow narrative comments like 'Here we...', 'Now we...', 'First, we...'. These read as step-by-step prose and add no information a future reader can't get from the code itself. Often a tell that the comment was generated by an agent describing its own changes. - **comment-hygiene/no-pr-reference-comments** [ERROR]: Disallow PR/issue references in comments. They belong in commit messages and PR descriptions — leaving them in source rots when the repo moves, the issue tracker migrates, or the numbering changes. +- **phaser/no-raw-scene-key-literal** [ERROR]: Pass scene keys as named constants, not string literals, to scene.start/launch/stop and Scene constructors. +- **phaser/no-raw-texture-key-literal** [WARN]: Pass texture and audio keys as named constants, not string literals, to load/add/textures/sound APIs. - **react-component-architecture/component-file-purity** [ERROR]: A component .tsx contains only imports and the component itself — types go to .types.ts, constants to .constants.ts, helpers to src/lib - **react-component-architecture/component-folder-structure** [ERROR]: A component .tsx must live in src/views//components/ or src/features//components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views|features//index.tsx - **react-component-architecture/no-inline-jsx-functions** [WARN]: Disallow inline function expressions in JSX attributes diff --git a/packages/core/src/loop/feedback/pack-rule-docs.ts b/packages/core/src/loop/feedback/pack-rule-docs.ts index 8ff36db9..e49d5963 100644 --- a/packages/core/src/loop/feedback/pack-rule-docs.ts +++ b/packages/core/src/loop/feedback/pack-rule-docs.ts @@ -698,4 +698,55 @@ export const PACK_RULE_DOCS: Record = { bad: 'import { Mesh } from "three";\nconst mesh = new Mesh();\nmesh.frustumCulled = false;', good: 'import { Mesh } from "three";\nconst mesh = new Mesh();\nmesh.frustumCulled = true;', }, + "tsforge/no-global-phaser": { + what: 'Do not use a script-tag global `Phaser` or `require("phaser")`. Import from the `phaser` package so every module shares one copy.', + bad: "const game = new Phaser.Game();\n", + good: 'import * as Phaser from "phaser";\nconst game = new Phaser.Game();\n', + }, + "tsforge/no-ignore-destroy": { + what: "Do not set `ignoreDestroy`. Scene/Group destroy will skip the object and you own the reference forever.", + bad: 'import * as Phaser from "phaser";\nexport function persist(obj: { ignoreDestroy: boolean }) {\n obj.ignoreDestroy = true;\n return Phaser;\n}\n', + good: 'import * as Phaser from "phaser";\nexport function persist(obj: { ignoreDestroy: boolean }) {\n obj.ignoreDestroy = false;\n return Phaser;\n}\n', + }, + "tsforge/no-loader-in-update": { + what: "Do not call the Phaser Loader from `update`/`tick`. Queue assets in `preload` or a one-shot load path.", + bad: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n update() {\n this.load.image("hero", "hero.png");\n }\n}\n', + good: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n preload() {\n this.load.image("hero", "hero.png");\n }\n update() {\n return;\n }\n}\n', + }, + "tsforge/no-phaser-alloc-in-update": { + what: "Do not construct Phaser GameObjects or Math/Geom objects inside `update`/`tick`. Create once and reuse; `setText` in the tick re-uploads a GPU texture.", + bad: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n update() {\n this.add.image(0, 0, "hero");\n }\n}\n', + good: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n create() {\n this.add.image(0, 0, "hero");\n }\n update() {\n return;\n }\n}\n', + }, + "tsforge/no-phaser-import-in-pure-layers": { + what: "Do not import `phaser` from domain, content, shared, or features layers. Move engine code to runtime, app, game, or scenes.", + bad: 'import * as Phaser from "phaser";\nexport function playerSpeed(base: number): number {\n return base + new Phaser.Math.Vector2().x;\n}\n', + good: "export function playerSpeed(base: number): number {\n return base;\n}\n", + exampleFile: "src/domain/score.ts", + }, + "tsforge/no-physics-collider-in-update": { + what: "Do not register Arcade overlap/collider handlers inside `update`/`tick`. Create them once in `create` or scene setup.", + bad: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n update() {\n this.physics.add.overlap(this, this, () => undefined);\n }\n}\n', + good: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n create() {\n this.physics.add.overlap(this, this, () => undefined);\n }\n update() {\n return;\n }\n}\n', + }, + "tsforge/no-raw-scene-key-literal": { + what: "Pass scene keys as named constants, not string literals, to `scene.start`/`launch`/`stop` and Scene constructors.", + bad: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n create() {\n this.scene.start("World");\n }\n}\n', + good: 'import * as Phaser from "phaser";\nconst WORLD = "World";\nexport class Play extends Phaser.Scene {\n create() {\n this.scene.start(WORLD);\n }\n}\n', + }, + "tsforge/no-raw-texture-key-literal": { + what: "Pass texture and audio keys as named constants, not string literals, to load/add/textures/sound APIs.", + bad: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n preload() {\n this.load.image("hero", "hero.png");\n }\n}\n', + good: 'import * as Phaser from "phaser";\nconst HERO = "hero";\nexport class Play extends Phaser.Scene {\n preload() {\n this.load.image(HERO, "hero.png");\n }\n}\n', + }, + "tsforge/no-unmanaged-global-listeners": { + what: "Do not attach window, document, Game, Registry, Scale, Animation, or Texture listeners from a Phaser.Scene. Use scene-owned `this.events` / `this.input`, or bind game-lifetime listeners in app bootstrap.", + bad: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n create() {\n window.addEventListener("resize", () => undefined);\n }\n}\n', + good: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n create() {\n this.input.on("pointerdown", () => undefined);\n }\n}\n', + }, + "tsforge/require-scene-shutdown-hook": { + what: "A Phaser.Scene that binds persistent listeners must register `Phaser.Scenes.Events.SHUTDOWN` so restarts do not leak callbacks.", + bad: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n create() {\n this.input.on("pointerdown", () => undefined);\n }\n}\n', + good: 'import * as Phaser from "phaser";\nexport class Play extends Phaser.Scene {\n create() {\n this.input.on("pointerdown", () => undefined);\n this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => undefined);\n }\n}\n', + }, }; diff --git a/packages/core/src/loop/feedback/rule-docs.generated.json b/packages/core/src/loop/feedback/rule-docs.generated.json index 44ad1597..6510fb49 100644 --- a/packages/core/src/loop/feedback/rule-docs.generated.json +++ b/packages/core/src/loop/feedback/rule-docs.generated.json @@ -479,6 +479,56 @@ "bad": "", "good": "" }, + "tsforge/no-global-phaser": { + "what": "Do not rely on a script-tag global `Phaser` identifier or `require('phaser')`. Import from the `phaser` package so the runtime is one module graph.", + "bad": "", + "good": "" + }, + "tsforge/no-ignore-destroy": { + "what": "Do not set GameObject.ignoreDestroy. Scene/Group destroy will skip the object and you own the reference forever.", + "bad": "", + "good": "" + }, + "tsforge/no-loader-in-update": { + "what": "Do not call the Phaser Loader from update/tick/preUpdate. Queue assets in preload or a declared runtime-load path.", + "bad": "", + "good": "" + }, + "tsforge/no-phaser-alloc-in-update": { + "what": "Do not construct Phaser GameObjects, Geom, or Math objects inside update/tick/preUpdate. Preallocate or pool; setText in the tick re-uploads a GPU texture.", + "bad": "", + "good": "" + }, + "tsforge/no-phaser-import-in-pure-layers": { + "what": "Do not import phaser from domain, content, shared, or features layers. Keep engine code in runtime, app, game, or scenes.", + "bad": "", + "good": "" + }, + "tsforge/no-physics-collider-in-update": { + "what": "Do not register Arcade overlap/collider handlers inside update/tick/preUpdate. Create them once in setup/create.", + "bad": "", + "good": "" + }, + "tsforge/no-raw-scene-key-literal": { + "what": "Pass scene keys as named constants, not string literals, to scene.start/launch/stop and Scene constructors.", + "bad": "", + "good": "" + }, + "tsforge/no-raw-texture-key-literal": { + "what": "Pass texture and audio keys as named constants, not string literals, to load/add/textures/sound APIs.", + "bad": "", + "good": "" + }, + "tsforge/no-unmanaged-global-listeners": { + "what": "Do not attach window/document/Game/Registry/Scale/Animation/Texture listeners from a Phaser.Scene. Scene-owned emitters are cleaned on shutdown; game-lifetime emitters belong in app bootstrap.", + "bad": "", + "good": "" + }, + "tsforge/require-scene-shutdown-hook": { + "what": "A Phaser.Scene that binds persistent listeners must register a SHUTDOWN (or DESTROY) handler so restarts do not leak callbacks.", + "bad": "", + "good": "" + }, "tsforge/component-file-purity": { "what": "A component .tsx contains only imports and the component itself — types go to .types.ts, constants to .constants.ts, helpers to src/lib", "bad": "", diff --git a/packages/core/src/rule-packs/index.ts b/packages/core/src/rule-packs/index.ts index 0600679b..a3011a7c 100644 --- a/packages/core/src/rule-packs/index.ts +++ b/packages/core/src/rule-packs/index.ts @@ -16,6 +16,7 @@ import { jwtCookiesPack } from "./jwt-cookies"; import { moduleBoundariesPack } from "./module-boundaries"; import { nextjsPack } from "./nextjs"; import { oauthSecurityPack } from "./oauth-security"; +import { phaserPack } from "./phaser"; import { reactComponentArchitecturePack } from "./react-component-architecture"; import { runtimeBoundariesPack } from "./runtime-boundaries"; import { securityPack } from "./security"; @@ -42,6 +43,7 @@ export const RULE_PACKS = { "module-boundaries": moduleBoundariesPack, nextjs: nextjsPack, "oauth-security": oauthSecurityPack, + phaser: phaserPack, "react-component-architecture": reactComponentArchitecturePack, "runtime-boundaries": runtimeBoundariesPack, security: securityPack, diff --git a/packages/core/src/rule-packs/phaser/index.ts b/packages/core/src/rule-packs/phaser/index.ts new file mode 100644 index 00000000..cf260261 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/index.ts @@ -0,0 +1,47 @@ +import type { TSESLint } from "@typescript-eslint/utils"; + +import { noGlobalPhaserRule } from "./rules/no-global-phaser"; +import { noIgnoreDestroyRule } from "./rules/no-ignore-destroy"; +import { noLoaderInUpdateRule } from "./rules/no-loader-in-update"; +import { noPhaserAllocInUpdateRule } from "./rules/no-phaser-alloc-in-update"; +import { noPhaserImportInPureLayersRule } from "./rules/no-phaser-import-in-pure-layers"; +import { noPhysicsColliderInUpdateRule } from "./rules/no-physics-collider-in-update"; +import { noRawSceneKeyLiteralRule } from "./rules/no-raw-scene-key-literal"; +import { noRawTextureKeyLiteralRule } from "./rules/no-raw-texture-key-literal"; +import { noUnmanagedGlobalListenersRule } from "./rules/no-unmanaged-global-listeners"; +import { requireSceneShutdownHookRule } from "./rules/require-scene-shutdown-hook"; +import type { IRulePack } from "../rule-packs.types"; + +const rules: Record> = { + "no-global-phaser": noGlobalPhaserRule, + "no-ignore-destroy": noIgnoreDestroyRule, + "no-loader-in-update": noLoaderInUpdateRule, + "no-phaser-alloc-in-update": noPhaserAllocInUpdateRule, + "no-phaser-import-in-pure-layers": noPhaserImportInPureLayersRule, + "no-physics-collider-in-update": noPhysicsColliderInUpdateRule, + "no-raw-scene-key-literal": noRawSceneKeyLiteralRule, + "no-raw-texture-key-literal": noRawTextureKeyLiteralRule, + "no-unmanaged-global-listeners": noUnmanagedGlobalListenersRule, + "require-scene-shutdown-hook": requireSceneShutdownHookRule, +}; + +export const phaserPack: IRulePack = { + id: "phaser", + description: + "Phaser 4 as a render substrate: scene shutdown ownership, no global emitter leaks, no Phaser factories in the tick, branded scene/texture keys", + rules, + rulesConfig: { + "no-global-phaser": "warn", + "no-ignore-destroy": "error", + "no-loader-in-update": "error", + "no-phaser-alloc-in-update": "warn", + "no-phaser-import-in-pure-layers": "error", + "no-physics-collider-in-update": "error", + "no-raw-scene-key-literal": "error", + "no-raw-texture-key-literal": "warn", + "no-unmanaged-global-listeners": "error", + "require-scene-shutdown-hook": "error", + }, +}; + +export default phaserPack; diff --git a/packages/core/src/rule-packs/phaser/rules/no-global-phaser.ts b/packages/core/src/rule-packs/phaser/rules/no-global-phaser.ts new file mode 100644 index 00000000..e3393445 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-global-phaser.ts @@ -0,0 +1,64 @@ +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + isImportedPhaserBinding, + isNonValueIdentifier, + isPhaserPackageSource, + requireSource, +} from "../utils"; + +export const RULE_NAME = "no-global-phaser"; + +type MessageIds = "globalPhaser" | "requirePhaser"; + +export const noGlobalPhaserRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "Do not rely on a script-tag global `Phaser` identifier or `require('phaser')`. Import from the `phaser` package so the runtime is one module graph.", + }, + schema: [], + messages: { + globalPhaser: + '`Phaser` is not imported in this file. Use `import * as Phaser from "phaser"` instead of a script-tag global.', + requirePhaser: + 'Use `import * as Phaser from "phaser"` instead of `require("phaser")`.', + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + Identifier(node) { + if (node.name !== "Phaser") { + return; + } + + if (isImportedPhaserBinding(node, imports)) { + return; + } + + if (isNonValueIdentifier(node)) { + return; + } + + context.report({ node, messageId: "globalPhaser" }); + }, + CallExpression(node) { + const source = requireSource(node); + + if (source === null || !isPhaserPackageSource(source)) { + return; + } + + context.report({ node, messageId: "requirePhaser" }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/no-ignore-destroy.ts b/packages/core/src/rule-packs/phaser/rules/no-ignore-destroy.ts new file mode 100644 index 00000000..fe7444f2 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-ignore-destroy.ts @@ -0,0 +1,79 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; + +import { createRule } from "../../create-rule"; +import { analyzePhaserImports, isLiteralTrue } from "../utils"; + +export const RULE_NAME = "no-ignore-destroy"; + +type MessageIds = "ignoreDestroy"; + +export const noIgnoreDestroyRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "Do not set GameObject.ignoreDestroy. Scene/Group destroy will skip the object and you own the reference forever.", + }, + schema: [], + messages: { + ignoreDestroy: + "Do not set `ignoreDestroy`. Keep cross-scene objects in a game-lifetime service, or pool them.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + AssignmentExpression(node) { + if (!imports.hasPhaserImport) { + return; + } + + if (node.left.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + if ( + node.left.computed || + node.left.property.type !== AST_NODE_TYPES.Identifier + ) { + return; + } + + if (node.left.property.name !== "ignoreDestroy") { + return; + } + + if (!isLiteralTrue(node.right)) { + return; + } + + context.report({ node, messageId: "ignoreDestroy" }); + }, + Property(node) { + if (!imports.hasPhaserImport) { + return; + } + + if (node.computed || node.key.type !== AST_NODE_TYPES.Identifier) { + return; + } + + if (node.key.name !== "ignoreDestroy") { + return; + } + + if (!isLiteralTrue(node.value)) { + return; + } + + context.report({ node, messageId: "ignoreDestroy" }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/no-loader-in-update.ts b/packages/core/src/rule-packs/phaser/rules/no-loader-in-update.ts new file mode 100644 index 00000000..fa9caf7d --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-loader-in-update.ts @@ -0,0 +1,57 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; + +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + createHotPathTracker, + hotPathVisitors, + isLoaderCall, + memberChain, +} from "../utils"; + +export const RULE_NAME = "no-loader-in-update"; + +type MessageIds = "loaderInUpdate"; + +export const noLoaderInUpdateRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "Do not call the Phaser Loader from update/tick/preUpdate. Queue assets in preload or a declared runtime-load path.", + }, + schema: [], + messages: { + loaderInUpdate: + "Do not call `load.*` inside `update`/`tick`/`preUpdate`. Queue assets in `preload()` or a one-shot load path.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + const hot = createHotPathTracker(); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + ...hotPathVisitors(hot), + CallExpression(node) { + if (!imports.hasPhaserImport || !hot.isInHotPath()) { + return; + } + + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + if (!isLoaderCall(memberChain(node.callee))) { + return; + } + + context.report({ node, messageId: "loaderInUpdate" }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/no-phaser-alloc-in-update.ts b/packages/core/src/rule-packs/phaser/rules/no-phaser-alloc-in-update.ts new file mode 100644 index 00000000..d4648e22 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-phaser-alloc-in-update.ts @@ -0,0 +1,81 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; + +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + createHotPathTracker, + hotPathVisitors, + isAddFactoryCall, + isPhaserNamespacedNew, + isPhysicsAddCtorCall, + isTextMutationCall, + memberChain, +} from "../utils"; + +export const RULE_NAME = "no-phaser-alloc-in-update"; + +type MessageIds = "factoryInUpdate" | "ctorInUpdate" | "textInUpdate"; + +export const noPhaserAllocInUpdateRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "suggestion", + docs: { + description: + "Do not construct Phaser GameObjects, Geom, or Math objects inside update/tick/preUpdate. Preallocate or pool; setText in the tick re-uploads a GPU texture.", + }, + schema: [], + messages: { + factoryInUpdate: + "Do not call `add`/`make`/`physics.add` factories inside `update`/`tick`. Create GameObjects once and reuse them.", + ctorInUpdate: + "Do not construct Phaser Math/Geom/GameObject instances inside `update`/`tick`. Use a preallocated buffer or pool.", + textInUpdate: + "Do not call `setText`/`setStyle` inside `update`/`tick` — Phaser 4 re-uploads the whole text texture. Update only when the string changes.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + const hot = createHotPathTracker(); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + ...hotPathVisitors(hot), + NewExpression(node) { + if (!imports.hasPhaserImport || !hot.isInHotPath()) { + return; + } + + if (!isPhaserNamespacedNew(node, imports)) { + return; + } + + context.report({ node, messageId: "ctorInUpdate" }); + }, + CallExpression(node) { + if (!imports.hasPhaserImport || !hot.isInHotPath()) { + return; + } + + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + const chain = memberChain(node.callee); + + if (isTextMutationCall(chain)) { + context.report({ node, messageId: "textInUpdate" }); + + return; + } + + if (isAddFactoryCall(chain) || isPhysicsAddCtorCall(chain)) { + context.report({ node, messageId: "factoryInUpdate" }); + } + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/no-phaser-import-in-pure-layers.ts b/packages/core/src/rule-packs/phaser/rules/no-phaser-import-in-pure-layers.ts new file mode 100644 index 00000000..66580dcc --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-phaser-import-in-pure-layers.ts @@ -0,0 +1,107 @@ +import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema"; + +import { createRule } from "../../create-rule"; +import { matchesAnyGlobPattern, ruleRelativePath } from "../../utils"; +import { isPhaserPackageSource, requireSource } from "../utils"; + +export const RULE_NAME = "no-phaser-import-in-pure-layers"; + +export interface INoPhaserImportInPureLayersOptions { + readonly denyGlobs?: readonly string[]; + readonly allowGlobs?: readonly string[]; +} + +type RuleOptions = [INoPhaserImportInPureLayersOptions]; +type MessageIds = "phaserInPureLayer"; + +export const DEFAULT_DENY_GLOBS = [ + "**/domain/**", + "**/content/**", + "**/shared/**", + "**/features/**", +] as const; + +export const DEFAULT_ALLOW_GLOBS = [ + "**/runtime/**", + "**/app/**", + "**/game/**", + "**/scenes/**", +] as const; + +const optionSchema: JSONSchema4 = { + type: "object", + additionalProperties: false, + properties: { + denyGlobs: { + type: "array", + items: { type: "string" }, + uniqueItems: true, + }, + allowGlobs: { + type: "array", + items: { type: "string" }, + uniqueItems: true, + }, + }, +}; + +export const noPhaserImportInPureLayersRule = createRule< + RuleOptions, + MessageIds +>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "Do not import phaser from domain, content, shared, or features layers. Keep engine code in runtime, app, game, or scenes.", + }, + schema: [optionSchema], + messages: { + phaserInPureLayer: + "Do not import `phaser` from domain, content, shared, or features layers. Move engine code to runtime, app, game, or scenes.", + }, + }, + defaultOptions: [ + { + denyGlobs: [...DEFAULT_DENY_GLOBS], + allowGlobs: [...DEFAULT_ALLOW_GLOBS], + }, + ], + create(context, [options]) { + const denyGlobs = options.denyGlobs ?? DEFAULT_DENY_GLOBS; + const allowGlobs = options.allowGlobs ?? DEFAULT_ALLOW_GLOBS; + const relative = ruleRelativePath(context.filename, context.cwd); + + if (matchesAnyGlobPattern(relative, allowGlobs)) { + return {}; + } + + if (!matchesAnyGlobPattern(relative, denyGlobs)) { + return {}; + } + + return { + ImportDeclaration(node) { + if (typeof node.source.value !== "string") { + return; + } + + if (!isPhaserPackageSource(node.source.value)) { + return; + } + + context.report({ node, messageId: "phaserInPureLayer" }); + }, + CallExpression(node) { + const source = requireSource(node); + + if (source === null || !isPhaserPackageSource(source)) { + return; + } + + context.report({ node, messageId: "phaserInPureLayer" }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/no-physics-collider-in-update.ts b/packages/core/src/rule-packs/phaser/rules/no-physics-collider-in-update.ts new file mode 100644 index 00000000..fe9f3389 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-physics-collider-in-update.ts @@ -0,0 +1,57 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; + +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + createHotPathTracker, + hotPathVisitors, + isPhysicsColliderCall, + memberChain, +} from "../utils"; + +export const RULE_NAME = "no-physics-collider-in-update"; + +type MessageIds = "colliderInUpdate"; + +export const noPhysicsColliderInUpdateRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "Do not register Arcade overlap/collider handlers inside update/tick/preUpdate. Create them once in setup/create.", + }, + schema: [], + messages: { + colliderInUpdate: + "Do not call `physics.add.overlap`/`collider` (or `world.addCollider`/`addOverlap`) inside `update`/`tick`. Register colliders once in `create` or scene setup.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + const hot = createHotPathTracker(); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + ...hotPathVisitors(hot), + CallExpression(node) { + if (!imports.hasPhaserImport || !hot.isInHotPath()) { + return; + } + + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + if (!isPhysicsColliderCall(memberChain(node.callee))) { + return; + } + + context.report({ node, messageId: "colliderInUpdate" }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/no-raw-scene-key-literal.ts b/packages/core/src/rule-packs/phaser/rules/no-raw-scene-key-literal.ts new file mode 100644 index 00000000..4c36dfbd --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-raw-scene-key-literal.ts @@ -0,0 +1,118 @@ +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"; + +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + isPhaserSceneClass, + isScenePluginCall, + memberChain, + stringLiteralValue, +} from "../utils"; + +export const RULE_NAME = "no-raw-scene-key-literal"; + +type MessageIds = "rawSceneKey" | "rawSuperKey"; + +export const noRawSceneKeyLiteralRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "Pass scene keys as named constants, not string literals, to scene.start/launch/stop and Scene constructors.", + }, + schema: [], + messages: { + rawSceneKey: + "Do not pass a string literal to `scene.{{method}}`. Use a named scene-key constant.", + rawSuperKey: + "Do not pass a string literal to `super()`. Use a named scene-key constant.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + CallExpression(node) { + if (!imports.hasPhaserImport) { + return; + } + + if (node.callee.type === AST_NODE_TYPES.Super) { + reportSuperLiteral(node, imports); + + return; + } + + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + const chain = memberChain(node.callee); + + if (!isScenePluginCall(chain)) { + return; + } + + const keyArg = node.arguments[0]; + + if (stringLiteralValue(keyArg) === null) { + return; + } + + const method = chain[chain.length - 1]; + + context.report({ + node: keyArg ?? node, + messageId: "rawSceneKey", + data: { method: method ?? "start" }, + }); + }, + }; + + function reportSuperLiteral( + node: TSESTree.CallExpression, + currentImports: typeof imports + ): void { + const keyArg = node.arguments[0]; + + if (stringLiteralValue(keyArg) === null) { + return; + } + + const cls = enclosingClass(node); + + if (cls === null || !isPhaserSceneClass(cls, currentImports)) { + return; + } + + context.report({ + node: keyArg ?? node, + messageId: "rawSuperKey", + }); + } + }, +}); + +function enclosingClass( + node: TSESTree.Node +): TSESTree.ClassDeclaration | TSESTree.ClassExpression | null { + let current: TSESTree.Node | undefined = node.parent; + + while (current !== undefined) { + if ( + current.type === AST_NODE_TYPES.ClassDeclaration || + current.type === AST_NODE_TYPES.ClassExpression + ) { + return current; + } + + current = current.parent; + } + + return null; +} diff --git a/packages/core/src/rule-packs/phaser/rules/no-raw-texture-key-literal.ts b/packages/core/src/rule-packs/phaser/rules/no-raw-texture-key-literal.ts new file mode 100644 index 00000000..18e43e27 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-raw-texture-key-literal.ts @@ -0,0 +1,59 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; + +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + isTextureKeyCall, + memberChain, + stringLiteralValue, +} from "../utils"; + +export const RULE_NAME = "no-raw-texture-key-literal"; + +type MessageIds = "rawTextureKey"; + +export const noRawTextureKeyLiteralRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "suggestion", + docs: { + description: + "Pass texture and audio keys as named constants, not string literals, to load/add/textures/sound APIs.", + }, + schema: [], + messages: { + rawTextureKey: + "Do not pass a string literal as a texture/audio key. Use a named key constant.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + CallExpression(node) { + if (!imports.hasPhaserImport) { + return; + } + + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + const keyArg = isTextureKeyCall( + memberChain(node.callee), + node.arguments + ); + + if (keyArg === null || stringLiteralValue(keyArg) === null) { + return; + } + + context.report({ node: keyArg, messageId: "rawTextureKey" }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/no-unmanaged-global-listeners.ts b/packages/core/src/rule-packs/phaser/rules/no-unmanaged-global-listeners.ts new file mode 100644 index 00000000..20ddde20 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/no-unmanaged-global-listeners.ts @@ -0,0 +1,58 @@ +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + isForbiddenGlobalListener, + isPhaserSceneClass, +} from "../utils"; + +export const RULE_NAME = "no-unmanaged-global-listeners"; + +type MessageIds = "unmanagedGlobal"; + +export const noUnmanagedGlobalListenersRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "Do not attach window/document/Game/Registry/Scale/Animation/Texture listeners from a Phaser.Scene. Scene-owned emitters are cleaned on shutdown; game-lifetime emitters belong in app bootstrap.", + }, + schema: [], + messages: { + unmanagedGlobal: + "Do not subscribe to window, document, Game, Registry, Scale, Animation, or Texture emitters from a Scene. Use `this.events` / `this.input` (scene-owned) or bind game-lifetime listeners in app bootstrap.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + let sceneDepth = 0; + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + ClassDeclaration(node) { + if (isPhaserSceneClass(node, imports)) { + sceneDepth += 1; + } + }, + "ClassDeclaration:exit"(node) { + if (isPhaserSceneClass(node, imports) && sceneDepth > 0) { + sceneDepth -= 1; + } + }, + CallExpression(node) { + if (!imports.hasPhaserImport || sceneDepth === 0) { + return; + } + + if (!isForbiddenGlobalListener(node)) { + return; + } + + context.report({ node, messageId: "unmanagedGlobal" }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/rules/require-scene-shutdown-hook.ts b/packages/core/src/rule-packs/phaser/rules/require-scene-shutdown-hook.ts new file mode 100644 index 00000000..b22019c9 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/rules/require-scene-shutdown-hook.ts @@ -0,0 +1,59 @@ +import { createRule } from "../../create-rule"; +import { + analyzePhaserImports, + classBindsPersistentListeners, + isPhaserSceneClass, +} from "../utils"; + +export const RULE_NAME = "require-scene-shutdown-hook"; + +type MessageIds = "missingShutdownHook"; + +export const requireSceneShutdownHookRule = createRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: "problem", + docs: { + description: + "A Phaser.Scene that binds persistent listeners must register a SHUTDOWN (or DESTROY) handler so restarts do not leak callbacks.", + }, + schema: [], + messages: { + missingShutdownHook: + "Scene '{{name}}' binds persistent listeners but never registers `Phaser.Scenes.Events.SHUTDOWN` (or DESTROY). Shutdown is the restart-safe dispose point.", + }, + }, + defaultOptions: [], + create(context) { + let imports = analyzePhaserImports(context.sourceCode.ast); + + return { + Program(program) { + imports = analyzePhaserImports(program); + }, + ClassDeclaration(node) { + if (!imports.hasPhaserImport) { + return; + } + + if (!isPhaserSceneClass(node, imports)) { + return; + } + + const { binds, hasShutdownHook } = classBindsPersistentListeners(node); + + if (!binds || hasShutdownHook) { + return; + } + + const name = node.id?.name ?? ""; + + context.report({ + node: node.id ?? node, + messageId: "missingShutdownHook", + data: { name }, + }); + }, + }; + }, +}); diff --git a/packages/core/src/rule-packs/phaser/utils.ts b/packages/core/src/rule-packs/phaser/utils.ts new file mode 100644 index 00000000..cf2ec105 --- /dev/null +++ b/packages/core/src/rule-packs/phaser/utils.ts @@ -0,0 +1,753 @@ +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"; + +import { walkAll } from "../utils"; + +export { walkAll }; + +export const PHASER_PACKAGE = "phaser"; + +export const HOT_PATH_NAMES = new Set(["update", "tick", "preUpdate"]); + +export const SCENE_PLUGIN_METHODS = new Set([ + "start", + "launch", + "stop", + "pause", + "resume", + "sleep", + "wake", + "switch", + "run", + "restart", + "remove", +]); + +export const ADD_FACTORIES = new Set([ + "image", + "sprite", + "text", + "graphics", + "rectangle", + "circle", + "ellipse", + "container", + "zone", + "tileSprite", + "blitter", + "nineslice", + "dynamicBitmapText", + "bitmapText", + "renderTexture", + "shader", + "video", + "particleEmitter", + "particles", + "rope", + "layer", + "arc", + "polygon", + "star", + "triangle", + "line", + "curve", +]); + +export const PHYSICS_ADD_CTORS = new Set([ + "sprite", + "image", + "existing", + "group", + "staticGroup", +]); + +export const PHYSICS_COLLIDER_METHODS = new Set([ + "overlap", + "collider", + "colliderGroup", +]); + +export const WORLD_COLLIDER_METHODS = new Set(["addCollider", "addOverlap"]); + +export const TEXTURE_LOAD_METHODS = new Set([ + "image", + "spritesheet", + "atlas", + "audio", + "tilemapTiledJSON", + "bitmapFont", +]); + +export const TEXTURE_ADD_METHODS = new Set([ + "image", + "sprite", + "tileSprite", + "nineslice", +]); + +export const TEXTURE_MANAGER_METHODS = new Set(["get", "exists", "remove"]); + +export const SOUND_KEY_METHODS = new Set(["add", "play"]); + +export const PHASER_ALLOC_NAMESPACES = new Set([ + "Math", + "Geom", + "GameObjects", + "Sound", +]); + +export const GLOBAL_TIMER_CALLEES = new Set([ + "setInterval", + "setTimeout", + "requestAnimationFrame", +]); + +export const DOM_GLOBALS = new Set(["window", "document", "globalThis"]); + +const LISTENER_METHODS = new Set(["on", "addListener", "addEventListener"]); + +export interface IPhaserImports { + readonly hasPhaserImport: boolean; + readonly namespaceNames: ReadonlySet; + readonly namedBindings: ReadonlyMap; +} + +export function isPhaserPackageSource(source: string): boolean { + return source === PHASER_PACKAGE || source.startsWith(`${PHASER_PACKAGE}/`); +} + +export function analyzePhaserImports( + program: TSESTree.Program +): IPhaserImports { + const namespaceNames = new Set(); + const namedBindings = new Map(); + let hasPhaserImport = false; + + for (const stmt of program.body) { + if (stmt.type !== AST_NODE_TYPES.ImportDeclaration) { + continue; + } + + if (typeof stmt.source.value !== "string") { + continue; + } + + if (!isPhaserPackageSource(stmt.source.value)) { + continue; + } + + hasPhaserImport = true; + recordPhaserSpecifiers(stmt, namespaceNames, namedBindings); + } + + return { hasPhaserImport, namespaceNames, namedBindings }; +} + +function recordPhaserSpecifiers( + stmt: TSESTree.ImportDeclaration, + namespaceNames: Set, + namedBindings: Map +): void { + if (stmt.importKind === "type") { + return; + } + + for (const specifier of stmt.specifiers) { + if ( + specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier || + specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier + ) { + namespaceNames.add(specifier.local.name); + continue; + } + + if (specifier.type !== AST_NODE_TYPES.ImportSpecifier) { + continue; + } + + if (specifier.importKind === "type") { + continue; + } + + if (specifier.imported.type !== AST_NODE_TYPES.Identifier) { + continue; + } + + namedBindings.set(specifier.local.name, specifier.imported.name); + } +} + +export function memberChain(node: TSESTree.Node): readonly string[] { + const parts: string[] = []; + let current: TSESTree.Node | undefined = node; + + while (current !== undefined) { + if (current.type === AST_NODE_TYPES.MemberExpression) { + if (current.computed) { + break; + } + + if (current.property.type !== AST_NODE_TYPES.Identifier) { + break; + } + + parts.unshift(current.property.name); + current = current.object; + continue; + } + + if (current.type === AST_NODE_TYPES.Identifier) { + parts.unshift(current.name); + break; + } + + if (current.type === AST_NODE_TYPES.ThisExpression) { + parts.unshift("this"); + break; + } + + if (current.type === AST_NODE_TYPES.Super) { + parts.unshift("super"); + break; + } + + break; + } + + return parts; +} + +export function chainEndsWith( + chain: readonly string[], + suffix: readonly string[] +): boolean { + if (chain.length < suffix.length) { + return false; + } + + const offset = chain.length - suffix.length; + + for (let i = 0; i < suffix.length; i += 1) { + if (chain[offset + i] !== suffix[i]) { + return false; + } + } + + return true; +} + +export function stringLiteralValue( + node: TSESTree.Node | undefined +): string | null { + if (node === undefined) { + return null; + } + + if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") { + return node.value; + } + + return null; +} + +export function isLiteralTrue(node: TSESTree.Node | undefined): boolean { + return node?.type === AST_NODE_TYPES.Literal && node.value === true; +} + +export function calleeName(node: TSESTree.CallExpression): string | null { + if (node.callee.type === AST_NODE_TYPES.Identifier) { + return node.callee.name; + } + + const chain = memberChain(node.callee); + const last = chain[chain.length - 1]; + + return last ?? null; +} + +export function isOnceCall(node: TSESTree.CallExpression): boolean { + return calleeName(node) === "once"; +} + +export function isListenerMethod(name: string | null): boolean { + return name !== null && LISTENER_METHODS.has(name); +} + +export function isPhaserSceneClass( + node: TSESTree.ClassDeclaration | TSESTree.ClassExpression, + imports: IPhaserImports +): boolean { + if (node.superClass === null || node.superClass === undefined) { + return false; + } + + const chain = memberChain(node.superClass); + + if (chain.length === 2) { + const ns = chain[0]; + const ident = chain[1]; + + return ( + ns !== undefined && ident === "Scene" && imports.namespaceNames.has(ns) + ); + } + + if (chain.length !== 1) { + return false; + } + + const name = chain[0]; + + if (name === undefined) { + return false; + } + + return imports.namedBindings.get(name) === "Scene"; +} + +export function isShutdownEventArg(node: TSESTree.Node | undefined): boolean { + if (node === undefined) { + return false; + } + + const literal = stringLiteralValue(node); + + if (literal === "shutdown" || literal === "destroy") { + return true; + } + + const chain = memberChain(node); + const last = chain[chain.length - 1]; + + return last === "SHUTDOWN" || last === "DESTROY"; +} + +function isHotPathKey(key: TSESTree.Node): boolean { + if (key.type === AST_NODE_TYPES.Identifier) { + return HOT_PATH_NAMES.has(key.name); + } + + if (key.type === AST_NODE_TYPES.Literal && typeof key.value === "string") { + return HOT_PATH_NAMES.has(key.value); + } + + return false; +} + +function isFunctionLike( + node: TSESTree.Node | null | undefined +): node is + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionDeclaration { + if (node === null || node === undefined) { + return false; + } + + return ( + node.type === AST_NODE_TYPES.FunctionExpression || + node.type === AST_NODE_TYPES.ArrowFunctionExpression || + node.type === AST_NODE_TYPES.FunctionDeclaration + ); +} + +function isHotPathBindingName(name: string | undefined): boolean { + return name !== undefined && HOT_PATH_NAMES.has(name); +} + +export function shouldEnterHotPath(node: TSESTree.Node): boolean { + if ( + node.type === AST_NODE_TYPES.MethodDefinition || + node.type === AST_NODE_TYPES.PropertyDefinition || + node.type === AST_NODE_TYPES.Property + ) { + if (!isHotPathKey(node.key)) { + return false; + } + + if (node.type === AST_NODE_TYPES.MethodDefinition) { + return true; + } + + return isFunctionLike(node.value); + } + + if (node.type === AST_NODE_TYPES.FunctionDeclaration) { + return isHotPathBindingName(node.id?.name); + } + + if ( + node.type !== AST_NODE_TYPES.FunctionExpression && + node.type !== AST_NODE_TYPES.ArrowFunctionExpression + ) { + return false; + } + + const parent = node.parent; + + if ( + parent.type === AST_NODE_TYPES.MethodDefinition || + parent.type === AST_NODE_TYPES.PropertyDefinition || + parent.type === AST_NODE_TYPES.Property + ) { + return false; + } + + if ( + parent.type === AST_NODE_TYPES.VariableDeclarator && + parent.id.type === AST_NODE_TYPES.Identifier + ) { + return isHotPathBindingName(parent.id.name); + } + + if ( + parent.type === AST_NODE_TYPES.AssignmentExpression && + parent.left.type === AST_NODE_TYPES.Identifier + ) { + return isHotPathBindingName(parent.left.name); + } + + return false; +} + +export function createHotPathTracker(): { + readonly isInHotPath: () => boolean; + readonly enter: (node: TSESTree.Node) => void; + readonly exit: (node: TSESTree.Node) => void; +} { + let depth = 0; + + return { + isInHotPath: () => depth > 0, + enter: (node) => { + if (shouldEnterHotPath(node)) { + depth += 1; + } + }, + exit: (node) => { + if (shouldEnterHotPath(node) && depth > 0) { + depth -= 1; + } + }, + }; +} + +export function hotPathVisitors(hot: { + readonly enter: (node: TSESTree.Node) => void; + readonly exit: (node: TSESTree.Node) => void; +}): Record void> { + return { + MethodDefinition: hot.enter, + "MethodDefinition:exit": hot.exit, + PropertyDefinition: hot.enter, + "PropertyDefinition:exit": hot.exit, + Property: hot.enter, + "Property:exit": hot.exit, + FunctionDeclaration: hot.enter, + "FunctionDeclaration:exit": hot.exit, + FunctionExpression: hot.enter, + "FunctionExpression:exit": hot.exit, + ArrowFunctionExpression: hot.enter, + "ArrowFunctionExpression:exit": hot.exit, + }; +} + +export function isPhaserNamespacedNew( + node: TSESTree.NewExpression, + imports: IPhaserImports +): boolean { + const chain = memberChain(node.callee); + + if (chain.length < 3) { + return false; + } + + const ns = chain[0]; + const bucket = chain[1]; + + return ( + ns !== undefined && + bucket !== undefined && + imports.namespaceNames.has(ns) && + PHASER_ALLOC_NAMESPACES.has(bucket) + ); +} + +export function isAddFactoryCall(chain: readonly string[]): boolean { + const method = chain[chain.length - 1]; + const owner = chain[chain.length - 2]; + + return ( + method !== undefined && + (owner === "add" || owner === "make") && + ADD_FACTORIES.has(method) + ); +} + +export function isPhysicsAddCtorCall(chain: readonly string[]): boolean { + const method = chain[chain.length - 1]; + + return ( + method !== undefined && + PHYSICS_ADD_CTORS.has(method) && + chainEndsWith(chain.slice(0, -1), ["physics", "add"]) + ); +} + +export function isPhysicsColliderCall(chain: readonly string[]): boolean { + const method = chain[chain.length - 1]; + + if (method === undefined) { + return false; + } + + if (PHYSICS_COLLIDER_METHODS.has(method)) { + return chainEndsWith(chain.slice(0, -1), ["physics", "add"]); + } + + if (WORLD_COLLIDER_METHODS.has(method)) { + return chainEndsWith(chain.slice(0, -1), ["physics", "world"]); + } + + return false; +} + +export function isLoaderCall(chain: readonly string[]): boolean { + return chain.length >= 2 && chain[chain.length - 2] === "load"; +} + +export function isScenePluginCall(chain: readonly string[]): boolean { + if (chain.length < 2) { + return false; + } + + const method = chain[chain.length - 1]; + const owner = chain[chain.length - 2]; + + return ( + owner === "scene" && + method !== undefined && + SCENE_PLUGIN_METHODS.has(method) + ); +} + +export function isTextureKeyCall( + chain: readonly string[], + args: readonly TSESTree.CallExpressionArgument[] +): TSESTree.CallExpressionArgument | null { + const method = chain[chain.length - 1]; + const owner = chain[chain.length - 2]; + + if (method === undefined || owner === undefined) { + return null; + } + + if (owner === "add" && TEXTURE_ADD_METHODS.has(method)) { + const key = args[2]; + + return key ?? null; + } + + if (owner === "load" && TEXTURE_LOAD_METHODS.has(method)) { + const key = args[0]; + + return key ?? null; + } + + if (owner === "textures" && TEXTURE_MANAGER_METHODS.has(method)) { + const key = args[0]; + + return key ?? null; + } + + if (owner === "sound" && SOUND_KEY_METHODS.has(method)) { + const key = args[0]; + + return key ?? null; + } + + return null; +} + +export function isTextMutationCall(chain: readonly string[]): boolean { + const method = chain[chain.length - 1]; + + return method === "setText" || method === "setStyle"; +} + +export function isForbiddenGlobalListener( + node: TSESTree.CallExpression +): boolean { + if (node.callee.type === AST_NODE_TYPES.Identifier) { + return GLOBAL_TIMER_CALLEES.has(node.callee.name); + } + + if (isOnceCall(node)) { + return false; + } + + const name = calleeName(node); + + if (!isListenerMethod(name)) { + return false; + } + + const chain = memberChain(node.callee); + const root = chain[0]; + + if (root !== undefined && DOM_GLOBALS.has(root) && chain.length === 2) { + return true; + } + + if (chainEndsWith(chain, ["game", "events", "on"])) { + return true; + } + + if (chainEndsWith(chain, ["registry", "events", "on"])) { + return true; + } + + if (chainEndsWith(chain, ["scale", "on"])) { + return true; + } + + if (chainEndsWith(chain, ["anims", "on"])) { + return true; + } + + if (chainEndsWith(chain, ["textures", "on"])) { + return true; + } + + return false; +} + +export function isRequireCall( + node: TSESTree.CallExpression +): node is TSESTree.CallExpression & { + arguments: [TSESTree.Literal, ...TSESTree.CallExpressionArgument[]]; +} { + if (node.callee.type !== AST_NODE_TYPES.Identifier) { + return false; + } + + if (node.callee.name !== "require") { + return false; + } + + const first = node.arguments[0]; + + return ( + first?.type === AST_NODE_TYPES.Literal && typeof first.value === "string" + ); +} + +export function requireSource(node: TSESTree.CallExpression): string | null { + if (!isRequireCall(node)) { + return null; + } + + const first = node.arguments[0]; + + if ( + first.type !== AST_NODE_TYPES.Literal || + typeof first.value !== "string" + ) { + return null; + } + + return first.value; +} + +export function isImportedPhaserBinding( + node: TSESTree.Identifier, + imports: IPhaserImports +): boolean { + return ( + imports.namespaceNames.has(node.name) || + imports.namedBindings.has(node.name) + ); +} + +export function isNonValueIdentifier(node: TSESTree.Identifier): boolean { + const parent = node.parent; + + if ( + parent.type === AST_NODE_TYPES.ImportSpecifier || + parent.type === AST_NODE_TYPES.ImportDefaultSpecifier || + parent.type === AST_NODE_TYPES.ImportNamespaceSpecifier || + parent.type === AST_NODE_TYPES.ExportSpecifier + ) { + return true; + } + + if ( + parent.type === AST_NODE_TYPES.MemberExpression && + parent.property === node + ) { + return true; + } + + if (parent.type === AST_NODE_TYPES.TSQualifiedName) { + return true; + } + + if (parent.type === AST_NODE_TYPES.TSTypeReference) { + return true; + } + + if (parent.type === AST_NODE_TYPES.TSTypeQuery) { + return true; + } + + if (parent.type === AST_NODE_TYPES.VariableDeclarator && parent.id === node) { + return true; + } + + if ( + parent.type === AST_NODE_TYPES.FunctionDeclaration && + parent.id === node + ) { + return true; + } + + if (parent.type === AST_NODE_TYPES.ClassDeclaration && parent.id === node) { + return true; + } + + return false; +} + +export function classBindsPersistentListeners( + cls: TSESTree.ClassDeclaration | TSESTree.ClassExpression +): { readonly binds: boolean; readonly hasShutdownHook: boolean } { + let binds = false; + let hasShutdownHook = false; + + walkAll(cls, (node) => { + if (node.type !== AST_NODE_TYPES.CallExpression) { + return; + } + + if (isShutdownEventArg(node.arguments[0]) && isLifecycleSubscribe(node)) { + hasShutdownHook = true; + + return; + } + + if (isOnceCall(node)) { + return; + } + + if (isListenerMethod(calleeName(node))) { + binds = true; + } + }); + + return { binds, hasShutdownHook }; +} + +function isLifecycleSubscribe(node: TSESTree.CallExpression): boolean { + const name = calleeName(node); + + return name === "once" || name === "on" || name === "addListener"; +} diff --git a/packages/core/src/rule-packs/rule-metadata.ts b/packages/core/src/rule-packs/rule-metadata.ts index 12f5ca1e..dc1bedd1 100644 --- a/packages/core/src/rule-packs/rule-metadata.ts +++ b/packages/core/src/rule-packs/rule-metadata.ts @@ -130,6 +130,21 @@ const RULE_ENTRIES: Readonly> = { tags: ["three"], falsePositiveRisk: "medium", }, + "no-phaser-import-in-pure-layers": { + tier: "safety", + tags: ["phaser"], + }, + "no-ignore-destroy": { tier: "safety", tags: ["phaser"] }, + "no-unmanaged-global-listeners": { tier: "safety", tags: ["phaser"] }, + "no-raw-scene-key-literal": { + tier: "architecture", + tags: ["phaser"], + }, + "no-raw-texture-key-literal": { + tier: "architecture", + tags: ["phaser"], + falsePositiveRisk: "medium", + }, }; const PACK_DEFAULT_TIER: Readonly> = { @@ -144,6 +159,7 @@ const PACK_DEFAULT_TIER: Readonly> = { drizzle: "framework", bullmq: "framework", three: "framework", + phaser: "framework", "comment-hygiene": "architecture", "code-flow": "framework", }; diff --git a/packages/core/src/stack-detection/packs.ts b/packages/core/src/stack-detection/packs.ts index d3d7f594..e0fdf6ff 100644 --- a/packages/core/src/stack-detection/packs.ts +++ b/packages/core/src/stack-detection/packs.ts @@ -226,6 +226,17 @@ export const PACK_REGISTRY = { guidance: "Keep i18n keys organized and validated.", } as const satisfies IRulePackDescriptor, + phaser: { + id: "phaser", + label: "Phaser", + description: + "Phaser 4 as a render substrate: scene shutdown ownership, no global emitter leaks, no Phaser factories in the tick, branded scene/texture keys", + category: "library", + appliesWhen: { anyDeps: ["phaser"] }, + guidance: + "Phaser is a render substrate: scenes are views with a SHUTDOWN dispose path, Game/Registry/Scale/window listeners are game-lifetime and must be removed, do not construct GameObjects in update(), scene and texture keys are constants not string literals.", + } as const satisfies IRulePackDescriptor, + three: { id: "three", label: "Three.js", diff --git a/packages/core/tests/fixtures/phaser-starter/src/domain/score.ts b/packages/core/tests/fixtures/phaser-starter/src/domain/score.ts new file mode 100644 index 00000000..19c711f6 --- /dev/null +++ b/packages/core/tests/fixtures/phaser-starter/src/domain/score.ts @@ -0,0 +1,3 @@ +export function nextScore(current: number, delta: number): number { + return current + delta; +} diff --git a/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/BootScene.ts b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/BootScene.ts new file mode 100644 index 00000000..9265445c --- /dev/null +++ b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/BootScene.ts @@ -0,0 +1,13 @@ +import * as Phaser from "phaser"; + +import { BOOT_SCENE_KEY, WORLD_SCENE_KEY } from "./keys"; + +export class BootScene extends Phaser.Scene { + constructor() { + super(BOOT_SCENE_KEY); + } + + create(): void { + this.scene.start(WORLD_SCENE_KEY); + } +} diff --git a/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.setup.ts b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.setup.ts new file mode 100644 index 00000000..828a2f67 --- /dev/null +++ b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.setup.ts @@ -0,0 +1,19 @@ +import type * as Phaser from "phaser"; + +export interface IWorldSceneRuntime { + update: (deltaMs: number) => void; + dispose: () => void; +} + +export function setupWorldScene(scene: Phaser.Scene): IWorldSceneRuntime { + const sprite = scene.add.rectangle(40, 40, 24, 24, 0x4caf50); + + return { + update(_deltaMs: number) { + sprite.x += 1; + }, + dispose() { + sprite.destroy(); + }, + }; +} diff --git a/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.ts b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.ts new file mode 100644 index 00000000..0610c02a --- /dev/null +++ b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/WorldScene.ts @@ -0,0 +1,24 @@ +import * as Phaser from "phaser"; + +import { WORLD_SCENE_KEY } from "./keys"; +import { setupWorldScene, type IWorldSceneRuntime } from "./WorldScene.setup"; + +export class WorldScene extends Phaser.Scene { + private runtime: IWorldSceneRuntime | null = null; + + constructor() { + super(WORLD_SCENE_KEY); + } + + create(): void { + this.runtime = setupWorldScene(this); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.runtime?.dispose(); + this.runtime = null; + }); + } + + override update(_time: number, delta: number): void { + this.runtime?.update(delta); + } +} diff --git a/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/keys.ts b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/keys.ts new file mode 100644 index 00000000..e5d0a011 --- /dev/null +++ b/packages/core/tests/fixtures/phaser-starter/src/runtime/phaser/scenes/keys.ts @@ -0,0 +1,2 @@ +export const BOOT_SCENE_KEY = "Boot"; +export const WORLD_SCENE_KEY = "World"; diff --git a/packages/core/tests/rule-pack-phaser.test.ts b/packages/core/tests/rule-pack-phaser.test.ts new file mode 100644 index 00000000..73af58d2 --- /dev/null +++ b/packages/core/tests/rule-pack-phaser.test.ts @@ -0,0 +1,570 @@ +import { test, expect, describe } from "bun:test"; +import { AST_NODE_TYPES, TSESLint } from "@typescript-eslint/utils"; +import tsParser from "@typescript-eslint/parser"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { TSESTree } from "@typescript-eslint/utils"; + +import { RULE_PACKS, buildPackEslintConfig } from "../src/rule-packs"; +import { + analyzePhaserImports, + isPhaserPackageSource, + memberChain, + shouldEnterHotPath, +} from "../src/rule-packs/phaser/utils"; + +function lint( + ruleName: string, + code: string, + filename = "src/runtime/example.ts", + options?: unknown[] +) { + const linter = new TSESLint.Linter(); + const pack = RULE_PACKS.phaser; + const rule = pack.rules[ruleName]; + + if (!rule) { + throw new Error(`Rule ${ruleName} not found in pack phaser`); + } + + const config = { + files: ["**/*.ts"], + plugins: { tsforge: { rules: { [ruleName]: rule } } }, + rules: { + [`tsforge/${ruleName}`]: options ? ["error", ...options] : "error", + }, + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, + }, + } satisfies TSESLint.FlatConfig.Config; + + return linter.verify(code, config, filename); +} + +function parseProgram(code: string): TSESTree.Program { + const result = tsParser.parseForESLint(code, { + range: true, + loc: true, + tokens: false, + comment: false, + ecmaVersion: 2022, + sourceType: "module", + }); + + return result.ast; +} + +const PHASER_IMPORT = 'import * as Phaser from "phaser";\n'; + +describe("phaser pack registry", () => { + test("exports the ten v1 rules with matching config keys", () => { + const pack = RULE_PACKS.phaser; + + expect(pack.id).toBe("phaser"); + expect(pack.description.toLowerCase()).toContain("phaser"); + expect(Object.keys(pack.rules).sort()).toEqual([ + "no-global-phaser", + "no-ignore-destroy", + "no-loader-in-update", + "no-phaser-alloc-in-update", + "no-phaser-import-in-pure-layers", + "no-physics-collider-in-update", + "no-raw-scene-key-literal", + "no-raw-texture-key-literal", + "no-unmanaged-global-listeners", + "require-scene-shutdown-hook", + ]); + expect(Object.keys(pack.rulesConfig).sort()).toEqual( + Object.keys(pack.rules).sort() + ); + expect(pack.rulesConfig["no-phaser-alloc-in-update"]).toBe("warn"); + expect(pack.rulesConfig["no-ignore-destroy"]).toBe("error"); + }); +}); + +describe("phaser utils", () => { + test("classifies package sources", () => { + expect(isPhaserPackageSource("phaser")).toBe(true); + expect(isPhaserPackageSource("phaser/types")).toBe(true); + expect(isPhaserPackageSource("./phaser")).toBe(false); + }); + + test("analyzePhaserImports records namespace, default, and named bindings", () => { + const program = parseProgram(` + import * as Phaser from "phaser"; + import Engine from "phaser"; + import { Scene as S } from "phaser"; + `); + const imports = analyzePhaserImports(program); + + expect(imports.hasPhaserImport).toBe(true); + expect(imports.namespaceNames.has("Phaser")).toBe(true); + expect(imports.namespaceNames.has("Engine")).toBe(true); + expect(imports.namedBindings.get("S")).toBe("Scene"); + }); + + test("analyzePhaserImports ignores type-only bindings but still marks the file", () => { + const program = parseProgram(`import type * as Phaser from "phaser";\n`); + const imports = analyzePhaserImports(program); + + expect(imports.hasPhaserImport).toBe(true); + expect(imports.namespaceNames.size).toBe(0); + }); + + test("memberChain walks this.physics.add.overlap", () => { + const program = parseProgram( + `${PHASER_IMPORT}this.physics.add.overlap(a, b);\n` + ); + const stmt = program.body[1]; + + if (stmt?.type !== AST_NODE_TYPES.ExpressionStatement) { + throw new Error("expected expression"); + } + + if (stmt.expression.type !== AST_NODE_TYPES.CallExpression) { + throw new Error("expected call"); + } + + expect(memberChain(stmt.expression.callee)).toEqual([ + "this", + "physics", + "add", + "overlap", + ]); + }); + + test("shouldEnterHotPath matches object-literal update methods", () => { + const program = parseProgram(` + const runtime = { + update(deltaMs) { + return deltaMs; + }, + }; + `); + const stmt = program.body[0]; + + if (stmt?.type !== AST_NODE_TYPES.VariableDeclaration) { + throw new Error("expected variable"); + } + + const [declarator] = stmt.declarations; + const init = declarator.init; + + if (init?.type !== AST_NODE_TYPES.ObjectExpression) { + throw new Error("expected object"); + } + + const prop = init.properties[0]; + + if (prop === undefined || prop.type === AST_NODE_TYPES.SpreadElement) { + throw new Error("expected property"); + } + + expect(shouldEnterHotPath(prop)).toBe(true); + }); +}); + +describe("no-ignore-destroy", () => { + test("flags ignoreDestroy = true", () => { + const code = `${PHASER_IMPORT}sprite.ignoreDestroy = true;\n`; + const messages = lint("no-ignore-destroy", code); + + expect(messages.map((m) => m.messageId)).toContain("ignoreDestroy"); + }); + + test("allows ignoreDestroy = false", () => { + const code = `${PHASER_IMPORT}sprite.ignoreDestroy = false;\n`; + + expect(lint("no-ignore-destroy", code)).toEqual([]); + }); +}); + +describe("no-loader-in-update", () => { + test("flags this.load.image inside update", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + update() { + this.load.image("hero", "hero.png"); + } +} +`; + const messages = lint("no-loader-in-update", code); + + expect(messages.map((m) => m.messageId)).toContain("loaderInUpdate"); + }); + + test("allows this.load.image in preload", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + preload() { + this.load.image("hero", "hero.png"); + } +} +`; + + expect(lint("no-loader-in-update", code)).toEqual([]); + }); +}); + +describe("no-physics-collider-in-update", () => { + test("flags physics.add.overlap in update", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + update() { + this.physics.add.overlap(this, this, () => undefined); + } +} +`; + const messages = lint("no-physics-collider-in-update", code); + + expect(messages.map((m) => m.messageId)).toContain("colliderInUpdate"); + }); + + test("allows physics.add.overlap in create", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + this.physics.add.overlap(this, this, () => undefined); + } +} +`; + + expect(lint("no-physics-collider-in-update", code)).toEqual([]); + }); +}); + +describe("no-raw-scene-key-literal", () => { + test("flags scene.start string literal", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + this.scene.start("World"); + } +} +`; + const messages = lint("no-raw-scene-key-literal", code); + + expect(messages.map((m) => m.messageId)).toContain("rawSceneKey"); + }); + + test("flags super('Boot') on a Scene subclass", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + constructor() { + super("Boot"); + } +} +`; + const messages = lint("no-raw-scene-key-literal", code); + + expect(messages.map((m) => m.messageId)).toContain("rawSuperKey"); + }); + + test("allows named constants", () => { + const code = `${PHASER_IMPORT} +const WORLD = "World"; +export class Play extends Phaser.Scene { + constructor() { + super(WORLD); + } + create() { + this.scene.start(WORLD); + } +} +`; + + expect(lint("no-raw-scene-key-literal", code)).toEqual([]); + }); +}); + +describe("no-raw-texture-key-literal", () => { + test("flags load.image string key", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + preload() { + this.load.image("hero", "hero.png"); + } +} +`; + const messages = lint("no-raw-texture-key-literal", code); + + expect(messages.map((m) => m.messageId)).toContain("rawTextureKey"); + }); + + test("allows identifier keys", () => { + const code = `${PHASER_IMPORT} +const HERO = "hero"; +export class Play extends Phaser.Scene { + preload() { + this.load.image(HERO, "hero.png"); + } +} +`; + + expect(lint("no-raw-texture-key-literal", code)).toEqual([]); + }); +}); + +describe("no-phaser-alloc-in-update", () => { + test("flags this.add.image in update", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + update() { + this.add.image(0, 0, "hero"); + } +} +`; + const messages = lint("no-phaser-alloc-in-update", code); + + expect(messages.map((m) => m.messageId)).toContain("factoryInUpdate"); + }); + + test("flags new Phaser.Math.Vector2 in object-literal tick", () => { + const code = `${PHASER_IMPORT} +export const runtime = { + tick() { + return new Phaser.Math.Vector2(); + }, +}; +`; + const messages = lint("no-phaser-alloc-in-update", code); + + expect(messages.map((m) => m.messageId)).toContain("ctorInUpdate"); + }); + + test("allows object spreads and domain news in update", () => { + const code = `${PHASER_IMPORT} +class Point { + constructor(readonly x: number) {} +} +export class Play extends Phaser.Scene { + update() { + const state = { ...{ x: 1 }, y: 2 }; + const point = new Point(state.x); + return point; + } +} +`; + + expect(lint("no-phaser-alloc-in-update", code)).toEqual([]); + }); + + test("allows factories in create", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + this.add.image(0, 0, "hero"); + } +} +`; + + expect(lint("no-phaser-alloc-in-update", code)).toEqual([]); + }); +}); + +describe("require-scene-shutdown-hook", () => { + test("flags persistent input.on without SHUTDOWN", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + this.input.on("pointerdown", () => undefined); + } +} +`; + const messages = lint("require-scene-shutdown-hook", code); + + expect(messages.map((m) => m.messageId)).toContain("missingShutdownHook"); + }); + + test("allows once(SHUTDOWN) alongside persistent listeners", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + this.input.on("pointerdown", () => undefined); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => undefined); + } +} +`; + + expect(lint("require-scene-shutdown-hook", code)).toEqual([]); + }); + + test("allows a Scene with no persistent listeners", () => { + const code = `${PHASER_IMPORT} +export class Boot extends Phaser.Scene { + create() { + this.scene.start(WORLD); + } +} +const WORLD = "World"; +`; + + expect(lint("require-scene-shutdown-hook", code)).toEqual([]); + }); +}); + +describe("no-unmanaged-global-listeners", () => { + test("flags window.addEventListener inside a Scene", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + window.addEventListener("resize", () => undefined); + } +} +`; + const messages = lint("no-unmanaged-global-listeners", code); + + expect(messages.map((m) => m.messageId)).toContain("unmanagedGlobal"); + }); + + test("flags this.scale.on inside a Scene", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + this.scale.on("resize", () => undefined); + } +} +`; + const messages = lint("no-unmanaged-global-listeners", code); + + expect(messages.map((m) => m.messageId)).toContain("unmanagedGlobal"); + }); + + test("allows this.input.on and this.events.on", () => { + const code = `${PHASER_IMPORT} +export class Play extends Phaser.Scene { + create() { + this.input.on("pointerdown", () => undefined); + this.events.on("wake", () => undefined); + } +} +`; + + expect(lint("no-unmanaged-global-listeners", code)).toEqual([]); + }); + + test("allows window.addEventListener outside a Scene class", () => { + const code = `${PHASER_IMPORT} +window.addEventListener("resize", () => undefined); +`; + + expect(lint("no-unmanaged-global-listeners", code)).toEqual([]); + }); +}); + +describe("no-phaser-import-in-pure-layers", () => { + test("flags phaser import in domain", () => { + const code = `${PHASER_IMPORT}export const n = 1;\n`; + const messages = lint( + "no-phaser-import-in-pure-layers", + code, + "src/domain/score.ts" + ); + + expect(messages.map((m) => m.messageId)).toContain("phaserInPureLayer"); + }); + + test("allows phaser import in runtime", () => { + const code = `${PHASER_IMPORT}export const n = 1;\n`; + + expect( + lint("no-phaser-import-in-pure-layers", code, "src/runtime/view.ts") + ).toEqual([]); + }); + + test("no-ops for a tutorial tree without deny globs", () => { + const code = `${PHASER_IMPORT}export const n = 1;\n`; + + expect( + lint("no-phaser-import-in-pure-layers", code, "src/scenes/Play.ts") + ).toEqual([]); + }); +}); + +describe("no-global-phaser", () => { + test("flags an unbound Phaser identifier", () => { + const code = "const game = new Phaser.Game();\n"; + const messages = lint("no-global-phaser", code); + + expect(messages.map((m) => m.messageId)).toContain("globalPhaser"); + }); + + test("allows import * as Phaser", () => { + const code = `${PHASER_IMPORT}const game = new Phaser.Game();\n`; + + expect(lint("no-global-phaser", code)).toEqual([]); + }); + + test("flags require('phaser')", () => { + const code = 'const Phaser = require("phaser");\n'; + const messages = lint("no-global-phaser", code); + + expect(messages.map((m) => m.messageId)).toContain("requirePhaser"); + }); +}); + +describe("phaser-starter fixture", () => { + test("lints clean at pack default severities", () => { + const root = join(import.meta.dir, "fixtures/phaser-starter"); + const files = collectTsFiles(root); + const { plugin, rules } = buildPackEslintConfig(["phaser"]); + const linter = new TSESLint.Linter(); + const errors: string[] = []; + + for (const abs of files) { + const code = readFileSync(abs, "utf8"); + const filename = abs.slice(root.length + 1).replaceAll("\\", "/"); + const messages = linter.verify( + code, + [ + { + files: ["**/*.ts"], + plugins: { tsforge: plugin }, + rules, + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, + }, + }, + ], + filename + ); + + for (const message of messages) { + if (message.severity === 2) { + errors.push(`${filename}: ${message.message}`); + } + } + } + + expect(files.length).toBeGreaterThan(0); + expect(errors).toEqual([]); + }); +}); + +function collectTsFiles(dir: string): string[] { + const out: string[] = []; + + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + const stat = statSync(abs); + + if (stat.isDirectory()) { + out.push(...collectTsFiles(abs)); + continue; + } + + if (entry.endsWith(".ts")) { + out.push(abs); + } + } + + return out; +} diff --git a/packages/core/tests/rule-packs.test.ts b/packages/core/tests/rule-packs.test.ts index 0659284b..800eab62 100644 --- a/packages/core/tests/rule-packs.test.ts +++ b/packages/core/tests/rule-packs.test.ts @@ -49,7 +49,7 @@ function lint( } describe("rule-packs: registry", () => { - test("should have all twenty-two packs registered", () => { + test("should have all twenty-three packs registered", () => { expect(Object.keys(RULE_PACKS).sort()).toEqual([ "ai-sdk", "authorization", @@ -65,6 +65,7 @@ describe("rule-packs: registry", () => { "module-boundaries", "nextjs", "oauth-security", + "phaser", "react-component-architecture", "runtime-boundaries", "security", diff --git a/packages/core/tests/stack-detection.test.ts b/packages/core/tests/stack-detection.test.ts index 11ae791c..ae312aab 100644 --- a/packages/core/tests/stack-detection.test.ts +++ b/packages/core/tests/stack-detection.test.ts @@ -86,6 +86,29 @@ test("project with vitest in devDeps: detects test-conventions pack", async () = } }); +test("project with phaser: detects phaser pack from anyDeps", async () => { + const dir = await tempDir(); + + try { + const pkg = { + name: "phaser-app", + dependencies: { + phaser: "4.2.1", + }, + }; + + await writeFile(join(dir, "package.json"), JSON.stringify(pkg)); + + const profile = await detectStack(dir); + + expect(profile.packs).toContain("phaser"); + expect(profile.confidence).toBe("certain"); + expect(profile.reason.toLowerCase()).toContain("phaser"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("project with three: detects three pack from anyDeps", async () => { const dir = await tempDir(); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 59be89e0..fc68e389 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -21,5 +21,5 @@ "skipLibCheck": true }, "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "tests/fixtures/phaser-starter"] }