Context
Operator ruling on PR #18061 (2026-09-02): the first head put two helper functions at module scope beside Neo.mixin.VdomLifecycle, and the review said this is a bad anti-pattern in Neo — an engine built for extension, where developers override and overwrite class logic — and that "the team is doing it more and more inside other classes too. This must change!" A module-level function is invisible to subclassing, to mixins and to Neo.overwrites; a method on the class is the seam every one of those reaches. The PR moved its helpers onto the mixin; the pattern survives in thirteen other engine classes, and nothing in the guidelines or the guards names it.
Measured on dev@8c86f4478f (files under src/ that declare a class AND a top-level function declaration or a const x = (…) => / const x = y => arrow):
| file |
top-level functions |
src/dashboard/dock/model/TopologyReconciler.mjs |
10 |
src/main/addon/EventSimulator.mjs |
4 |
src/ai/WriteGuard.mjs |
3 |
src/mixin/VdomLifecycle.mjs |
2 (removed by #18061's rework) |
src/ai/client/TourRunner.mjs |
2 |
src/ai/TransactionService.mjs |
2 |
src/vdom/VNode.mjs, src/state/Provider.mjs, src/main/draggable/sensor/Touch.mjs, src/main/draggable/sensor/Mouse.mjs, src/main/draggable/Resize.mjs, src/draggable/dashboard/SortZone.mjs, src/ai/client/InteractionService.mjs, src/Main.mjs |
1 each |
for f in $(grep -rl "^class " src --include='*.mjs'); do n=$(grep -cE "^(async )?function [A-Za-z_]|^const [A-Za-z_]+ *= *(async )?\(?[A-Za-z_, ]*\)? *=>" "$f"); [ "$n" -gt 0 ] && echo "$n $f"; done | sort -rn
Live latest-open sweep: the latest 20 open issues read at 2026-09-02T09:43Z, no equivalent; the corpus hits for "module-level" are the AiConfig A1 lints (#15213 and kin — env re-derivation, a different concern); A2A lane-claims in the last window: none on this scope.
The Problem
.github/CODING_GUIDELINES.md §4 (anatomy of a class) and §8 (class methods) show the shape of a method and say nothing about module scope, so a helper that "does not need this" drifts out of the class by habit — the habit every other codebase teaches. In Neo that helper cannot be overridden by a subclass, cannot be replaced through Neo.overwrites, is invisible to a mixin's consumer and to the Neural Link's get_method_source, and it fragments the class's own reading order. The count is 14 files today and was 15 last night; without a guard it grows one PR at a time, each locally justified.
The Architectural Reality
.github/CODING_GUIDELINES.md — §4 "Anatomy of a neo class / JS module", §8 "Class methods": the places a reader looks; neither carries the rule.
buildScripts/util/check-engine-brain-boundary.mjs + check-engine-brain-boundary-baseline.json — the burndown-baseline precedent (acorn AST walk, existing violations listed in a baseline, new ones fail); check-fixed-sleeps.mjs + its baseline is the second instance of the shape.
package.json lint-staged: {buildScripts,src}/**/*.mjs runs check-shorthand / check-jsdoc-types / check-parse — the hook a new guard joins.
- Legitimate module-scope code that must stay allowed:
imports, const constants and lookup tables (const livenessPollDefault = 15000, a Set of state names), the Neo.setupClass export, and pure src/util/** modules that export functions by design (they declare no class).
The Fix
- Guideline. One paragraph in §8 (cross-linked from §4): logic belongs on the class — an instance method, a
static method, or a getter — or in a dedicated src/util/** module consumed by import; never as a module-scope function beside a class definition, because subclasses, mixins and Neo.overwrites can reach a method and cannot reach a closure. Constants and data at module scope stay fine.
- Guard.
buildScripts/util/check-class-module-scope.mjs (acorn, the sibling shape): for every src/**/*.mjs whose program body declares a class, report top-level FunctionDeclarations and VariableDeclarators whose init is a FunctionExpression / ArrowFunctionExpression. Existing hits go into check-class-module-scope-baseline.json; a file whose count exceeds its baseline fails; a file that drops below it updates the baseline (burndown). Wired into the {buildScripts,src}/**/*.mjs lint-staged block and the lint CI job beside its siblings.
- Burndown. Each baselined file migrates its helpers on its next touch —
TopologyReconciler.mjs (10) is the one worth its own leaf when the dock work next opens it.
Decision Record impact: none (a coding guideline and a build guard; no ADR authority touched).
Acceptance Criteria
Out of Scope
- Migrating the 14 baselined files (the burndown;
TopologyReconciler.mjs as its own leaf).
apps/**, examples/**, test/** and the Brain repository's ai/ (the split owns its own guards).
- Module-scope constants, data, and
src/util/** function modules — allowed by the rule.
Avoided Traps
- An ESLint rule. The repository's guards are acorn scripts with baselines and run under lint-staged; a second linter would mean a second config surface and a second failure grammar for the same line.
- A blanket ban on module-scope code. Constants, lookup tables and the
setupClass export are the anatomy §4 already shows; the rule is about functions beside a class, not about module scope.
- Fixing the 14 files in the guard PR. The guard ships with a baseline so it lands in one bounded PR; the burndown rides the files' own next touches.
Related
- PR #18061 / #18060 — the instance the ruling came from (helpers moved onto
Neo.mixin.VdomLifecycle).
#15213 — the AiConfig A1 lint (module-level env re-derivation): the same "baselined acorn guard" shape for a different concern.
Structure-map gate (§1c): N/A — buildScripts/util + .github, no ai/ touch. Structural pre-flight: the new guard lifts the check-*.mjs sibling pattern (fast path).
Origin Session ID: 91f83b9c-df95-4f72-a68f-d33f470792ac
Retrieval Hint: module-level function beside class anti-pattern override reach guideline lint baseline check-class-module-scope
📜 Clio
Context
Operator ruling on PR #18061 (2026-09-02): the first head put two helper functions at module scope beside
Neo.mixin.VdomLifecycle, and the review said this is a bad anti-pattern in Neo — an engine built for extension, where developers override and overwrite class logic — and that "the team is doing it more and more inside other classes too. This must change!" A module-level function is invisible to subclassing, to mixins and toNeo.overwrites; a method on the class is the seam every one of those reaches. The PR moved its helpers onto the mixin; the pattern survives in thirteen other engine classes, and nothing in the guidelines or the guards names it.Measured on
dev@8c86f4478f(files undersrc/that declare a class AND a top-levelfunctiondeclaration or aconst x = (…) =>/const x = y =>arrow):src/dashboard/dock/model/TopologyReconciler.mjssrc/main/addon/EventSimulator.mjssrc/ai/WriteGuard.mjssrc/mixin/VdomLifecycle.mjssrc/ai/client/TourRunner.mjssrc/ai/TransactionService.mjssrc/vdom/VNode.mjs,src/state/Provider.mjs,src/main/draggable/sensor/Touch.mjs,src/main/draggable/sensor/Mouse.mjs,src/main/draggable/Resize.mjs,src/draggable/dashboard/SortZone.mjs,src/ai/client/InteractionService.mjs,src/Main.mjsfor f in $(grep -rl "^class " src --include='*.mjs'); do n=$(grep -cE "^(async )?function [A-Za-z_]|^const [A-Za-z_]+ *= *(async )?\(?[A-Za-z_, ]*\)? *=>" "$f"); [ "$n" -gt 0 ] && echo "$n $f"; done | sort -rnLive latest-open sweep: the latest 20 open issues read at 2026-09-02T09:43Z, no equivalent; the corpus hits for "module-level" are the AiConfig A1 lints (
#15213and kin — env re-derivation, a different concern); A2A lane-claims in the last window: none on this scope.The Problem
.github/CODING_GUIDELINES.md§4 (anatomy of a class) and §8 (class methods) show the shape of a method and say nothing about module scope, so a helper that "does not needthis" drifts out of the class by habit — the habit every other codebase teaches. In Neo that helper cannot be overridden by a subclass, cannot be replaced throughNeo.overwrites, is invisible to a mixin's consumer and to the Neural Link'sget_method_source, and it fragments the class's own reading order. The count is 14 files today and was 15 last night; without a guard it grows one PR at a time, each locally justified.The Architectural Reality
.github/CODING_GUIDELINES.md— §4 "Anatomy of a neo class / JS module", §8 "Class methods": the places a reader looks; neither carries the rule.buildScripts/util/check-engine-brain-boundary.mjs+check-engine-brain-boundary-baseline.json— the burndown-baseline precedent (acorn AST walk, existing violations listed in a baseline, new ones fail);check-fixed-sleeps.mjs+ its baseline is the second instance of the shape.package.jsonlint-staged:{buildScripts,src}/**/*.mjsrunscheck-shorthand/check-jsdoc-types/check-parse— the hook a new guard joins.imports,constconstants and lookup tables (const livenessPollDefault = 15000, aSetof state names), theNeo.setupClassexport, and puresrc/util/**modules that export functions by design (they declare no class).The Fix
staticmethod, or a getter — or in a dedicatedsrc/util/**module consumed by import; never as a module-scope function beside a class definition, because subclasses, mixins andNeo.overwritescan reach a method and cannot reach a closure. Constants and data at module scope stay fine.buildScripts/util/check-class-module-scope.mjs(acorn, the sibling shape): for everysrc/**/*.mjswhose program body declares a class, report top-levelFunctionDeclarations andVariableDeclarators whose init is aFunctionExpression/ArrowFunctionExpression. Existing hits go intocheck-class-module-scope-baseline.json; a file whose count exceeds its baseline fails; a file that drops below it updates the baseline (burndown). Wired into the{buildScripts,src}/**/*.mjslint-staged block and thelintCI job beside its siblings.TopologyReconciler.mjs(10) is the one worth its own leaf when the dock work next opens it.Decision Record impact: none (a coding guideline and a build guard; no ADR authority touched).
Acceptance Criteria
.github/CODING_GUIDELINES.mdstates the rule with the reason (override reach), and §4 links to it.check-class-module-scope.mjsreports a module-scope function beside a class and passes on constants, lookup tables,src/util/**function modules and files without a class — red-first unit arms for each, beside the other guard specs.src/**/*.mjsfails lint-staged and CI; a burndown below baseline rewrites the baseline.npm run lintand the full unit project green at the head.Out of Scope
TopologyReconciler.mjsas its own leaf).apps/**,examples/**,test/**and the Brain repository'sai/(the split owns its own guards).src/util/**function modules — allowed by the rule.Avoided Traps
setupClassexport are the anatomy §4 already shows; the rule is about functions beside a class, not about module scope.Related
Neo.mixin.VdomLifecycle).#15213— the AiConfig A1 lint (module-level env re-derivation): the same "baselined acorn guard" shape for a different concern.Structure-map gate (§1c): N/A —
buildScripts/util+.github, noai/touch. Structural pre-flight: the new guard lifts thecheck-*.mjssibling pattern (fast path).Origin Session ID: 91f83b9c-df95-4f72-a68f-d33f470792ac
Retrieval Hint:
module-level function beside class anti-pattern override reach guideline lint baseline check-class-module-scope📜 Clio