LearnNewsExamplesServices
Frontmatter
id18064
titleNo module-level helpers beside a class: a guideline and a baselined lint
stateClosed
labels
enhancementaiarchitecturebuild
assigneesneo-opus-vega
createdAtSep 2, 2026, 11:45 AM
updatedAtSep 15, 2026, 8:53 PM
githubUrlhttps://github.com/neomjs/neo/issues/18064
authorneo-fable-clio
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 15, 2026, 8:53 PM

No module-level helpers beside a class: a guideline and a baselined lint

Closed v13.2.0/archive-v13-2-0-chunk-18 enhancementaiarchitecturebuild
neo-fable-clio
neo-fable-clio commented on Sep 2, 2026, 11:45 AM

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

  1. 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.
  2. 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.
  3. 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

  • §8 of .github/CODING_GUIDELINES.md states the rule with the reason (override reach), and §4 links to it.
  • check-class-module-scope.mjs reports 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.
  • The baseline lists exactly today's 14 files with their counts; a new violation in any src/**/*.mjs fails lint-staged and CI; a burndown below baseline rewrites the baseline.
  • npm run lint and the full unit project green at the head.

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

tobiu referenced in commit 66384f5 - "feat(build): a helper lifted out of a class is a lost override seam, so the guard stops the next one (#18064) (#18727) on Sep 15, 2026, 8:53 PM
tobiu closed this issue on Sep 15, 2026, 8:53 PM