Skip to content

sort-script-setup Design

Status: the initial implementation is available. This document records the design contract for vue-perfectionist/sort-script-setup. See the rule documentation for usage and supported automatic fixes.

The rule targets Vue 3 <script setup> top-level statements. It preserves order within groups by default and gives initialization dependencies and execution behavior priority over style preferences.

1. Design Decisions

Use Perfectionist-style sorting options, Vue-specific semantic groups, and conservative automatic fixes:

  • groups determines category order; type, order, and fallbackSort determine order within each group.
  • The default type: 'unsorted' still checks group order and configured whitespace.
  • Classification covers macros, reactive declarations, composables, watchers, and lifecycle hooks. Successful classification does not imply safe movement.
  • A complete top-level statement is the smallest movement unit. The rule does not split declarations, reorder arguments, or sort inside function bodies.
  • There is no option to disable dependency protection or declare calls pure.
  • The initial API supports a documented subset of upstream options and rejects unsupported rule options.

The rule requires an ESLint configuration with a Vue parser.

2. Perfectionist Compatibility Baseline

The recorded design baseline is upstream commit c0b3b8e, whose package version is 5.11.0. This is a fixed reference, not a promise of automatic compatibility with every upstream release.

The option shapes follow common-options.ts, common-groups-options.ts, and common-partition-options.ts. Defaults and Vue extensions are local design decisions.

OptionDefaultContract
type'unsorted'alphabetical, natural, line-length, custom, or unsorted
order'asc'Affects comparisons within groups only
fallbackSort{ type: 'unsorted' }Breaks primary ties; also supports subgroup-order
alphabet''A nonempty alphabet is required for effective custom comparators
ignoreCasetrueAffects names, not grouping regular expressions
specialCharacters'keep'keep, trim, or remove
locales'en-US'A BCP 47 string or nonempty array of locale strings
groupsSee section 5Names, merged arrays, group overrides, and newline separators
customGroups[]Ordered matching conditions, including anyOf
partitionByCommentfalseBoolean, regex options, or separate line/block options
partitionByNewLinefalseExisting blank lines create independent partitions
newlinesBetween'ignore'A nonnegative integer or ignore
newlinesInside'ignore'A nonnegative integer, ignore, or legacy newlinesBetween
vueImportSources['vue']Exact import sources identifying Vue runtime APIs
vueGlobals[]Explicit names of unbound, auto-imported Vue runtime APIs
fix'safe'safe or none

Intentional differences include preserved order within groups by default, ignored blank-line counts by default, Vue-specific selectors, and a smaller set of automatically movable statements.

Neither type nor fallbackSort.type accepts usage. Immediate reads, deferred captures, and synchronous callbacks need separate dependency models; dependency protection applies regardless of the comparator.

The rule does not accept tsconfig, additionalModuleBlockTypes, useExperimentalDependencyDetection, newlinesBetweenOverloadSignatures, commentAbove, or useConfigurationIf. It targets SFCs, preserves overloads, and does not generate group headings. The schema rejects these rule fields.

3. Scope and Movement Units

SourceBehavior
JavaScript or TypeScript Vue 3 <script setup>Inspect top-level candidates
Ordinary <script>, Options API, plain .ts/.jsSkip
SFC with both script blocksSelect setup by its SFC range; never cross blocks
Imports, including side-effect importsFixed boundaries
Control flow, including if, loops, switch, try, and blocksFixed boundaries; no recursive sorting
Top-level execution containing await or for awaitFixed boundaries
Assignments, updates, and complex expression statementsFixed boundaries
Direct call expression statementsClassifiable candidates; movement requires separate safety analysis
Function and callback bodiesPreserve text; inspect only for dependencies
Classes and enumsClassifiable, with no automatic movement
Namespaces and unsupported declaration formsFixed boundaries

Use SFC information from vue-eslint-parser, rather than relying on the file extension or the combined Program.body. Skip files without SFC information. TypeScript requires a TypeScript-capable inner parser.

