@znight/http 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +154 -35
  2. package/dist/compiler.d.mts +77 -2
  3. package/dist/compiler.mjs +259 -101
  4. package/dist/compiler.mjs.map +1 -1
  5. package/dist/express.mjs +3 -2
  6. package/dist/express.mjs.map +1 -1
  7. package/dist/fastify.mjs +200 -23
  8. package/dist/fastify.mjs.map +1 -1
  9. package/dist/hono.mjs +174 -43
  10. package/dist/hono.mjs.map +1 -1
  11. package/dist/index.d.mts +36 -4
  12. package/dist/index.mjs +1005 -257
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
  15. package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  16. package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
  17. package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  18. package/dist/node.mjs +1 -1
  19. package/dist/utils-DIWdzVht.mjs +46 -0
  20. package/dist/utils-DIWdzVht.mjs.map +1 -0
  21. package/package.json +4 -4
  22. package/src/adapters/express.ts +4 -1
  23. package/src/adapters/fastify.ts +265 -25
  24. package/src/adapters/hono.ts +225 -58
  25. package/src/adapters/node-io.ts +128 -58
  26. package/src/adapters/node.ts +4 -1
  27. package/src/body.ts +31 -2
  28. package/src/coerce.ts +31 -0
  29. package/src/compiler/analyze.ts +3 -3
  30. package/src/compiler/generate.ts +127 -22
  31. package/src/compiler/index.ts +13 -0
  32. package/src/compiler/route-decorators.ts +45 -2
  33. package/src/compiler/route-groups.ts +216 -0
  34. package/src/compiler/validate.ts +15 -126
  35. package/src/context.ts +148 -78
  36. package/src/decorators.ts +19 -3
  37. package/src/index.ts +2 -1
  38. package/src/pipeline.ts +936 -177
  39. package/src/plugin-routes.ts +19 -0
  40. package/src/plugin.ts +7 -3
  41. package/src/response.ts +6 -8
  42. package/src/serialize.ts +48 -21
  43. package/src/utils.ts +25 -2
  44. package/src/validate.ts +28 -12
  45. package/dist/utils-DCbu3a4w.mjs +0 -27
  46. package/dist/utils-DCbu3a4w.mjs.map +0 -1
@@ -6,6 +6,10 @@
6
6
  * (the options) are statically evaluable. Its argument is evaluated like a `value` decorator
7
7
  * argument (literals, `const` references, references to exported bindings); without argument the
8
8
  * value is `true`. Decorators of one group are alternatives: one per controller or route.
9
+ *
10
+ * Every helper takes the analysis context of **any** extension: other extensions read route
11
+ * decorators on their own targets (gateways, resolvers) with {@link readRouteMeta}, exported by
12
+ * `@znight/http/compiler`.
9
13
  */
