@opsydyn/elysia-spectral 0.2.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.
@@ -0,0 +1,1099 @@
1
+ import spectralCore from "@stoplight/spectral-core";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ 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
+ import signale from "signale";
9
+ import { styleText } from "node:util";
10
+ //#region src/core/finding-guidance.ts
11
+ const guidanceByCode = {
12
+ "elysia-operation-summary": "Add detail.summary to the Elysia route options so generated docs and clients have a short operation label.",
13
+ "elysia-operation-tags": "Add detail.tags with at least one stable tag, for example ['Users'] or ['Dev'].",
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."
16
+ };
17
+ const getFindingRecommendation = (code, message) => {
18
+ const direct = guidanceByCode[code];
19
+ if (direct) return direct;
20
+ 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(...) }.";
21
+ if (code.startsWith("operation-")) return "Add the missing operation metadata under detail on the Elysia route options.";
22
+ };
23
+ //#endregion
24
+ //#region src/core/normalize-findings.ts
25
+ const httpMethods = new Set([
26
+ "get",
27
+ "put",
28
+ "post",
29
+ "delete",
30
+ "options",
31
+ "head",
32
+ "patch",
33
+ "trace"
34
+ ]);
35
+ const normalizeFindings = (diagnostics, spec) => {
36
+ const findings = diagnostics.map((diagnostic) => normalizeFinding(diagnostic, spec));
37
+ const summary = findings.reduce((current, finding) => {
38
+ current[finding.severity] += 1;
39
+ current.total += 1;
40
+ return current;
41
+ }, {
42
+ error: 0,
43
+ warn: 0,
44
+ info: 0,
45
+ hint: 0,
46
+ total: 0
47
+ });
48
+ return {
49
+ ok: summary.error === 0,
50
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
51
+ summary,
52
+ findings
53
+ };
54
+ };
55
+ const normalizeFinding = (diagnostic, spec) => {
56
+ const path = [...diagnostic.path];
57
+ const finding = {
58
+ code: String(diagnostic.code),
59
+ message: diagnostic.message,
60
+ severity: toLintSeverity(diagnostic.severity),
61
+ path,
62
+ documentPointer: toDocumentPointer(path)
63
+ };
64
+ const recommendation = getFindingRecommendation(String(diagnostic.code), diagnostic.message);
65
+ if (recommendation) finding.recommendation = recommendation;
66
+ if (diagnostic.source !== void 0) finding.source = diagnostic.source;
67
+ if (diagnostic.range) finding.range = {
68
+ start: diagnostic.range.start,
69
+ end: diagnostic.range.end
70
+ };
71
+ const operation = inferOperation(path, spec);
72
+ if (operation) finding.operation = operation;
73
+ return finding;
74
+ };
75
+ const toLintSeverity = (severity) => {
76
+ switch (severity) {
77
+ case 0: return "error";
78
+ case 1: return "warn";
79
+ case 2: return "info";
80
+ default: return "hint";
81
+ }
82
+ };
83
+ const toDocumentPointer = (path) => {
84
+ if (path.length === 0) return "";
85
+ return `/${path.map((segment) => String(segment).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
86
+ };
87
+ const inferOperation = (path, spec) => {
88
+ if (path[0] !== "paths") return;
89
+ const routePath = typeof path[1] === "string" ? path[1] : void 0;
90
+ const method = typeof path[2] === "string" && httpMethods.has(path[2]) ? path[2] : void 0;
91
+ if (!routePath && !method) return;
92
+ const operationRecord = routePath && method ? getNestedValue(spec, [
93
+ "paths",
94
+ routePath,
95
+ method
96
+ ]) : void 0;
97
+ const operation = {};
98
+ if (routePath !== void 0) operation.path = routePath;
99
+ if (method !== void 0) operation.method = method;
100
+ if (operationRecord && typeof operationRecord === "object" && "operationId" in operationRecord) operation.operationId = String(operationRecord.operationId);
101
+ return operation;
102
+ };
103
+ const getNestedValue = (value, path) => {
104
+ let current = value;
105
+ for (const segment of path) {
106
+ if (current === null || typeof current !== "object") return;
107
+ current = current[segment];
108
+ }
109
+ return current;
110
+ };
111
+ //#endregion
112
+ //#region src/core/lint-openapi.ts
113
+ const { Spectral } = spectralCore;
114
+ const lintOpenApi = async (spec, ruleset) => {
115
+ const spectral = new Spectral();
116
+ spectral.setRuleset(ruleset);
117
+ return normalizeFindings(await spectral.run(spec, { ignoreUnknownFormat: false }), spec);
118
+ };
119
+ //#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"]],
126
+ rules: {
127
+ "oas3-api-servers": "off",
128
+ "info-contact": "off",
129
+ "elysia-operation-summary": {
130
+ description: "Operations should define a summary for generated docs and clients.",
131
+ severity: "warn",
132
+ given: operationSelector,
133
+ then: {
134
+ field: "summary",
135
+ function: truthy$1
136
+ }
137
+ },
138
+ "elysia-operation-tags": {
139
+ description: "Operations should declare at least one tag for grouping and downstream tooling.",
140
+ severity: "warn",
141
+ given: operationSelector,
142
+ then: {
143
+ field: "tags",
144
+ function: schema$1,
145
+ functionOptions: { schema: {
146
+ type: "array",
147
+ minItems: 1
148
+ } }
149
+ }
150
+ }
151
+ }
152
+ };
153
+ //#endregion
154
+ //#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;
157
+ const functionMap = {
158
+ alphabetical,
159
+ casing,
160
+ defined,
161
+ enumeration,
162
+ falsy,
163
+ length,
164
+ or,
165
+ pattern,
166
+ schema,
167
+ truthy,
168
+ undefined: undefinedFunction,
169
+ unreferencedReusableObject,
170
+ xor
171
+ };
172
+ const extendsMap = { "spectral:oas": oas };
173
+ const autodiscoverRulesetFilenames = [
174
+ "spectral.yaml",
175
+ "spectral.yml",
176
+ "spectral.ts",
177
+ "spectral.mts",
178
+ "spectral.cts",
179
+ "spectral.js",
180
+ "spectral.mjs",
181
+ "spectral.cjs",
182
+ "spectral.config.yaml",
183
+ "spectral.config.yml",
184
+ "spectral.config.ts",
185
+ "spectral.config.mts",
186
+ "spectral.config.cts",
187
+ "spectral.config.js",
188
+ "spectral.config.mjs",
189
+ "spectral.config.cjs"
190
+ ];
191
+ var RulesetLoadError = class extends Error {
192
+ constructor(message, options) {
193
+ super(message);
194
+ this.name = "RulesetLoadError";
195
+ if (options?.cause !== void 0) this.cause = options.cause;
196
+ }
197
+ };
198
+ const loadRuleset = async (input, baseDirOrOptions = process.cwd()) => {
199
+ return (await loadResolvedRuleset(input, baseDirOrOptions)).ruleset;
200
+ };
201
+ const loadResolvedRuleset = async (input, baseDirOrOptions = process.cwd()) => {
202
+ const options = normalizeLoadResolvedRulesetOptions(baseDirOrOptions);
203
+ const context = {
204
+ baseDir: options.baseDir,
205
+ defaultRuleset,
206
+ mergeAutodiscoveredWithDefault: options.mergeAutodiscoveredWithDefault
207
+ };
208
+ for (const resolver of options.resolvers) {
209
+ const loaded = await resolver(input, context);
210
+ if (loaded) {
211
+ const normalized = { ruleset: normalizeRulesetDefinition(loaded.ruleset) };
212
+ if (loaded.source) normalized.source = loaded.source;
213
+ return normalized;
214
+ }
215
+ }
216
+ if (input === void 0) return { ruleset: defaultRuleset };
217
+ throw new RulesetLoadError("Ruleset input could not be resolved.");
218
+ };
219
+ const normalizeLoadResolvedRulesetOptions = (value) => {
220
+ if (typeof value === "string") return {
221
+ baseDir: value,
222
+ resolvers: defaultRulesetResolvers,
223
+ mergeAutodiscoveredWithDefault: true
224
+ };
225
+ return {
226
+ baseDir: value.baseDir ?? process.cwd(),
227
+ resolvers: value.resolvers ?? defaultRulesetResolvers,
228
+ mergeAutodiscoveredWithDefault: value.mergeAutodiscoveredWithDefault ?? true
229
+ };
230
+ };
231
+ const resolveAutodiscoveredRuleset = async (input, context) => {
232
+ if (input !== void 0) return;
233
+ const autodiscoveredPath = await findAutodiscoveredRulesetPath(context.baseDir);
234
+ if (!autodiscoveredPath) return;
235
+ const loaded = await loadResolvedPathRuleset(autodiscoveredPath, context);
236
+ if (!context.mergeAutodiscoveredWithDefault) return {
237
+ ...loaded,
238
+ source: {
239
+ path: autodiscoveredPath,
240
+ autodiscovered: true,
241
+ mergedWithDefault: false
242
+ }
243
+ };
244
+ return {
245
+ ruleset: mergeRulesets(context.defaultRuleset, loaded.ruleset),
246
+ source: {
247
+ path: autodiscoveredPath,
248
+ autodiscovered: true,
249
+ mergedWithDefault: true
250
+ }
251
+ };
252
+ };
253
+ const resolvePathRuleset = async (input, context) => {
254
+ if (typeof input !== "string") return;
255
+ return await loadResolvedPathRuleset(input, context);
256
+ };
257
+ const resolveInlineRuleset = async (input) => {
258
+ if (input === void 0 || typeof input === "string") return;
259
+ return { ruleset: normalizeRulesetDefinition(input) };
260
+ };
261
+ const defaultRulesetResolvers = [
262
+ resolveAutodiscoveredRuleset,
263
+ resolvePathRuleset,
264
+ resolveInlineRuleset
265
+ ];
266
+ const loadResolvedPathRuleset = async (inputPath, context) => {
267
+ const resolvedPath = path.resolve(context.baseDir, inputPath);
268
+ if (isYamlRulesetPath(resolvedPath)) return {
269
+ ruleset: await loadYamlRuleset(resolvedPath),
270
+ source: {
271
+ path: inputPath,
272
+ autodiscovered: false,
273
+ mergedWithDefault: false
274
+ }
275
+ };
276
+ if (!isModuleRulesetPath(resolvedPath)) throw new RulesetLoadError(`Unsupported ruleset path: ${inputPath}. Supported local rulesets are .yaml, .yml, .js, .mjs, .cjs, .ts, .mts, and .cts.`);
277
+ return {
278
+ ruleset: await loadModuleRuleset(resolvedPath),
279
+ source: {
280
+ path: inputPath,
281
+ autodiscovered: false,
282
+ mergedWithDefault: false
283
+ }
284
+ };
285
+ };
286
+ const findAutodiscoveredRulesetPath = async (baseDir) => {
287
+ for (const filename of autodiscoverRulesetFilenames) {
288
+ const candidatePath = path.resolve(baseDir, filename);
289
+ try {
290
+ await access(candidatePath);
291
+ return `./${filename}`;
292
+ } catch (error) {
293
+ if (error.code !== "ENOENT") throw error;
294
+ }
295
+ }
296
+ };
297
+ const loadYamlRuleset = async (resolvedPath) => {
298
+ let fileContents;
299
+ try {
300
+ fileContents = await readFile(resolvedPath, "utf8");
301
+ } catch (error) {
302
+ throw new RulesetLoadError(`Unable to read ruleset at ${resolvedPath}.`, { cause: error });
303
+ }
304
+ let parsed;
305
+ try {
306
+ parsed = YAML.parse(fileContents);
307
+ } catch (error) {
308
+ throw new RulesetLoadError(`Unable to parse YAML ruleset at ${resolvedPath}.`, { cause: error });
309
+ }
310
+ return normalizeRulesetDefinition(parsed);
311
+ };
312
+ const loadModuleRuleset = async (resolvedPath) => {
313
+ let imported;
314
+ try {
315
+ imported = await import(pathToFileURL(resolvedPath).href);
316
+ } catch (error) {
317
+ throw new RulesetLoadError(`Unable to import module ruleset at ${resolvedPath}.`, { cause: error });
318
+ }
319
+ const resolvedRuleset = resolveModuleRulesetValue(imported);
320
+ if (resolvedRuleset === void 0) throw new RulesetLoadError(`Module ruleset at ${resolvedPath} must export a ruleset as the default export or a named "ruleset" export.`);
321
+ return normalizeRulesetDefinition(resolvedRuleset, {
322
+ ...functionMap,
323
+ ...resolveModuleFunctions(imported)
324
+ });
325
+ };
326
+ const resolveModuleRulesetValue = (imported) => {
327
+ if (!isRecord(imported)) return;
328
+ if ("default" in imported) return imported.default;
329
+ if ("ruleset" in imported) return imported.ruleset;
330
+ };
331
+ const resolveModuleFunctions = (imported) => {
332
+ if (!isRecord(imported) || !("functions" in imported)) return {};
333
+ 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.");
335
+ const entries = Object.entries(functions).filter(([, value]) => typeof value === "function");
336
+ return Object.fromEntries(entries);
337
+ };
338
+ const isYamlRulesetPath = (value) => value.endsWith(".yaml") || value.endsWith(".yml");
339
+ const isModuleRulesetPath = (value) => value.endsWith(".js") || value.endsWith(".mjs") || value.endsWith(".cjs") || value.endsWith(".ts") || value.endsWith(".mts") || value.endsWith(".cts");
340
+ const normalizeRulesetDefinition = (input, availableFunctions = functionMap) => {
341
+ if (!isRecord(input)) throw new RulesetLoadError("Ruleset must be an object.");
342
+ const normalized = { ...input };
343
+ if ("extends" in normalized) normalized.extends = normalizeExtends(normalized.extends);
344
+ if ("rules" in normalized) normalized.rules = normalizeRules(normalized.rules, availableFunctions);
345
+ return normalized;
346
+ };
347
+ const mergeRulesets = (baseRuleset, overrideRuleset) => {
348
+ const mergedBase = baseRuleset;
349
+ const mergedOverride = overrideRuleset;
350
+ const baseRules = isRecord(mergedBase.rules) ? mergedBase.rules : {};
351
+ const overrideRules = isRecord(mergedOverride.rules) ? mergedOverride.rules : {};
352
+ const mergedRules = {
353
+ ...baseRules,
354
+ ...overrideRules
355
+ };
356
+ const baseExtends = toExtendsArray(mergedBase.extends);
357
+ const overrideExtends = toExtendsArray(mergedOverride.extends);
358
+ const mergedExtends = [...baseExtends, ...overrideExtends];
359
+ const merged = {
360
+ ...mergedBase,
361
+ ...mergedOverride
362
+ };
363
+ delete merged.extends;
364
+ delete merged.rules;
365
+ if (mergedExtends.length > 0) merged.extends = mergedExtends;
366
+ if (Object.keys(mergedRules).length > 0) merged.rules = mergedRules;
367
+ return merged;
368
+ };
369
+ const toExtendsArray = (value) => {
370
+ if (value === void 0) return [];
371
+ return Array.isArray(value) ? [...value] : [value];
372
+ };
373
+ const normalizeExtends = (value) => {
374
+ if (typeof value === "string") return resolveExtendsEntry(value);
375
+ if (!Array.isArray(value)) return value;
376
+ return value.map((entry) => {
377
+ if (typeof entry === "string") return resolveExtendsEntry(entry);
378
+ if (Array.isArray(entry) && entry.length >= 1 && typeof entry[0] === "string") return [resolveExtendsEntry(entry[0]), entry[1]];
379
+ return entry;
380
+ });
381
+ };
382
+ const resolveExtendsEntry = (value) => {
383
+ const resolved = extendsMap[value];
384
+ if (!resolved) throw new RulesetLoadError(`Unsupported ruleset extend target: ${value}. v0.1 supports spectral:oas.`);
385
+ return resolved;
386
+ };
387
+ const normalizeRules = (value, availableFunctions) => {
388
+ if (!isRecord(value)) return value;
389
+ const entries = Object.entries(value).map(([ruleName, ruleValue]) => [ruleName, normalizeRule(ruleValue, availableFunctions)]);
390
+ return Object.fromEntries(entries);
391
+ };
392
+ const normalizeRule = (value, availableFunctions) => {
393
+ if (!isRecord(value)) return value;
394
+ const normalized = { ...value };
395
+ if ("then" in normalized) normalized.then = normalizeThen(normalized.then, availableFunctions);
396
+ return normalized;
397
+ };
398
+ const normalizeThen = (value, availableFunctions) => {
399
+ if (Array.isArray(value)) return value.map((entry) => normalizeThenEntry(entry, availableFunctions));
400
+ return normalizeThenEntry(value, availableFunctions);
401
+ };
402
+ const normalizeThenEntry = (value, availableFunctions) => {
403
+ if (!isRecord(value)) return value;
404
+ const normalized = { ...value };
405
+ if (typeof normalized.function === "string") {
406
+ const resolved = availableFunctions[normalized.function];
407
+ if (!resolved) throw new RulesetLoadError(`Unsupported Spectral function: ${String(normalized.function)}.`);
408
+ normalized.function = resolved;
409
+ }
410
+ return normalized;
411
+ };
412
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
413
+ //#endregion
414
+ //#region src/output/terminal-format.ts
415
+ const severityStyles = {
416
+ error: [
417
+ "bold",
418
+ "white",
419
+ "bgRed"
420
+ ],
421
+ warn: [
422
+ "bold",
423
+ "black",
424
+ "bgYellow"
425
+ ],
426
+ info: ["bold", "cyan"],
427
+ hint: ["bold", "gray"]
428
+ };
429
+ const formatSeverityLabel = (severity) => ` ${styleText(severityStyles[severity], severity.toUpperCase())} `;
430
+ const formatPassCard = (summary) => {
431
+ return styleText("green", buildCard([
432
+ `${styleText(["bold", "white"], "OPENAPI LINT")} ${styleText([
433
+ "bold",
434
+ "white",
435
+ "bgGreen"
436
+ ], "PASS")}`,
437
+ `${styleText(["bold", "green"], String(summary.error))} errors ${styleText(["bold", "green"], String(summary.warn))} warns ${styleText(["bold", "green"], String(summary.info))} info`,
438
+ styleText(["bold", "green"], "SPEC IS TIGHT, SHIP IT RIGHT")
439
+ ]));
440
+ };
441
+ const formatSectionHeading = (value, severity) => {
442
+ if (!severity) return styleText([
443
+ "bold",
444
+ "white",
445
+ "bgBlue"
446
+ ], ` ${value} `);
447
+ switch (severity) {
448
+ case "error": return styleText([
449
+ "bold",
450
+ "white",
451
+ "bgRed"
452
+ ], ` ${value} `);
453
+ case "warn": return styleText([
454
+ "bold",
455
+ "black",
456
+ "bgYellow"
457
+ ], ` ${value} `);
458
+ case "info": return styleText([
459
+ "bold",
460
+ "white",
461
+ "bgBlue"
462
+ ], ` ${value} `);
463
+ case "hint": return styleText([
464
+ "bold",
465
+ "white",
466
+ "bgBlack"
467
+ ], ` ${value} `);
468
+ }
469
+ };
470
+ const formatKey = (kind) => {
471
+ switch (kind) {
472
+ case "issue": return styleText(["bold", "cyan"], "Issue");
473
+ case "fix": return styleText(["bold", "green"], "Fix");
474
+ case "spec": return styleText(["bold", "magenta"], "Spec");
475
+ case "threshold": return styleText(["bold", "yellow"], "Threshold");
476
+ }
477
+ };
478
+ const formatDivider = () => styleText("gray", "------------------------------------------------------------");
479
+ const formatCount = (value, severity) => {
480
+ switch (severity) {
481
+ case "error": return styleText(["bold", "red"], String(value));
482
+ case "warn": return styleText(["bold", "yellow"], String(value));
483
+ case "info": return styleText(["bold", "cyan"], String(value));
484
+ default: return styleText(["bold", "gray"], String(value));
485
+ }
486
+ };
487
+ const formatSummaryCounts = (summary) => [
488
+ `${formatCount(summary.error, "error")} error(s)`,
489
+ `${formatCount(summary.warn, "warn")} warning(s)`,
490
+ `${formatCount(summary.info, "info")} info finding(s)`,
491
+ `${formatCount(summary.hint, "hint")} hint(s)`
492
+ ].join(" ");
493
+ const formatCompactSummaryCounts = (summary) => [
494
+ `${formatCount(summary.error, "error")} errors`,
495
+ `${formatCount(summary.warn, "warn")} warnings`,
496
+ `${formatCount(summary.info, "info")} info`,
497
+ `${formatCount(summary.hint, "hint")} hints`
498
+ ].join(" ");
499
+ const formatSpecReference = (value) => {
500
+ const [filePath, pointer] = value.split("#", 2);
501
+ if (!filePath) return value;
502
+ const shortPath = path.isAbsolute(filePath) ? path.relative(process.cwd(), filePath) || path.basename(filePath) : filePath;
503
+ return pointer ? `${shortPath}#${pointer}` : shortPath;
504
+ };
505
+ const formatFindingTitle = (finding) => {
506
+ const location = finding.operation?.method && finding.operation?.path ? `${finding.operation.method.toUpperCase()} ${finding.operation.path}` : finding.documentPointer ?? "(document)";
507
+ return `${formatSeverityLabel(finding.severity)} ${styleText("bold", finding.code)} ${location}`;
508
+ };
509
+ const buildCard = (lines) => {
510
+ const innerWidth = Math.max(...lines.map((line) => stripAnsi(line).length)) + 2;
511
+ const top = `┌${"─".repeat(innerWidth)}┐`;
512
+ const bottom = `└${"─".repeat(innerWidth)}┘`;
513
+ return [
514
+ top,
515
+ ...lines.map((line) => padCardLine(line, innerWidth)),
516
+ bottom
517
+ ].join("\n");
518
+ };
519
+ const padCardLine = (content, innerWidth) => {
520
+ const plainLength = stripAnsi(content).length;
521
+ const padding = Math.max(innerWidth - plainLength - 1, 0);
522
+ return `│ ${content}${" ".repeat(padding)}│`;
523
+ };
524
+ const stripAnsi = (value) => value.replace(/\u001B\[[0-9;]*m/g, "");
525
+ //#endregion
526
+ //#region src/output/console-reporter.ts
527
+ const { Signale } = signale;
528
+ const defaultSignale = new Signale({
529
+ scope: "elysia-spectral",
530
+ interactive: false,
531
+ types: {
532
+ artifact: {
533
+ badge: "◆",
534
+ color: "cyan",
535
+ label: "artifact",
536
+ logLevel: "info"
537
+ },
538
+ ruleset: {
539
+ badge: "◌",
540
+ color: "blue",
541
+ label: "ruleset",
542
+ logLevel: "info"
543
+ },
544
+ report: {
545
+ badge: "↺",
546
+ color: "magenta",
547
+ label: "report",
548
+ logLevel: "warn"
549
+ },
550
+ hype: {
551
+ badge: "▲",
552
+ color: "magenta",
553
+ label: "hype",
554
+ logLevel: "info"
555
+ }
556
+ }
557
+ });
558
+ const defaultReporter = {
559
+ info: (message) => defaultSignale.log(message),
560
+ warn: (message) => defaultSignale.warn(message),
561
+ error: (message) => defaultSignale.error(message),
562
+ log: (message) => defaultSignale.log(message),
563
+ note: (message) => defaultSignale.note(message),
564
+ start: (message) => defaultSignale.start(message),
565
+ complete: (message) => defaultSignale.complete(message),
566
+ success: (message) => defaultSignale.success(message),
567
+ awaiting: (message) => defaultSignale.await(message),
568
+ artifact: (message) => defaultSignale.artifact(message),
569
+ ruleset: (message) => defaultSignale.ruleset(message),
570
+ report: (message) => defaultSignale.report(message),
571
+ hype: (message) => defaultSignale.hype(message)
572
+ };
573
+ const resolveReporter = (logger) => {
574
+ if (!logger) return defaultReporter;
575
+ return {
576
+ ...logger,
577
+ log: (message) => logger.info(message),
578
+ note: (message) => logger.info(message),
579
+ start: (message) => logger.info(message),
580
+ complete: (message) => logger.info(message),
581
+ success: (message) => logger.info(message),
582
+ awaiting: (message) => logger.info(message),
583
+ artifact: (message) => logger.info(message),
584
+ ruleset: (message) => logger.info(message),
585
+ report: (message) => logger.warn(message),
586
+ hype: (message) => logger.info(message)
587
+ };
588
+ };
589
+ const reportToConsole = (result, logger = defaultReporter) => {
590
+ const reporter = resolveReporter(logger);
591
+ const summaryCounts = formatSummaryCounts(result.summary);
592
+ if (result.summary.total === 0) {
593
+ reporter.success("OpenAPI lint passed.");
594
+ reporter.note(formatCompactSummaryCounts(result.summary));
595
+ reporter.hype(formatPassCard(result.summary));
596
+ return;
597
+ }
598
+ if (result.summary.error > 0) reporter.error(`OpenAPI lint found contract failures. ${summaryCounts}`);
599
+ else reporter.warn(`OpenAPI lint found warnings. ${summaryCounts}`);
600
+ reporter.log(formatDivider());
601
+ const sections = [
602
+ {
603
+ severity: "error",
604
+ title: `ERRORS (${result.summary.error})`,
605
+ findings: result.findings.filter((finding) => finding.severity === "error")
606
+ },
607
+ {
608
+ severity: "warn",
609
+ title: `WARNINGS (${result.summary.warn})`,
610
+ findings: result.findings.filter((finding) => finding.severity === "warn")
611
+ },
612
+ {
613
+ severity: "info",
614
+ title: `INFO (${result.summary.info})`,
615
+ findings: result.findings.filter((finding) => finding.severity === "info")
616
+ },
617
+ {
618
+ severity: "hint",
619
+ title: `HINTS (${result.summary.hint})`,
620
+ findings: result.findings.filter((finding) => finding.severity === "hint")
621
+ }
622
+ ].filter((section) => section.findings.length > 0);
623
+ for (const section of sections) {
624
+ reporter.log(formatSectionHeading(section.title, section.severity));
625
+ reporter.log(formatDivider());
626
+ for (const finding of section.findings) logFinding(finding, reporter, result.artifacts?.specSnapshotPath);
627
+ }
628
+ reporter.note(`Summary: ${summaryCounts}`);
629
+ };
630
+ const logFinding = (finding, reporter, specSnapshotPath) => {
631
+ const specReference = buildSpecReference(finding, specSnapshotPath);
632
+ const title = formatFindingTitle(finding);
633
+ reporter.log(title);
634
+ reporter.log(` ${formatKey("issue")} ${finding.message}`);
635
+ if (finding.recommendation) reporter.log(` ${formatKey("fix")} ${finding.recommendation}`);
636
+ if (specReference) reporter.log(` ${formatKey("spec")} ${formatSpecReference(specReference)}`);
637
+ reporter.log(formatDivider());
638
+ };
639
+ const buildSpecReference = (finding, specSnapshotPath) => {
640
+ if (!finding.documentPointer) return specSnapshotPath;
641
+ if (!specSnapshotPath) return `openapi.json#${finding.documentPointer}`;
642
+ return `${specSnapshotPath}#${finding.documentPointer}`;
643
+ };
644
+ //#endregion
645
+ //#region src/output/json-reporter.ts
646
+ const DEFAULT_SPEC_SNAPSHOT_FILENAME = "open-api.json";
647
+ const writeJsonArtifact = async (artifactPath, payload, pretty = true) => {
648
+ const resolvedPath = path.resolve(process.cwd(), artifactPath);
649
+ await mkdir(path.dirname(resolvedPath), { recursive: true });
650
+ await writeFile(resolvedPath, `${JSON.stringify(payload, null, pretty ? 2 : 0)}\n`, "utf8");
651
+ return resolvedPath;
652
+ };
653
+ const writeJsonReport = async (reportPath, result, pretty = true) => writeJsonArtifact(reportPath, result, pretty);
654
+ const writeSpecSnapshot = async (snapshotPath, spec, pretty = true) => writeJsonArtifact(snapshotPath, spec, pretty);
655
+ const sanitizePackageNameForFilename = (packageName) => packageName.replace(/^@/, "").replace(/\//g, "-");
656
+ const resolveDefaultSpecSnapshotPath = async () => {
657
+ try {
658
+ const packageJsonPath = path.resolve(process.cwd(), "package.json");
659
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
660
+ if (typeof packageJson.name === "string" && packageJson.name.length > 0) return `./${sanitizePackageNameForFilename(packageJson.name)}.open-api.json`;
661
+ } catch {}
662
+ return `./${DEFAULT_SPEC_SNAPSHOT_FILENAME}`;
663
+ };
664
+ //#endregion
665
+ //#region src/output/junit-reporter.ts
666
+ const escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
667
+ const writeJunitReport = async (reportPath, result) => {
668
+ const resolvedPath = path.resolve(process.cwd(), reportPath);
669
+ await mkdir(path.dirname(resolvedPath), { recursive: true });
670
+ await writeFile(resolvedPath, `${buildJunitReport(result)}\n`, "utf8");
671
+ return resolvedPath;
672
+ };
673
+ const buildJunitReport = (result) => {
674
+ const testCases = result.findings.length === 0 ? [buildPassingTestCase()] : result.findings.map((finding) => buildFailingTestCase(finding));
675
+ const tests = testCases.length;
676
+ const failures = result.findings.length;
677
+ const timestamp = escapeXml(result.generatedAt);
678
+ return [
679
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
680
+ `<testsuites tests="${tests}" failures="${failures}" errors="0" skipped="0" time="0">`,
681
+ ` <testsuite name="OpenAPI lint" tests="${tests}" failures="${failures}" errors="0" skipped="0" time="0" timestamp="${timestamp}">`,
682
+ ...testCases.map((testCase) => ` ${testCase}`),
683
+ " </testsuite>",
684
+ "</testsuites>"
685
+ ].join("\n");
686
+ };
687
+ const buildPassingTestCase = () => "<testcase classname=\"openapi.lint\" name=\"OpenAPI lint\" time=\"0\" />";
688
+ const buildFailingTestCase = (finding) => {
689
+ const className = escapeXml(finding.operation?.path ? `openapi${finding.operation.path.replaceAll("/", ".")}` : "openapi.document");
690
+ const name = escapeXml(buildTestName(finding));
691
+ const failureType = escapeXml(finding.severity);
692
+ const failureMessage = escapeXml(finding.message);
693
+ const body = escapeXml(buildFailureBody(finding));
694
+ return [
695
+ `<testcase classname="${className}" name="${name}" time="0">`,
696
+ ` <failure type="${failureType}" message="${failureMessage}">${body}</failure>`,
697
+ " </testcase>"
698
+ ].join("\n");
699
+ };
700
+ const buildTestName = (finding) => {
701
+ return `${finding.code} ${finding.operation?.path ? `${finding.operation.method?.toUpperCase() ?? "DOC"} ${finding.operation.path}` : "document"}`;
702
+ };
703
+ const buildFailureBody = (finding) => {
704
+ const lines = [`Issue: ${finding.message}`];
705
+ if (finding.recommendation) lines.push(`Fix: ${finding.recommendation}`);
706
+ if (finding.documentPointer) lines.push(`Spec: openapi.json#${finding.documentPointer}`);
707
+ return lines.join("\n");
708
+ };
709
+ //#endregion
710
+ //#region src/output/sarif-reporter.ts
711
+ const SARIF_SCHEMA_URI = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/schemas/sarif-schema-2.1.0.json";
712
+ const writeSarifReport = async (reportPath, result, pretty = true) => {
713
+ const resolvedPath = path.resolve(process.cwd(), reportPath);
714
+ await mkdir(path.dirname(resolvedPath), { recursive: true });
715
+ await writeFile(resolvedPath, `${JSON.stringify(buildSarifReport(result), null, pretty ? 2 : 0)}\n`, "utf8");
716
+ return resolvedPath;
717
+ };
718
+ const buildSarifReport = (result) => {
719
+ const defaultArtifactPath = result.artifacts?.specSnapshotPath && result.artifacts.specSnapshotPath.length > 0 ? toSarifArtifactUri(result.artifacts.specSnapshotPath) : "openapi.json";
720
+ const rulesById = buildSarifRules(result.findings);
721
+ return {
722
+ $schema: SARIF_SCHEMA_URI,
723
+ version: "2.1.0",
724
+ runs: [{
725
+ automationDetails: { id: "@opsydyn/elysia-spectral/openapi-lint" },
726
+ originalUriBaseIds: { "%SRCROOT%": { uri: pathToFileURL(`${process.cwd()}${path.sep}`).href } },
727
+ tool: { driver: {
728
+ name: "@opsydyn/elysia-spectral",
729
+ informationUri: "https://github.com/stoplightio/spectral",
730
+ rules: [...rulesById.values()]
731
+ } },
732
+ results: result.findings.map((finding) => buildSarifResult(finding, defaultArtifactPath, rulesById))
733
+ }]
734
+ };
735
+ };
736
+ const buildSarifRules = (findings) => {
737
+ const findingsById = /* @__PURE__ */ new Map();
738
+ for (const finding of findings) if (!findingsById.has(finding.code)) findingsById.set(finding.code, finding);
739
+ return new Map([...findingsById.values()].map((finding) => [finding.code, {
740
+ id: finding.code,
741
+ name: finding.code,
742
+ shortDescription: { text: finding.message },
743
+ helpUri: "https://github.com/stoplightio/spectral",
744
+ defaultConfiguration: { level: toSarifLevel(finding.severity) },
745
+ help: finding.recommendation ? { text: finding.recommendation } : void 0,
746
+ properties: { tags: [
747
+ "openapi",
748
+ "spectral",
749
+ finding.severity
750
+ ] }
751
+ }]));
752
+ };
753
+ const buildSarifResult = (finding, defaultArtifactPath, rulesById) => {
754
+ const location = buildSarifLocation(finding, defaultArtifactPath);
755
+ return {
756
+ ruleId: finding.code,
757
+ ruleIndex: [...rulesById.keys()].indexOf(finding.code),
758
+ level: toSarifLevel(finding.severity),
759
+ message: { text: finding.message },
760
+ locations: location ? [location] : void 0,
761
+ partialFingerprints: { primaryLocationLineHash: [
762
+ finding.code,
763
+ finding.documentPointer ?? "(document)",
764
+ finding.message
765
+ ].join(":") },
766
+ properties: {
767
+ severity: finding.severity,
768
+ documentPointer: finding.documentPointer,
769
+ operationId: finding.operation?.operationId
770
+ }
771
+ };
772
+ };
773
+ const buildSarifLocation = (finding, defaultArtifactPath) => {
774
+ const region = finding.range?.start && finding.range?.end ? {
775
+ startLine: finding.range.start.line + 1,
776
+ startColumn: finding.range.start.character + 1,
777
+ endLine: finding.range.end.line + 1,
778
+ endColumn: finding.range.end.character + 1
779
+ } : void 0;
780
+ const uri = defaultArtifactPath;
781
+ if (!uri) return;
782
+ return {
783
+ physicalLocation: {
784
+ artifactLocation: {
785
+ uri,
786
+ uriBaseId: "%SRCROOT%"
787
+ },
788
+ region
789
+ },
790
+ logicalLocations: finding.operation?.path ? [{
791
+ name: finding.operation.method && finding.operation.path ? `${finding.operation.method.toUpperCase()} ${finding.operation.path}` : finding.operation.path,
792
+ fullyQualifiedName: finding.operation.operationId
793
+ }] : void 0
794
+ };
795
+ };
796
+ const toSarifLevel = (severity) => {
797
+ switch (severity) {
798
+ case "error": return "error";
799
+ case "warn": return "warning";
800
+ default: return "note";
801
+ }
802
+ };
803
+ const toSarifArtifactUri = (value) => {
804
+ if (!path.isAbsolute(value)) return value;
805
+ return path.relative(process.cwd(), value) || path.basename(value);
806
+ };
807
+ //#endregion
808
+ //#region src/output/sinks.ts
809
+ const createOutputSinks = (options) => {
810
+ const reporter = resolveReporter(options.logger);
811
+ const sinks = [];
812
+ const configuredSpecSnapshotPath = options.output?.specSnapshotPath;
813
+ const configuredJsonReportPath = options.output?.jsonReportPath;
814
+ const configuredJunitReportPath = options.output?.junitReportPath;
815
+ const configuredSarifReportPath = options.output?.sarifReportPath;
816
+ if (configuredSpecSnapshotPath) sinks.push({
817
+ name: "spec snapshot",
818
+ kind: "artifact",
819
+ async write(_result, context) {
820
+ const writtenSpecSnapshotPath = await writeSpecSnapshot(configuredSpecSnapshotPath === true ? await resolveDefaultSpecSnapshotPath() : configuredSpecSnapshotPath, context.spec, options.output?.pretty !== false);
821
+ reporter.artifact(`OpenAPI lint wrote spec snapshot to ${writtenSpecSnapshotPath}.`);
822
+ return { specSnapshotPath: writtenSpecSnapshotPath };
823
+ }
824
+ });
825
+ if (configuredJsonReportPath) sinks.push({
826
+ name: "JSON report",
827
+ kind: "artifact",
828
+ async write(result) {
829
+ const writtenJsonReportPath = await writeJsonReport(configuredJsonReportPath, result, options.output?.pretty !== false);
830
+ reporter.artifact(`OpenAPI lint wrote JSON report to ${writtenJsonReportPath}.`);
831
+ return { jsonReportPath: writtenJsonReportPath };
832
+ }
833
+ });
834
+ if (configuredJunitReportPath) sinks.push({
835
+ name: "JUnit report",
836
+ kind: "artifact",
837
+ async write(result) {
838
+ const writtenJunitReportPath = await writeJunitReport(configuredJunitReportPath, result);
839
+ reporter.artifact(`OpenAPI lint wrote JUnit report to ${writtenJunitReportPath}.`);
840
+ return { junitReportPath: writtenJunitReportPath };
841
+ }
842
+ });
843
+ if (configuredSarifReportPath) sinks.push({
844
+ name: "SARIF report",
845
+ kind: "artifact",
846
+ async write(result) {
847
+ const writtenSarifReportPath = await writeSarifReport(configuredSarifReportPath, result, options.output?.pretty !== false);
848
+ reporter.artifact(`OpenAPI lint wrote SARIF report to ${writtenSarifReportPath}.`);
849
+ return { sarifReportPath: writtenSarifReportPath };
850
+ }
851
+ });
852
+ for (const sink of options.output?.sinks ?? []) sinks.push({
853
+ name: sink.name,
854
+ kind: "custom",
855
+ write: async (result, context) => await Promise.resolve(sink.write(result, context))
856
+ });
857
+ if (options.output?.console !== false) sinks.push({
858
+ name: "console",
859
+ kind: "report",
860
+ async write(result) {
861
+ reportToConsole(result, reporter);
862
+ }
863
+ });
864
+ return sinks;
865
+ };
866
+ //#endregion
867
+ //#region src/providers/spec-provider.ts
868
+ var BaseSpecProvider = class {
869
+ constructor(app, options = {}) {
870
+ this.app = app;
871
+ this.options = options;
872
+ }
873
+ get specPath() {
874
+ return normalizeSpecPath(this.options?.specPath ?? "/openapi/json");
875
+ }
876
+ };
877
+ const normalizeSpecPath = (value) => {
878
+ if (value.length === 0) return "/openapi/json";
879
+ return value.startsWith("/") ? value : `/${value}`;
880
+ };
881
+ const createInProcessRequest = (specPath) => new Request(new URL(specPath, "http://localhost").toString());
882
+ //#endregion
883
+ //#region src/providers/public-spec-provider.ts
884
+ var PublicSpecProviderError = class extends Error {
885
+ constructor(message, options) {
886
+ super(message);
887
+ this.name = "PublicSpecProviderError";
888
+ if (options?.cause !== void 0) this.cause = options.cause;
889
+ }
890
+ };
891
+ var PublicSpecProvider = class extends BaseSpecProvider {
892
+ constructor(app, options = {}) {
893
+ super(app, options);
894
+ }
895
+ async getSpec() {
896
+ const inProcessRequest = createInProcessRequest(this.specPath);
897
+ const sourceLabel = `app.handle(Request) at ${this.specPath}`;
898
+ try {
899
+ const response = await this.app.handle(inProcessRequest);
900
+ if (response.ok) return await parseSpecResponse(response, sourceLabel, this.specPath);
901
+ if (this.options?.baseUrl) return await this.fetchViaLoopback();
902
+ const body = await safeReadBody(response);
903
+ 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 mounted and exposing "${this.specPath}", or update source.specPath to the correct OpenAPI JSON route.`].join(" "));
904
+ } catch (error) {
905
+ if (this.options?.baseUrl) return await this.fetchViaLoopback(error);
906
+ if (error instanceof PublicSpecProviderError) throw error;
907
+ throw new PublicSpecProviderError([`Unable to resolve OpenAPI JSON from ${this.specPath} via ${sourceLabel}.`, "Fix: ensure the app can serve the configured OpenAPI JSON route, or set source.baseUrl if the document is only reachable over HTTP."].join(" "), { cause: error });
908
+ }
909
+ }
910
+ async fetchViaLoopback(cause) {
911
+ const baseUrl = this.options?.baseUrl;
912
+ if (!baseUrl) throw new PublicSpecProviderError("Loopback fetch requires source.baseUrl.", { cause });
913
+ const url = new URL(this.specPath, ensureTrailingSlash(baseUrl)).toString();
914
+ const response = await fetch(url);
915
+ if (!response.ok) {
916
+ const body = await safeReadBody(response);
917
+ throw new PublicSpecProviderError([`Unable to load OpenAPI JSON from ${url}: received ${describeResponse(response)}${body ? ` with body ${body}.` : "."}`, "Fix: ensure the HTTP endpoint is reachable and returns the generated OpenAPI JSON, or update source.baseUrl/source.specPath."].join(" "), { cause });
918
+ }
919
+ return parseSpecResponse(response, url, this.specPath);
920
+ }
921
+ };
922
+ const parseSpecResponse = async (response, sourceLabel, specPath) => {
923
+ const body = await response.text();
924
+ try {
925
+ return JSON.parse(body);
926
+ } catch (error) {
927
+ throw new PublicSpecProviderError([`Unable to parse OpenAPI JSON from ${sourceLabel}: response was not valid JSON${body.trim() ? ` (body preview: ${formatBodyPreview(body)}).` : "."}`, `Fix: ensure the configured endpoint for "${specPath}" returns the generated OpenAPI document as JSON.`].join(" "), { cause: error });
928
+ }
929
+ };
930
+ const ensureTrailingSlash = (value) => value.endsWith("/") ? value : `${value}/`;
931
+ const safeReadBody = async (response) => {
932
+ try {
933
+ const body = await response.text();
934
+ return body.trim() ? formatBodyPreview(body) : "";
935
+ } catch {
936
+ return "";
937
+ }
938
+ };
939
+ const describeResponse = (response) => response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
940
+ const formatBodyPreview = (value) => {
941
+ const normalized = value.replace(/\s+/g, " ").trim();
942
+ if (normalized.length <= 120) return JSON.stringify(normalized);
943
+ return JSON.stringify(`${normalized.slice(0, 117)}...`);
944
+ };
945
+ //#endregion
946
+ //#region src/core/thresholds.ts
947
+ const severityRank = {
948
+ error: 0,
949
+ warn: 1,
950
+ info: 2,
951
+ hint: 3
952
+ };
953
+ const thresholdRank = {
954
+ error: 0,
955
+ warn: 1,
956
+ info: 2,
957
+ hint: 3
958
+ };
959
+ var OpenApiLintThresholdError = class extends Error {
960
+ constructor(threshold, result) {
961
+ super(buildThresholdMessage(threshold, result));
962
+ this.threshold = threshold;
963
+ this.result = result;
964
+ this.name = "OpenApiLintThresholdError";
965
+ }
966
+ };
967
+ const exceedsThreshold = (severity, threshold) => {
968
+ if (threshold === "never") return false;
969
+ return severityRank[severity] <= thresholdRank[threshold];
970
+ };
971
+ const shouldFail = (result, threshold) => {
972
+ if (threshold === "never") return false;
973
+ return result.findings.some((finding) => exceedsThreshold(finding.severity, threshold));
974
+ };
975
+ const enforceThreshold = (result, threshold) => {
976
+ if (shouldFail(result, threshold)) throw new OpenApiLintThresholdError(threshold, result);
977
+ };
978
+ const buildThresholdMessage = (threshold, result) => {
979
+ const topFindings = result.findings.filter((finding) => exceedsThreshold(finding.severity, threshold)).slice(0, 5).map((finding) => {
980
+ const operation = finding.operation?.method && finding.operation?.path ? ` ${finding.operation.method.toUpperCase()} ${finding.operation.path}` : "";
981
+ return `[${finding.severity}] ${finding.code}${operation}: ${finding.message}`;
982
+ });
983
+ const lines = [`OpenAPI lint failed at threshold "${threshold}".`, `Summary: ${result.summary.error} error(s), ${result.summary.warn} warning(s), ${result.summary.info} info finding(s), ${result.summary.hint} hint(s).`];
984
+ if (topFindings.length > 0) {
985
+ lines.push("Top findings:");
986
+ lines.push(...topFindings);
987
+ }
988
+ return lines.join("\n");
989
+ };
990
+ //#endregion
991
+ //#region src/core/runtime.ts
992
+ const createOpenApiLintRuntime = (options = {}) => {
993
+ const reporter = resolveReporter(options.logger);
994
+ const artifactWriteFailureMode = options.output?.artifactWriteFailures ?? "warn";
995
+ let inFlight = null;
996
+ const runtime = {
997
+ status: "idle",
998
+ startedAt: null,
999
+ completedAt: null,
1000
+ durationMs: null,
1001
+ latest: null,
1002
+ lastSuccess: null,
1003
+ lastFailure: null,
1004
+ running: false,
1005
+ async run(app) {
1006
+ if (inFlight) return await inFlight;
1007
+ inFlight = (async () => {
1008
+ const startedAt = /* @__PURE__ */ new Date();
1009
+ runtime.running = true;
1010
+ runtime.status = "running";
1011
+ runtime.startedAt = startedAt.toISOString();
1012
+ runtime.completedAt = null;
1013
+ runtime.durationMs = null;
1014
+ reporter.start("OpenAPI lint started.");
1015
+ try {
1016
+ const spec = await new PublicSpecProvider(app, options.source).getSpec();
1017
+ const loadedRuleset = await loadResolvedRuleset(options.ruleset);
1018
+ if (loadedRuleset.source?.autodiscovered) reporter.ruleset(`OpenAPI lint autodiscovered ruleset ${loadedRuleset.source.path} and merged it with the package default ruleset.`);
1019
+ else if (loadedRuleset.source?.path) reporter.ruleset(`OpenAPI lint loaded ruleset ${loadedRuleset.source.path}.`);
1020
+ const result = await lintOpenApi(spec, loadedRuleset.ruleset);
1021
+ await writeOutputSinks(result, spec, options, artifactWriteFailureMode);
1022
+ runtime.latest = result;
1023
+ reporter.complete("OpenAPI lint completed.");
1024
+ enforceThreshold(result, options.failOn ?? "error");
1025
+ runtime.status = "passed";
1026
+ runtime.lastSuccess = result;
1027
+ finalizeRuntimeRun(runtime, startedAt);
1028
+ return result;
1029
+ } catch (error) {
1030
+ runtime.status = "failed";
1031
+ runtime.lastFailure = toRuntimeFailure(error);
1032
+ finalizeRuntimeRun(runtime, startedAt);
1033
+ throw error;
1034
+ }
1035
+ })();
1036
+ try {
1037
+ return await inFlight;
1038
+ } finally {
1039
+ runtime.running = false;
1040
+ inFlight = null;
1041
+ }
1042
+ }
1043
+ };
1044
+ return runtime;
1045
+ };
1046
+ const finalizeRuntimeRun = (runtime, startedAt) => {
1047
+ const completedAt = /* @__PURE__ */ new Date();
1048
+ runtime.completedAt = completedAt.toISOString();
1049
+ runtime.durationMs = completedAt.getTime() - startedAt.getTime();
1050
+ };
1051
+ const toRuntimeFailure = (error) => ({
1052
+ name: error instanceof Error ? error.name : "Error",
1053
+ message: error instanceof Error ? error.message : String(error),
1054
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1055
+ });
1056
+ var OpenApiLintArtifactWriteError = class extends Error {
1057
+ constructor(artifact, cause) {
1058
+ super(`OpenAPI lint could not write ${artifact}: ${cause instanceof Error ? cause.message : String(cause)}`);
1059
+ this.artifact = artifact;
1060
+ this.cause = cause;
1061
+ this.name = "OpenApiLintArtifactWriteError";
1062
+ }
1063
+ };
1064
+ const handleArtifactWriteFailure = (artifact, error, mode, reporter) => {
1065
+ const wrappedError = new OpenApiLintArtifactWriteError(artifact, error);
1066
+ if (mode === "error") throw wrappedError;
1067
+ reporter.warn(wrappedError.message);
1068
+ };
1069
+ const writeOutputSinks = async (result, spec, options, artifactWriteFailureMode) => {
1070
+ const reporter = resolveReporter(options.logger);
1071
+ const sinks = createOutputSinks(options);
1072
+ for (const sink of sinks) try {
1073
+ const artifacts = await sink.write(result, {
1074
+ spec,
1075
+ logger: reporter
1076
+ });
1077
+ if (artifacts) result.artifacts = mergeArtifacts(result.artifacts, artifacts);
1078
+ } catch (error) {
1079
+ if (sink.kind === "artifact") {
1080
+ handleArtifactWriteFailure(sink.name, error, artifactWriteFailureMode, reporter);
1081
+ continue;
1082
+ }
1083
+ throw error;
1084
+ }
1085
+ };
1086
+ const mergeArtifacts = (current, next) => ({
1087
+ ...current,
1088
+ ...next
1089
+ });
1090
+ const isEnabled = (options = {}) => {
1091
+ return resolveStartupMode(options) !== "off";
1092
+ };
1093
+ const resolveStartupMode = (options = {}) => {
1094
+ if (options.startup?.mode) return options.startup.mode;
1095
+ if (typeof options.enabled === "function") return options.enabled(process.env) ? "enforce" : "off";
1096
+ return options.enabled === false ? "off" : "enforce";
1097
+ };
1098
+ //#endregion
1099
+ 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 };