A movement unit includes clearly attached leading and trailing comments. Overload signatures and implementations, merging declarations, ambient declarations, and protected directives remain fixed. A declaration such as const a = ref(0), b = computed(...) is a boundary: it is neither split nor assigned an arbitrary single category. Destructuring is one candidate, and its members are never reordered.

4. Public Option Contract

The rule accepts one options object. Use ESLint's files configuration for file-specific preferences rather than adding conditional routing to the rule.

The authoritative TypeScript contracts are in sort-script-setup.ts:

  • CommonSortOptions contains shared comparison, partition, and spacing options.
  • SortScriptSetupOptions adds groups, customGroups, vueImportSources, vueGlobals, and fix.
  • GroupEntry accepts a name, a merged name array, an object combining group with GroupOverrides, or a { newlinesBetween } separator.
  • GroupOverrides contains type, order, fallbackSort, and newlinesInside.
  • RegexOption accepts a string, { pattern, flags? }, or an array of either.
  • CustomGroup combines groupName and overrides with either direct matching fields or anyOf conditions.

Types describe the shape. Schema and semantic validation also enforce nonempty arrays where required, legal group names, nonnegative integers, valid regular expressions, and constraints between fields.

5. Built-in Groups

Default Groups

js
groups: [
  ['interface', 'type'],
  'define-options',
  'define-props',
  'define-emits',
  'define-slots',
  'define-model',
  'constant',
  'inject',
  'composable',
  'template-ref',
  ['ref', 'reactive'],
  'computed',
  'variable',
  ['enum', 'class'],
  'function',
  'watch',
  'lifecycle-hook',
  'provide',
  'define-expose',
]

This is a configurable reading order. Dependencies preserve required initialization order when a composable consumes state or an expression reads another binding. define-expose is a preference within the current partition, not an instruction to move a call to the end of the file.

Classification

SelectorMatchesNotes
interface, typeTypeScript interfaces and aliasesMember order is unchanged
enum, classTop-level enums and classesMay perform runtime initialization
define-optionsdefineOptions(...)Compiler macro
define-propsdefineProps(...) and withDefaults(defineProps(...), ...)Includes destructuring; wrapper is one props candidate
define-emitsdefineEmits(...)Bound or standalone calls
define-slotsdefineSlots(...)Bound or standalone calls
define-modeldefineModel(...)Includes destructured results
define-exposedefineExpose(...)Preserve argument dependencies and await boundaries
constantSingle const binding with a primitive literal or interpolation-free templateExcludes regexes, objects, arrays, calls, and property reads; signed numeric literals qualify
injectVue inject(...)Default factories may execute
template-refVue useTemplateRef(...)Do not infer from ref(null) or binding names
refVue ref, shallowRef, customRef, toRef, toRefsA customRef factory may execute synchronously
reactiveVue reactive, shallowReactive, readonly, shallowReadonlyOne reactive-object family
computedVue computed(...)Deferred captures are distinct from immediate reads
composableDirect statically imported calls whose exported name matches ^use[A-Z0-9]Dedicated Vue categories take precedence; the name does not prove purity
variableOther single-declarator variablesIncludes objects, arrays, and other initializers
functionNamed function declarations and single bindings initialized with arrows or function expressionsExcludes calls returning functions
watchVue watch, watchEffect, watchPostEffect, watchSyncEffectIncludes declarations binding a stop handle
lifecycle-hookExplicit Vue lifecycle registration APIsDoes not match arbitrary onXxx names
provideVue provide(...)Arguments may have dependencies
callOther direct call statementsAvailable to custom groups; omitted from default groups

The lifecycle list is onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, onActivated, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, and onServerPrefetch. Use custom groups for onScopeDispose or onWatcherCleanup when needed.

There are no additional macros, state, or effects aliases. Express merged categories directly, for example ['ref', 'reactive', 'computed'].

Classification Priority and Modifiers

Derive statement facts before choosing one base selector: compiler macro, recognized Vue API, imported useXxx, function initializer, primitive constant, then ordinary variable/call. Direct declarations use their AST kind. For example, const count = ref(0) has selector ref.

Modifiers record independent facts: declare, async, destructured, and the declaration kinds const, let, and var. Examples include const-ref, destructured-define-props, and async-function.

