@opsydyn/elysia-spectral 1.5.0 → 1.5.2
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 +14 -0
- package/README.md +51 -3
- package/dist/core/index.d.mts +1 -1
- package/dist/core/index.mjs +4 -1
- package/dist/{index-CyJXdIRT.d.mts → index-DzJWrqPA.d.mts} +7 -5
- package/dist/index.d.mts +8 -3
- package/dist/index.mjs +16 -2
- package/dist/lint-openapi-D76sC7S5.mjs +122 -0
- package/dist/load-ruleset-CiikrzWx.mjs +301 -0
- package/dist/presets-CCfU_diN.mjs +132 -0
- package/dist/recommended-DgrTqq-3.mjs +40 -0
- package/dist/rolldown-runtime-wcPFST8Q.mjs +13 -0
- package/dist/ruleset-load-error-CogUOC7W.mjs +10 -0
- package/dist/{core-BLJeXQ15.mjs → runtime-PGHAFx-E.mjs} +60 -584
- package/package.json +1 -1
|
@@ -1,446 +1,10 @@
|
|
|
1
|
-
import spectralCore from "@stoplight/spectral-core";
|
|
2
|
-
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
1
|
import path from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
import spectralFunctions from "@stoplight/spectral-functions";
|
|
6
|
-
import spectralRulesets from "@stoplight/spectral-rulesets";
|
|
7
|
-
import YAML from "yaml";
|
|
8
2
|
import signale from "signale";
|
|
9
3
|
import { styleText } from "node:util";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
5
|
import { brunoToOpenCollection, openApiToBruno } from "@usebruno/converters";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"elysia-operation-summary": "Add detail.summary to the Elysia route options so generated docs and clients have a short operation label.",
|
|
14
|
-
"elysia-operation-tags": "Add detail.tags with at least one stable tag, for example ['Users'] or ['Dev'].",
|
|
15
|
-
"operation-description": "Add detail.description with a short user-facing explanation of what the route does.",
|
|
16
|
-
"operation-tags": "Add a non-empty detail.tags array on the route so the OpenAPI operation is grouped consistently.",
|
|
17
|
-
"operation-operationId": "Add detail.operationId with a unique camelCase identifier so generated clients and SDKs have stable method names.",
|
|
18
|
-
"operation-success-response": "Add at least one 2xx response schema to the route, for example response: { 200: t.Object(...) }.",
|
|
19
|
-
"oas3-api-servers": "Add a servers array to the OpenAPI documentation config with at least one base URL.",
|
|
20
|
-
"info-contact": "Add an info.contact object to the OpenAPI documentation config with a name and url or email.",
|
|
21
|
-
"rfc9457-problem-details": "Add an \"application/problem+json\" content entry to the error response. See RFC 9457 for the Problem Details schema."
|
|
22
|
-
};
|
|
23
|
-
const getFindingRecommendation = (code, message) => {
|
|
24
|
-
const direct = guidanceByCode[code];
|
|
25
|
-
if (direct) return direct;
|
|
26
|
-
if (code === "oas3-schema" && message.includes("required property \"responses\"")) return "Add a response schema to the route, for example response: { 200: t.Object(...) } or response: { 200: t.Array(...) }.";
|
|
27
|
-
if (code.startsWith("operation-")) return "Add the missing operation metadata under detail on the Elysia route options.";
|
|
28
|
-
};
|
|
29
|
-
//#endregion
|
|
30
|
-
//#region src/core/normalize-findings.ts
|
|
31
|
-
const httpMethods = new Set([
|
|
32
|
-
"get",
|
|
33
|
-
"put",
|
|
34
|
-
"post",
|
|
35
|
-
"delete",
|
|
36
|
-
"options",
|
|
37
|
-
"head",
|
|
38
|
-
"patch",
|
|
39
|
-
"trace"
|
|
40
|
-
]);
|
|
41
|
-
const normalizeFindings = (diagnostics, spec) => {
|
|
42
|
-
const findings = diagnostics.map((diagnostic) => normalizeFinding(diagnostic, spec));
|
|
43
|
-
const summary = findings.reduce((current, finding) => {
|
|
44
|
-
current[finding.severity] += 1;
|
|
45
|
-
current.total += 1;
|
|
46
|
-
return current;
|
|
47
|
-
}, {
|
|
48
|
-
error: 0,
|
|
49
|
-
warn: 0,
|
|
50
|
-
info: 0,
|
|
51
|
-
hint: 0,
|
|
52
|
-
total: 0
|
|
53
|
-
});
|
|
54
|
-
return {
|
|
55
|
-
ok: summary.error === 0,
|
|
56
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
57
|
-
source: "manual",
|
|
58
|
-
failOn: "error",
|
|
59
|
-
durationMs: null,
|
|
60
|
-
summary,
|
|
61
|
-
findings
|
|
62
|
-
};
|
|
63
|
-
};
|
|
64
|
-
const normalizeFinding = (diagnostic, spec) => {
|
|
65
|
-
const path = [...diagnostic.path];
|
|
66
|
-
const finding = {
|
|
67
|
-
code: String(diagnostic.code),
|
|
68
|
-
message: diagnostic.message,
|
|
69
|
-
severity: toLintSeverity(diagnostic.severity),
|
|
70
|
-
path,
|
|
71
|
-
documentPointer: toDocumentPointer(path)
|
|
72
|
-
};
|
|
73
|
-
const recommendation = getFindingRecommendation(String(diagnostic.code), diagnostic.message);
|
|
74
|
-
if (recommendation) finding.recommendation = recommendation;
|
|
75
|
-
if (diagnostic.source !== void 0) finding.source = diagnostic.source;
|
|
76
|
-
if (diagnostic.range) finding.range = {
|
|
77
|
-
start: diagnostic.range.start,
|
|
78
|
-
end: diagnostic.range.end
|
|
79
|
-
};
|
|
80
|
-
const operation = inferOperation(path, spec);
|
|
81
|
-
if (operation) finding.operation = operation;
|
|
82
|
-
return finding;
|
|
83
|
-
};
|
|
84
|
-
const toLintSeverity = (severity) => {
|
|
85
|
-
switch (severity) {
|
|
86
|
-
case 0: return "error";
|
|
87
|
-
case 1: return "warn";
|
|
88
|
-
case 2: return "info";
|
|
89
|
-
default: return "hint";
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
const toDocumentPointer = (path) => {
|
|
93
|
-
if (path.length === 0) return "";
|
|
94
|
-
return `/${path.map((segment) => String(segment).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
|
|
95
|
-
};
|
|
96
|
-
const inferOperation = (path, spec) => {
|
|
97
|
-
if (path[0] !== "paths") return;
|
|
98
|
-
const routePath = typeof path[1] === "string" ? path[1] : void 0;
|
|
99
|
-
const method = typeof path[2] === "string" && httpMethods.has(path[2]) ? path[2] : void 0;
|
|
100
|
-
if (!routePath && !method) return;
|
|
101
|
-
const operationRecord = routePath && method ? getNestedValue(spec, [
|
|
102
|
-
"paths",
|
|
103
|
-
routePath,
|
|
104
|
-
method
|
|
105
|
-
]) : void 0;
|
|
106
|
-
const operation = {};
|
|
107
|
-
if (routePath !== void 0) operation.path = routePath;
|
|
108
|
-
if (method !== void 0) operation.method = method;
|
|
109
|
-
if (operationRecord && typeof operationRecord === "object" && "operationId" in operationRecord) operation.operationId = String(operationRecord.operationId);
|
|
110
|
-
return operation;
|
|
111
|
-
};
|
|
112
|
-
const getNestedValue = (value, path) => {
|
|
113
|
-
let current = value;
|
|
114
|
-
for (const segment of path) {
|
|
115
|
-
if (current === null || typeof current !== "object") return;
|
|
116
|
-
current = current[segment];
|
|
117
|
-
}
|
|
118
|
-
return current;
|
|
119
|
-
};
|
|
120
|
-
//#endregion
|
|
121
|
-
//#region src/core/lint-openapi.ts
|
|
122
|
-
const { Spectral } = spectralCore;
|
|
123
|
-
const lintOpenApi = async (spec, ruleset) => {
|
|
124
|
-
const spectral = new Spectral();
|
|
125
|
-
spectral.setRuleset(ruleset);
|
|
126
|
-
return normalizeFindings(await spectral.run(spec, { ignoreUnknownFormat: false }), spec);
|
|
127
|
-
};
|
|
128
|
-
//#endregion
|
|
129
|
-
//#region src/presets/recommended.ts
|
|
130
|
-
const { schema: schema$3, truthy: truthy$3 } = spectralFunctions;
|
|
131
|
-
const { oas: oas$3 } = spectralRulesets;
|
|
132
|
-
const operationSelector$2 = "$.paths[*][get,put,post,delete,options,head,patch,trace]";
|
|
133
|
-
/**
|
|
134
|
-
* Baseline quality preset. Equivalent to the package default ruleset.
|
|
135
|
-
*
|
|
136
|
-
* - Extends spectral:oas/recommended
|
|
137
|
-
* - elysia-operation-summary and elysia-operation-tags at warn
|
|
138
|
-
* - oas3-api-servers and info-contact disabled (local-dev friendly)
|
|
139
|
-
*/
|
|
140
|
-
const recommended = {
|
|
141
|
-
extends: [[oas$3, "recommended"]],
|
|
142
|
-
rules: {
|
|
143
|
-
"oas3-api-servers": "off",
|
|
144
|
-
"info-contact": "off",
|
|
145
|
-
"elysia-operation-summary": {
|
|
146
|
-
description: "Operations should define a summary for generated docs and clients.",
|
|
147
|
-
severity: "warn",
|
|
148
|
-
given: operationSelector$2,
|
|
149
|
-
then: {
|
|
150
|
-
field: "summary",
|
|
151
|
-
function: truthy$3
|
|
152
|
-
}
|
|
153
|
-
},
|
|
154
|
-
"elysia-operation-tags": {
|
|
155
|
-
description: "Operations should declare at least one tag for grouping and downstream tooling.",
|
|
156
|
-
severity: "warn",
|
|
157
|
-
given: operationSelector$2,
|
|
158
|
-
then: {
|
|
159
|
-
field: "tags",
|
|
160
|
-
function: schema$3,
|
|
161
|
-
functionOptions: { schema: {
|
|
162
|
-
type: "array",
|
|
163
|
-
minItems: 1
|
|
164
|
-
} }
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
//#endregion
|
|
170
|
-
//#region src/rulesets/default-ruleset.ts
|
|
171
|
-
const defaultRuleset = recommended;
|
|
172
|
-
//#endregion
|
|
173
|
-
//#region src/core/load-ruleset.ts
|
|
174
|
-
const { alphabetical, casing, defined, enumeration, falsy, length, or, pattern, schema: schema$2, truthy: truthy$2, undefined: undefinedFunction, unreferencedReusableObject, xor } = spectralFunctions;
|
|
175
|
-
const { oas: oas$2 } = spectralRulesets;
|
|
176
|
-
const functionMap = {
|
|
177
|
-
alphabetical,
|
|
178
|
-
casing,
|
|
179
|
-
defined,
|
|
180
|
-
enumeration,
|
|
181
|
-
falsy,
|
|
182
|
-
length,
|
|
183
|
-
or,
|
|
184
|
-
pattern,
|
|
185
|
-
schema: schema$2,
|
|
186
|
-
truthy: truthy$2,
|
|
187
|
-
undefined: undefinedFunction,
|
|
188
|
-
unreferencedReusableObject,
|
|
189
|
-
xor
|
|
190
|
-
};
|
|
191
|
-
const extendsMap = { "spectral:oas": oas$2 };
|
|
192
|
-
const autodiscoverRulesetFilenames = [
|
|
193
|
-
"spectral.yaml",
|
|
194
|
-
"spectral.yml",
|
|
195
|
-
"spectral.ts",
|
|
196
|
-
"spectral.mts",
|
|
197
|
-
"spectral.cts",
|
|
198
|
-
"spectral.js",
|
|
199
|
-
"spectral.mjs",
|
|
200
|
-
"spectral.cjs",
|
|
201
|
-
"spectral.config.yaml",
|
|
202
|
-
"spectral.config.yml",
|
|
203
|
-
"spectral.config.ts",
|
|
204
|
-
"spectral.config.mts",
|
|
205
|
-
"spectral.config.cts",
|
|
206
|
-
"spectral.config.js",
|
|
207
|
-
"spectral.config.mjs",
|
|
208
|
-
"spectral.config.cjs"
|
|
209
|
-
];
|
|
210
|
-
var RulesetLoadError = class extends Error {
|
|
211
|
-
constructor(message, options) {
|
|
212
|
-
super(message);
|
|
213
|
-
this.name = "RulesetLoadError";
|
|
214
|
-
if (options?.cause !== void 0) this.cause = options.cause;
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
const loadRuleset = async (input, baseDirOrOptions = process.cwd()) => {
|
|
218
|
-
return (await loadResolvedRuleset(input, baseDirOrOptions)).ruleset;
|
|
219
|
-
};
|
|
220
|
-
const loadResolvedRuleset = async (input, baseDirOrOptions = process.cwd()) => {
|
|
221
|
-
const options = normalizeLoadResolvedRulesetOptions(baseDirOrOptions);
|
|
222
|
-
const context = {
|
|
223
|
-
baseDir: options.baseDir,
|
|
224
|
-
defaultRuleset: options.defaultRuleset,
|
|
225
|
-
mergeAutodiscoveredWithDefault: options.mergeAutodiscoveredWithDefault
|
|
226
|
-
};
|
|
227
|
-
for (const resolver of options.resolvers) {
|
|
228
|
-
const loaded = await resolver(input, context);
|
|
229
|
-
if (loaded) {
|
|
230
|
-
const normalized = { ruleset: normalizeRulesetDefinition(loaded.ruleset) };
|
|
231
|
-
if (loaded.source) normalized.source = loaded.source;
|
|
232
|
-
return normalized;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (input === void 0) return { ruleset: options.defaultRuleset };
|
|
236
|
-
throw new RulesetLoadError("Ruleset input could not be resolved.");
|
|
237
|
-
};
|
|
238
|
-
const normalizeLoadResolvedRulesetOptions = (value) => {
|
|
239
|
-
if (typeof value === "string") return {
|
|
240
|
-
baseDir: value,
|
|
241
|
-
resolvers: defaultRulesetResolvers,
|
|
242
|
-
mergeAutodiscoveredWithDefault: true,
|
|
243
|
-
defaultRuleset
|
|
244
|
-
};
|
|
245
|
-
return {
|
|
246
|
-
baseDir: value.baseDir ?? process.cwd(),
|
|
247
|
-
resolvers: value.resolvers ?? defaultRulesetResolvers,
|
|
248
|
-
mergeAutodiscoveredWithDefault: value.mergeAutodiscoveredWithDefault ?? true,
|
|
249
|
-
defaultRuleset: value.defaultRuleset ?? defaultRuleset
|
|
250
|
-
};
|
|
251
|
-
};
|
|
252
|
-
const resolveAutodiscoveredRuleset = async (input, context) => {
|
|
253
|
-
if (input !== void 0) return;
|
|
254
|
-
const autodiscoveredPath = await findAutodiscoveredRulesetPath(context.baseDir);
|
|
255
|
-
if (!autodiscoveredPath) return;
|
|
256
|
-
const loaded = await loadResolvedPathRuleset(autodiscoveredPath, context);
|
|
257
|
-
if (!context.mergeAutodiscoveredWithDefault) return {
|
|
258
|
-
...loaded,
|
|
259
|
-
source: {
|
|
260
|
-
path: autodiscoveredPath,
|
|
261
|
-
autodiscovered: true,
|
|
262
|
-
mergedWithDefault: false
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
return {
|
|
266
|
-
ruleset: mergeRulesets(context.defaultRuleset, loaded.ruleset),
|
|
267
|
-
source: {
|
|
268
|
-
path: autodiscoveredPath,
|
|
269
|
-
autodiscovered: true,
|
|
270
|
-
mergedWithDefault: true
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
};
|
|
274
|
-
const resolvePathRuleset = async (input, context) => {
|
|
275
|
-
if (typeof input !== "string") return;
|
|
276
|
-
return await loadResolvedPathRuleset(input, context);
|
|
277
|
-
};
|
|
278
|
-
const resolveInlineRuleset = async (input) => {
|
|
279
|
-
if (input === void 0 || typeof input === "string") return;
|
|
280
|
-
return { ruleset: normalizeRulesetDefinition(input) };
|
|
281
|
-
};
|
|
282
|
-
const defaultRulesetResolvers = [
|
|
283
|
-
resolveAutodiscoveredRuleset,
|
|
284
|
-
resolvePathRuleset,
|
|
285
|
-
resolveInlineRuleset
|
|
286
|
-
];
|
|
287
|
-
const loadResolvedPathRuleset = async (inputPath, context) => {
|
|
288
|
-
const resolvedPath = path.resolve(context.baseDir, inputPath);
|
|
289
|
-
if (isYamlRulesetPath(resolvedPath)) return {
|
|
290
|
-
ruleset: await loadYamlRuleset(resolvedPath),
|
|
291
|
-
source: {
|
|
292
|
-
path: inputPath,
|
|
293
|
-
autodiscovered: false,
|
|
294
|
-
mergedWithDefault: false
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
if (!isModuleRulesetPath(resolvedPath)) throw new RulesetLoadError(`Unsupported ruleset path: ${inputPath}. Supported local rulesets are .yaml, .yml, .js, .mjs, .cjs, .ts, .mts, and .cts.`);
|
|
298
|
-
return {
|
|
299
|
-
ruleset: await loadModuleRuleset(resolvedPath),
|
|
300
|
-
source: {
|
|
301
|
-
path: inputPath,
|
|
302
|
-
autodiscovered: false,
|
|
303
|
-
mergedWithDefault: false
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
};
|
|
307
|
-
const findAutodiscoveredRulesetPath = async (baseDir) => {
|
|
308
|
-
for (const filename of autodiscoverRulesetFilenames) {
|
|
309
|
-
const candidatePath = path.resolve(baseDir, filename);
|
|
310
|
-
try {
|
|
311
|
-
await access(candidatePath);
|
|
312
|
-
return `./${filename}`;
|
|
313
|
-
} catch (error) {
|
|
314
|
-
if (error.code !== "ENOENT") throw error;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
const loadYamlRuleset = async (resolvedPath) => {
|
|
319
|
-
let fileContents;
|
|
320
|
-
try {
|
|
321
|
-
fileContents = await readFile(resolvedPath, "utf8");
|
|
322
|
-
} catch (error) {
|
|
323
|
-
throw new RulesetLoadError(`Unable to read ruleset at ${resolvedPath}.`, { cause: error });
|
|
324
|
-
}
|
|
325
|
-
let parsed;
|
|
326
|
-
try {
|
|
327
|
-
parsed = YAML.parse(fileContents);
|
|
328
|
-
} catch (error) {
|
|
329
|
-
throw new RulesetLoadError(`Unable to parse YAML ruleset at ${resolvedPath}.`, { cause: error });
|
|
330
|
-
}
|
|
331
|
-
return normalizeRulesetDefinition(parsed);
|
|
332
|
-
};
|
|
333
|
-
const loadModuleRuleset = async (resolvedPath) => {
|
|
334
|
-
let imported;
|
|
335
|
-
try {
|
|
336
|
-
imported = await import(pathToFileURL(resolvedPath).href);
|
|
337
|
-
} catch (error) {
|
|
338
|
-
throw new RulesetLoadError(`Unable to import module ruleset at ${resolvedPath}.`, { cause: error });
|
|
339
|
-
}
|
|
340
|
-
const resolvedRuleset = resolveModuleRulesetValue(imported);
|
|
341
|
-
if (resolvedRuleset === void 0) throw new RulesetLoadError(`Module ruleset at ${resolvedPath} must export a ruleset as the default export or a named "ruleset" export.`);
|
|
342
|
-
return normalizeRulesetDefinition(resolvedRuleset, {
|
|
343
|
-
...functionMap,
|
|
344
|
-
...resolveModuleFunctions(imported)
|
|
345
|
-
});
|
|
346
|
-
};
|
|
347
|
-
const resolveModuleRulesetValue = (imported) => {
|
|
348
|
-
if (!isRecord$1(imported)) return;
|
|
349
|
-
if ("default" in imported) return imported.default;
|
|
350
|
-
if ("ruleset" in imported) return imported.ruleset;
|
|
351
|
-
};
|
|
352
|
-
const resolveModuleFunctions = (imported) => {
|
|
353
|
-
if (!isRecord$1(imported) || !("functions" in imported)) return {};
|
|
354
|
-
const { functions } = imported;
|
|
355
|
-
if (!isRecord$1(functions)) throw new RulesetLoadError("Module ruleset \"functions\" export must be an object map of function names to Spectral functions.");
|
|
356
|
-
const entries = Object.entries(functions).filter(([, value]) => typeof value === "function");
|
|
357
|
-
return Object.fromEntries(entries);
|
|
358
|
-
};
|
|
359
|
-
const isYamlRulesetPath = (value) => value.endsWith(".yaml") || value.endsWith(".yml");
|
|
360
|
-
const isModuleRulesetPath = (value) => value.endsWith(".js") || value.endsWith(".mjs") || value.endsWith(".cjs") || value.endsWith(".ts") || value.endsWith(".mts") || value.endsWith(".cts");
|
|
361
|
-
const normalizeRulesetDefinition = (input, availableFunctions = functionMap) => {
|
|
362
|
-
if (!isRecord$1(input)) throw new RulesetLoadError("Ruleset must be an object.");
|
|
363
|
-
const normalized = { ...input };
|
|
364
|
-
if ("extends" in normalized) normalized.extends = normalizeExtends(normalized.extends);
|
|
365
|
-
if ("rules" in normalized) normalized.rules = normalizeRules(normalized.rules, availableFunctions);
|
|
366
|
-
return normalized;
|
|
367
|
-
};
|
|
368
|
-
const mergeRuleEntry = (base, override) => {
|
|
369
|
-
if (!isRecord$1(override)) return override;
|
|
370
|
-
if ("given" in override || "then" in override) return override;
|
|
371
|
-
if (isRecord$1(base) && ("given" in base || "then" in base)) return {
|
|
372
|
-
...base,
|
|
373
|
-
...override
|
|
374
|
-
};
|
|
375
|
-
const keys = Object.keys(override);
|
|
376
|
-
if (keys.length === 1 && keys[0] === "severity") return override.severity;
|
|
377
|
-
return override;
|
|
378
|
-
};
|
|
379
|
-
const mergeRulesets = (baseRuleset, overrideRuleset) => {
|
|
380
|
-
const mergedBase = baseRuleset;
|
|
381
|
-
const mergedOverride = overrideRuleset;
|
|
382
|
-
const baseRules = isRecord$1(mergedBase.rules) ? mergedBase.rules : {};
|
|
383
|
-
const overrideRules = isRecord$1(mergedOverride.rules) ? mergedOverride.rules : {};
|
|
384
|
-
const mergedRules = { ...baseRules };
|
|
385
|
-
for (const [name, overrideRule] of Object.entries(overrideRules)) mergedRules[name] = mergeRuleEntry(baseRules[name], overrideRule);
|
|
386
|
-
const baseExtends = toExtendsArray(mergedBase.extends);
|
|
387
|
-
const overrideExtends = toExtendsArray(mergedOverride.extends);
|
|
388
|
-
const mergedExtends = [...baseExtends, ...overrideExtends];
|
|
389
|
-
const merged = {
|
|
390
|
-
...mergedBase,
|
|
391
|
-
...mergedOverride
|
|
392
|
-
};
|
|
393
|
-
delete merged.extends;
|
|
394
|
-
delete merged.rules;
|
|
395
|
-
if (mergedExtends.length > 0) merged.extends = mergedExtends;
|
|
396
|
-
if (Object.keys(mergedRules).length > 0) merged.rules = mergedRules;
|
|
397
|
-
return merged;
|
|
398
|
-
};
|
|
399
|
-
const toExtendsArray = (value) => {
|
|
400
|
-
if (value === void 0) return [];
|
|
401
|
-
return Array.isArray(value) ? [...value] : [value];
|
|
402
|
-
};
|
|
403
|
-
const normalizeExtends = (value) => {
|
|
404
|
-
if (typeof value === "string") return resolveExtendsEntry(value);
|
|
405
|
-
if (!Array.isArray(value)) return value;
|
|
406
|
-
return value.map((entry) => {
|
|
407
|
-
if (typeof entry === "string") return resolveExtendsEntry(entry);
|
|
408
|
-
if (Array.isArray(entry) && entry.length >= 1 && typeof entry[0] === "string") return [resolveExtendsEntry(entry[0]), entry[1]];
|
|
409
|
-
return entry;
|
|
410
|
-
});
|
|
411
|
-
};
|
|
412
|
-
const resolveExtendsEntry = (value) => {
|
|
413
|
-
const resolved = extendsMap[value];
|
|
414
|
-
if (!resolved) throw new RulesetLoadError(`Unsupported ruleset extend target: "${value}". Supported extend targets: spectral:oas.`);
|
|
415
|
-
return resolved;
|
|
416
|
-
};
|
|
417
|
-
const normalizeRules = (value, availableFunctions) => {
|
|
418
|
-
if (!isRecord$1(value)) return value;
|
|
419
|
-
const entries = Object.entries(value).map(([ruleName, ruleValue]) => [ruleName, normalizeRule(ruleValue, availableFunctions)]);
|
|
420
|
-
return Object.fromEntries(entries);
|
|
421
|
-
};
|
|
422
|
-
const normalizeRule = (value, availableFunctions) => {
|
|
423
|
-
if (!isRecord$1(value)) return value;
|
|
424
|
-
const normalized = { ...value };
|
|
425
|
-
if ("then" in normalized) normalized.then = normalizeThen(normalized.then, availableFunctions);
|
|
426
|
-
return normalized;
|
|
427
|
-
};
|
|
428
|
-
const normalizeThen = (value, availableFunctions) => {
|
|
429
|
-
if (Array.isArray(value)) return value.map((entry) => normalizeThenEntry(entry, availableFunctions));
|
|
430
|
-
return normalizeThenEntry(value, availableFunctions);
|
|
431
|
-
};
|
|
432
|
-
const normalizeThenEntry = (value, availableFunctions) => {
|
|
433
|
-
if (!isRecord$1(value)) return value;
|
|
434
|
-
const normalized = { ...value };
|
|
435
|
-
if (typeof normalized.function === "string") {
|
|
436
|
-
const resolved = availableFunctions[normalized.function];
|
|
437
|
-
if (!resolved) throw new RulesetLoadError(`Unsupported Spectral function: ${String(normalized.function)}.`);
|
|
438
|
-
normalized.function = resolved;
|
|
439
|
-
}
|
|
440
|
-
return normalized;
|
|
441
|
-
};
|
|
442
|
-
const isRecord$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
443
|
-
//#endregion
|
|
6
|
+
import YAML from "yaml";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
444
8
|
//#region src/output/terminal-format.ts
|
|
445
9
|
const severityStyles = {
|
|
446
10
|
error: [
|
|
@@ -847,6 +411,11 @@ const toSarifArtifactUri = (value) => {
|
|
|
847
411
|
};
|
|
848
412
|
//#endregion
|
|
849
413
|
//#region src/output/sinks.ts
|
|
414
|
+
const relativiseArtifactPath = (artifactPath) => {
|
|
415
|
+
const resolvedPath = path.resolve(process.cwd(), artifactPath);
|
|
416
|
+
const relativePath = path.relative(process.cwd(), resolvedPath);
|
|
417
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
418
|
+
};
|
|
850
419
|
const createOutputSinks = (options) => {
|
|
851
420
|
const reporter = resolveReporter(options.logger);
|
|
852
421
|
const sinks = [];
|
|
@@ -857,24 +426,17 @@ const createOutputSinks = (options) => {
|
|
|
857
426
|
if (configuredSpecSnapshotPath) sinks.push({
|
|
858
427
|
name: "spec snapshot",
|
|
859
428
|
kind: "artifact",
|
|
429
|
+
phase: "pre-finalize",
|
|
860
430
|
async write(_result, context) {
|
|
861
431
|
const writtenSpecSnapshotPath = await writeSpecSnapshot(configuredSpecSnapshotPath === true ? await resolveDefaultSpecSnapshotPath() : configuredSpecSnapshotPath, context.spec, options.output?.pretty !== false);
|
|
862
432
|
reporter.artifact(`OpenAPI lint wrote spec snapshot to ${writtenSpecSnapshotPath}.`);
|
|
863
433
|
return { specSnapshotPath: writtenSpecSnapshotPath };
|
|
864
434
|
}
|
|
865
435
|
});
|
|
866
|
-
if (configuredJsonReportPath) sinks.push({
|
|
867
|
-
name: "JSON report",
|
|
868
|
-
kind: "artifact",
|
|
869
|
-
async write(result) {
|
|
870
|
-
const writtenJsonReportPath = await writeJsonReport(configuredJsonReportPath, result, options.output?.pretty !== false);
|
|
871
|
-
reporter.artifact(`OpenAPI lint wrote JSON report to ${writtenJsonReportPath}.`);
|
|
872
|
-
return { jsonReportPath: writtenJsonReportPath };
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
436
|
if (configuredJunitReportPath) sinks.push({
|
|
876
437
|
name: "JUnit report",
|
|
877
438
|
kind: "artifact",
|
|
439
|
+
phase: "pre-finalize",
|
|
878
440
|
async write(result) {
|
|
879
441
|
const writtenJunitReportPath = await writeJunitReport(configuredJunitReportPath, result);
|
|
880
442
|
reporter.artifact(`OpenAPI lint wrote JUnit report to ${writtenJunitReportPath}.`);
|
|
@@ -885,6 +447,7 @@ const createOutputSinks = (options) => {
|
|
|
885
447
|
if (configuredBrunoCollectionPath) sinks.push({
|
|
886
448
|
name: "Bruno collection",
|
|
887
449
|
kind: "artifact",
|
|
450
|
+
phase: "pre-finalize",
|
|
888
451
|
async write(_result, context) {
|
|
889
452
|
const writtenPath = await writeBrunoCollection(configuredBrunoCollectionPath, context.spec);
|
|
890
453
|
reporter.artifact(`OpenAPI lint wrote Bruno collection to ${writtenPath}.`);
|
|
@@ -894,6 +457,7 @@ const createOutputSinks = (options) => {
|
|
|
894
457
|
if (configuredSarifReportPath) sinks.push({
|
|
895
458
|
name: "SARIF report",
|
|
896
459
|
kind: "artifact",
|
|
460
|
+
phase: "pre-finalize",
|
|
897
461
|
async write(result) {
|
|
898
462
|
const writtenSarifReportPath = await writeSarifReport(configuredSarifReportPath, result, options.output?.pretty !== false);
|
|
899
463
|
reporter.artifact(`OpenAPI lint wrote SARIF report to ${writtenSarifReportPath}.`);
|
|
@@ -903,11 +467,29 @@ const createOutputSinks = (options) => {
|
|
|
903
467
|
for (const sink of options.output?.sinks ?? []) sinks.push({
|
|
904
468
|
name: sink.name,
|
|
905
469
|
kind: "custom",
|
|
470
|
+
phase: "post-finalize",
|
|
906
471
|
write: async (result, context) => await Promise.resolve(sink.write(result, context))
|
|
907
472
|
});
|
|
473
|
+
if (configuredJsonReportPath) sinks.push({
|
|
474
|
+
name: "JSON report",
|
|
475
|
+
kind: "artifact",
|
|
476
|
+
phase: "post-finalize",
|
|
477
|
+
async write(result) {
|
|
478
|
+
const writtenJsonReportPath = await writeJsonReport(configuredJsonReportPath, {
|
|
479
|
+
...result,
|
|
480
|
+
artifacts: {
|
|
481
|
+
...result.artifacts ?? {},
|
|
482
|
+
jsonReportPath: relativiseArtifactPath(configuredJsonReportPath)
|
|
483
|
+
}
|
|
484
|
+
}, options.output?.pretty !== false);
|
|
485
|
+
reporter.artifact(`OpenAPI lint wrote JSON report to ${writtenJsonReportPath}.`);
|
|
486
|
+
return { jsonReportPath: writtenJsonReportPath };
|
|
487
|
+
}
|
|
488
|
+
});
|
|
908
489
|
if (options.output?.console !== false) sinks.push({
|
|
909
490
|
name: "console",
|
|
910
491
|
kind: "report",
|
|
492
|
+
phase: "post-finalize",
|
|
911
493
|
async write(result) {
|
|
912
494
|
reportToConsole(result, reporter);
|
|
913
495
|
}
|
|
@@ -915,133 +497,6 @@ const createOutputSinks = (options) => {
|
|
|
915
497
|
return sinks;
|
|
916
498
|
};
|
|
917
499
|
//#endregion
|
|
918
|
-
//#region src/presets/server.ts
|
|
919
|
-
const { schema: schema$1, truthy: truthy$1 } = spectralFunctions;
|
|
920
|
-
const { oas: oas$1 } = spectralRulesets;
|
|
921
|
-
const operationSelector$1 = "$.paths[*][get,put,post,delete,options,head,patch,trace]";
|
|
922
|
-
/**
|
|
923
|
-
* Production API quality preset. Suitable as a CI gate for teams shipping
|
|
924
|
-
* public or internal APIs where contract quality matters.
|
|
925
|
-
*
|
|
926
|
-
* Tightens recommended:
|
|
927
|
-
* - elysia-operation-summary and elysia-operation-tags escalated to error
|
|
928
|
-
* - operation-description, operation-operationId, operation-success-response at warn
|
|
929
|
-
* - oas3-api-servers at warn (servers should be declared in production specs)
|
|
930
|
-
*/
|
|
931
|
-
const server = {
|
|
932
|
-
extends: [[oas$1, "recommended"]],
|
|
933
|
-
rules: {
|
|
934
|
-
"oas3-api-servers": "warn",
|
|
935
|
-
"info-contact": "off",
|
|
936
|
-
"elysia-operation-summary": {
|
|
937
|
-
description: "Operations should define a summary for generated docs and clients.",
|
|
938
|
-
severity: "error",
|
|
939
|
-
given: operationSelector$1,
|
|
940
|
-
then: {
|
|
941
|
-
field: "summary",
|
|
942
|
-
function: truthy$1
|
|
943
|
-
}
|
|
944
|
-
},
|
|
945
|
-
"elysia-operation-tags": {
|
|
946
|
-
description: "Operations should declare at least one tag for grouping and downstream tooling.",
|
|
947
|
-
severity: "error",
|
|
948
|
-
given: operationSelector$1,
|
|
949
|
-
then: {
|
|
950
|
-
field: "tags",
|
|
951
|
-
function: schema$1,
|
|
952
|
-
functionOptions: { schema: {
|
|
953
|
-
type: "array",
|
|
954
|
-
minItems: 1
|
|
955
|
-
} }
|
|
956
|
-
}
|
|
957
|
-
},
|
|
958
|
-
"operation-description": "warn",
|
|
959
|
-
"operation-operationId": "warn",
|
|
960
|
-
"operation-success-response": "warn"
|
|
961
|
-
}
|
|
962
|
-
};
|
|
963
|
-
//#endregion
|
|
964
|
-
//#region src/presets/strict.ts
|
|
965
|
-
const { schema, truthy } = spectralFunctions;
|
|
966
|
-
const { oas } = spectralRulesets;
|
|
967
|
-
const operationSelector = "$.paths[*][get,put,post,delete,options,head,patch,trace]";
|
|
968
|
-
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
969
|
-
/**
|
|
970
|
-
* Checks that all 4xx/5xx responses declare application/problem+json as
|
|
971
|
-
* their content type, conforming to RFC 9457 Problem Details for HTTP APIs.
|
|
972
|
-
*/
|
|
973
|
-
const checkProblemDetails = (operation) => {
|
|
974
|
-
if (!isRecord(operation) || !isRecord(operation.responses)) return;
|
|
975
|
-
const results = [];
|
|
976
|
-
for (const [statusCode, response] of Object.entries(operation.responses)) {
|
|
977
|
-
const code = Number(statusCode);
|
|
978
|
-
if (!Number.isFinite(code) || code < 400) continue;
|
|
979
|
-
if (!isRecord(response)) continue;
|
|
980
|
-
const content = response.content;
|
|
981
|
-
if (!isRecord(content) || !("application/problem+json" in content)) results.push({
|
|
982
|
-
message: `${statusCode} error response should use "application/problem+json" content type (RFC 9457 Problem Details).`,
|
|
983
|
-
path: ["responses", statusCode]
|
|
984
|
-
});
|
|
985
|
-
}
|
|
986
|
-
return results.length > 0 ? results : void 0;
|
|
987
|
-
};
|
|
988
|
-
/**
|
|
989
|
-
* Full API governance preset. Suitable for teams with formal API governance
|
|
990
|
-
* requirements, public API programs, or downstream client generation pipelines.
|
|
991
|
-
*
|
|
992
|
-
* Tightens server:
|
|
993
|
-
* - All elysia rules and operation metadata rules escalated to error
|
|
994
|
-
* - info-contact at warn (API ownership should be declared)
|
|
995
|
-
* - oas3-api-servers at error (server declaration is required)
|
|
996
|
-
* - rfc9457-problem-details at warn (error responses should use Problem Details)
|
|
997
|
-
*/
|
|
998
|
-
const strict = {
|
|
999
|
-
extends: [[oas, "recommended"]],
|
|
1000
|
-
rules: {
|
|
1001
|
-
"oas3-api-servers": "error",
|
|
1002
|
-
"info-contact": "warn",
|
|
1003
|
-
"elysia-operation-summary": {
|
|
1004
|
-
description: "Operations should define a summary for generated docs and clients.",
|
|
1005
|
-
severity: "error",
|
|
1006
|
-
given: operationSelector,
|
|
1007
|
-
then: {
|
|
1008
|
-
field: "summary",
|
|
1009
|
-
function: truthy
|
|
1010
|
-
}
|
|
1011
|
-
},
|
|
1012
|
-
"elysia-operation-tags": {
|
|
1013
|
-
description: "Operations should declare at least one tag for grouping and downstream tooling.",
|
|
1014
|
-
severity: "error",
|
|
1015
|
-
given: operationSelector,
|
|
1016
|
-
then: {
|
|
1017
|
-
field: "tags",
|
|
1018
|
-
function: schema,
|
|
1019
|
-
functionOptions: { schema: {
|
|
1020
|
-
type: "array",
|
|
1021
|
-
minItems: 1
|
|
1022
|
-
} }
|
|
1023
|
-
}
|
|
1024
|
-
},
|
|
1025
|
-
"operation-description": "error",
|
|
1026
|
-
"operation-operationId": "error",
|
|
1027
|
-
"operation-success-response": "error",
|
|
1028
|
-
"rfc9457-problem-details": {
|
|
1029
|
-
description: "Error responses (4xx, 5xx) should use RFC 9457 Problem Details (application/problem+json).",
|
|
1030
|
-
message: "{{error}}",
|
|
1031
|
-
severity: "warn",
|
|
1032
|
-
given: operationSelector,
|
|
1033
|
-
then: { function: checkProblemDetails }
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
};
|
|
1037
|
-
//#endregion
|
|
1038
|
-
//#region src/presets/index.ts
|
|
1039
|
-
const presets = {
|
|
1040
|
-
recommended,
|
|
1041
|
-
server,
|
|
1042
|
-
strict
|
|
1043
|
-
};
|
|
1044
|
-
//#endregion
|
|
1045
500
|
//#region src/providers/spec-provider.ts
|
|
1046
501
|
var BaseSpecProvider = class {
|
|
1047
502
|
constructor(app, options = {}) {
|
|
@@ -1078,11 +533,11 @@ var PublicSpecProvider = class extends BaseSpecProvider {
|
|
|
1078
533
|
if (response.ok) return await parseSpecResponse(response, sourceLabel, this.specPath);
|
|
1079
534
|
if (this.options?.baseUrl) return await this.fetchViaLoopback();
|
|
1080
535
|
const body = await safeReadBody(response);
|
|
1081
|
-
throw new PublicSpecProviderError([`Unable to load OpenAPI JSON from ${this.specPath} via ${sourceLabel}: received ${describeResponse(response)}${body ? ` with body ${body}.` : "."}`, `Fix: ensure @elysiajs/openapi is
|
|
536
|
+
throw new PublicSpecProviderError([`Unable to load OpenAPI JSON from ${this.specPath} via ${sourceLabel}: received ${describeResponse(response)}${body ? ` with body ${body}.` : "."}`, `Fix: ensure an OpenAPI generator (for example @elysiajs/openapi) is installed and mounted so it exposes "${this.specPath}", or update source.specPath to the correct OpenAPI JSON route.`].join(" "));
|
|
1082
537
|
} catch (error) {
|
|
1083
538
|
if (this.options?.baseUrl) return await this.fetchViaLoopback(error);
|
|
1084
539
|
if (error instanceof PublicSpecProviderError) throw error;
|
|
1085
|
-
throw new PublicSpecProviderError([`Unable to resolve OpenAPI JSON from ${this.specPath} via ${sourceLabel}.`, "Fix: ensure the app
|
|
540
|
+
throw new PublicSpecProviderError([`Unable to resolve OpenAPI JSON from ${this.specPath} via ${sourceLabel}.`, "Fix: ensure the app mounts an OpenAPI generator (for example @elysiajs/openapi) or otherwise serves the configured OpenAPI JSON route, or set source.baseUrl if the document is only reachable over HTTP."].join(" "), { cause: error });
|
|
1086
541
|
}
|
|
1087
542
|
}
|
|
1088
543
|
async fetchViaLoopback(cause) {
|
|
@@ -1192,7 +647,13 @@ const createOpenApiLintRuntime = (options = {}) => {
|
|
|
1192
647
|
reporter.start("OpenAPI lint started.");
|
|
1193
648
|
try {
|
|
1194
649
|
const spec = await new PublicSpecProvider(app, options.source).getSpec();
|
|
1195
|
-
const
|
|
650
|
+
const [{ lintOpenApi }, { loadResolvedRuleset }, presetsModule] = await Promise.all([
|
|
651
|
+
import("./lint-openapi-D76sC7S5.mjs").then((n) => n.n),
|
|
652
|
+
import("./load-ruleset-CiikrzWx.mjs").then((n) => n.i),
|
|
653
|
+
options.preset ? import("./presets-CCfU_diN.mjs").then((n) => n.n) : Promise.resolve(null)
|
|
654
|
+
]);
|
|
655
|
+
const defaultRuleset = options.preset ? presetsModule?.presets[options.preset] : void 0;
|
|
656
|
+
const loadedRuleset = await loadResolvedRuleset(options.ruleset, { ...defaultRuleset ? { defaultRuleset } : {} });
|
|
1196
657
|
if (loadedRuleset.source?.autodiscovered) {
|
|
1197
658
|
const base = options.preset ? `"${options.preset}" preset` : "package default ruleset";
|
|
1198
659
|
reporter.ruleset(`OpenAPI lint autodiscovered ruleset ${loadedRuleset.source.path} and merged it with the ${base}.`);
|
|
@@ -1202,10 +663,12 @@ const createOpenApiLintRuntime = (options = {}) => {
|
|
|
1202
663
|
result.source = source;
|
|
1203
664
|
result.failOn = options.failOn ?? "error";
|
|
1204
665
|
result.ok = !shouldFail(result, result.failOn);
|
|
1205
|
-
|
|
1206
|
-
|
|
666
|
+
const { preFinalizeSinks, postFinalizeSinks } = partitionOutputSinks(createOutputSinks(options));
|
|
667
|
+
await writeOutputSinks(preFinalizeSinks, result, spec, reporter, artifactWriteFailureMode);
|
|
1207
668
|
finalizeRuntimeRun(runtime, startedAt);
|
|
1208
669
|
result.durationMs = runtime.durationMs;
|
|
670
|
+
await writeOutputSinks(postFinalizeSinks, result, spec, reporter, artifactWriteFailureMode);
|
|
671
|
+
runtime.latest = result;
|
|
1209
672
|
reporter.complete("OpenAPI lint completed.");
|
|
1210
673
|
enforceThreshold(result, options.failOn ?? "error");
|
|
1211
674
|
runtime.status = "passed";
|
|
@@ -1251,9 +714,7 @@ const handleArtifactWriteFailure = (artifact, error, mode, reporter) => {
|
|
|
1251
714
|
if (mode === "error") throw wrappedError;
|
|
1252
715
|
reporter.warn(wrappedError.message);
|
|
1253
716
|
};
|
|
1254
|
-
const writeOutputSinks = async (result, spec,
|
|
1255
|
-
const reporter = resolveReporter(options.logger);
|
|
1256
|
-
const sinks = createOutputSinks(options);
|
|
717
|
+
const writeOutputSinks = async (sinks, result, spec, reporter, artifactWriteFailureMode) => {
|
|
1257
718
|
for (const sink of sinks) try {
|
|
1258
719
|
const artifacts = await sink.write(result, {
|
|
1259
720
|
spec,
|
|
@@ -1268,6 +729,21 @@ const writeOutputSinks = async (result, spec, options, artifactWriteFailureMode)
|
|
|
1268
729
|
throw error;
|
|
1269
730
|
}
|
|
1270
731
|
};
|
|
732
|
+
const partitionOutputSinks = (sinks) => {
|
|
733
|
+
const preFinalizeSinks = [];
|
|
734
|
+
const postFinalizeSinks = [];
|
|
735
|
+
for (const sink of sinks) {
|
|
736
|
+
if (sink.phase === "post-finalize") {
|
|
737
|
+
postFinalizeSinks.push(sink);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
preFinalizeSinks.push(sink);
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
preFinalizeSinks,
|
|
744
|
+
postFinalizeSinks
|
|
745
|
+
};
|
|
746
|
+
};
|
|
1271
747
|
const relativiseArtifacts = (artifacts) => {
|
|
1272
748
|
const cwd = process.cwd();
|
|
1273
749
|
const result = {};
|
|
@@ -1287,4 +763,4 @@ const resolveStartupMode = (options = {}) => {
|
|
|
1287
763
|
return options.enabled === false ? "off" : "enforce";
|
|
1288
764
|
};
|
|
1289
765
|
//#endregion
|
|
1290
|
-
export { enforceThreshold as a,
|
|
766
|
+
export { enforceThreshold as a, OpenApiLintThresholdError as i, createOpenApiLintRuntime as n, shouldFail as o, resolveStartupMode as r, resolveReporter as s, OpenApiLintArtifactWriteError as t };
|