@opsydyn/elysia-spectral 1.5.1 → 1.5.3

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.3](https://github.com/opsydyn/elysia-spectral/compare/v1.5.2...v1.5.3) (2026-05-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * stabilize public API docs and smoke coverage ([d2373db](https://github.com/opsydyn/elysia-spectral/commit/d2373db7a40dc615b3d60c6c5e421ee848a1ffdf))
9
+
10
+ ## [1.5.2](https://github.com/opsydyn/elysia-spectral/compare/v1.5.1...v1.5.2) (2026-05-12)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * complete self-describing lint reports ([b0917ce](https://github.com/opsydyn/elysia-spectral/commit/b0917ce4c977a1378ba851efc643058a70fea70b))
16
+
3
17
  ## [1.5.1](https://github.com/opsydyn/elysia-spectral/compare/v1.5.0...v1.5.1) (2026-05-12)
4
18
 
5
19
 
package/README.md CHANGED
@@ -58,6 +58,7 @@ Current package scope:
58
58
  - resolver pipeline for advanced ruleset loading
59
59
  - console output
60
60
  - JSON report output
61
+ - self-describing JSON report metadata (`failOn`, `durationMs`, relative artifact paths)
61
62
  - JUnit report output
62
63
  - SARIF report output
63
64
  - OpenAPI snapshot output
@@ -346,6 +347,8 @@ spectralPlugin({
346
347
 
347
348
  This is useful for local development and intentionally broken fixtures.
348
349
 
350
+ `startup.mode: 'report'` relaxes threshold failures only. Missing OpenAPI routes, invalid spec JSON, broken rulesets, or artifact writes promoted to `output.artifactWriteFailures: 'error'` still surface as startup errors.
351
+
349
352
  ### Disable startup lint entirely
350
353
 
351
354
  Use `startup.mode: 'off'` when you only want manual or healthcheck-triggered runs.
@@ -360,6 +363,8 @@ spectralPlugin({
360
363
 
361
364
  `enabled: false` is still supported as a backwards-compatible way to disable startup lint.
362
365
 
366
+ `startup.mode` takes precedence over the legacy `enabled` flag.
367
+
363
368
  ### Add a lint healthcheck endpoint
364
369
 
365
370
  The healthcheck route is opt-in.
@@ -509,7 +514,10 @@ Custom sinks run after linting and can read:
509
514
  If you use the runtime programmatically, you can provide your own resolver pipeline to `loadRuleset` or `loadResolvedRuleset`.
510
515
 
511
516
  ```ts
512
- import { loadRuleset } from '@opsydyn/elysia-spectral/core'
517
+ import {
518
+ defaultRulesetResolvers,
519
+ loadRuleset,
520
+ } from '@opsydyn/elysia-spectral/core'
513
521
 
514
522
  const ruleset = await loadRuleset('virtual://team-ruleset', {
515
523
  resolvers: [
@@ -529,11 +537,14 @@ const ruleset = await loadRuleset('virtual://team-ruleset', {
529
537
  }
530
538
  }
531
539
  }
532
- : undefined
540
+ : undefined,
541
+ ...defaultRulesetResolvers
533
542
  ]
534
543
  })
535
544
  ```
536
545
 
546
+ `defaultRulesetResolvers` is a supported advanced export from `@opsydyn/elysia-spectral/core`. Prefer prepending your resolver and then spreading the defaults so built-in autodiscovery, path loading, and inline ruleset resolution continue to work.
547
+
537
548
  This is an advanced extension point. Most apps should continue using repo-level `spectral.*` files.
538
549
 
539
550
  ### Make artifact write failures fatal in CI
@@ -755,6 +766,26 @@ That example uses `startup.mode: 'report'`, so the app still boots while the pac
755
766
 
756
767
  ## Reference
757
768
 
769
+ ### Supported import surfaces
770
+
771
+ Use `@opsydyn/elysia-spectral` for the primary stable consumer API:
772
+
773
+ - `spectralPlugin`
774
+ - `createOpenApiLintRuntime`
775
+ - `recommended`, `server`, `strict`, and `presets`
776
+ - `loadRuleset`, `loadResolvedRuleset`, and `lintOpenApi`
777
+ - `shouldFail`, `enforceThreshold`, and the exported error classes
778
+
779
+ Use `@opsydyn/elysia-spectral/core` for advanced composition details:
780
+
781
+ - `defaultRulesetResolvers`
782
+ - resolver pipeline types such as `RulesetResolver`, `RulesetResolverInput`, and `LoadResolvedRulesetOptions`
783
+ - the same programmatic runtime helpers when you want an explicitly advanced import surface
784
+
785
+ `defaultRulesetResolvers` is a supported extension hook. Prefer `[myResolver, ...defaultRulesetResolvers]` when extending the resolver pipeline instead of replacing the defaults outright.
786
+
787
+ When used as a plugin, `app.store.openApiLint` is part of the supported plugin contract.
788
+
758
789
  ### Package API
759
790
 
760
791
  ```ts
@@ -804,7 +835,7 @@ type SpectralPluginOptions = {
804
835
  * Controls startup lint behaviour.
805
836
  * startup.mode takes precedence over the legacy enabled option.
806
837
  * 'enforce' — lint runs at startup and throws on threshold failure (default)
807
- * 'report' — lint runs at startup, prints findings, but never blocks boot
838
+ * 'report' — lint runs at startup, prints findings, but does not block boot on threshold failures
808
839
  * 'off' — startup lint is skipped entirely
809
840
  */
810
841
  startup?: {
@@ -846,6 +877,10 @@ type LintRunResult = {
846
877
  generatedAt: string
847
878
  /** Where the lint run was triggered from. */
848
879
  source: LintRunSource
880
+ /** The configured threshold that produced this result. */
881
+ failOn: SeverityThreshold
882
+ /** Duration of the completed lint run in milliseconds. */
883
+ durationMs: number | null
849
884
  summary: {
850
885
  error: number
851
886
  warn: number
@@ -887,6 +922,28 @@ type OpenApiLintRuntime = {
887
922
 
888
923
  function createOpenApiLintRuntime(options?: SpectralPluginOptions): OpenApiLintRuntime
889
924
 
925
+ // ── Root utility exports ──────────────────────────────────────────────────────
926
+
927
+ const presets: Record<PresetName, RulesetDefinition>
928
+
929
+ function loadRuleset(
930
+ input?: string | RulesetDefinition | Record<string, unknown>,
931
+ baseDirOrOptions?: string | LoadResolvedRulesetOptions,
932
+ ): Promise<RulesetDefinition>
933
+
934
+ function loadResolvedRuleset(
935
+ input?: string | RulesetDefinition | Record<string, unknown>,
936
+ baseDirOrOptions?: string | LoadResolvedRulesetOptions,
937
+ ): Promise<LoadedRuleset>
938
+
939
+ function lintOpenApi(
940
+ spec: Record<string, unknown>,
941
+ ruleset: RulesetDefinition,
942
+ ): Promise<LintRunResult>
943
+
944
+ function shouldFail(result: LintRunResult, threshold: SeverityThreshold): boolean
945
+ function enforceThreshold(result: LintRunResult, threshold: SeverityThreshold): void
946
+
890
947
  // ── Extension points (advanced) ───────────────────────────────────────────────
891
948
 
892
949
  type SpectralLogger = {
@@ -908,8 +965,14 @@ type OpenApiLintSink = {
908
965
  ) => undefined | Partial<OpenApiLintArtifacts> | Promise<undefined | Partial<OpenApiLintArtifacts>>
909
966
  }
910
967
 
968
+ type RulesetResolverInput =
969
+ | string
970
+ | RulesetDefinition
971
+ | Record<string, unknown>
972
+ | undefined
973
+
911
974
  type RulesetResolver = (
912
- input: string | RulesetDefinition | Record<string, unknown> | undefined,
975
+ input: RulesetResolverInput,
913
976
  context: RulesetResolverContext,
914
977
  ) => Promise<ResolvedRulesetCandidate | undefined>
915
978
 
@@ -941,6 +1004,8 @@ type LoadResolvedRulesetOptions = {
941
1004
  defaultRuleset?: RulesetDefinition
942
1005
  }
943
1006
 
1007
+ const defaultRulesetResolvers: RulesetResolver[]
1008
+
944
1009
  // ── Error classes ─────────────────────────────────────────────────────────────
945
1010
 
946
1011
  class OpenApiLintThresholdError extends Error {
@@ -993,7 +1058,7 @@ The runtime object exposes:
993
1058
  - `lastFailure`: last thrown runtime error summary
994
1059
  - `running`: boolean convenience flag
995
1060
 
996
- When used as a plugin, the runtime is also available on `app.store.openApiLint`.
1061
+ When used as a plugin, the runtime is also available on `app.store.openApiLint` as part of the supported plugin contract.
997
1062
 
998
1063
  ### Healthcheck response shape
999
1064
 
@@ -1008,6 +1073,8 @@ Example successful response:
1008
1073
  "ok": true,
1009
1074
  "generatedAt": "2026-04-06T12:00:00.000Z",
1010
1075
  "source": "startup",
1076
+ "failOn": "error",
1077
+ "durationMs": 42,
1011
1078
  "summary": {
1012
1079
  "error": 0,
1013
1080
  "warn": 0,
@@ -1036,9 +1103,12 @@ Example successful response:
1036
1103
 
1037
1104
  - startup mode `enforce` throws on threshold failures
1038
1105
  - startup mode `report` prints the same lint report but allows boot to continue on threshold failures
1106
+ - startup mode defaults to `enforce`
1039
1107
  - startup mode `off` skips startup lint
1108
+ - `failOn` defaults to `error`
1040
1109
  - missing OpenAPI generator / missing OpenAPI JSON route, bad `source.specPath`, or invalid spec JSON produces an actionable provider error
1041
1110
  - artifact writes warn by default and can be made fatal with `output.artifactWriteFailures: 'error'`
1111
+ - `enabled: false` remains a backwards-compatible alias for `startup.mode: 'off'`, and `startup.mode` takes precedence when both are provided
1042
1112
 
1043
1113
  ### Output model
1044
1114
 
@@ -1049,6 +1119,8 @@ The current output model has two layers:
1049
1119
 
1050
1120
  The convenience options compile down to built-in sinks so the current API stays simple while the internal output model becomes extensible.
1051
1121
 
1122
+ Persisted JSON reports are self-describing: they embed the configured `failOn` threshold, the completed `durationMs`, and relative artifact paths so the same report shape is portable across CI runners and developer machines.
1123
+
1052
1124
  ## Explanation
1053
1125
 
1054
1126
  ### Why this package exists
@@ -1096,4 +1168,6 @@ Production-grade linting needs more than a pass/fail boolean. The runtime tracks
1096
1168
 
1097
1169
  ### Project status
1098
1170
 
1099
- The package is published to npm and used in production. Ongoing work additional rules, deeper Elysia integrations, and dashboard polish is tracked in [roadmap.md](../../roadmap.md).
1171
+ The package is published to npm and used in production. The current pre-`1.0` feature roadmap is functionally complete: startup/runtime flows, presets, output sinks, CI workflows, dashboard support, and self-describing result artifacts are all shipped. Remaining work is primarily final `1.0` release-readiness and package-boundary polish, tracked in [roadmap.md](../../roadmap.md).
1172
+
1173
+ If you are upgrading from an early `v0.1`-style or other pre-`1.0` setup, see the published migration guide: [1.0 migration notes](../../docs/1.0-migration-notes.md).
@@ -1,5 +1,5 @@
1
1
  import { t as RulesetLoadError } from "../ruleset-load-error-CogUOC7W.mjs";
2
- import { a as enforceThreshold, i as OpenApiLintThresholdError, n as createOpenApiLintRuntime, o as shouldFail, t as OpenApiLintArtifactWriteError } from "../runtime-4LlfDIZv.mjs";
2
+ import { a as enforceThreshold, i as OpenApiLintThresholdError, n as createOpenApiLintRuntime, o as shouldFail, t as OpenApiLintArtifactWriteError } from "../runtime-PGHAFx-E.mjs";
3
3
  import { n as loadResolvedRuleset, r as loadRuleset, t as defaultRulesetResolvers } from "../load-ruleset-CiikrzWx.mjs";
4
4
  import { t as lintOpenApi } from "../lint-openapi-D76sC7S5.mjs";
5
5
  export { OpenApiLintArtifactWriteError, OpenApiLintThresholdError, RulesetLoadError, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, lintOpenApi, loadResolvedRuleset, loadRuleset, shouldFail };
package/dist/index.d.mts CHANGED
@@ -77,4 +77,4 @@ declare const loadRuleset: (input?: RulesetResolverInput, baseDirOrOptions?: str
77
77
  declare const loadResolvedRuleset: (input?: RulesetResolverInput, baseDirOrOptions?: string | LoadResolvedRulesetOptions) => Promise<LoadedRuleset>;
78
78
  declare const lintOpenApi: (spec: Record<string, unknown>, ruleset: RulesetDefinition) => Promise<LintRunResult>;
79
79
  //#endregion
80
- export { ArtifactWriteFailureMode, LintFinding, LintRunResult, LintRunSource, LintSeverity, type LoadResolvedRulesetOptions, type LoadedRuleset, OpenApiLintArtifactWriteError, OpenApiLintArtifacts, OpenApiLintRuntime, OpenApiLintRuntimeFailure, OpenApiLintRuntimeStatus, OpenApiLintSink, OpenApiLintSinkContext, OpenApiLintThresholdError, PresetName, type ResolvedRulesetCandidate, RulesetLoadError, type RulesetResolver, type RulesetResolverContext, type RulesetResolverInput, SeverityThreshold, SpectralLogger, SpectralPluginOptions, StartupLintMode, createOpenApiLintRuntime, enforceThreshold, lintOpenApi, loadResolvedRuleset, loadRuleset, presets, recommended, server, shouldFail, spectralPlugin, strict };
80
+ export { type ArtifactWriteFailureMode, type LintFinding, type LintRunResult, type LintRunSource, type LintSeverity, type LoadResolvedRulesetOptions, type LoadedRuleset, OpenApiLintArtifactWriteError, type OpenApiLintArtifacts, type OpenApiLintRuntime, type OpenApiLintRuntimeFailure, type OpenApiLintRuntimeStatus, type OpenApiLintSink, type OpenApiLintSinkContext, OpenApiLintThresholdError, type PresetName, type ResolvedRulesetCandidate, RulesetLoadError, type RulesetResolver, type RulesetResolverContext, type RulesetResolverInput, type SeverityThreshold, type SpectralLogger, type SpectralPluginOptions, type StartupLintMode, createOpenApiLintRuntime, enforceThreshold, lintOpenApi, loadResolvedRuleset, loadRuleset, presets, recommended, server, shouldFail, spectralPlugin, strict };
package/dist/index.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  import { t as RulesetLoadError } from "./ruleset-load-error-CogUOC7W.mjs";
2
- import { a as enforceThreshold, i as OpenApiLintThresholdError, n as createOpenApiLintRuntime, o as shouldFail, r as resolveStartupMode, s as resolveReporter, t as OpenApiLintArtifactWriteError } from "./runtime-4LlfDIZv.mjs";
2
+ import { a as enforceThreshold, i as OpenApiLintThresholdError, n as createOpenApiLintRuntime, o as shouldFail, r as resolveStartupMode, s as resolveReporter, t as OpenApiLintArtifactWriteError } from "./runtime-PGHAFx-E.mjs";
3
3
  import { t as recommended } from "./recommended-DgrTqq-3.mjs";
4
4
  import { i as server, r as strict, t as presets } from "./presets-CCfU_diN.mjs";
5
5
  import { Elysia } from "elysia";
6
- //#region \0inline-text:3.mjs
7
- var _inline_text_3_default = "(() => {\n const THEME_KEY = 'elysia-spectral-theme';\n const THEMES = ['astro', 'elysia', 'tron', '808'];\n let stored = null;\n try {\n stored = localStorage.getItem(THEME_KEY);\n } catch {}\n const initial = THEMES.includes(stored) ? stored : 'astro';\n document.documentElement.dataset.theme = initial;\n\n const themeSel = document.querySelector('[data-theme-switcher]');\n if (themeSel) {\n themeSel.value = initial;\n themeSel.addEventListener('change', () => {\n const next = THEMES.includes(themeSel.value) ? themeSel.value : 'astro';\n document.documentElement.dataset.theme = next;\n try {\n localStorage.setItem(THEME_KEY, next);\n } catch {}\n });\n }\n\n const rel = (iso) => {\n const t = Date.parse(iso);\n if (Number.isNaN(t)) return '';\n const s = Math.round((Date.now() - t) / 1000);\n if (s < 60) return s + 's ago';\n if (s < 3600) return Math.round(s / 60) + 'm ago';\n if (s < 86400) return Math.round(s / 3600) + 'h ago';\n return Math.round(s / 86400) + 'd ago';\n };\n for (const el of document.querySelectorAll('[data-relative-time]')) {\n el.textContent = rel(el.getAttribute('data-relative-time'));\n }\n\n const rows = Array.from(document.querySelectorAll('[data-findings] tr'));\n const search = document.querySelector('[data-search]');\n const empty = document.querySelector('[data-empty-findings]');\n const chips = Array.from(document.querySelectorAll('[data-filter]'));\n let activeSeverity = 'all';\n let query = '';\n\n const apply = () => {\n let visible = 0;\n for (const tr of rows) {\n const sev = tr.getAttribute('data-severity');\n const hay = tr.getAttribute('data-haystack') || '';\n const sevOk = activeSeverity === 'all' || sev === activeSeverity;\n const qOk = !query || hay.includes(query);\n const show = sevOk && qOk;\n tr.classList.toggle('is-hidden', !show);\n if (show) visible += 1;\n }\n if (empty)\n empty.classList.toggle('hidden', visible !== 0 || rows.length === 0);\n };\n\n for (const chip of chips) {\n const select = () => {\n activeSeverity = chip.getAttribute('data-filter') || 'all';\n for (const c of chips) c.classList.toggle('is-active', c === chip);\n apply();\n };\n chip.addEventListener('click', select);\n chip.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n select();\n }\n });\n }\n\n if (search) {\n search.addEventListener('input', () => {\n query = search.value.trim().toLowerCase();\n apply();\n });\n }\n\n for (const btn of document.querySelectorAll('[data-copy]')) {\n btn.addEventListener('click', async () => {\n const value = btn.getAttribute('data-copy') || '';\n try {\n await navigator.clipboard.writeText(value);\n btn.classList.add('copied');\n btn.textContent = 'copied';\n setTimeout(() => {\n btn.classList.remove('copied');\n btn.textContent = 'copy';\n }, 1200);\n } catch {}\n });\n }\n\n document.addEventListener('keydown', (e) => {\n if (\n e.target &&\n (e.target.tagName === 'INPUT' ||\n e.target.tagName === 'TEXTAREA' ||\n e.target.tagName === 'SELECT')\n )\n return;\n if (e.key === 'r') {\n const link = document.querySelector('[data-refresh]');\n if (link) link.click();\n }\n if (e.key === '/' && search) {\n e.preventDefault();\n search.focus();\n }\n });\n})();\n";
8
- //#endregion
9
6
  //#region \0inline-text:2.mjs
10
- var _inline_text_2_default = ":root {\n color-scheme: dark;\n --bg: #0e1018;\n --fg: #eef0f9;\n --muted: #8a93a0;\n --line: #262a33;\n --surface: #161922;\n --surface-2: #1d2027;\n --accent: #ad5dca;\n --accent-soft: #2b7eca;\n --accent-2: #2dd4bf;\n --pass: #23d18b;\n --fail: #dc3657;\n --warn: #ffc368;\n --info: #54b9ff;\n --hint: #545864;\n --grid-line: rgba(255, 255, 255, 0.04);\n --grid-size: 2rem;\n --mono:\n \"IBM Plex Mono\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,\n \"Liberation Mono\", \"Courier New\", monospace;\n --display: \"Bangers\", var(--mono);\n}\n\n[data-theme=\"elysia\"] {\n --bg: oklch(21.2% 0.019 322.12);\n --fg: oklch(98.5% 0 0);\n --muted: oklch(71.1% 0.019 323.02);\n --line: oklch(36.4% 0.029 323.89);\n --surface: oklch(26.3% 0.024 320.12);\n --surface-2: oklch(36.4% 0.029 323.89);\n --accent: oklch(89.2% 0.058 10.001);\n --accent-soft: oklch(43.5% 0.029 321.78);\n --accent-2: #d2a8ff;\n --pass: #a3ffa9;\n --fail: #ffa3a3;\n --warn: #fffca3;\n --info: #a5d6ff;\n --hint: oklch(54.2% 0.034 322.5);\n --grid-line: rgba(242, 184, 235, 0.05);\n}\n\n[data-theme=\"tron\"] {\n --bg: #06080d;\n --fg: #e6f7ff;\n --muted: #5d8aa3;\n --line: #0e2c3d;\n --surface: #0a1320;\n --surface-2: #0d1a2b;\n --accent: #38f3ff;\n --accent-soft: #1ea7ff;\n --accent-2: #38f3ff;\n --pass: #7de8ff;\n --fail: #ff3c5f;\n --warn: #a8f5ff;\n --info: #73d8ff;\n --hint: #273746;\n --grid-line: rgba(56, 243, 255, 0.06);\n}\n\n[data-theme=\"808\"] {\n --bg: #1a1a1a;\n --fg: #ffffff;\n --muted: #9a958a;\n --line: #2a2a2a;\n --surface: #222222;\n --surface-2: #2a2a2a;\n --accent: #f8a125;\n --accent-soft: #e72e2e;\n --accent-2: #f1f827;\n --pass: #f1f827;\n --fail: #e72e2e;\n --warn: #f8a125;\n --info: #f1f827;\n --hint: #404040;\n --grid-line: rgba(248, 161, 37, 0.06);\n}\n\n* {\n box-sizing: border-box;\n}\n\nhtml {\n background:\n radial-gradient(\n circle at top left,\n color-mix(in srgb, var(--accent) 14%, transparent),\n transparent 32%\n ),\n radial-gradient(\n circle at top right,\n color-mix(in srgb, var(--accent-2) 10%, transparent),\n transparent 30%\n ),\n var(--bg);\n min-height: 100%;\n}\n\nbody {\n margin: 0;\n font-family: var(--mono);\n background:\n linear-gradient(\n 180deg,\n color-mix(in srgb, var(--bg) 92%, transparent),\n transparent 16rem\n ),\n linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),\n linear-gradient(var(--grid-line) 1px, transparent 1px), transparent;\n background-size:\n auto,\n var(--grid-size) var(--grid-size),\n var(--grid-size) var(--grid-size),\n auto;\n background-position:\n 0 0,\n center top,\n center top,\n 0 0;\n color: var(--fg);\n font-size: 13px;\n line-height: 1.55;\n}\n\nheader {\n position: sticky;\n top: 0;\n z-index: 10;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 18px 24px;\n background: color-mix(in srgb, var(--bg) 82%, transparent);\n backdrop-filter: blur(16px);\n box-shadow: 0 1px 0 color-mix(in srgb, var(--fg) 8%, transparent);\n overflow: hidden;\n}\n\nheader::before {\n content: \"\";\n position: absolute;\n inset: 0;\n z-index: 0;\n background:\n radial-gradient(900px 220px at 12% -30%, var(--accent), transparent 60%),\n radial-gradient(\n 700px 200px at 88% -20%,\n var(--accent-soft),\n transparent 65%\n );\n opacity: 0.35;\n pointer-events: none;\n}\n\nheader::after {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n height: 1px;\n background: linear-gradient(\n 90deg,\n transparent,\n color-mix(in srgb, var(--accent) 55%, transparent),\n transparent\n );\n}\n\nheader > * {\n position: relative;\n z-index: 1;\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.dot {\n width: 10px;\n height: 10px;\n background: linear-gradient(135deg, var(--accent), var(--accent-2));\n box-shadow:\n 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent),\n 0 0 18px color-mix(in srgb, var(--accent) 60%, transparent);\n}\n\nh1 {\n margin: 0;\n font-family: var(--display);\n font-size: 22px;\n font-weight: 400;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n text-shadow: 0.04em 0.04em 0\n color-mix(in srgb, var(--accent) 22%, transparent);\n}\n\n.actions {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.visually-hidden {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n}\n\n.theme-switch {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 4px 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--surface) 85%, transparent);\n}\n\n.theme-label {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: var(--muted);\n font-weight: 600;\n}\n\n.theme-switch select {\n font-family: var(--mono);\n font-size: 11px;\n padding: 4px 22px 4px 8px;\n border: 1px solid var(--line);\n border-radius: 0;\n background: var(--surface-2)\n url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='none' stroke='%238a93a0' stroke-width='1.4' d='M1 1l4 4 4-4'/></svg>\")\n no-repeat right 8px center;\n color: var(--fg);\n -webkit-appearance: none;\n appearance: none;\n cursor: pointer;\n}\n.theme-switch select:hover {\n border-color: var(--accent);\n}\n.theme-switch select:focus {\n outline: none;\n border-color: var(--accent);\n}\n\nkbd {\n font-family: var(--mono);\n font-size: 11px;\n padding: 3px 7px;\n border: 1px solid var(--line);\n border-bottom-width: 2px;\n border-radius: 0;\n color: var(--muted);\n background: var(--surface);\n}\n\n.refresh {\n color: var(--fg);\n text-decoration: none;\n padding: 7px 14px;\n border: 1px solid var(--line);\n border-radius: 0;\n font-size: 12px;\n font-family: var(--mono);\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n background: var(--surface);\n box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);\n transition:\n border-color 0.15s,\n transform 0.05s,\n color 0.15s;\n}\n.refresh:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.refresh:active {\n transform: translateY(1px);\n}\n\nmain {\n padding: 28px 24px 64px;\n max-width: 1100px;\n margin: 0 auto;\n}\n\n.banner {\n position: relative;\n padding: 16px 18px;\n border-radius: 0;\n margin-bottom: 20px;\n font-size: 13px;\n border: 1px solid var(--line);\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--surface) 80%, transparent),\n color-mix(in srgb, var(--bg) 92%, transparent)\n );\n box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);\n}\n.banner::before {\n content: \"\";\n position: absolute;\n left: 0;\n top: 0;\n bottom: 0;\n width: 3px;\n background: var(--accent);\n}\n.banner-pass::before {\n background: var(--pass);\n}\n.banner-fail::before {\n background: var(--fail);\n}\n.banner-pass {\n border-color: color-mix(in srgb, var(--pass) 45%, var(--line));\n}\n.banner-fail {\n border-color: color-mix(in srgb, var(--fail) 45%, var(--line));\n}\n.banner strong {\n font-family: var(--display);\n font-weight: 400;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n font-size: 16px;\n margin-right: 6px;\n}\n.tagline {\n display: block;\n margin-top: 8px;\n font-weight: 700;\n letter-spacing: 0.16em;\n color: var(--pass);\n text-transform: uppercase;\n font-size: 11px;\n}\n\n.meta {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n gap: 10px;\n margin: 0 0 20px;\n padding: 0;\n}\n.meta div {\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--surface) 90%, var(--accent) 10%),\n color-mix(in srgb, var(--bg) 96%, transparent)\n );\n border: 1px solid var(--line);\n border-radius: 0;\n padding: 10px 14px;\n box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);\n}\n.meta dt {\n color: var(--muted);\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n margin-bottom: 4px;\n font-weight: 600;\n}\n.meta dd {\n margin: 0;\n font-size: 12px;\n}\n.muted-line {\n display: block;\n color: var(--muted);\n font-size: 11px;\n margin-top: 2px;\n}\n\n.summary {\n list-style: none;\n padding: 0;\n margin: 0 0 28px;\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n.summary li {\n padding: 7px 14px;\n border: 1px solid var(--line);\n border-radius: 0;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n background: color-mix(in srgb, var(--surface) 80%, transparent);\n transition:\n border-color 0.12s,\n background 0.12s,\n color 0.12s;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n font-weight: 600;\n}\n.summary li:hover {\n border-color: var(--muted);\n color: var(--fg);\n}\n.summary li:focus {\n outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);\n outline-offset: 2px;\n}\n.summary li.is-active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n border-color: var(--accent);\n color: var(--fg);\n}\n.summary li span {\n color: var(--fg);\n font-weight: 700;\n margin-right: 8px;\n font-family: var(--display);\n letter-spacing: 0.05em;\n}\n.summary .sev-error span {\n color: var(--fail);\n}\n.summary .sev-warn span {\n color: var(--warn);\n}\n.summary .sev-info span {\n color: var(--info);\n}\n\nsection {\n margin-top: 28px;\n}\nh2 {\n font-family: var(--display);\n font-size: 14px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--fg);\n margin: 0 0 14px;\n font-weight: 400;\n}\n\n.findings-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 12px;\n}\n.findings-head h2 {\n margin: 0;\n}\n\n[data-search] {\n font-family: var(--mono);\n font-size: 12px;\n padding: 8px 12px;\n min-width: 260px;\n background: var(--surface);\n color: var(--fg);\n border: 1px solid var(--line);\n border-radius: 0;\n}\n[data-search]:focus {\n outline: none;\n border-color: var(--accent);\n}\n\n.artifacts {\n list-style: none;\n padding: 0;\n margin: 0;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--surface) 70%, transparent);\n}\n.artifacts li {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n}\n.artifacts li:last-child {\n border-bottom: 0;\n}\n.artifacts code {\n color: var(--muted);\n min-width: 160px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: 600;\n font-size: 11px;\n}\n.artifacts .path {\n flex: 1;\n word-break: break-all;\n}\n\n.copy {\n font-family: var(--mono);\n font-size: 10px;\n padding: 4px 10px;\n border: 1px solid var(--line);\n border-radius: 0;\n background: var(--surface);\n color: var(--muted);\n cursor: pointer;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n font-weight: 600;\n}\n.copy:hover {\n color: var(--fg);\n border-color: var(--accent);\n}\n.copy.copied {\n color: var(--pass);\n border-color: var(--pass);\n}\n\ntable {\n width: 100%;\n border-collapse: collapse;\n font-size: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--surface) 70%, transparent);\n}\nth,\ntd {\n text-align: left;\n padding: 10px 14px;\n border-bottom: 1px solid var(--line);\n vertical-align: top;\n}\ntr:last-child td {\n border-bottom: 0;\n}\nth {\n color: var(--muted);\n font-weight: 600;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n background: color-mix(in srgb, var(--surface-2) 70%, transparent);\n}\ntbody tr:hover {\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n}\ntr.is-hidden {\n display: none;\n}\n\n.badge {\n display: inline-block;\n padding: 3px 9px;\n border-radius: 0;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n font-family: var(--mono);\n font-weight: 700;\n border: 1px solid transparent;\n}\n.sev-error .badge {\n background: color-mix(in srgb, var(--fail) 18%, transparent);\n color: var(--fail);\n border-color: color-mix(in srgb, var(--fail) 35%, transparent);\n}\n.sev-warn .badge {\n background: color-mix(in srgb, var(--warn) 18%, transparent);\n color: var(--warn);\n border-color: color-mix(in srgb, var(--warn) 35%, transparent);\n}\n.sev-info .badge {\n background: color-mix(in srgb, var(--info) 18%, transparent);\n color: var(--info);\n border-color: color-mix(in srgb, var(--info) 35%, transparent);\n}\n.sev-hint .badge {\n background: color-mix(in srgb, var(--hint) 22%, transparent);\n color: var(--hint);\n border-color: color-mix(in srgb, var(--hint) 35%, transparent);\n}\n\n.recommendation {\n margin: 6px 0 0;\n color: var(--muted);\n font-size: 11px;\n}\n.pointer {\n display: block;\n margin-top: 6px;\n color: var(--muted);\n font-size: 11px;\n}\n.empty {\n color: var(--muted);\n font-size: 12px;\n}\n.hidden {\n display: none;\n}\n\n:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n}\n";
7
+ var _inline_text_2_default = "(() => {\n const THEME_KEY = 'elysia-spectral-theme';\n const THEMES = ['astro', 'elysia', 'tron', '808'];\n let stored = null;\n try {\n stored = localStorage.getItem(THEME_KEY);\n } catch {}\n const initial = THEMES.includes(stored) ? stored : 'astro';\n document.documentElement.dataset.theme = initial;\n\n const themeSel = document.querySelector('[data-theme-switcher]');\n if (themeSel) {\n themeSel.value = initial;\n themeSel.addEventListener('change', () => {\n const next = THEMES.includes(themeSel.value) ? themeSel.value : 'astro';\n document.documentElement.dataset.theme = next;\n try {\n localStorage.setItem(THEME_KEY, next);\n } catch {}\n });\n }\n\n const rel = (iso) => {\n const t = Date.parse(iso);\n if (Number.isNaN(t)) return '';\n const s = Math.round((Date.now() - t) / 1000);\n if (s < 60) return s + 's ago';\n if (s < 3600) return Math.round(s / 60) + 'm ago';\n if (s < 86400) return Math.round(s / 3600) + 'h ago';\n return Math.round(s / 86400) + 'd ago';\n };\n for (const el of document.querySelectorAll('[data-relative-time]')) {\n el.textContent = rel(el.getAttribute('data-relative-time'));\n }\n\n const rows = Array.from(document.querySelectorAll('[data-findings] tr'));\n const search = document.querySelector('[data-search]');\n const empty = document.querySelector('[data-empty-findings]');\n const chips = Array.from(document.querySelectorAll('[data-filter]'));\n let activeSeverity = 'all';\n let query = '';\n\n const apply = () => {\n let visible = 0;\n for (const tr of rows) {\n const sev = tr.getAttribute('data-severity');\n const hay = tr.getAttribute('data-haystack') || '';\n const sevOk = activeSeverity === 'all' || sev === activeSeverity;\n const qOk = !query || hay.includes(query);\n const show = sevOk && qOk;\n tr.classList.toggle('is-hidden', !show);\n if (show) visible += 1;\n }\n if (empty)\n empty.classList.toggle('hidden', visible !== 0 || rows.length === 0);\n };\n\n for (const chip of chips) {\n const select = () => {\n activeSeverity = chip.getAttribute('data-filter') || 'all';\n for (const c of chips) c.classList.toggle('is-active', c === chip);\n apply();\n };\n chip.addEventListener('click', select);\n chip.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n select();\n }\n });\n }\n\n if (search) {\n search.addEventListener('input', () => {\n query = search.value.trim().toLowerCase();\n apply();\n });\n }\n\n for (const btn of document.querySelectorAll('[data-copy]')) {\n btn.addEventListener('click', async () => {\n const value = btn.getAttribute('data-copy') || '';\n try {\n await navigator.clipboard.writeText(value);\n btn.classList.add('copied');\n btn.textContent = 'copied';\n setTimeout(() => {\n btn.classList.remove('copied');\n btn.textContent = 'copy';\n }, 1200);\n } catch {}\n });\n }\n\n document.addEventListener('keydown', (e) => {\n if (\n e.target &&\n (e.target.tagName === 'INPUT' ||\n e.target.tagName === 'TEXTAREA' ||\n e.target.tagName === 'SELECT')\n )\n return;\n if (e.key === 'r') {\n const link = document.querySelector('[data-refresh]');\n if (link) link.click();\n }\n if (e.key === '/' && search) {\n e.preventDefault();\n search.focus();\n }\n });\n})();\n";
8
+ //#endregion
9
+ //#region \0inline-text:3.mjs
10
+ var _inline_text_3_default = ":root {\n color-scheme: dark;\n --bg: #0e1018;\n --fg: #eef0f9;\n --muted: #8a93a0;\n --line: #262a33;\n --surface: #161922;\n --surface-2: #1d2027;\n --accent: #ad5dca;\n --accent-soft: #2b7eca;\n --accent-2: #2dd4bf;\n --pass: #23d18b;\n --fail: #dc3657;\n --warn: #ffc368;\n --info: #54b9ff;\n --hint: #545864;\n --grid-line: rgba(255, 255, 255, 0.04);\n --grid-size: 2rem;\n --mono:\n \"IBM Plex Mono\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,\n \"Liberation Mono\", \"Courier New\", monospace;\n --display: \"Bangers\", var(--mono);\n}\n\n[data-theme=\"elysia\"] {\n --bg: oklch(21.2% 0.019 322.12);\n --fg: oklch(98.5% 0 0);\n --muted: oklch(71.1% 0.019 323.02);\n --line: oklch(36.4% 0.029 323.89);\n --surface: oklch(26.3% 0.024 320.12);\n --surface-2: oklch(36.4% 0.029 323.89);\n --accent: oklch(89.2% 0.058 10.001);\n --accent-soft: oklch(43.5% 0.029 321.78);\n --accent-2: #d2a8ff;\n --pass: #a3ffa9;\n --fail: #ffa3a3;\n --warn: #fffca3;\n --info: #a5d6ff;\n --hint: oklch(54.2% 0.034 322.5);\n --grid-line: rgba(242, 184, 235, 0.05);\n}\n\n[data-theme=\"tron\"] {\n --bg: #06080d;\n --fg: #e6f7ff;\n --muted: #5d8aa3;\n --line: #0e2c3d;\n --surface: #0a1320;\n --surface-2: #0d1a2b;\n --accent: #38f3ff;\n --accent-soft: #1ea7ff;\n --accent-2: #38f3ff;\n --pass: #7de8ff;\n --fail: #ff3c5f;\n --warn: #a8f5ff;\n --info: #73d8ff;\n --hint: #273746;\n --grid-line: rgba(56, 243, 255, 0.06);\n}\n\n[data-theme=\"808\"] {\n --bg: #1a1a1a;\n --fg: #ffffff;\n --muted: #9a958a;\n --line: #2a2a2a;\n --surface: #222222;\n --surface-2: #2a2a2a;\n --accent: #f8a125;\n --accent-soft: #e72e2e;\n --accent-2: #f1f827;\n --pass: #f1f827;\n --fail: #e72e2e;\n --warn: #f8a125;\n --info: #f1f827;\n --hint: #404040;\n --grid-line: rgba(248, 161, 37, 0.06);\n}\n\n* {\n box-sizing: border-box;\n}\n\nhtml {\n background:\n radial-gradient(\n circle at top left,\n color-mix(in srgb, var(--accent) 14%, transparent),\n transparent 32%\n ),\n radial-gradient(\n circle at top right,\n color-mix(in srgb, var(--accent-2) 10%, transparent),\n transparent 30%\n ),\n var(--bg);\n min-height: 100%;\n}\n\nbody {\n margin: 0;\n font-family: var(--mono);\n background:\n linear-gradient(\n 180deg,\n color-mix(in srgb, var(--bg) 92%, transparent),\n transparent 16rem\n ),\n linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),\n linear-gradient(var(--grid-line) 1px, transparent 1px), transparent;\n background-size:\n auto,\n var(--grid-size) var(--grid-size),\n var(--grid-size) var(--grid-size),\n auto;\n background-position:\n 0 0,\n center top,\n center top,\n 0 0;\n color: var(--fg);\n font-size: 13px;\n line-height: 1.55;\n}\n\nheader {\n position: sticky;\n top: 0;\n z-index: 10;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 18px 24px;\n background: color-mix(in srgb, var(--bg) 82%, transparent);\n backdrop-filter: blur(16px);\n box-shadow: 0 1px 0 color-mix(in srgb, var(--fg) 8%, transparent);\n overflow: hidden;\n}\n\nheader::before {\n content: \"\";\n position: absolute;\n inset: 0;\n z-index: 0;\n background:\n radial-gradient(900px 220px at 12% -30%, var(--accent), transparent 60%),\n radial-gradient(\n 700px 200px at 88% -20%,\n var(--accent-soft),\n transparent 65%\n );\n opacity: 0.35;\n pointer-events: none;\n}\n\nheader::after {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n height: 1px;\n background: linear-gradient(\n 90deg,\n transparent,\n color-mix(in srgb, var(--accent) 55%, transparent),\n transparent\n );\n}\n\nheader > * {\n position: relative;\n z-index: 1;\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.dot {\n width: 10px;\n height: 10px;\n background: linear-gradient(135deg, var(--accent), var(--accent-2));\n box-shadow:\n 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent),\n 0 0 18px color-mix(in srgb, var(--accent) 60%, transparent);\n}\n\nh1 {\n margin: 0;\n font-family: var(--display);\n font-size: 22px;\n font-weight: 400;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n text-shadow: 0.04em 0.04em 0\n color-mix(in srgb, var(--accent) 22%, transparent);\n}\n\n.actions {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.visually-hidden {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n}\n\n.theme-switch {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 4px 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--surface) 85%, transparent);\n}\n\n.theme-label {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: var(--muted);\n font-weight: 600;\n}\n\n.theme-switch select {\n font-family: var(--mono);\n font-size: 11px;\n padding: 4px 22px 4px 8px;\n border: 1px solid var(--line);\n border-radius: 0;\n background: var(--surface-2)\n url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='none' stroke='%238a93a0' stroke-width='1.4' d='M1 1l4 4 4-4'/></svg>\")\n no-repeat right 8px center;\n color: var(--fg);\n -webkit-appearance: none;\n appearance: none;\n cursor: pointer;\n}\n.theme-switch select:hover {\n border-color: var(--accent);\n}\n.theme-switch select:focus {\n outline: none;\n border-color: var(--accent);\n}\n\nkbd {\n font-family: var(--mono);\n font-size: 11px;\n padding: 3px 7px;\n border: 1px solid var(--line);\n border-bottom-width: 2px;\n border-radius: 0;\n color: var(--muted);\n background: var(--surface);\n}\n\n.refresh {\n color: var(--fg);\n text-decoration: none;\n padding: 7px 14px;\n border: 1px solid var(--line);\n border-radius: 0;\n font-size: 12px;\n font-family: var(--mono);\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n background: var(--surface);\n box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);\n transition:\n border-color 0.15s,\n transform 0.05s,\n color 0.15s;\n}\n.refresh:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.refresh:active {\n transform: translateY(1px);\n}\n\nmain {\n padding: 28px 24px 64px;\n max-width: 1100px;\n margin: 0 auto;\n}\n\n.banner {\n position: relative;\n padding: 16px 18px;\n border-radius: 0;\n margin-bottom: 20px;\n font-size: 13px;\n border: 1px solid var(--line);\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--surface) 80%, transparent),\n color-mix(in srgb, var(--bg) 92%, transparent)\n );\n box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);\n}\n.banner::before {\n content: \"\";\n position: absolute;\n left: 0;\n top: 0;\n bottom: 0;\n width: 3px;\n background: var(--accent);\n}\n.banner-pass::before {\n background: var(--pass);\n}\n.banner-fail::before {\n background: var(--fail);\n}\n.banner-pass {\n border-color: color-mix(in srgb, var(--pass) 45%, var(--line));\n}\n.banner-fail {\n border-color: color-mix(in srgb, var(--fail) 45%, var(--line));\n}\n.banner strong {\n font-family: var(--display);\n font-weight: 400;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n font-size: 16px;\n margin-right: 6px;\n}\n.tagline {\n display: block;\n margin-top: 8px;\n font-weight: 700;\n letter-spacing: 0.16em;\n color: var(--pass);\n text-transform: uppercase;\n font-size: 11px;\n}\n\n.meta {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n gap: 10px;\n margin: 0 0 20px;\n padding: 0;\n}\n.meta div {\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--surface) 90%, var(--accent) 10%),\n color-mix(in srgb, var(--bg) 96%, transparent)\n );\n border: 1px solid var(--line);\n border-radius: 0;\n padding: 10px 14px;\n box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);\n}\n.meta dt {\n color: var(--muted);\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n margin-bottom: 4px;\n font-weight: 600;\n}\n.meta dd {\n margin: 0;\n font-size: 12px;\n}\n.muted-line {\n display: block;\n color: var(--muted);\n font-size: 11px;\n margin-top: 2px;\n}\n\n.summary {\n list-style: none;\n padding: 0;\n margin: 0 0 28px;\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n.summary li {\n padding: 7px 14px;\n border: 1px solid var(--line);\n border-radius: 0;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n background: color-mix(in srgb, var(--surface) 80%, transparent);\n transition:\n border-color 0.12s,\n background 0.12s,\n color 0.12s;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n font-weight: 600;\n}\n.summary li:hover {\n border-color: var(--muted);\n color: var(--fg);\n}\n.summary li:focus {\n outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);\n outline-offset: 2px;\n}\n.summary li.is-active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n border-color: var(--accent);\n color: var(--fg);\n}\n.summary li span {\n color: var(--fg);\n font-weight: 700;\n margin-right: 8px;\n font-family: var(--display);\n letter-spacing: 0.05em;\n}\n.summary .sev-error span {\n color: var(--fail);\n}\n.summary .sev-warn span {\n color: var(--warn);\n}\n.summary .sev-info span {\n color: var(--info);\n}\n\nsection {\n margin-top: 28px;\n}\nh2 {\n font-family: var(--display);\n font-size: 14px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--fg);\n margin: 0 0 14px;\n font-weight: 400;\n}\n\n.findings-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 12px;\n}\n.findings-head h2 {\n margin: 0;\n}\n\n[data-search] {\n font-family: var(--mono);\n font-size: 12px;\n padding: 8px 12px;\n min-width: 260px;\n background: var(--surface);\n color: var(--fg);\n border: 1px solid var(--line);\n border-radius: 0;\n}\n[data-search]:focus {\n outline: none;\n border-color: var(--accent);\n}\n\n.artifacts {\n list-style: none;\n padding: 0;\n margin: 0;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--surface) 70%, transparent);\n}\n.artifacts li {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n}\n.artifacts li:last-child {\n border-bottom: 0;\n}\n.artifacts code {\n color: var(--muted);\n min-width: 160px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: 600;\n font-size: 11px;\n}\n.artifacts .path {\n flex: 1;\n word-break: break-all;\n}\n\n.copy {\n font-family: var(--mono);\n font-size: 10px;\n padding: 4px 10px;\n border: 1px solid var(--line);\n border-radius: 0;\n background: var(--surface);\n color: var(--muted);\n cursor: pointer;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n font-weight: 600;\n}\n.copy:hover {\n color: var(--fg);\n border-color: var(--accent);\n}\n.copy.copied {\n color: var(--pass);\n border-color: var(--pass);\n}\n\ntable {\n width: 100%;\n border-collapse: collapse;\n font-size: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--surface) 70%, transparent);\n}\nth,\ntd {\n text-align: left;\n padding: 10px 14px;\n border-bottom: 1px solid var(--line);\n vertical-align: top;\n}\ntr:last-child td {\n border-bottom: 0;\n}\nth {\n color: var(--muted);\n font-weight: 600;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n background: color-mix(in srgb, var(--surface-2) 70%, transparent);\n}\ntbody tr:hover {\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n}\ntr.is-hidden {\n display: none;\n}\n\n.badge {\n display: inline-block;\n padding: 3px 9px;\n border-radius: 0;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n font-family: var(--mono);\n font-weight: 700;\n border: 1px solid transparent;\n}\n.sev-error .badge {\n background: color-mix(in srgb, var(--fail) 18%, transparent);\n color: var(--fail);\n border-color: color-mix(in srgb, var(--fail) 35%, transparent);\n}\n.sev-warn .badge {\n background: color-mix(in srgb, var(--warn) 18%, transparent);\n color: var(--warn);\n border-color: color-mix(in srgb, var(--warn) 35%, transparent);\n}\n.sev-info .badge {\n background: color-mix(in srgb, var(--info) 18%, transparent);\n color: var(--info);\n border-color: color-mix(in srgb, var(--info) 35%, transparent);\n}\n.sev-hint .badge {\n background: color-mix(in srgb, var(--hint) 22%, transparent);\n color: var(--hint);\n border-color: color-mix(in srgb, var(--hint) 35%, transparent);\n}\n\n.recommendation {\n margin: 6px 0 0;\n color: var(--muted);\n font-size: 11px;\n}\n.pointer {\n display: block;\n margin-top: 6px;\n color: var(--muted);\n font-size: 11px;\n}\n.empty {\n color: var(--muted);\n font-size: 12px;\n}\n.hidden {\n display: none;\n}\n\n:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n}\n";
11
11
  //#endregion
12
12
  //#region src/output/dashboard.ts
13
13
  const renderDashboard = (input) => {
@@ -22,7 +22,7 @@ const renderDashboard = (input) => {
22
22
  <link rel="preconnect" href="https://fonts.googleapis.com" />
23
23
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
24
24
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bangers&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" />
25
- <style>${_inline_text_2_default}</style>
25
+ <style>${_inline_text_3_default}</style>
26
26
  </head>
27
27
  <body>
28
28
  <header>
@@ -45,7 +45,7 @@ const renderDashboard = (input) => {
45
45
  </div>
46
46
  </header>
47
47
  <main>${body}</main>
48
- <script>${_inline_text_3_default}<\/script>
48
+ <script>${_inline_text_2_default}<\/script>
49
49
  </body>
50
50
  </html>`;
51
51
  };
@@ -411,6 +411,11 @@ const toSarifArtifactUri = (value) => {
411
411
  };
412
412
  //#endregion
413
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
+ };
414
419
  const createOutputSinks = (options) => {
415
420
  const reporter = resolveReporter(options.logger);
416
421
  const sinks = [];
@@ -421,24 +426,17 @@ const createOutputSinks = (options) => {
421
426
  if (configuredSpecSnapshotPath) sinks.push({
422
427
  name: "spec snapshot",
423
428
  kind: "artifact",
429
+ phase: "pre-finalize",
424
430
  async write(_result, context) {
425
431
  const writtenSpecSnapshotPath = await writeSpecSnapshot(configuredSpecSnapshotPath === true ? await resolveDefaultSpecSnapshotPath() : configuredSpecSnapshotPath, context.spec, options.output?.pretty !== false);
426
432
  reporter.artifact(`OpenAPI lint wrote spec snapshot to ${writtenSpecSnapshotPath}.`);
427
433
  return { specSnapshotPath: writtenSpecSnapshotPath };
428
434
  }
429
435
  });
430
- if (configuredJsonReportPath) sinks.push({
431
- name: "JSON report",
432
- kind: "artifact",
433
- async write(result) {
434
- const writtenJsonReportPath = await writeJsonReport(configuredJsonReportPath, result, options.output?.pretty !== false);
435
- reporter.artifact(`OpenAPI lint wrote JSON report to ${writtenJsonReportPath}.`);
436
- return { jsonReportPath: writtenJsonReportPath };
437
- }
438
- });
439
436
  if (configuredJunitReportPath) sinks.push({
440
437
  name: "JUnit report",
441
438
  kind: "artifact",
439
+ phase: "pre-finalize",
442
440
  async write(result) {
443
441
  const writtenJunitReportPath = await writeJunitReport(configuredJunitReportPath, result);
444
442
  reporter.artifact(`OpenAPI lint wrote JUnit report to ${writtenJunitReportPath}.`);
@@ -449,6 +447,7 @@ const createOutputSinks = (options) => {
449
447
  if (configuredBrunoCollectionPath) sinks.push({
450
448
  name: "Bruno collection",
451
449
  kind: "artifact",
450
+ phase: "pre-finalize",
452
451
  async write(_result, context) {
453
452
  const writtenPath = await writeBrunoCollection(configuredBrunoCollectionPath, context.spec);
454
453
  reporter.artifact(`OpenAPI lint wrote Bruno collection to ${writtenPath}.`);
@@ -458,6 +457,7 @@ const createOutputSinks = (options) => {
458
457
  if (configuredSarifReportPath) sinks.push({
459
458
  name: "SARIF report",
460
459
  kind: "artifact",
460
+ phase: "pre-finalize",
461
461
  async write(result) {
462
462
  const writtenSarifReportPath = await writeSarifReport(configuredSarifReportPath, result, options.output?.pretty !== false);
463
463
  reporter.artifact(`OpenAPI lint wrote SARIF report to ${writtenSarifReportPath}.`);
@@ -467,11 +467,29 @@ const createOutputSinks = (options) => {
467
467
  for (const sink of options.output?.sinks ?? []) sinks.push({
468
468
  name: sink.name,
469
469
  kind: "custom",
470
+ phase: "post-finalize",
470
471
  write: async (result, context) => await Promise.resolve(sink.write(result, context))
471
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
+ });
472
489
  if (options.output?.console !== false) sinks.push({
473
490
  name: "console",
474
491
  kind: "report",
492
+ phase: "post-finalize",
475
493
  async write(result) {
476
494
  reportToConsole(result, reporter);
477
495
  }
@@ -645,10 +663,12 @@ const createOpenApiLintRuntime = (options = {}) => {
645
663
  result.source = source;
646
664
  result.failOn = options.failOn ?? "error";
647
665
  result.ok = !shouldFail(result, result.failOn);
648
- await writeOutputSinks(result, spec, options, artifactWriteFailureMode);
649
- runtime.latest = result;
666
+ const { preFinalizeSinks, postFinalizeSinks } = partitionOutputSinks(createOutputSinks(options));
667
+ await writeOutputSinks(preFinalizeSinks, result, spec, reporter, artifactWriteFailureMode);
650
668
  finalizeRuntimeRun(runtime, startedAt);
651
669
  result.durationMs = runtime.durationMs;
670
+ await writeOutputSinks(postFinalizeSinks, result, spec, reporter, artifactWriteFailureMode);
671
+ runtime.latest = result;
652
672
  reporter.complete("OpenAPI lint completed.");
653
673
  enforceThreshold(result, options.failOn ?? "error");
654
674
  runtime.status = "passed";
@@ -694,9 +714,7 @@ const handleArtifactWriteFailure = (artifact, error, mode, reporter) => {
694
714
  if (mode === "error") throw wrappedError;
695
715
  reporter.warn(wrappedError.message);
696
716
  };
697
- const writeOutputSinks = async (result, spec, options, artifactWriteFailureMode) => {
698
- const reporter = resolveReporter(options.logger);
699
- const sinks = createOutputSinks(options);
717
+ const writeOutputSinks = async (sinks, result, spec, reporter, artifactWriteFailureMode) => {
700
718
  for (const sink of sinks) try {
701
719
  const artifacts = await sink.write(result, {
702
720
  spec,
@@ -711,6 +729,21 @@ const writeOutputSinks = async (result, spec, options, artifactWriteFailureMode)
711
729
  throw error;
712
730
  }
713
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
+ };
714
747
  const relativiseArtifacts = (artifacts) => {
715
748
  const cwd = process.cwd();
716
749
  const result = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsydyn/elysia-spectral",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Thin Elysia plugin that lints generated OpenAPI documents with Spectral.",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "publishConfig": {