Configured custom groups take precedence over built-in groups. Built-in matches prefer more modifiers; ties use the order declare, async, destructured, const, let, var. Plain selectors are considered last. Modifiers may appear in any order before the selector. Normalize names before checking duplicates, and reject invalid selector/modifier combinations.

unknown is a fallback group name, not an AST selector. Unmatched candidates are fixed boundaries unless unknown is explicitly configured. Its members retain their order even under a global comparator; use { group: 'unknown', type: 'natural' } to override this behavior. Hard boundaries such as control flow remain fixed.

6. Group Sorting and Overrides

Merged Arrays

js
groups: [['interface', 'type'], ['ref', 'reactive'], 'computed']

Each nested array forms one group. Natural sorting interleaves its categories by name; unsorted preserves their existing relative order.

fallbackSort: { type: 'subgroup-order' } uses positions within that array only to break primary ties. It does not impose separate category order. With type: 'unsorted', all comparisons within the group, including the fallback, are disabled. This follows the fixed upstream comparator baseline.

Override Priority

For a standalone custom group, higher-priority values are:

  1. Explicit overrides on its customGroups definition.
  2. Corresponding overrides on its groups object.
  3. Resolved rule options.

Inside a merged array, individual custom-group overrides do not apply. The whole group needs one consistent comparator and spacing policy. Use { group: ['a', 'b'], type: 'natural' } for a shared override. This follows the upstream group override baseline.

Sorting Keys

StatementName key
Function, type, class, enumDeclaration identifier
Single variable bindingLocal binding name
DestructuringFirst local binding in source traversal order; empty patterns fall back to the call name or statement text
Unbound callResolved call name

For const route = useRoute(), sorting uses route; classification uses useRoute. line-length measures the statement's source length excluding a trailing semicolon and external attached comments. Internal comments and newlines count; it is not the longest physical line's width.

If primary and fallback comparisons tie, preserve the original index. Descending order never reverses dependency edges. Natural sorting negotiates locale arrays in preference order. With case sensitivity enabled, locale-sensitive case comparisons break natural ties while retaining numeric precedence. Custom alphabets compare Unicode code points and reject duplicate characters. Unlisted characters have equal priority, with code-point name length breaking remaining prefix ties.

Comparator regressions cover case, numbers, leading _/$, punctuation, Unicode, and characters missing from custom alphabets. Do not replace the baseline's special-character handling with an unrelated \W expression or assume a simplified numeric localeCompare is fully equivalent.

7. Custom Groups

Matching Fields

FieldMeaning
groupNameUser-defined name, which must appear in groups
selectorBase selector from section 5
modifiersAll listed modifiers must be present
elementNamePatternLocal declaration name; any destructuring binding may match; unbound calls use their call name
callNamePatternCanonical name of the direct initializer call or standalone call
importSourcePatternStatic import source of that direct call
anyOfAny complete condition may match

Fields within one condition are ANDed; pattern arrays are ORed. The first matching configured custom group wins. Require at least one nonempty matching field; an explicit elementNamePattern: '.*' can serve as a fallback. anyOf cannot coexist with top-level filters and is not recursive.

Patterns use strings or { pattern, flags }, not RegExp instances. Do not add implicit anchors. Reject invalid expressions and flags, and reset lastIndex between matches so g/y flags remain deterministic. Patterns match original names without ignoreCase or specialCharacters normalization.

Calls inside function bodies do not classify the containing declaration. The canonical call name for withDefaults(defineProps(...)) is defineProps.

Project-specific Groups

js
{
  groups: [
    ['interface', 'type'],
    'define-props',
    'define-emits',
    'router',
    'stores',
    'composable',
    ['ref', 'reactive'],
    'computed',
    { group: 'handlers', type: 'natural' },
    'function',
    'watch',
    'lifecycle-hook',
    'define-expose',
  ],
  customGroups: [
    {
      groupName: 'router',
      callNamePattern: '^use(Route|Router)$',
      importSourcePattern: '^vue-router$',
    },
    {
      groupName: 'stores',
      callNamePattern: '^use[A-Z].*Store$',
      importSourcePattern: '^(@/stores/|~/stores/)',
    },
    {
      groupName: 'handlers',
      selector: 'function',
      elementNamePattern: '^(handle|on)[A-Z]',
    },
  ],
}

