@gmloop/lint
July 14, 2026 ยท View on GitHub
@gmloop/lint is the ESLint language plugin and rule bundle for GameMaker Language (.gml) in this monorepo.
It owns lint diagnostics and semantic/content rewrites (via lint rules and --write), while formatter-only layout behavior stays in @gmloop/format.
- Owns:
- ESLint language wiring for GML (
language: "gml/gml") - Lint rules and single-file-safe autofix behavior. Lint rule autofixes are responsible for fixing valid-but-forbidden syntax (e.g., style violations or deprecated patterns that are still syntactically valid).
- ESLint language wiring for GML (
- Does not own:
- Prettier formatting behavior (should not directly manipulate whitespace, semicolons, line breaks, indentation, etc.). The formatter never repairs invalid syntax and only formats valid AST. Should NOT depend on
@gmloop/formator its internal APIs. - Parser internals/grammar ownership
- Project-wide identifier indexing, rename safety, or hoist-name generation
- Refactor transaction planning/execution. Codemod/fixer commands are responsible for repairing non-parsable source text to restore parsability.
- Prettier formatting behavior (should not directly manipulate whitespace, semicolons, line breaks, indentation, etc.). The formatter never repairs invalid syntax and only formats valid AST. Should NOT depend on
See ../../docs/target-state.md for the split contract.
Install and Peer Requirements
- Package:
@gmloop/lint - Peer dependency:
eslint>=9.39.0 <10 - Runtime: Node
>=22
Quick Start (Flat Config)
// eslint.config.js
import * as LintWorkspace from "@gmloop/lint";
export default [...LintWorkspace.Lint.configs.recommended];
This wires:
plugins.gml = Lint.pluginlanguage = "gml/gml"- The curated recommended
gml/*rule levels plus the conservative safe Feather subset (gm1003,gm1009,gm1033,gm1041,gm1051,gm2007,gm2020)
Config Sets
Lint.configs exposes these immutable flat-config sets:
all: everygml/*andfeather/*rule at its recommended levelrecommended: the curated defaultgml/*rules plus a conservative safefeather/*subsetfeather:feather/gm####overlay rules from the feather manifest
Enable every lint rule with one config spread:
import * as LintWorkspace from "@gmloop/lint";
export default [...LintWorkspace.Lint.configs.all];
The all config preserves each rule's recommended "warn" or "error" severity.
gml/normalize-operator-aliases is intentionally omitted from recommended. Enable it explicitly or use
configs.all when operator-alias diagnostics are desired.
Language Behavior
The plugin registers gml/gml as an ESLint v9 language implementation and returns parser services under parserServices.gml:
schemaVersionfilePathrecoverydirectivesenums
Recovery mode is controlled by language options:
recovery: "limited"(default)recovery: "none"
Public API
The workspace exports a single namespace:
import * as LintWorkspace from "@gmloop/lint";
LintWorkspace.Lint;
Lint contains:
plugin: ESLint plugin object forgml/*(rules,languages)featherPlugin: ESLint plugin object forfeather/*(rules)configs:all,recommended,feather,performanceruleIds: PascalCase map keys to canonical full IDs (gml/...,feather/...)services: single-file-safe support values only; no project registries, project roots, or semantic indexes
GML Rule IDs
Built-in gml/* rule short names:
prefer-hoistable-loop-accessors(includes formerprefer-loop-length-hoistscenarios)prefer-loop-invariant-expressionsprefer-struct-literal-assignmentsprefer-array-pushprefer-compound-assignmentsprefer-increment-decrement-operatorsprefer-direct-returnprefer-direct-boolean-returnno-boolean-literal-comparisonsoptimize-logical-flowno-globalvarno-empty-regionsno-scientific-notationno-unary-plus-on-identifierno-unnecessary-string-interpolationremove-default-commentsremove-doc-function-tagsnormalize-banner-commentsnormalize-doc-comment-tagsnormalize-doc-commentsnormalize-doc-returnsnormalize-doc-param-defaultsnormalize-doc-param-separatorsnormalize-doc-param-undefined-defaultsnormalize-directivesnormalize-block-keyword-aliasesrequire-control-flow-bracesrequire-region-pairsno-assignment-in-conditionprefer-is-undefined-checkprefer-epsilon-comparisonsnormalize-operator-aliasesprefer-string-interpolationoptimize-math-expressionsrequire-argument-separatorssimplify-real-calls
prefer-compound-assignments rewrites safe self-assignment forms
x = x <op> y to x <op>= y for arithmetic/bitwise operators that GML
actually supports in compound form, plus ??. It never rewrites x = x << y or x = x >> y, because GML has no <<= or >>= operator.
prefer-array-push rewrites direct append assignments of the form
array[array_length(array)] = value; to array_push(array, value); when the
array receiver is side-effect-free and the replacement would stay within a
single standalone statement.
prefer-increment-decrement-operators rewrites standalone += 1 / -= 1
statements to ++ / -- when the increment amount is a numeric literal equal to one. It intentionally skips for header update expressions and
comment-bearing statement spans.
prefer-direct-return rewrites adjacent local-return boilerplate from
var value = expression; return value; to return expression; when no comments would be dropped and the initializer does not reference the declared identifier.
prefer-direct-boolean-return rewrites boolean passthrough branches such as
if (cond) return true; return false; and if (cond) return false; else return true;
to direct boolean returns. It owns this focused fix instead of the broader
optimize-logical-flow rule.
no-boolean-literal-comparisons rewrites boolean literal comparisons such as
ready == true, true == ready, ready != false, ready == false, and
ready != true to the direct expression or its negation. It owns this focused
fix instead of the broader optimize-logical-flow rule.
require-control-flow-braces reports and autofixes unbraced control-flow statements by inserting structural { ... } blocks. It does not depend on the formatter for that rewrite; the formatter remains responsible only for subsequent layout/canonical rendering.
require-region-pairs reports malformed #region / #endregion pairs. The autofix removes standalone #endregion directives and appends missing #endregion directives at the bottom of the file.
prefer-struct-literal-assignments only rewrites contiguous property assignments when they immediately follow an empty struct creation (var foo = {}; or foo = {};). Property writes against existing structs are left unchanged.
prefer-loop-invariant-expressions hoists a single side-effect-free, loop-invariant expression into a cached var declared immediately before the loop. Equivalent occurrences inside the same loop reuse that single cache declaration, and later lint passes skip re-hoisting the synthetic cached_* initializers into ancestor loops. The rule is intentionally conservative: it checks the body and every loop-control expression for dependency mutations, does not move expressions across nested function, with, or exception-handling boundaries, and skips unknown calls, non-deterministic reads (for example current_time), dynamic DS/map accessors, and member/index reads that could be invalidated by loop-local mutations or impure calls.
remove-default-comments removes default GameMaker placeholder and migration-banner comments. If an object event file contains only those placeholder comments, the rule replaces them with // Intentionally empty: overrides inherited/default object event behavior. instead of making the file empty. Comment-only object events can be intentional in GameMaker because the event file can override inherited or default event behavior, such as disabling an automatic draw event.
remove-doc-function-tags removes legacy function marker lines such as /// @function ..., /// @func ..., /// @funct ..., and /// @method ... from documentation blocks while preserving neighboring doc metadata such as @param, @returns, @override, and custom tags.
normalize-banner-comments canonicalizes decorative banner comments (line and block forms) and rewrites method-list /// banner lines (outside of function declarations) to plain // comments.
normalize-doc-comment-tags canonicalizes documentation comment markers and focused tag aliases, such as // @param value to /// @param value, // / Summary to /// Summary, //// @func to escaped /// / @func, @arg/@argument/@params to @param, @return to @returns, and @private to @ignore. It does not synthesize function docs or rewrite legacy function marker tags.
normalize-doc-comments canonicalizes function documentation blocks within a single file. It promotes leading doc text into descriptions and synthesizes missing tags for declaration/assignment-style function docs. When it rewrites a touched function-doc block, it consumes the same focused tag-alias canonicalization as normalize-doc-comment-tags so broad function-doc fixes do not reintroduce stale aliases. Constructors, including for inherited constructors (function X(...) : Parent(...) constructor). For struct/object literal property functions, the rule synthesizes docs, including @returns. Canonical ordering keeps non-param metadata tags before the param block, but preserves custom tags interleaved between @param lines when intentionally authored that way.
normalize-doc-returns converts legacy return description lines into canonical @returns metadata (for example, Returns: Boolean, indicating success to @returns {Boolean} Indicating success).
normalize-doc-param-defaults owns optional @param default cleanup when the default text cannot be represented safely on one doc-comment line. For example, when synthesized docs would otherwise include a multiline default expression, it collapses that tag to a default-free optional parameter such as /// @param [matrix].
normalize-doc-param-separators removes legacy @param separator hyphens in doc comments (for example, @param value - desc to @param value desc).
normalize-doc-param-undefined-defaults removes explicit undefined defaults from optional @param names (for example, @param [value=undefined] to @param [value]).
normalize-operator-aliases is intentionally syntax-safety scoped: it repairs invalid not operator usage to ! in executable code (while skipping uses in comments, string literals, and user-defined identifiers like not(value) or #macro not 1), and avoids style rewrites. Since logical not is strictly rejected by the GML parser, the linter's pre-parser recovery maps invalid logical not/NOT to ! (preserving source offsets) so that the file remains parseable for this rule to diagnose and permanently fix it.
Logical operator style normalization (&&/||/^^ vs and/or/xor) belongs to the formatter (@gmloop/format, logicalOperatorsStyle), so lint does not rewrite those forms.
optimize-logical-flow simplifies logical expressions and conditional assignment branches. Boolean literal comparisons are owned by no-boolean-literal-comparisons. Direct boolean return passthroughs are owned by prefer-direct-boolean-return. Nullish guard assignments (if (is_undefined(x)) x = y; / if (x == undefined) x = y;) are owned by feather/gm2061, because they correspond to Feather's official nullish-coalescing diagnostic. Comment-bearing ranges are intentionally skipped so autofixes never strip authored comments while optimizing nearby comment-free logic.
optimize-logical-flow and optimize-math-expressions clone candidate AST fragments using a traversal-link-stripping helper (skipping parent/context pointers) so autofix performance remains stable on very large scripts.
prefer-loop-invariant-expressions memoizes subtree hoistability checks per loop, caches normalized in-scope identifier names across loop iterations, reuses a single replacement target set for equivalent invariant expressions, and uses indexed comment-token range checks so large loop-heavy files avoid repeated full-source rescans.
optimize-math-expressions only performs reciprocal-term cancellation on side-effect-free operands (identifiers/member accesses/literals). Call-expression operands are intentionally excluded from that cancellation path.
simplify-real-calls detects calls to GML's built-in real() function that take a single string literal argument whose content is a valid numeric literal, and replaces the entire call expression with just the numeric literal. E.g. real("0.5") โ 0.5. This rewrite is safe because real() with a string literal argument is deterministic and has no observable side effects.
Feather rules are exposed as feather/gm#### and sourced from Lint.services.featherManifest. All feather-namespace lint rules follow the naming pattern feather/gm####, where the lint rule diagnoses and fixes only the issue for the associated Feather rule/diagnostic. For example, lint rule feather/gm1000 identifies and fixes the specific issue described in Feather rule gm1000: "No enclosing loop from which to break." This creates a clear, traceable link between each Feather rule and its corresponding lint rule, and allows new lint rules for new Feather rules to be added through the manifest.
feather/gm1010 uses a conservative numeric-casting strategy: it only wraps num* identifiers with real(...) when they are directly added to a numeric literal (for example, 5 + numFive), and leaves mixed string-concatenation chains untouched.
Migrated Feather ownership is split by diagnostic category: feather/gm1017
handles deprecated callable APIs, feather/gm1023 deprecated constants,
feather/gm1024 deprecated built-in variables, feather/gm1028
data-structure accessor correction, feather/gm1056 trailing optional
parameter defaults, and feather/gm2004 safe unused-index for to repeat
conversion. These rules retain scoped AST checks and only expose local fixes
that can be proven safe.
When a safe, single-file gml/* fix overlaps an official Feather diagnostic,
the Feather rule owns that exact autofix. The overlapping gml/* rule may keep
fixes for behavior outside the Feather diagnostic, but must not also fix the
same diagnostic-owned span.
Ownership cleanup must preserve existing safe fixes. If purifying a Feather
rule would otherwise remove safe local behavior, split that behavior into a
smaller canonical rule, usually a focused gml/* rule when there is no matching
Feather diagnostic.
Before adding a new rule or fixer, check Lint.services.featherManifest and
the Feather metadata catalog for an unimplemented GM#### diagnostic that
already owns the behavior or could own a safe local fixer. Implement that
metadata-correct Feather ID before introducing a new gml/* rule.
Reset-state ownership follows the same diagnostic boundary. feather/gm2049
owns gpu_set_zfunc(cmpfunc_lessequal), feather/gm2062 owns
draw_set_colour(c_white), and feather/gm2063 owns draw_set_alpha(1).
The z-write and z-test resets have no metadata-correct Feather diagnostic, so
they are implemented as focused GML rules:
gml/require-zwrite-enabled-reset and gml/require-ztest-enabled-reset.
Fixture suites may compose multiple lint rules in their local gmloop.json
when a golden output represents more than one domain-correct fix. A Feather
fixture should not keep a fixer assigned to the wrong GM diagnostic solely to
preserve historical output; compose the canonical rule instead, or make the
Feather expectation report-only when no domain-correct fixer exists.
Development
pnpm --filter @gmloop/lint run build:types
pnpm --filter @gmloop/lint run test
Performance-sensitive autofix rules also have dedicated regression coverage under test/rules/optimized-autofix-performance.test.ts. Those tests run as part of the normal compiled Node test suite, so CI enforces both fix correctness and the current runtime budgets for the measured hot paths.
TODO
- Add an ESLint auto-fix rule that detects simple numeric accumulation loops like
alpha += indexover a fixed range and replaces them with the equivalent arithmetic-series expression. Example:for index = 0..9can becomealpha += count * (count - 1) * 0.5, avoiding unnecessary runtime iteration. - Continue splitting the remaining multi-purpose
optimize-logical-flowbehaviors into focused rules; direct boolean return passthroughs and boolean literal comparisons now live in focused rules. - BUG: Lint auto-fix(es) related to doc-comments can produce invalid/duplicate params, ex.:
-/// @function scr_timeline_play -/// @param {Resource.GMTimeline} timeline_to_play - A timeline asset index +/// @param {Resource.GMTimeline} timeline_to_play A timeline asset index /// @param {Function} *func_callback - A function to call after the timeline has completed +/// @param func_callback /// @returns {obj} timeline_controller function scr_timeline_play(timeline_to_play, func_callback) { /* ... */ }