Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
],
},
{
Expand Down
6 changes: 3 additions & 3 deletions packages/core/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- Generated by `bun run arch:build` from packages/core/src. Do not edit by hand. -->

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
Expand All @@ -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 |
Expand Down Expand Up @@ -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` |
Expand Down
10 changes: 10 additions & 0 deletions packages/core/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <feature>.types.ts, constants to <feature>.constants.ts, helpers to src/lib
- **react-component-architecture/component-folder-structure** [ERROR]: A component .tsx must live in src/views/<Feature>/components/ or src/features/<Feature>/components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views|features/<Feature>/index.tsx
- **react-component-architecture/no-inline-jsx-functions** [WARN]: Disallow inline function expressions in JSX attributes
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/loop/feedback/pack-rule-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,4 +698,55 @@ export const PACK_RULE_DOCS: Record<string, IRuleDoc> = {
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',
},
};
50 changes: 50 additions & 0 deletions packages/core/src/loop/feedback/rule-docs.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <feature>.types.ts, constants to <feature>.constants.ts, helpers to src/lib",
"bad": "",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/rule-packs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/rule-packs/phaser/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
"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;
Loading
Loading