This replaces the complete group list. Omitted categories follow the unknown group policy. Store composables commonly come from project files, so their source should not be assumed to be pinia.

Calls such as Nuxt's definePageMeta can use a custom condition like { selector: 'call', callNamePattern: '^definePageMeta$' }. Classification does not model framework-specific hoisting or authorize movement. Also use custom groups to distinguish watcher or lifecycle APIs; there are no separate watchOrder, lifecycleOrder, or macrosOrder options.

8. Vue Call Sources and Auto-imports

vueImportSources contains exact module names and replaces the default array. For example, use ['vue', '@vue/reactivity'] to recognize both sources. This identifies APIs without making a purity claim.

ts
import { ref as createRef } from 'vue'
import * as Vue from 'vue'
import { ref } from './local-helper'

const first = createRef(0) // ref
const second = Vue.ref(0) // ref
const third = ref(0) // variable

Resolve bindings through ESLint scopes. Named imports use their exported names; default imports use local binding names. Type-only imports provide no runtime source. Recognize static namespace members, while leaving dynamic members, optional calls, additional variable aliases, and cross-module re-exports unresolved. Custom groups may match an unresolved member callee such as api.useThing, but it has no inferred import source.

vueGlobals recognizes only unbound runtime names from the built-in Vue API table, for example ['ref', 'computed', 'watch', 'onMounted']. Local or imported bindings take precedence. Compiler macros need no entry and are recognized only without conflicting local or external imported bindings.

Use name conditions for auto-imported business composables. Without a static import, importSourcePattern cannot match an invented module source. No Nuxt-specific mode is required.

9. Partitions, Blank Lines, and Comments

Partition options determine which statements can be compared together; groups determines ordering within a partition.

js
{
  partitionByComment: {
    line: '^\\s*#region\\b',
    block: false,
  },
  newlinesBetween: 1,
  newlinesInside: 'ignore',
}

Only comments between top-level statements create partitions. A trailing comment belongs to the preceding statement. A standalone partition marker stays at the boundary. Clearly attached leading comments move with the next statement; ambiguous ownership suppresses movement fixes.

Blank-line counts refer to empty physical lines. 1 means exactly one empty line between statements. The legacy newlinesInside: 'newlinesBetween' resolves to ignore when global newlinesBetween is ignore, otherwise 0. Prefer explicit numbers or ignore in new configurations.

js
groups: [
  'define-props',
  { newlinesBetween: 0 },
  'define-emits',
  { newlinesBetween: 1 },
  ['ref', 'reactive'],
]

When intermediate groups are absent, combine the crossed boundaries between the actual neighboring groups: use the largest positive count, otherwise ignore if present, otherwise 0. Unspecified boundaries inherit the global value. This follows the upstream newline boundary baseline.

With partitionByNewLine: true, effective global and group spacing options must be ignore or unset. Normalize legacy values first, then reject conflicts, including inherited ones. Otherwise newly inserted blank lines could change partitioning on the next lint pass.

Whitespace fixes apply only between actual neighboring candidates within a partition. They do not cross imports, control flow, await, or unmatched candidates, and do not format statement interiors or script-tag padding.

ESLint disable/enable directives protect affected statements and boundaries. TypeScript directives such as @ts-expect-error and @ts-ignore, and other tool directives, also remain fixed with their affected statements. Clearly attached JSDoc may move.

10. Rule Configuration

Rule preferences use the following precedence:

text
rule options
  > rule defaults

Configure all preferences directly in rule options. Shared ESLint settings are not supported: settings.perfectionist and settings['vue-perfectionist'] are neither read nor validated.

Rule options replace defaults by field, including whole arrays and fallbackSort objects, following the upstream completion baseline. Group-level fallback overrides inherit individual fields; a missing fallback order uses the group's order.