10
14
  import type {
11
15
  AnalyzeContext,
@@ -13,15 +17,16 @@ import type {
13
17
  DecoratorInfo,
14
18
  Diagnostic,
15
19
  Location,
20
+ MethodInfo,
16
21
  StaticValue,
17
22
  ValueRef,
18
23
  } from '@znight/compiler'
19
- import type { HttpControllerData, MetaEntry } from './model.ts'
24
+ import type { MetaEntry } from './model.ts'
20
25
 
21
26
  import { type AstNode, child, children, isNode, unwrap } from './ast.ts'
22
27
  import { Codes, GROUP_NAME, Ids } from './model.ts'
23
28
 
24
- type Context = AnalyzeContext<HttpControllerData>
29
+ type Context = AnalyzeContext<unknown>
25
30
  type Project = Context['project']
26
31
  type ScopeInput = Parameters<Project['evaluate']>[0]
27
32
  type ExpressionInput = Parameters<Project['evaluate']>[1]
@@ -295,6 +300,44 @@ export function routeMeta(
295
300
  return entries
296
301
  }
297
302
 
303
+ /**
304
+ * The route metadata entries of a class (`method` omitted) or of one of its methods: the route
305
+ * decorators (`createRouteDecorator` consts) applied to that target, in source order, read exactly
306
+ * as the http extension reads controllers and routes (keys, statically evaluated values, groups,
307
+ * `required`). Problems are reported as ZNIGHT005 through `ctx`, once per call: read each target
308
+ * once. Decorators that are not route decorators are ignored.
309
+ *
310
+ * Works with the analysis context of any extension. The entries of a target are its own: combine a
311
+ * class and a method with {@link effectiveRouteMeta}.
312
+ *
313
+ * ```ts
314
+ * analyzeClass(cls, ctx) {
315
+ * const classMeta = readRouteMeta(ctx, cls)
316
+ * for (const method of cls.methods) {
317
+ * const meta = effectiveRouteMeta(classMeta, readRouteMeta(ctx, cls, method))
318
+ * }
319
+ * }
320
+ * ```
321
+ */
322
+ export function readRouteMeta(ctx: AnalyzeContext<unknown>, cls: ClassInfo, method?: MethodInfo): MetaEntry[] {
323
+ return method === undefined
324
+ ? routeMeta(ctx, cls, cls.decorators, cls.displayName)
325
+ : routeMeta(ctx, cls, method.decorators, `${cls.displayName}.${method.name}`)
326
+ }
327
+
328
+ /**
329
+ * The effective metadata of a method from the entries of its class and its own (RFC 0001 §5.1):
330
+ * the class entries, except those of a group the method decides itself, then the method entries.
331
+ * The same rule as `ctx.route.meta` at runtime.
332
+ */
333
+ export function effectiveRouteMeta(classMeta: readonly MetaEntry[], methodMeta: readonly MetaEntry[]): MetaEntry[] {
334
+ if (methodMeta.length === 0) {
335
+ return [...classMeta]
336
+ }
337
+ const decided = new Set(methodMeta.map(entry => entry.group))
338
+ return [...classMeta.filter(entry => !decided.has(entry.group)), ...methodMeta]
339
+ }
340
+
298
341
  /** `true` when the decorator is a route metadata decorator. */
299
342
  export function isRouteMetaDecorator(ctx: Context, decorator: DecoratorInfo): boolean {
300
343
  return (
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Route decorator groups of the application (RFC 0001 §5.1): every `createRouteDecorator`
3
+ * definition, applied or not, the consistency of their options and the **required** groups.
4
+ *
5
+ * The http extension reports the inconsistencies (ZNIGHT005) and checks every route against the
6
+ * required groups (ZNIGHT023). Other extensions whose targets carry route decorators (WebSocket
7
+ * gateways, GraphQL root fields) call {@link requiredRouteGroups} to check their own targets with
8
+ * exactly the same groups, without reporting the inconsistencies a second time.
9
+ */
10
+ import type { Diagnostic, Location, ValidateContext } from '@znight/compiler'
11
+ import type { MetaEntry } from './model.ts'
12
+
13
+ import { compareStrings } from '../path.ts'
14
+ import { Codes, Ids } from './model.ts'
15
+ import { parseRouteDecoratorOptions } from './route-decorators.ts'
16
+
17
+ /** A route decorator definition of the application (`export const Roles = createRouteDecorator(...)`). */
18
+ export interface RouteGroupMember {
19
+ /** Canonical identity of the decorator `const` (`src/auth/access.ts#Roles`). */
20
+ readonly identity: string
21
+ /** Name of the `const` (`Roles`). */
22
+ readonly name: string
23
+ readonly key: string
24
+ /** Decision group (`options.group ?? key`). */
25
+ readonly group: string
26
+ readonly required: boolean
27
+ /** Location of the definition. */
28
+ readonly definition: Location
29
+ /**
30
+ * A flag (`@Public()`): created without type argument, and applied without argument wherever it
31
+ * was seen. Decides how the decorator is suggested: `@Public()` or `@Roles(...)`.
32
+ */
33
+ readonly flag: boolean
34
+ }
35
+
36
+ /** A required route decorator group: every route-like target must carry a decision of it. */
37
+ export interface RequiredRouteGroup {
38
+ /** Group name (`access`). */
39
+ readonly group: string
40
+ /** The decorators of the group, in source order (file, then position). Never empty. */
41
+ readonly decorators: readonly RouteGroupMember[]
42
+ /** The decorators as a hint: `@Public(), @Authenticated() or @Roles(...)`. */
43
+ readonly suggestion: string
44
+ }
45
+
46
+ /** Result of {@link analyzeRouteGroups}. */
47
+ export interface RouteGroupsAnalysis {
48
+ /** Inconsistent definitions (ZNIGHT005), at the second definition; not reported. */
49
+ readonly conflicts: readonly Diagnostic[]
50
+ /** The required groups, in the order of their first definition by identity. */
51
+ readonly required: readonly RequiredRouteGroup[]
52
+ }
53
+
54
+ /** A definition while flags are computed. */
55
+ type MutableMember = { -readonly [K in keyof RouteGroupMember]: RouteGroupMember[K] }
56
+
57
+ /**
58
+ * Every route decorator definition, sorted by identity: the applied ones (`applied`, first usage
59
+ * wins) and, when the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are
60
+ * left to the usages, which report them).
61
+ */
62
+ function routeDecoratorDefinitions(
63
+ ctx: Pick<ValidateContext<unknown>, 'definitionsOf'>,
64
+ applied: Iterable<MetaEntry>,
65
+ ): MutableMember[] {
66
+ const definitions = new Map<string, MutableMember>()
67
+ for (const entry of applied) {
68
+ const known = definitions.get(entry.identity)
69
+ if (known === undefined) {
70
+ definitions.set(entry.identity, {
71
+ identity: entry.identity,
72
+ name: entry.decorator,
73
+ key: entry.key,
74
+ group: entry.group,
75
+ required: entry.required,
76
+ definition: entry.definition,
77
+ flag: entry.value === true,
78
+ })
79
+ } else if (entry.value !== true) {
80
+ known.flag = false
81
+ }
82
+ }
83
+ // compilers before RFC 0001 have no definitionsOf: only applied decorators are known then
84
+ const all = typeof ctx.definitionsOf === 'function' ? ctx.definitionsOf(Ids.createRouteDecorator) : []
85
+ for (const found of all) {
86
+ const [keyArg, optionsArg, ...extra] = found.args
87
+ const key = keyArg?.evaluated === true ? keyArg.value : undefined
88
+ if (
89
+ typeof key !== 'string' ||
90
+ key === '' ||
91
+ extra.length > 0 ||
92
+ (optionsArg !== undefined && !optionsArg.evaluated)
93
+ ) {
94
+ continue
95
+ }
96
+ const parsed = parseRouteDecoratorOptions(key, optionsArg?.value)
97
+ if ('error' in parsed) {
98
+ continue
99
+ }
100
+ const flag = found.typeArgs.length === 0
101
+ const known = definitions.get(found.identity)
102
+ if (known !== undefined) {
103
+ known.flag &&= flag
104
+ continue
105
+ }
106
+ definitions.set(found.identity, {
107
+ identity: found.identity,
108
+ name: found.name,
109
+ key,
110
+ ...parsed,
111
+ definition: found.location,
112
+ flag,
113
+ })
114
+ }
115
+ return [...definitions.values()].sort((a, b) => compareStrings(a.identity, b.identity))
116
+ }
117
+
118
+ /**
119
+ * The route decorator groups of the application (RFC 0001 §5.1): every definition of a key must use
120
+ * the same options and the decorators of a group must agree on `required` (a conflict is returned
121
+ * at the second definition, which is then ignored); the required groups with their decorators.
122
+ * `applied` are the entries of the targets the caller analysed (their first usage of a decorator
123
+ * wins over its `definitionsOf` entry).
124
+ */
125
+ export function analyzeRouteGroups(
126
+ ctx: Pick<ValidateContext<unknown>, 'definitionsOf'>,
127
+ applied: Iterable<MetaEntry>,
128
+ ): RouteGroupsAnalysis {
129
+ const definitions = routeDecoratorDefinitions(ctx, applied)
130
+ if (definitions.length === 0) {
131
+ return { conflicts: [], required: [] }
132
+ }
133
+ const conflicts: Diagnostic[] = []
134
+ const byKey = new Map<string, RouteGroupMember>()
135
+ const byGroup = new Map<string, RouteGroupMember>()
136
+ const members = new Map<string, RouteGroupMember[]>()
137
+ for (const definition of definitions) {
138
+ const sameKey = byKey.get(definition.key)
139
+ if (sameKey !== undefined && (sameKey.group !== definition.group || sameKey.required !== definition.required)) {
140
+ conflicts.push({
141
+ code: Codes.InvalidDecorator,
142
+ severity: 'error',
143
+ message: `The route decorator ${definition.name} defines the key "${definition.key}" with ${describeOptions(definition)}, but ${sameKey.name} defines it with ${describeOptions(sameKey)}`,
144
+ location: definition.definition,
145
+ hints: ['Every definition of a key must use the same options: share one definition'],
146
+ related: [{ message: `${sameKey.name} is defined here`, location: sameKey.definition }],
147
+ })
148
+ continue
149
+ }
150
+ const sameGroup = byGroup.get(definition.group)
151
+ if (sameGroup !== undefined && sameGroup.required !== definition.required) {
152
+ conflicts.push({
153
+ code: Codes.InvalidDecorator,
154
+ severity: 'error',
155
+ message: `The "${definition.group}" group is ${definition.required ? '' : 'not '}required for ${definition.name} but ${sameGroup.required ? '' : 'not '}required for ${sameGroup.name}`,
156
+ location: definition.definition,
157
+ hints: [`All the decorators of the "${definition.group}" group must agree on required`],
158
+ related: [{ message: `${sameGroup.name} is defined here`, location: sameGroup.definition }],
159
+ })
160
+ continue
161
+ }
162
+ byKey.set(definition.key, sameKey ?? definition)
163
+ byGroup.set(definition.group, sameGroup ?? definition)
164
+ const list = members.get(definition.group) ?? []
165
+ list.push(definition)
166
+ members.set(definition.group, list)
167
+ }
168
+ const required: RequiredRouteGroup[] = []
169
+ for (const first of byGroup.values()) {
170
+ if (!first.required) {
171
+ continue
172
+ }
173
+ // the decorators of the group in source order: `@Public(), @Authenticated() or @Roles(...)`
174
+ const decorators = [...(members.get(first.group) ?? [first])]
175
+ .sort((a, b) => compareStrings(a.definition.file, b.definition.file) || a.definition.start - b.definition.start)
176
+ .map(member => Object.freeze({ ...member }))
177
+ const usages = decorators.map(member => `@${member.name}(${member.flag ? '' : '...'})`)
178
+ const suggestion =
179
+ usages.length === 1 ? (usages[0] as string) : `${usages.slice(0, -1).join(', ')} or ${usages.at(-1) as string}`
180
+ required.push(Object.freeze({ group: first.group, decorators: Object.freeze(decorators), suggestion }))
181
+ }
182
+ return { conflicts, required }
183
+ }
184
+
185
+ /**
186
+ * The required route decorator groups of the application (RFC 0001): what every route-like target
187
+ * of another extension (a gateway, a GraphQL root field) must decide, exactly as HTTP routes must.
188
+ * A target without a decision of a returned group is **ZNIGHT023**, reported by the caller.
189
+ *
190
+ * `applied` are the route metadata entries the caller read on its own targets
191
+ * ({@link readRouteMeta}): they make decorators known even when the compiler has no
192
+ * `definitionsOf`, and refine how flags are suggested. Inconsistent definitions are left out and
193
+ * not reported here: the http extension reports them (ZNIGHT005) once.
194
+ *
195
+ * ```ts
196
+ * validate(ctx) {
197
+ * const required = requiredRouteGroups(ctx, ctx.stored.flatMap(gateway => gateway.meta))
198
+ * for (const gateway of ctx.stored)
199
+ * for (const group of required)
200
+ * if (!gateway.meta.some(entry => entry.group === group.group))
201
+ * ctx.report({ code: 'ZNIGHT023', severity: 'error', message: `${gateway.name} has no "${group.group}" decision`,
202
+ * location: gateway.location, hints: [`Add ${group.suggestion} to ${gateway.name}`] })
203
+ * }
204
+ * ```
205
+ */
206
+ export function requiredRouteGroups(
207
+ ctx: ValidateContext<unknown>,
208
+ applied: Iterable<MetaEntry> = [],
209
+ ): readonly RequiredRouteGroup[] {
210
+ return analyzeRouteGroups(ctx, applied).required
211
+ }
212
+
213
+ function describeOptions(entry: RouteGroupMember): string {
214
+ const group = entry.group === entry.key ? 'no group' : `group "${entry.group}"`
215
+ return entry.required ? `${group}, required` : group
216
+ }
@@ -7,10 +7,11 @@
7
7
  import type { Location, ValidateContext } from '@znight/compiler'
8
8
  import type { RoutePath } from '../path.ts'
9
9
  import type { HttpControllerData, HttpRouteData, MetaEntry } from './model.ts'
10
+ import type { RequiredRouteGroup, RouteGroupMember } from './route-groups.ts'
10
11
 
11
12
  import { checkRoutePath, compareStrings, parseRoutePath, WILDCARD_PARAM } from '../path.ts'
12
- import { Codes, Ids } from './model.ts'
13
- import { parseRouteDecoratorOptions } from './route-decorators.ts'
13
+ import { Codes } from './model.ts'
14
+ import { analyzeRouteGroups } from './route-groups.ts'
14
15
 
15
16
  type Context = ValidateContext<HttpControllerData>
16
17
 
@@ -69,80 +70,12 @@ export function validateHttp(ctx: Context): void {
69
70
  validateDecisions(ctx, controllers)
70
71
  }
71
72
 
72
- /** A route decorator definition seen during the compile (applied, or found by `definitionsOf`). */
73
- interface Definition {
74
- readonly identity: string
75
- readonly name: string
76
- readonly key: string
77
- readonly group: string
78
- readonly required: boolean
79
- readonly definition: Location
80
- /** A flag (`@Public()`): no type argument, or every usage without argument. */
81
- flag: boolean
82
- }
83
-
84
- /**
85
- * Every route decorator definition, sorted by identity: the applied ones (first usage) and, when
86
- * the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are left to the
87
- * usages, which report them).
88
- */
89
- function routeDecoratorDefinitions(ctx: Context, controllers: readonly HttpControllerData[]): Definition[] {
90
- const definitions = new Map<string, Definition>()
91
- const visit = (entries: readonly MetaEntry[]): void => {
92
- for (const entry of entries) {
93
- const known = definitions.get(entry.identity)
94
- if (known === undefined) {
95
- definitions.set(entry.identity, {
96
- identity: entry.identity,
97
- name: entry.decorator,
98
- key: entry.key,
99
- group: entry.group,
100
- required: entry.required,
101
- definition: entry.definition,
102
- flag: entry.value === true,
103
- })
104
- } else if (entry.value !== true) {
105
- known.flag = false
106
- }
107
- }
108
- }
73
+ /** Every route metadata entry of the controllers: controller entries, then route entries. */
74
+ function* appliedEntries(controllers: readonly HttpControllerData[]): Generator<MetaEntry> {
109
75
  for (const controller of controllers) {
110
- visit(controller.meta)
111
- for (const route of controller.routes) visit(route.meta)
112
- }
113
- // compilers before RFC 0001 have no definitionsOf: only applied decorators are known then
114
- const all = typeof ctx.definitionsOf === 'function' ? ctx.definitionsOf(Ids.createRouteDecorator) : []
115
- for (const found of all) {
116
- const [keyArg, optionsArg, ...extra] = found.args
117
- const key = keyArg?.evaluated === true ? keyArg.value : undefined
118
- if (
119
- typeof key !== 'string' ||
120
- key === '' ||
121
- extra.length > 0 ||
122
- (optionsArg !== undefined && !optionsArg.evaluated)
123
- ) {
124
- continue
125
- }
126
- const parsed = parseRouteDecoratorOptions(key, optionsArg?.value)
127
- if ('error' in parsed) {
128
- continue
129
- }
130
- const flag = found.typeArgs.length === 0
131
- const known = definitions.get(found.identity)
132
- if (known !== undefined) {
133
- known.flag &&= flag
134
- continue
135
- }
136
- definitions.set(found.identity, {
137
- identity: found.identity,
138
- name: found.name,
139
- key,
140
- ...parsed,
141
- definition: found.location,
142
- flag,
143
- })
76
+ yield* controller.meta
77
+ for (const route of controller.routes) yield* route.meta
144
78
  }
145
- return [...definitions.values()].sort((a, b) => compareStrings(a.identity, b.identity))
146
79
  }
147
80
 
148
81
  /**
@@ -151,45 +84,10 @@ function routeDecoratorDefinitions(ctx: Context, controllers: readonly HttpContr
151
84
  * a decision of every required group, its own or its controller's (ZNIGHT023).
152
85
  */
153
86
  function validateDecisions(ctx: Context, controllers: readonly HttpControllerData[]): void {
154
- const definitions = routeDecoratorDefinitions(ctx, controllers)
155
- if (definitions.length === 0) {
156
- return
157
- }
158
- const byKey = new Map<string, Definition>()
159
- const byGroup = new Map<string, Definition>()
160
- const members = new Map<string, Definition[]>()
161
- for (const definition of definitions) {
162
- const sameKey = byKey.get(definition.key)
163
- if (sameKey !== undefined && (sameKey.group !== definition.group || sameKey.required !== definition.required)) {
164
- ctx.report({
165
- code: Codes.InvalidDecorator,
166
- severity: 'error',
167
- message: `The route decorator ${definition.name} defines the key "${definition.key}" with ${describeOptions(definition)}, but ${sameKey.name} defines it with ${describeOptions(sameKey)}`,
168
- location: definition.definition,
169
- hints: ['Every definition of a key must use the same options: share one definition'],
170
- related: [{ message: `${sameKey.name} is defined here`, location: sameKey.definition }],
171
- })
172
- continue
173
- }
174
- const sameGroup = byGroup.get(definition.group)
175
- if (sameGroup !== undefined && sameGroup.required !== definition.required) {
176
- ctx.report({
177
- code: Codes.InvalidDecorator,
178
- severity: 'error',
179
- message: `The "${definition.group}" group is ${definition.required ? '' : 'not '}required for ${definition.name} but ${sameGroup.required ? '' : 'not '}required for ${sameGroup.name}`,
180
- location: definition.definition,
181
- hints: [`All the decorators of the "${definition.group}" group must agree on required`],
182
- related: [{ message: `${sameGroup.name} is defined here`, location: sameGroup.definition }],
183
- })
184
- continue
185
- }
186
- byKey.set(definition.key, sameKey ?? definition)
187
- byGroup.set(definition.group, sameGroup ?? definition)
188
- const list = members.get(definition.group) ?? []
189
- list.push(definition)
190
- members.set(definition.group, list)
87
+ const { conflicts, required } = analyzeRouteGroups(ctx, appliedEntries(controllers))
88
+ for (const conflict of conflicts) {
89
+ ctx.report(conflict)
191
90
  }
192
- const required = [...byGroup.values()].filter(definition => definition.required)
193
91
  if (required.length === 0) {
194
92
  return
195
93
  }
@@ -201,23 +99,14 @@ function validateDecisions(ctx: Context, controllers: readonly HttpControllerDat
201
99
  if (decided.has(group.group)) {
202
100
  continue
203
101
  }
204
- // the decorators of the group in source order: `@Public(), @Authenticated() or @Roles(...)`
205
- const inOrder = [...(members.get(group.group) ?? [group])].sort(
206
- (a, b) => compareStrings(a.definition.file, b.definition.file) || a.definition.start - b.definition.start,
207
- )
208
- const decorators = inOrder.map(definition => `@${definition.name}(${definition.flag ? '' : '...'})`)
209
- const listed =
210
- decorators.length === 1
211
- ? (decorators[0] as string)
212
- : `${decorators.slice(0, -1).join(', ')} or ${decorators.at(-1) as string}`
213
102
  ctx.report({
214
103
  code: Codes.MissingRouteDecision,
215
104
  severity: 'error',
216
105
  message: `Route ${route.method} ${route.fullPath} (${controller.displayName}.${route.handler}) has no "${group.group}" decision`,
217
106
  location: route.location,
218
- hints: [`Add ${listed} to the route or to ${controller.displayName}`],
107
+ hints: [`Add ${group.suggestion} to the route or to ${controller.displayName}`],
219
108
  related: [
220
- { message: `The "${group.group}" group is required`, location: (inOrder[0] ?? group).definition },
109
+ { message: `The "${group.group}" group is required`, location: firstDefinition(group) },
221
110
  { message: `${controller.displayName} is declared here`, location: controller.location },
222
111
  ],
223
112
  })
@@ -226,9 +115,9 @@ function validateDecisions(ctx: Context, controllers: readonly HttpControllerDat
226
115
  }
227
116
  }
228
117
 
229
- function describeOptions(entry: Definition): string {
230
- const group = entry.group === entry.key ? 'no group' : `group "${entry.group}"`
231
- return entry.required ? `${group}, required` : group
118
+ /** Where the first decorator of a group (source order) is defined. */
119
+ function firstDefinition(group: RequiredRouteGroup): Location {
120
+ return (group.decorators[0] as RouteGroupMember).definition
232
121
  }
233
122
 
234
123
  const GRAMMAR_HINTS: readonly string[] = [