@opsydyn/elysia-spectral 0.2.5 → 0.3.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/opsydyn/elysia-spectral/compare/v0.2.5...v0.3.0) (2026-04-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * add recommended, server, and strict governance presets ([692061e](https://github.com/opsydyn/elysia-spectral/commit/692061e23ba5735fc0ad1a8d1a9110ced1f869fe))
9
+
3
10
  ## [0.2.5](https://github.com/opsydyn/elysia-spectral/compare/v0.2.4...v0.2.5) (2026-04-14)
4
11
 
5
12
 
package/README.md CHANGED
@@ -6,6 +6,13 @@
6
6
 
7
7
  Thin Elysia plugin that lints the OpenAPI document generated by `@elysiajs/openapi` with Spectral.
8
8
 
9
+ ## What Is Elysia?
10
+
11
+ Elysia is a fast, ergonomic TypeScript web framework for Bun. It uses a plugin model for composing functionality and integrates with `@elysiajs/openapi` to generate OpenAPI documentation directly from route schemas — no separate spec file or annotation layer required.
12
+
13
+ - Official site: <https://elysiajs.com/>
14
+ - `@elysiajs/openapi`: <https://elysiajs.com/plugins/openapi>
15
+
9
16
  ## What Is Spectral?
10
17
 
11
18
  Spectral is an open-source linter and style guide engine for API descriptions. It was built with OpenAPI, AsyncAPI, and JSON Schema in mind, and is commonly used to enforce API style guides, catch weak or inconsistent contract definitions, and improve the usefulness of generated API descriptions.
@@ -25,6 +32,12 @@ Official Spectral references:
25
32
  - Stoplight overview: <https://stoplight.io/open-source/spectral>
26
33
  - GitHub repository: <https://github.com/stoplightio/spectral>
27
34
 
35
+ ## Standards
36
+
37
+ - RFC 9457 — Problem Details for HTTP APIs: <https://datatracker.ietf.org/doc/html/rfc9457>
38
+
39
+ The `strict` preset enforces RFC 9457 by requiring that all 4xx and 5xx responses declare `application/problem+json` as their content type.
40
+
28
41
  This README is organized using the Diataxis documentation model:
29
42
 
30
43
  - Tutorial: learn by building a working setup
@@ -51,7 +64,7 @@ Current package scope:
51
64
 
52
65
  ### Add OpenAPI linting to an Elysia app
53
66
 
54
- This tutorial takes a minimal Elysia app and adds startup OpenAPI linting with a repo-level ruleset and JSON artifacts.
67
+ This tutorial takes a minimal Elysia app and adds startup OpenAPI linting using the `strict` preset. The `strict` preset is the recommended starting point for teams shipping production APIs — it enforces summaries, descriptions, operation IDs, tags, server declarations, and RFC 9457 Problem Details on error responses.
55
68
 
56
69
  1. Install the dependencies.
57
70
 
@@ -63,24 +76,7 @@ bun add elysia @elysiajs/openapi @opsydyn/elysia-spectral
63
76
  npm install elysia @elysiajs/openapi @opsydyn/elysia-spectral
64
77
  ```
65
78
 
66
- 2. Create a repo-level `spectral.yaml`.
67
-
68
- ```yaml
69
- extends:
70
- - spectral:oas
71
-
72
- rules:
73
- operation-description:
74
- severity: error
75
-
76
- operation-tags:
77
- severity: warn
78
-
79
- elysia-operation-summary:
80
- severity: error
81
- ```
82
-
83
- 3. Add `@elysiajs/openapi` and `spectralPlugin` to your app.
79
+ 2. Add `@elysiajs/openapi` and `spectralPlugin` to your app with the `strict` preset.
84
80
 
85
81
  ```ts
86
82
  import { Elysia, t } from 'elysia'
@@ -93,18 +89,19 @@ new Elysia()
93
89
  documentation: {
94
90
  info: {
95
91
  title: 'Example API',
96
- version: '1.0.0'
92
+ version: '1.0.0',
93
+ contact: { name: 'API Team', url: 'https://example.com/support' }
97
94
  },
95
+ servers: [{ url: 'https://api.example.com' }],
98
96
  tags: [{ name: 'Users', description: 'User operations' }]
99
97
  }
100
98
  })
101
99
  )