js
{
  rules: {
    'vue-perfectionist/sort-script-setup': ['error', {
      newlinesBetween: 1,
      newlinesInside: 'ignore',
      groups: [
        ['interface', 'type'],
        'define-props',
        'define-emits',
        ['ref', 'reactive'],
        'computed',
        { group: 'function', type: 'natural' },
        'watch',
        'lifecycle-hook',
        'define-expose',
      ],
    }],
  },
}

This preserves order within ordinary groups and naturally sorts functions. Omitting type uses the rule's default unsorted.

The plugin provides rules only, with no built-in configurations. Users register the plugin, configure the parser, and enable their chosen rules. Set type to natural or alphabetical in rule options to sort names within groups.

11. Dependencies and Fix Contract

Separate Checking from Fixing

Compute a stable target order using category preferences and immediate-read dependencies, then evaluate the safety of the required movement:

  • Never compare or fix across hard boundaries.
  • Initialization dependencies take precedence over group and name order.
  • Report style differences requiring uncertain execution changes without a fix or an unverified suggestion.
  • Do not report a reversal already ruled out by a dependency constraint.

Dependency-adjusted order may repeat categories. Calculate spacing using the actual adjacent categories rather than forcing every category to be contiguous.

ts
const count = ref(0)
const snapshot = count.value

Preserve count before snapshot, even if variable precedes ref in the configured groups.

ts
const result = computed(() => input.value)
const input = ref(0)

Creating a closure does not immediately read input. Deferred captures are not automatically initialization dependencies, although later computed reads or synchronously executed callbacks may trigger evaluation.

ts
const initial = readLater()
const later = 1

function readLater() {
  return later
}

Function hoisting does not eliminate reads made by immediate calls. The rule does not repair pre-existing initialization errors or perform whole-program effect analysis. Unresolved execution paths do not justify movement fixes.

For recognized Vue calls, follow inline and locally declared callbacks that may execute during registration: watch source getters and source arrays, immediate watch callbacks, watchEffect, watchSyncEffect, and customRef factories. Ordinary watch callbacks, watchPostEffect, and watchEffect with flush: 'post' remain deferred. Unknown options conservatively retain possible immediate dependencies. Nested calls use their own import identity; foreign functions sharing Vue API names do not gain Vue callback semantics.

Safe Fixes

fix: 'safe' permits only proven-safe movement within contiguous fragments:

  • Independent type/interface declarations without declaration merging.
  • Independent single-identifier primitive constant declarations without decorators or protected directives.
  • Ordinary top-level function declarations without overload ambiguity or conflicting names; their bodies remain unchanged.
  • Whitespace adjustments that preserve directive meaning and parsing.

These units cannot cross runtime calls. Arrow-function variables and other unproven forms are not automatically included. Type erasure alone does not prove safety because declaration merging and overload order can affect types.

Vue runtime APIs, business composables, watchers/effects, lifecycle registrations, provide/inject, property reads, destructuring, new, class/enum initialization, compiler macros, and macro wrappers receive no movement fix merely because no explicit dependency was found. The initial fixer mainly organizes types, simple declarations, and whitespace.

fix: 'none' preserves diagnostics and disables every fix, including spacing. It differs from type: 'unsorted', which disables comparisons within groups.

Await and Side Effects

Setup executes during instance creation; macros involve compiler processing and hoisting, and top-level await introduces asynchronous boundaries.

ts
const count = ref(0)
watchEffect(() => console.log(count.value))
count.value = 1

Watcher registration is executable behavior, including when its result is assigned to a const stop handle.

ts
const data = await loadData()
defineExpose({ data })

Do not move expose across await in either direction. Dedicated Vue rules handle existing expose-after-await problems.

Retain cycle members in their original order without movement fixes involving the cycle. Independent fragments can still be checked. Stable topological selection uses group order, the group comparator, and original index. Uncertain side effects participate in diagnostics but prevent unsafe fixes.

Choose independently validated contiguous fragments with nonoverlapping fix ranges. Preserve source text, comments, and line endings. Validate ASI, semicolons, comment ownership, and parsing; formatting the whole script must not mask a change in meaning. Repeated fixing must converge without changes on the next pass.

12. Validation and Diagnostics

Beyond basic schema validation, reject:

  • Duplicate or unknown group names, including normalized modifier duplicates and undefined custom groups.
  • Empty, duplicate, built-in-conflicting, or unreferenced custom-group names.
  • Deeply nested or empty merged arrays; normalize single-element arrays.
  • Leading, trailing, or consecutive newline separators, or separators mixed with a group field.
  • Nonfinite, negative, or fractional newline counts, and numeric spacing combined with blank-line partitioning.
  • Effective custom comparators without valid alphabets, including fallbacks and group overrides.
  • Duplicate, incompatible, or selector-inapplicable modifiers.
  • Empty regex arrays, unconditional anyOf branches, invalid expressions, flags or locales, and unknown Vue global names.
  • Unknown fields in rule options.

groups: [] disables ordering and spacing diagnostics without restoring defaults. customGroups: [] clears custom matching.

Message IDMeaningFix
unexpectedGroupOrderA category should precede another categoryWhen safety is proven
unexpectedOrderA name or length is out of order within a groupWhen safety is proven
unexpectedNewlinesBetweenIncorrect blank-line count between groupsWhen independently safe
unexpectedNewlinesInsideIncorrect blank-line count inside a groupWhen independently safe
unsafeReorderPreferred movement cannot be proven safeNone

Report each discrepancy once. Do not issue both a fixable and unsafe report for the same move. fix: 'none' does not rename a safe discrepancy to unsafeReorder. Throw configuration errors as configuration errors, rather than reporting them as source diagnostics.

13. Cooperation with Other Rules

  • Leave imports to perfectionist/sort-imports, including moving scattered imports to the top of a file.
  • Disable perfectionist/sort-modules in .vue files where this rule owns top-level type/interface/function ordering.
  • Disable vue/define-macros-order where this rule owns macro ordering.
  • Keep correctness rules such as vue/no-expose-after-await, vue/no-watch-after-await, and vue/no-lifecycle-after-await.
  • Leave object keys, parameters, and destructuring members to dedicated rules.
  • Align whitespace preferences with padding rules and formatters. Default ignore values reduce competing fixes.

The following override assumes the corresponding plugins and parsers have already been configured. Place it after related presets:

js
{
  files: ['**/*.vue'],
  rules: {
    'perfectionist/sort-modules': 'off',
    'vue/define-macros-order': 'off',
    'vue-perfectionist/sort-script-setup': ['error', {
      type: 'unsorted',
      newlinesBetween: 'ignore',
      newlinesInside: 'ignore',
      fix: 'safe',
    }],
  },
}

14. Acceptance Matrix

This matrix records acceptance requirements rather than claiming that every combination has a dedicated test. Executable cases live in the rule tests.

AreaRequired behavior
ScopeJS/TS setup, ordinary script skip, dual-script isolation, missing SFC services
GroupsDefaults, partial/empty lists, merged arrays, explicit/omitted unknown
MacrosStandalone/bound calls, withDefaults, props destructuring, multiple models, shadowing
Call sourcesImport aliases, namespaces, foreign names, globals, dynamic callees
Custom groupsFirst match, AND/OR, static sources, unreferenced groups, name conflicts
ComparatorsFive types, fallback, subgroup order, descending order, stable ties, Unicode
OptionsRule/group/custom priority, array replacement, ignored ESLint settings
WhitespaceInside/between, absent groups, partition conflicts, comments, CRLF
DependenciesInitialization order, cross-group reads, immediate calls, captures, callbacks, cycles, overloads
Side effectsImmediate effects, unknown watch options, composables, inject factories
Fix boundariesAwait, assignments, control flow, multiple declarators, disable/directive comments
Safe casesIndependent types/interfaces, primitive constants, ordinary functions, spacing
StabilityIdempotent fixes, unchanged unsafe input, nonoverlapping fix ranges
IntegrationParser and manual rule configuration, import sorting coexistence, disabled overlapping rules, formatter stability

Implementation proceeds from option resolution and classification to grouping and diagnostics, then dependency protection and conservative fixing, followed by plugin integration and documentation. Any expansion of movement safety requires semantic regressions before broadening the allowed forms.

Implementation