102
100
  .use(
103
101
  spectralPlugin({
102
+ preset: 'strict',
104
103
  failOn: 'error',
105
- startup: {
106
- mode: 'enforce'
107
- },
104
+ startup: { mode: 'enforce' },
108
105
  output: {
109
106
  console: true,
110
107
  jsonReportPath: './artifacts/openapi-lint.json',
@@ -113,36 +110,44 @@ new Elysia()
113
110
  })
114
111
  )
115
112
  .get('/users', () => [{ id: '1', name: 'Ada Lovelace' }], {
116
- response: {
117
- 200: t.Array(
118
- t.Object({
119
- id: t.String(),
120
- name: t.String()
121
- })
122
- )
123
- },
124
113
  detail: {
125
114
  summary: 'List users',
126
- description: 'Return all users.',
115
+ description: 'Return all registered users.',
127
116
  operationId: 'listUsers',
128
117
  tags: ['Users']
118
+ },
119
+ response: {
120
+ 200: t.Array(
121
+ t.Object({ id: t.String(), name: t.String() })
122
+ ),
123
+ 500: t.Object(
124
+ {
125
+ type: t.String(),
126
+ title: t.String(),
127
+ status: t.Number(),
128
+ detail: t.String()
129
+ },
130
+ { description: 'Internal server error (RFC 9457 Problem Details)' }
131
+ )
129
132
  }
130
133
  })
131
134
  .listen(3000)
132
135
  ```
133
136
 
134
- 4. Start the app.
137
+ The `strict` preset requires error responses to use `application/problem+json` (RFC 9457). The `500` response above satisfies that when `@elysiajs/openapi` generates the schema with the correct content type.
138
+
139
+ 3. Start the app.
135
140
 
136
141
  ```bash
137
142
  bun run src/index.ts
138
143
  ```
139
144
 
140
- 5. Confirm the outcome.
145
+ 4. Confirm the outcome.
141
146
 
142
147
  - the app serves OpenAPI JSON at `/openapi/json`
143
- - the plugin lints that generated document during startup
144
- - a clean run prints Signale-style success and hype lines in the terminal
145
- - `./artifacts/openapi-lint.json` contains the lint result
148
+ - the plugin lints that generated document during startup using the `strict` preset
149
+ - a clean run prints a success banner in the terminal
150
+ - `./artifacts/openapi-lint.json` contains the full lint result
146
151
  - `./<package-name>.open-api.json` contains the generated OpenAPI snapshot
147
152
 
148
153
  If startup fails, the terminal output includes:
@@ -152,6 +157,27 @@ If startup fails, the terminal output includes:
152
157
  - a fix hint when one is known
153
158
  - a spec reference in `open-api.json#/json/pointer` form
154
159
 
160
+ ### Choose a preset
161
+
162
+ Three first-party presets are available. Import them directly or set `preset` in the plugin options.
163
+
164
+ | Preset | Use case | elysia rules | description / operationId | servers | RFC 9457 |
165
+ |---|---|---|---|---|---|
166
+ | `recommended` | local dev, low friction | warn | — | off | — |
167
+ | `server` | production API gate | error | warn | warn | — |
168
+ | `strict` | full API governance | error | error | error | warn |
169
+
170
+ ```ts
171
+ // simplest — preset string
172
+ spectralPlugin({ preset: 'strict' })
173
+
174
+ // or import the preset object and pass as ruleset
175
+ import { strict } from '@opsydyn/elysia-spectral'
176
+ spectralPlugin({ ruleset: strict })
177
+ ```
178
+
179
+ When `preset` is set, autodiscovered `spectral.yaml` overrides merge on top of the preset rather than the package default. This lets you tighten or loosen individual rules without losing the preset baseline.
180
+
155
181
  ## How-to Guides
156
182
 
157
183
  ### Use a repo-level ruleset
@@ -453,6 +479,8 @@ That example uses `startup.mode: 'report'`, so the app still boots while the pac
453
479
  ### Package API
454
480
 
455
481
  ```ts
482
+ type PresetName = 'recommended' | 'server' | 'strict'
483
+
456
484
  type SeverityThreshold = 'error' | 'warn' | 'info' | 'hint' | 'never'
457
485
 
458
486
  type StartupLintMode = 'enforce' | 'report' | 'off'
@@ -474,7 +502,7 @@ type OpenApiLintSink = {
474
502
  spec: Record<string, unknown>
475
503
  logger: SpectralLogger
476
504
  }
477
- ) => void | Partial<OpenApiLintArtifacts> | Promise<void | Partial<OpenApiLintArtifacts>>
505
+ ) => undefined | Partial<OpenApiLintArtifacts> | Promise<undefined | Partial<OpenApiLintArtifacts>>
478
506
  }
479
507
 
480
508
  type RulesetResolver = (
@@ -490,9 +518,14 @@ type LoadResolvedRulesetOptions = {
490
518
  baseDir?: string
491
519
  resolvers?: RulesetResolver[]
492
520
  mergeAutodiscoveredWithDefault?: boolean
521
+ /** Override the base ruleset used for autodiscovery merging and the fallback when no ruleset is configured. */
522
+ defaultRuleset?: RulesetDefinition
493
523
  }
494
524
 
495
525
  type SpectralPluginOptions = {
526
+ /** First-party governance preset. Sets the base ruleset and autodiscovery merge target. */
527
+ preset?: PresetName
528
+ /** Custom ruleset path, object, or inline definition. Merged on top of preset when both are set. */
496
529
  ruleset?: string | RulesetDefinition | Record<string, unknown>
497
530
  failOn?: SeverityThreshold
498
531
  healthcheck?: false | { path?: string }
@@ -522,6 +555,30 @@ type SpectralPluginOptions = {
522
555
  }
523
556
  ```
524
557
 
558
+ ### Presets
559
+
560
+ | Preset | Extends | elysia-operation-summary | elysia-operation-tags | operation-description | operation-operationId | oas3-api-servers | info-contact | rfc9457-problem-details |
561
+ |---|---|---|---|---|---|---|---|---|
562
+ | `recommended` | spectral:oas/recommended | warn | warn | — | — | off | off | — |
563
+ | `server` | spectral:oas/recommended | error | error | warn | warn | warn | off | — |
564
+ | `strict` | spectral:oas/recommended | error | error | error | error | error | warn | warn |
565
+
566
+ All three presets are exported as `RulesetDefinition` objects and can be passed directly to `ruleset` if you need to compose them:
567
+
568
+ ```ts
569
+ import { strict } from '@opsydyn/elysia-spectral'
570
+
571
+ spectralPlugin({
572
+ ruleset: {
573
+ ...strict,
574
+ rules: {
575
+ ...(strict.rules as object),
576
+ 'rfc9457-problem-details': 'error' // escalate to error for this service
577
+ }
578
+ }
579
+ })
580
+ ```
581
+
525
582
  ### Runtime state
526
583
 
527
584
  The runtime object exposes:
@@ -1,2 +1,2 @@
1
- import { _ as defaultRulesetResolvers, a as OpenApiLintArtifactWriteError, b as lintOpenApi, c as resolveStartupMode, d as LoadedRuleset, f as ResolvedRulesetCandidate, g as RulesetResolverInput, h as RulesetResolverContext, i as shouldFail, l as normalizeFindings, m as RulesetResolver, n as enforceThreshold, o as createOpenApiLintRuntime, p as RulesetLoadError, r as exceedsThreshold, s as isEnabled, t as OpenApiLintThresholdError, u as LoadResolvedRulesetOptions, v as loadResolvedRuleset, y as loadRuleset } from "../index-BrFQCFDI.mjs";
1
+ import { _ as defaultRulesetResolvers, a as OpenApiLintArtifactWriteError, b as lintOpenApi, c as resolveStartupMode, d as LoadedRuleset, f as ResolvedRulesetCandidate, g as RulesetResolverInput, h as RulesetResolverContext, i as shouldFail, l as normalizeFindings, m as RulesetResolver, n as enforceThreshold, o as createOpenApiLintRuntime, p as RulesetLoadError, r as exceedsThreshold, s as isEnabled, t as OpenApiLintThresholdError, u as LoadResolvedRulesetOptions, v as loadResolvedRuleset, y as loadRuleset } from "../index-YZElrxnl.mjs";
2
2
  export { LoadResolvedRulesetOptions, LoadedRuleset, OpenApiLintArtifactWriteError, OpenApiLintThresholdError, ResolvedRulesetCandidate, RulesetLoadError, RulesetResolver, RulesetResolverContext, RulesetResolverInput, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, exceedsThreshold, isEnabled, lintOpenApi, loadResolvedRuleset, loadRuleset, normalizeFindings, resolveStartupMode, shouldFail };
@@ -1,2 +1,2 @@
1
- import { a as OpenApiLintThresholdError, c as shouldFail, d as defaultRulesetResolvers, f as loadResolvedRuleset, h as normalizeFindings, i as resolveStartupMode, m as lintOpenApi, n as createOpenApiLintRuntime, o as enforceThreshold, p as loadRuleset, r as isEnabled, s as exceedsThreshold, t as OpenApiLintArtifactWriteError, u as RulesetLoadError } from "../core-CTQQBA_q.mjs";
1
+ import { a as OpenApiLintThresholdError, c as shouldFail, g as loadRuleset, h as loadResolvedRuleset, i as resolveStartupMode, m as defaultRulesetResolvers, n as createOpenApiLintRuntime, o as enforceThreshold, p as RulesetLoadError, r as isEnabled, s as exceedsThreshold, t as OpenApiLintArtifactWriteError, v as lintOpenApi, y as normalizeFindings } from "../core-DBjV7-E8.mjs";
2
2
  export { OpenApiLintArtifactWriteError, OpenApiLintThresholdError, RulesetLoadError, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, exceedsThreshold, isEnabled, lintOpenApi, loadResolvedRuleset, loadRuleset, normalizeFindings, resolveStartupMode, shouldFail };
@@ -12,7 +12,12 @@ const guidanceByCode = {
12
12
  "elysia-operation-summary": "Add detail.summary to the Elysia route options so generated docs and clients have a short operation label.",
13
13
  "elysia-operation-tags": "Add detail.tags with at least one stable tag, for example ['Users'] or ['Dev'].",
14
14
  "operation-description": "Add detail.description with a short user-facing explanation of what the route does.",
15
- "operation-tags": "Add a non-empty detail.tags array on the route so the OpenAPI operation is grouped consistently."
15
+ "operation-tags": "Add a non-empty detail.tags array on the route so the OpenAPI operation is grouped consistently.",
16
+ "operation-operationId": "Add detail.operationId with a unique camelCase identifier so generated clients and SDKs have stable method names.",
17
+ "operation-success-response": "Add at least one 2xx response schema to the route, for example response: { 200: t.Object(...) }.",
18
+ "oas3-api-servers": "Add a servers array to the OpenAPI documentation config with at least one base URL.",
19
+ "info-contact": "Add an info.contact object to the OpenAPI documentation config with a name and url or email.",
20
+ "rfc9457-problem-details": "Add an \"application/problem+json\" content entry to the error response. See RFC 9457 for the Problem Details schema."
16
21
  };
17
22
  const getFindingRecommendation = (code, message) => {
18
23
  const direct = guidanceByCode[code];
@@ -117,31 +122,38 @@ const lintOpenApi = async (spec, ruleset) => {
117
122
  return normalizeFindings(await spectral.run(spec, { ignoreUnknownFormat: false }), spec);
118
123
  };
119
124
  //#endregion
120
- //#region src/rulesets/default-ruleset.ts
121
- const { schema: schema$1, truthy: truthy$1 } = spectralFunctions;
122
- const { oas: oas$1 } = spectralRulesets;
123
- const operationSelector = "$.paths[*][get,put,post,delete,options,head,patch,trace]";
124
- const defaultRuleset = {
125
- extends: [[oas$1, "recommended"]],
125
+ //#region src/presets/recommended.ts
126
+ const { schema: schema$3, truthy: truthy$3 } = spectralFunctions;
127
+ const { oas: oas$3 } = spectralRulesets;
128
+ const operationSelector$2 = "$.paths[*][get,put,post,delete,options,head,patch,trace]";
129
+ /**
130
+ * Baseline quality preset. Equivalent to the package default ruleset.
131
+ *
132
+ * - Extends spectral:oas/recommended
133
+ * - elysia-operation-summary and elysia-operation-tags at warn
134
+ * - oas3-api-servers and info-contact disabled (local-dev friendly)
135
+ */
136
+ const recommended = {
137
+ extends: [[oas$3, "recommended"]],
126
138
  rules: {
127
139
  "oas3-api-servers": "off",
128
140
  "info-contact": "off",
129
141
  "elysia-operation-summary": {
130
142
  description: "Operations should define a summary for generated docs and clients.",
131
143
  severity: "warn",
132
- given: operationSelector,
144
+ given: operationSelector$2,
133
145
  then: {
134
146
  field: "summary",
135
- function: truthy$1
147
+ function: truthy$3
136
148
  }
137
149
  },
138
150
  "elysia-operation-tags": {
139
151
  description: "Operations should declare at least one tag for grouping and downstream tooling.",
140
152
  severity: "warn",
141
- given: operationSelector,
153
+ given: operationSelector$2,
142
154
  then: {
143
155
  field: "tags",
144
- function: schema$1,
156
+ function: schema$3,
145
157
  functionOptions: { schema: {
146
158
  type: "array",
147
159
  minItems: 1
@@ -151,9 +163,12 @@ const defaultRuleset = {
151
163
  }
152
164
  };
153
165
  //#endregion
166
+ //#region src/rulesets/default-ruleset.ts
167
+ const defaultRuleset = recommended;
168
+ //#endregion
154
169
  //#region src/core/load-ruleset.ts
155
- const { alphabetical, casing, defined, enumeration, falsy, length, or, pattern, schema, truthy, undefined: undefinedFunction, unreferencedReusableObject, xor } = spectralFunctions;
156
- const { oas } = spectralRulesets;
170
+ const { alphabetical, casing, defined, enumeration, falsy, length, or, pattern, schema: schema$2, truthy: truthy$2, undefined: undefinedFunction, unreferencedReusableObject, xor } = spectralFunctions;
171
+ const { oas: oas$2 } = spectralRulesets;
157
172
  const functionMap = {
158
173
  alphabetical,
159
174
  casing,
@@ -163,13 +178,13 @@ const functionMap = {
163
178
  length,
164
179
  or,
165
180
  pattern,
166
- schema,
167
- truthy,
181
+ schema: schema$2,
182
+ truthy: truthy$2,
168
183
  undefined: undefinedFunction,
169
184
  unreferencedReusableObject,
170
185
  xor
171
186
  };
172
- const extendsMap = { "spectral:oas": oas };
187
+ const extendsMap = { "spectral:oas": oas$2 };
173
188
  const autodiscoverRulesetFilenames = [
174
189
  "spectral.yaml",
175
190
  "spectral.yml",
@@ -202,7 +217,7 @@ const loadResolvedRuleset = async (input, baseDirOrOptions = process.cwd()) => {
202
217
  const options = normalizeLoadResolvedRulesetOptions(baseDirOrOptions);
203
218
  const context = {
204
219
  baseDir: options.baseDir,
205
- defaultRuleset,
220
+ defaultRuleset: options.defaultRuleset,
206
221
  mergeAutodiscoveredWithDefault: options.mergeAutodiscoveredWithDefault
207
222
  };
208
223
  for (const resolver of options.resolvers) {
@@ -213,19 +228,21 @@ const loadResolvedRuleset = async (input, baseDirOrOptions = process.cwd()) => {
213
228
  return normalized;
214
229
  }
215
230
  }
216
- if (input === void 0) return { ruleset: defaultRuleset };
231
+ if (input === void 0) return { ruleset: options.defaultRuleset };
217
232
  throw new RulesetLoadError("Ruleset input could not be resolved.");
218
233
  };
219
234
  const normalizeLoadResolvedRulesetOptions = (value) => {
220
235
  if (typeof value === "string") return {
221
236
  baseDir: value,
222
237
  resolvers: defaultRulesetResolvers,
223
- mergeAutodiscoveredWithDefault: true
238
+ mergeAutodiscoveredWithDefault: true,
239
+ defaultRuleset
224
240
  };
225
241
  return {
226
242
  baseDir: value.baseDir ?? process.cwd(),
227
243
  resolvers: value.resolvers ?? defaultRulesetResolvers,
228
- mergeAutodiscoveredWithDefault: value.mergeAutodiscoveredWithDefault ?? true
244
+ mergeAutodiscoveredWithDefault: value.mergeAutodiscoveredWithDefault ?? true,
245
+ defaultRuleset: value.defaultRuleset ?? defaultRuleset
229
246
  };
230
247
  };
231
248
  const resolveAutodiscoveredRuleset = async (input, context) => {
@@ -324,30 +341,30 @@ const loadModuleRuleset = async (resolvedPath) => {
324
341
  });
325
342
  };
326
343
  const resolveModuleRulesetValue = (imported) => {
327
- if (!isRecord(imported)) return;
344
+ if (!isRecord$1(imported)) return;
328
345
  if ("default" in imported) return imported.default;
329
346
  if ("ruleset" in imported) return imported.ruleset;
330
347
  };
331
348
  const resolveModuleFunctions = (imported) => {
332
- if (!isRecord(imported) || !("functions" in imported)) return {};
349
+ if (!isRecord$1(imported) || !("functions" in imported)) return {};
333
350
  const { functions } = imported;
334
- if (!isRecord(functions)) throw new RulesetLoadError("Module ruleset \"functions\" export must be an object map of function names to Spectral functions.");
351
+ if (!isRecord$1(functions)) throw new RulesetLoadError("Module ruleset \"functions\" export must be an object map of function names to Spectral functions.");
335
352
  const entries = Object.entries(functions).filter(([, value]) => typeof value === "function");
336
353
  return Object.fromEntries(entries);
337
354
  };
338
355
  const isYamlRulesetPath = (value) => value.endsWith(".yaml") || value.endsWith(".yml");
339
356
  const isModuleRulesetPath = (value) => value.endsWith(".js") || value.endsWith(".mjs") || value.endsWith(".cjs") || value.endsWith(".ts") || value.endsWith(".mts") || value.endsWith(".cts");
340
357
  const normalizeRulesetDefinition = (input, availableFunctions = functionMap) => {
341
- if (!isRecord(input)) throw new RulesetLoadError("Ruleset must be an object.");
358
+ if (!isRecord$1(input)) throw new RulesetLoadError("Ruleset must be an object.");
342
359
  const normalized = { ...input };
343
360
  if ("extends" in normalized) normalized.extends = normalizeExtends(normalized.extends);
344
361
  if ("rules" in normalized) normalized.rules = normalizeRules(normalized.rules, availableFunctions);
345
362
  return normalized;
346
363
  };
347
364
  const mergeRuleEntry = (base, override) => {
348
- if (!isRecord(override)) return override;
365
+ if (!isRecord$1(override)) return override;
349
366
  if ("given" in override || "then" in override) return override;
350
- if (isRecord(base) && ("given" in base || "then" in base)) return {
367
+ if (isRecord$1(base) && ("given" in base || "then" in base)) return {
351
368
  ...base,
352
369
  ...override
353
370
  };
@@ -358,8 +375,8 @@ const mergeRuleEntry = (base, override) => {
358
375
  const mergeRulesets = (baseRuleset, overrideRuleset) => {
359
376
  const mergedBase = baseRuleset;
360
377
  const mergedOverride = overrideRuleset;
361
- const baseRules = isRecord(mergedBase.rules) ? mergedBase.rules : {};
362
- const overrideRules = isRecord(mergedOverride.rules) ? mergedOverride.rules : {};
378
+ const baseRules = isRecord$1(mergedBase.rules) ? mergedBase.rules : {};
379
+ const overrideRules = isRecord$1(mergedOverride.rules) ? mergedOverride.rules : {};
363
380
  const mergedRules = { ...baseRules };
364
381
  for (const [name, overrideRule] of Object.entries(overrideRules)) mergedRules[name] = mergeRuleEntry(baseRules[name], overrideRule);
365
382
  const baseExtends = toExtendsArray(mergedBase.extends);
@@ -394,12 +411,12 @@ const resolveExtendsEntry = (value) => {
394
411
  return resolved;
395
412
  };
396
413
  const normalizeRules = (value, availableFunctions) => {
397
- if (!isRecord(value)) return value;
414
+ if (!isRecord$1(value)) return value;
398
415
  const entries = Object.entries(value).map(([ruleName, ruleValue]) => [ruleName, normalizeRule(ruleValue, availableFunctions)]);
399
416
  return Object.fromEntries(entries);
400
417
  };
401
418
  const normalizeRule = (value, availableFunctions) => {
402
- if (!isRecord(value)) return value;
419
+ if (!isRecord$1(value)) return value;
403
420
  const normalized = { ...value };
404
421
  if ("then" in normalized) normalized.then = normalizeThen(normalized.then, availableFunctions);
405
422
  return normalized;
@@ -409,7 +426,7 @@ const normalizeThen = (value, availableFunctions) => {
409
426
  return normalizeThenEntry(value, availableFunctions);
410
427
  };
411
428
  const normalizeThenEntry = (value, availableFunctions) => {
412
- if (!isRecord(value)) return value;
429
+ if (!isRecord$1(value)) return value;
413
430
  const normalized = { ...value };
414
431
  if (typeof normalized.function === "string") {
415
432
  const resolved = availableFunctions[normalized.function];
@@ -418,7 +435,7 @@ const normalizeThenEntry = (value, availableFunctions) => {
418
435
  }
419
436
  return normalized;
420
437
  };
421
- const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
438
+ const isRecord$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
422
439
  //#endregion
423
440
  //#region src/output/terminal-format.ts
424
441
  const severityStyles = {
@@ -873,6 +890,133 @@ const createOutputSinks = (options) => {
873
890
  return sinks;
874
891
  };
875
892
  //#endregion
893
+ //#region src/presets/server.ts
894
+ const { schema: schema$1, truthy: truthy$1 } = spectralFunctions;
895
+ const { oas: oas$1 } = spectralRulesets;
896
+ const operationSelector$1 = "$.paths[*][get,put,post,delete,options,head,patch,trace]";
897
+ /**
898
+ * Production API quality preset. Suitable as a CI gate for teams shipping
899
+ * public or internal APIs where contract quality matters.
900
+ *
901
+ * Tightens recommended:
902
+ * - elysia-operation-summary and elysia-operation-tags escalated to error
903
+ * - operation-description, operation-operationId, operation-success-response at warn
904
+ * - oas3-api-servers at warn (servers should be declared in production specs)
905
+ */
906
+ const server = {
907
+ extends: [[oas$1, "recommended"]],
908
+ rules: {
909
+ "oas3-api-servers": "warn",
910
+ "info-contact": "off",
911
+ "elysia-operation-summary": {
912
+ description: "Operations should define a summary for generated docs and clients.",
913
+ severity: "error",
914
+ given: operationSelector$1,
915
+ then: {
916
+ field: "summary",
917
+ function: truthy$1
918
+ }
919
+ },
920
+ "elysia-operation-tags": {
921
+ description: "Operations should declare at least one tag for grouping and downstream tooling.",
922
+ severity: "error",
923
+ given: operationSelector$1,
924
+ then: {
925
+ field: "tags",
926
+ function: schema$1,
927
+ functionOptions: { schema: {
928
+ type: "array",
929
+ minItems: 1
930
+ } }
931
+ }
932
+ },
933
+ "operation-description": "warn",
934
+ "operation-operationId": "warn",
935
+ "operation-success-response": "warn"
936
+ }
937
+ };
938
+ //#endregion
939
+ //#region src/presets/strict.ts
940
+ const { schema, truthy } = spectralFunctions;
941
+ const { oas } = spectralRulesets;
942
+ const operationSelector = "$.paths[*][get,put,post,delete,options,head,patch,trace]";
943
+ const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
944
+ /**
945
+ * Checks that all 4xx/5xx responses declare application/problem+json as
946
+ * their content type, conforming to RFC 9457 Problem Details for HTTP APIs.
947
+ */
948
+ const checkProblemDetails = (operation) => {
949
+ if (!isRecord(operation) || !isRecord(operation.responses)) return;
950
+ const results = [];
951
+ for (const [statusCode, response] of Object.entries(operation.responses)) {
952
+ const code = Number(statusCode);
953
+ if (!Number.isFinite(code) || code < 400) continue;
954
+ if (!isRecord(response)) continue;
955
+ const content = response.content;
956
+ if (!isRecord(content) || !("application/problem+json" in content)) results.push({
957
+ message: `${statusCode} error response should use "application/problem+json" content type (RFC 9457 Problem Details).`,
958
+ path: ["responses", statusCode]
959
+ });
960
+ }
961
+ return results.length > 0 ? results : void 0;
962
+ };
963
+ /**
964
+ * Full API governance preset. Suitable for teams with formal API governance
965
+ * requirements, public API programs, or downstream client generation pipelines.
966
+ *
967
+ * Tightens server:
968
+ * - All elysia rules and operation metadata rules escalated to error
969
+ * - info-contact at warn (API ownership should be declared)
970
+ * - oas3-api-servers at error (server declaration is required)
971
+ * - rfc9457-problem-details at warn (error responses should use Problem Details)
972
+ */
973
+ const strict = {
974
+ extends: [[oas, "recommended"]],
975
+ rules: {
976
+ "oas3-api-servers": "error",
977
+ "info-contact": "warn",
978
+ "elysia-operation-summary": {
979
+ description: "Operations should define a summary for generated docs and clients.",
980
+ severity: "error",
981
+ given: operationSelector,
982
+ then: {
983
+ field: "summary",
984
+ function: truthy
985
+ }
986
+ },
987
+ "elysia-operation-tags": {
988
+ description: "Operations should declare at least one tag for grouping and downstream tooling.",
989
+ severity: "error",
990
+ given: operationSelector,
991
+ then: {
992
+ field: "tags",
993
+ function: schema,
994
+ functionOptions: { schema: {
995
+ type: "array",
996
+ minItems: 1
997
+ } }
998
+ }
999
+ },
1000
+ "operation-description": "error",
1001
+ "operation-operationId": "error",
1002
+ "operation-success-response": "error",
1003
+ "rfc9457-problem-details": {
1004
+ description: "Error responses (4xx, 5xx) should use RFC 9457 Problem Details (application/problem+json).",
1005
+ message: "{{error}}",
1006
+ severity: "warn",
1007
+ given: operationSelector,
1008
+ then: { function: checkProblemDetails }
1009
+ }
1010
+ }
1011
+ };
1012
+ //#endregion
1013
+ //#region src/presets/index.ts
1014
+ const presets = {
1015
+ recommended,
1016
+ server,
1017
+ strict
1018
+ };
1019
+ //#endregion
876
1020
  //#region src/providers/spec-provider.ts
877
1021
  var BaseSpecProvider = class {
878
1022
  constructor(app, options = {}) {
@@ -1023,8 +1167,11 @@ const createOpenApiLintRuntime = (options = {}) => {
1023
1167
  reporter.start("OpenAPI lint started.");
1024
1168
  try {
1025
1169
  const spec = await new PublicSpecProvider(app, options.source).getSpec();
1026
- const loadedRuleset = await loadResolvedRuleset(options.ruleset);
1027
- if (loadedRuleset.source?.autodiscovered) reporter.ruleset(`OpenAPI lint autodiscovered ruleset ${loadedRuleset.source.path} and merged it with the package default ruleset.`);
1170
+ const loadedRuleset = await loadResolvedRuleset(options.ruleset, { ...options.preset ? { defaultRuleset: presets[options.preset] } : {} });
1171
+ if (loadedRuleset.source?.autodiscovered) {
1172
+ const base = options.preset ? `"${options.preset}" preset` : "package default ruleset";
1173
+ reporter.ruleset(`OpenAPI lint autodiscovered ruleset ${loadedRuleset.source.path} and merged it with the ${base}.`);
1174
+ } else if (options.preset && !loadedRuleset.source?.path) reporter.ruleset(`OpenAPI lint using "${options.preset}" preset.`);
1028
1175
  else if (loadedRuleset.source?.path) reporter.ruleset(`OpenAPI lint loaded ruleset ${loadedRuleset.source.path}.`);
1029
1176
  const result = await lintOpenApi(spec, loadedRuleset.ruleset);
1030
1177
  await writeOutputSinks(result, spec, options, artifactWriteFailureMode);
@@ -1105,4 +1252,4 @@ const resolveStartupMode = (options = {}) => {
1105
1252
  return options.enabled === false ? "off" : "enforce";
1106
1253
  };
1107
1254
  //#endregion
1108
- export { OpenApiLintThresholdError as a, shouldFail as c, defaultRulesetResolvers as d, loadResolvedRuleset as f, normalizeFindings as h, resolveStartupMode as i, resolveReporter as l, lintOpenApi as m, createOpenApiLintRuntime as n, enforceThreshold as o, loadRuleset as p, isEnabled as r, exceedsThreshold as s, OpenApiLintArtifactWriteError as t, RulesetLoadError as u };
1255
+ export { recommended as _, OpenApiLintThresholdError as a, shouldFail as c, server as d, resolveReporter as f, loadRuleset as g, loadResolvedRuleset as h, resolveStartupMode as i, presets as l, defaultRulesetResolvers as m, createOpenApiLintRuntime as n, enforceThreshold as o, RulesetLoadError as p, isEnabled as r, exceedsThreshold as s, OpenApiLintArtifactWriteError as t, strict as u, lintOpenApi as v, normalizeFindings as y };
@@ -2,6 +2,7 @@ import { ISpectralDiagnostic, RulesetDefinition } from "@stoplight/spectral-core
2
2
  import { AnyElysia } from "elysia";
3
3
 
4
4
  //#region src/types.d.ts
5
+ type PresetName = 'recommended' | 'server' | 'strict';
5
6
  type SeverityThreshold = 'error' | 'warn' | 'info' | 'hint' | 'never';
6
7
  type LintSeverity = 'error' | 'warn' | 'info' | 'hint';
7
8
  type StartupLintMode = 'enforce' | 'report' | 'off';
@@ -27,6 +28,7 @@ type OpenApiLintSink = {
27
28
  write: (result: LintRunResult, context: OpenApiLintSinkContext) => undefined | Partial<OpenApiLintArtifacts> | Promise<undefined | Partial<OpenApiLintArtifacts>>;
28
29
  };
29
30
  type SpectralPluginOptions = {
31
+ preset?: PresetName;
30
32
  ruleset?: string | RulesetDefinition | Record<string, unknown>;
31
33
  failOn?: SeverityThreshold;
32
34
  healthcheck?: false | {
@@ -135,7 +137,8 @@ type RulesetResolver = (input: RulesetResolverInput, context: RulesetResolverCon
135
137
  type LoadResolvedRulesetOptions = {
136
138
  baseDir?: string;
137
139
  resolvers?: RulesetResolver[];
138
- mergeAutodiscoveredWithDefault?: boolean;
140
+ mergeAutodiscoveredWithDefault?: boolean; /** Override the ruleset used as the merge base for autodiscovery and the fallback when no ruleset is configured. Defaults to the package default (recommended preset). */
141
+ defaultRuleset?: RulesetDefinition;
139
142
  };
140
143
  declare class RulesetLoadError extends Error {
141
144
  constructor(message: string, options?: {
@@ -169,4 +172,4 @@ declare const exceedsThreshold: (severity: LintSeverity, threshold: SeverityThre
169
172
  declare const shouldFail: (result: LintRunResult, threshold: SeverityThreshold) => boolean;
170
173
  declare const enforceThreshold: (result: LintRunResult, threshold: SeverityThreshold) => void;
171
174
  //#endregion
172
- export { OpenApiLintSinkContext as A, LintRunResult as C, OpenApiLintRuntimeFailure as D, OpenApiLintRuntime as E, StartupLintMode as F, SpecProvider as M, SpectralLogger as N, OpenApiLintRuntimeStatus as O, SpectralPluginOptions as P, LintFinding as S, OpenApiLintArtifacts as T, defaultRulesetResolvers as _, OpenApiLintArtifactWriteError as a, lintOpenApi as b, resolveStartupMode as c, LoadedRuleset as d, ResolvedRulesetCandidate as f, RulesetResolverInput as g, RulesetResolverContext as h, shouldFail as i, SeverityThreshold as j, OpenApiLintSink as k, normalizeFindings as l, RulesetResolver as m, enforceThreshold as n, createOpenApiLintRuntime as o, RulesetLoadError as p, exceedsThreshold as r, isEnabled as s, OpenApiLintThresholdError as t, LoadResolvedRulesetOptions as u, loadResolvedRuleset as v, LintSeverity as w, ArtifactWriteFailureMode as x, loadRuleset as y };
175
+ export { OpenApiLintSinkContext as A, LintRunResult as C, OpenApiLintRuntimeFailure as D, OpenApiLintRuntime as E, SpectralPluginOptions as F, StartupLintMode as I, SeverityThreshold as M, SpecProvider as N, OpenApiLintRuntimeStatus as O, SpectralLogger as P, LintFinding as S, OpenApiLintArtifacts as T, defaultRulesetResolvers as _, OpenApiLintArtifactWriteError as a, lintOpenApi as b, resolveStartupMode as c, LoadedRuleset as d, ResolvedRulesetCandidate as f, RulesetResolverInput as g, RulesetResolverContext as h, shouldFail as i, PresetName as j, OpenApiLintSink as k, normalizeFindings as l, RulesetResolver as m, enforceThreshold as n, createOpenApiLintRuntime as o, RulesetLoadError as p, exceedsThreshold as r, isEnabled as s, OpenApiLintThresholdError as t, LoadResolvedRulesetOptions as u, loadResolvedRuleset as v, LintSeverity as w, ArtifactWriteFailureMode as x, loadRuleset as y };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { A as OpenApiLintSinkContext, C as LintRunResult, D as OpenApiLintRuntimeFailure, E as OpenApiLintRuntime, F as StartupLintMode, M as SpecProvider, N as SpectralLogger, O as OpenApiLintRuntimeStatus, P as SpectralPluginOptions, S as LintFinding, T as OpenApiLintArtifacts, _ as defaultRulesetResolvers, a as OpenApiLintArtifactWriteError, b as lintOpenApi, c as resolveStartupMode, d as LoadedRuleset, f as ResolvedRulesetCandidate, g as RulesetResolverInput, h as RulesetResolverContext, i as shouldFail, j as SeverityThreshold, k as OpenApiLintSink, l as normalizeFindings, m as RulesetResolver, n as enforceThreshold, o as createOpenApiLintRuntime, p as RulesetLoadError, r as exceedsThreshold, s as isEnabled, t as OpenApiLintThresholdError, u as LoadResolvedRulesetOptions, v as loadResolvedRuleset, w as LintSeverity, x as ArtifactWriteFailureMode, y as loadRuleset } from "./index-BrFQCFDI.mjs";
1
+ import { A as OpenApiLintSinkContext, C as LintRunResult, D as OpenApiLintRuntimeFailure, E as OpenApiLintRuntime, F as SpectralPluginOptions, I as StartupLintMode, M as SeverityThreshold, N as SpecProvider, O as OpenApiLintRuntimeStatus, P as SpectralLogger, S as LintFinding, T as OpenApiLintArtifacts, _ as defaultRulesetResolvers, a as OpenApiLintArtifactWriteError, b as lintOpenApi, c as resolveStartupMode, d as LoadedRuleset, f as ResolvedRulesetCandidate, g as RulesetResolverInput, h as RulesetResolverContext, i as shouldFail, j as PresetName, k as OpenApiLintSink, l as normalizeFindings, m as RulesetResolver, n as enforceThreshold, o as createOpenApiLintRuntime, p as RulesetLoadError, r as exceedsThreshold, s as isEnabled, t as OpenApiLintThresholdError, u as LoadResolvedRulesetOptions, v as loadResolvedRuleset, w as LintSeverity, x as ArtifactWriteFailureMode, y as loadRuleset } from "./index-YZElrxnl.mjs";
2
+ import { RulesetDefinition } from "@stoplight/spectral-core";
2
3
  import { Elysia } from "elysia";
3
4
 
4
5
  //#region src/plugin.d.ts
@@ -33,4 +34,42 @@ declare const spectralPlugin: (options?: SpectralPluginOptions) => Elysia<"", {
33
34
  response: {};
34
35
  }>;
35
36
  //#endregion
36
- export { ArtifactWriteFailureMode, LintFinding, LintRunResult, LintSeverity, LoadResolvedRulesetOptions, LoadedRuleset, OpenApiLintArtifactWriteError, OpenApiLintArtifacts, OpenApiLintRuntime, OpenApiLintRuntimeFailure, OpenApiLintRuntimeStatus, OpenApiLintSink, OpenApiLintSinkContext, OpenApiLintThresholdError, ResolvedRulesetCandidate, RulesetLoadError, RulesetResolver, RulesetResolverContext, RulesetResolverInput, SeverityThreshold, SpecProvider, SpectralLogger, SpectralPluginOptions, StartupLintMode, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, exceedsThreshold, isEnabled, lintOpenApi, loadResolvedRuleset, loadRuleset, normalizeFindings, resolveStartupMode, shouldFail, spectralPlugin };
37
+ //#region src/presets/recommended.d.ts
38
+ /**
39
+ * Baseline quality preset. Equivalent to the package default ruleset.
40
+ *
41
+ * - Extends spectral:oas/recommended
42
+ * - elysia-operation-summary and elysia-operation-tags at warn
43
+ * - oas3-api-servers and info-contact disabled (local-dev friendly)
44
+ */
45
+ declare const recommended: RulesetDefinition;
46
+ //#endregion
47
+ //#region src/presets/server.d.ts
48
+ /**
49
+ * Production API quality preset. Suitable as a CI gate for teams shipping
50
+ * public or internal APIs where contract quality matters.
51
+ *
52
+ * Tightens recommended:
53
+ * - elysia-operation-summary and elysia-operation-tags escalated to error
54
+ * - operation-description, operation-operationId, operation-success-response at warn
55
+ * - oas3-api-servers at warn (servers should be declared in production specs)
56
+ */
57
+ declare const server: RulesetDefinition;
58
+ //#endregion
59
+ //#region src/presets/strict.d.ts
60
+ /**
61
+ * Full API governance preset. Suitable for teams with formal API governance
62
+ * requirements, public API programs, or downstream client generation pipelines.
63
+ *
64
+ * Tightens server:
65
+ * - All elysia rules and operation metadata rules escalated to error
66
+ * - info-contact at warn (API ownership should be declared)
67
+ * - oas3-api-servers at error (server declaration is required)
68
+ * - rfc9457-problem-details at warn (error responses should use Problem Details)
69
+ */
70
+ declare const strict: RulesetDefinition;
71
+ //#endregion
72
+ //#region src/presets/index.d.ts
73
+ declare const presets: Record<PresetName, RulesetDefinition>;
74
+ //#endregion
75
+ export { ArtifactWriteFailureMode, LintFinding, LintRunResult, LintSeverity, LoadResolvedRulesetOptions, LoadedRuleset, OpenApiLintArtifactWriteError, OpenApiLintArtifacts, OpenApiLintRuntime, OpenApiLintRuntimeFailure, OpenApiLintRuntimeStatus, OpenApiLintSink, OpenApiLintSinkContext, OpenApiLintThresholdError, PresetName, ResolvedRulesetCandidate, RulesetLoadError, RulesetResolver, RulesetResolverContext, RulesetResolverInput, SeverityThreshold, SpecProvider, SpectralLogger, SpectralPluginOptions, StartupLintMode, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, exceedsThreshold, isEnabled, lintOpenApi, loadResolvedRuleset, loadRuleset, normalizeFindings, presets, recommended, resolveStartupMode, server, shouldFail, spectralPlugin, strict };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as OpenApiLintThresholdError, c as shouldFail, d as defaultRulesetResolvers, f as loadResolvedRuleset, h as normalizeFindings, i as resolveStartupMode, l as resolveReporter, m as lintOpenApi, n as createOpenApiLintRuntime, o as enforceThreshold, p as loadRuleset, r as isEnabled, s as exceedsThreshold, t as OpenApiLintArtifactWriteError, u as RulesetLoadError } from "./core-CTQQBA_q.mjs";
1
+ import { _ as recommended, a as OpenApiLintThresholdError, c as shouldFail, d as server, f as resolveReporter, g as loadRuleset, h as loadResolvedRuleset, i as resolveStartupMode, l as presets, m as defaultRulesetResolvers, n as createOpenApiLintRuntime, o as enforceThreshold, p as RulesetLoadError, r as isEnabled, s as exceedsThreshold, t as OpenApiLintArtifactWriteError, u as strict, v as lintOpenApi, y as normalizeFindings } from "./core-DBjV7-E8.mjs";
2
2
  import { Elysia } from "elysia";
3
3
  //#region src/plugin.ts
4
4
  const spectralPlugin = (options = {}) => {
@@ -82,4 +82,4 @@ const spectralPlugin = (options = {}) => {
82
82
  return plugin;
83
83
  };
84
84
  //#endregion
85
- export { OpenApiLintArtifactWriteError, OpenApiLintThresholdError, RulesetLoadError, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, exceedsThreshold, isEnabled, lintOpenApi, loadResolvedRuleset, loadRuleset, normalizeFindings, resolveStartupMode, shouldFail, spectralPlugin };
85
+ export { OpenApiLintArtifactWriteError, OpenApiLintThresholdError, RulesetLoadError, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, exceedsThreshold, isEnabled, lintOpenApi, loadResolvedRuleset, loadRuleset, normalizeFindings, presets, recommended, resolveStartupMode, server, shouldFail, spectralPlugin, strict };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsydyn/elysia-spectral",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Thin Elysia plugin that lints generated OpenAPI documents with Spectral.",
5
5
  "packageManager": "bun@1.2.9",
6
6
  "publishConfig": {