model-eol 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import { matchesAnyGlob } from './glob.mjs'
6
+
7
+ export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
8
+
9
+ const stableValue = value => {
10
+ if (Array.isArray(value)) return value.map(stableValue)
11
+ if (value && typeof value === 'object') {
12
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]))
13
+ }
14
+ return value
15
+ }
16
+
17
+ export const stableJson = value => JSON.stringify(stableValue(value))
18
+
19
+ export const sortedJsonDigest = values => {
20
+ const sorted = values.slice().sort((a, b) => {
21
+ const [left, right] = [stableJson(a), stableJson(b)]
22
+ return left < right ? -1 : left > right ? 1 : 0
23
+ })
24
+ return sha256(stableJson(sorted))
25
+ }
26
+
27
+ export const itemDigest = items => sortedJsonDigest(items)
28
+
29
+ export const activeWaiver = item => item?.waiver?.active === true ? item.waiver : null
30
+
31
+ export const repoPath = (file, root) => {
32
+ const absolute = path.isAbsolute(file) ? file : path.resolve(root, file)
33
+ const relative = path.relative(root, absolute)
34
+ return relative.split(path.sep).join('/').replace(/^\.\//, '')
35
+ }
36
+
37
+ const withoutGenerated = plan => {
38
+ const { generated, ...stable } = plan
39
+ return stable
40
+ }
41
+
42
+ export const planDigest = plan => sha256(stableJson(withoutGenerated(plan)))
43
+
44
+ export const effectiveEval = (config, commandOverride = null) => ({
45
+ ...config.eval,
46
+ command: typeof commandOverride === 'string' && commandOverride.trim() ? commandOverride : config.eval.command,
47
+ })
48
+
49
+ export const evalConfigDigest = settings => sha256(stableJson({
50
+ command: settings.command ?? null,
51
+ timeout_ms: settings.timeout_ms,
52
+ max_report_bytes: settings.max_report_bytes,
53
+ pass_env: settings.pass_env,
54
+ }))
55
+
56
+ export const evalIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
57
+
58
+ export const feedContext = feedsDir => {
59
+ const records = new Map()
60
+ let files = []
61
+ try {
62
+ files = fs.readdirSync(feedsDir).filter(file => file.endsWith('.json')).sort()
63
+ } catch {
64
+ return records
65
+ }
66
+ for (const file of files) {
67
+ let feed
68
+ try {
69
+ feed = JSON.parse(fs.readFileSync(path.join(feedsDir, file), 'utf8'))
70
+ } catch {
71
+ continue
72
+ }
73
+ if (feed?.spec !== 'model-eol/0.1' || !Array.isArray(feed.models)) continue
74
+ for (const entry of feed.models) {
75
+ for (const key of [entry.id, ...(entry.aliases ?? [])]) {
76
+ if (typeof key === 'string') records.set(`${feed.publisher}\0${key}`, { feed, entry })
77
+ }
78
+ }
79
+ }
80
+ return records
81
+ }
82
+
83
+ export const contextFor = (records, publisher, id, via) => {
84
+ const record = records.get(`${publisher}\0${id}`)
85
+ if (!record) return { announced: null, notes: [], entry: null }
86
+ const distribution = via ? (record.entry.distributions ?? []).find(item => item.via === via) : null
87
+ return {
88
+ announced: distribution ? distribution.announced ?? null : record.entry.announced ?? null,
89
+ notes: [record.feed.note, record.entry.notes].filter(Boolean),
90
+ entry: record.entry,
91
+ }
92
+ }
93
+
94
+ export const ignoredModelIds = (plan, config) => {
95
+ const ignored = new Set(config.ignore.models)
96
+ const ids = new Set()
97
+ for (const item of [...plan.items, ...plan.issues]) {
98
+ if (item.id && (ignored.has(item.id) || ignored.has(item.matched))) ids.add(item.id)
99
+ }
100
+ return ids
101
+ }
102
+
103
+ export const isIgnored = (entry, root, config, ignoredIds) => {
104
+ if (entry.id && ignoredIds.has(entry.id)) return true
105
+ if (entry.id && config.ignore.models.includes(entry.id)) return true
106
+ if (entry.matched && config.ignore.models.includes(entry.matched)) return true
107
+ return matchesAnyGlob(repoPath(entry.file, root), config.ignore.paths)
108
+ }
109
+
110
+ // the capture section reads clocks outside the plan items, so they join the digest that gates body updates
111
+ const clockRows = entry => entry
112
+ ? {
113
+ shutdown: entry.shutdown ?? null,
114
+ date_precision: entry.date_precision ?? null,
115
+ distributions: itemDigest((entry.distributions ?? []).map(row => ({ via: row.via, shutdown: row.shutdown ?? null, status: row.status ?? null, date_precision: row.date_precision ?? null }))),
116
+ }
117
+ : null
118
+ export const groupDigest = (items, entry) => sha256(stableJson({ items: itemDigest(items), clocks: clockRows(entry) }))
119
+
120
+ export const modelGroups = (plan, config, root, records, decorate = () => ({})) => {
121
+ const ignoredIds = ignoredModelIds(plan, config)
122
+ const groups = new Map()
123
+ for (const item of plan.items) {
124
+ if (activeWaiver(item)) continue
125
+ if (isIgnored(item, root, config, ignoredIds)) continue
126
+ const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : plan.via
127
+ const key = evalIdentity(item.publisher, item.id, via)
128
+ if (!groups.has(key)) {
129
+ groups.set(key, {
130
+ kind: 'model',
131
+ id: item.id,
132
+ publisher: item.publisher,
133
+ items: [],
134
+ via,
135
+ ...decorate(item.publisher, item.id, via),
136
+ context: contextFor(records, item.publisher, item.id, via),
137
+ })
138
+ }
139
+ groups.get(key).items.push(item)
140
+ }
141
+ for (const group of groups.values()) {
142
+ group.items.sort((a, b) => `${a.file}:${a.line}:${a.occurrence}`.localeCompare(`${b.file}:${b.line}:${b.occurrence}`))
143
+ group.feedDigest = groupDigest(group.items, group.context?.entry)
144
+ }
145
+ return [...groups.values()].sort((a, b) => `${a.publisher}/${a.id}`.localeCompare(`${b.publisher}/${b.id}`))
146
+ }
@@ -0,0 +1,216 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ export const EVAL_RESULTS_SCHEMA = 'model-eol.eval/0.1'
6
+ export const FEED_REFRESH_RECEIPT_SCHEMA = 'model-eol/feed-refresh-receipt@0.1'
7
+ export const RELEASE_RECEIPT_SCHEMA = 'model-eol/npm-release-result@0.2'
8
+ export const VERIFICATION_SCHEMA = 'model-eol/verification@0.1'
9
+
10
+ export const SHA_PATTERN = /^[0-9a-f]{40,64}$/
11
+ export const DIGEST_PATTERN = /^[0-9a-f]{64}$/
12
+ export const RELEASE_VERSION_PATTERN = /^0\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
13
+ export const FEED_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*\.json$/
14
+
15
+ const EVAL_STATUSES = new Set(['pass', 'fail', 'timeout'])
16
+
17
+ export const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
18
+
19
+ export const assertExactKeys = (value, expected, label) => {
20
+ const actual = Object.keys(value).sort()
21
+ const wanted = [...expected].sort()
22
+ if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
23
+ throw new Error(`${label} fields must be exactly ${wanted.join(', ')}; got ${actual.join(', ')}`)
24
+ }
25
+ }
26
+
27
+ export const assertInstant = (value, label) => {
28
+ const match = typeof value === 'string'
29
+ ? /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(value)
30
+ : null
31
+ const parsed = match ? new Date(value) : null
32
+ if (!match || !Number.isFinite(parsed.getTime())
33
+ || parsed.getUTCFullYear() !== Number(match[1])
34
+ || parsed.getUTCMonth() + 1 !== Number(match[2])
35
+ || parsed.getUTCDate() !== Number(match[3])
36
+ || parsed.getUTCHours() !== Number(match[4])
37
+ || parsed.getUTCMinutes() !== Number(match[5])
38
+ || parsed.getUTCSeconds() !== Number(match[6])) {
39
+ throw new Error(`${label} must be a valid UTC ISO 8601 instant`)
40
+ }
41
+ return value
42
+ }
43
+
44
+ export const assertSha = (value, label) => {
45
+ if (typeof value !== 'string' || !SHA_PATTERN.test(value)) throw new Error(`${label} must be a 40-64 character lowercase hexadecimal Git commit`)
46
+ return value
47
+ }
48
+
49
+ export const assertDigest = (value, label) => {
50
+ if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) throw new Error(`${label} must be a 64 character lowercase hexadecimal SHA-256 digest`)
51
+ return value
52
+ }
53
+
54
+ export const assertHttpsUrl = (value, label) => {
55
+ let parsed
56
+ try {
57
+ parsed = new URL(value)
58
+ } catch {
59
+ throw new Error(`${label} must be a valid HTTPS URL`)
60
+ }
61
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new Error(`${label} must be a valid HTTPS URL`)
62
+ return parsed.toString()
63
+ }
64
+
65
+ export const assertReleaseVersion = value => {
66
+ if (typeof value !== 'string' || !RELEASE_VERSION_PATTERN.test(value)) throw new Error(`release version must be an exact stable 0.x version, got ${value}`)
67
+ return value
68
+ }
69
+
70
+ export const assertSha512Integrity = (value, label = 'integrity') => {
71
+ if (typeof value !== 'string' || !value.startsWith('sha512-')) {
72
+ throw new Error(`${label} must be an npm sha512 Subresource Integrity value`)
73
+ }
74
+ const encoded = value.slice('sha512-'.length)
75
+ let digest
76
+ try {
77
+ digest = Buffer.from(encoded, 'base64')
78
+ } catch {
79
+ digest = Buffer.alloc(0)
80
+ }
81
+ if (digest.length !== 64 || digest.toString('base64') !== encoded) {
82
+ throw new Error(`${label} must contain one canonical 64-byte SHA-512 digest`)
83
+ }
84
+ return value
85
+ }
86
+
87
+ export const sha512Integrity = bytes => `sha512-${crypto.createHash('sha512').update(bytes).digest('base64')}`
88
+
89
+ const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex')
90
+
91
+ export const hashFeedFiles = feedDir => {
92
+ const names = fs.readdirSync(feedDir).filter(name => name.endsWith('.json')).sort()
93
+ if (!names.length) throw new Error(`no JSON feeds found in ${feedDir}`)
94
+ return names.map(name => ({
95
+ path: `feeds/${name}`,
96
+ sha256: sha256(fs.readFileSync(path.join(feedDir, name))),
97
+ }))
98
+ }
99
+
100
+ export const hashFeedDirectory = repoDir => hashFeedFiles(path.join(path.resolve(repoDir), 'feeds'))
101
+
102
+ export function assertValidFeedRefreshReceipt(receipt) {
103
+ if (!isPlainObject(receipt)) throw new Error('feed refresh receipt must be a JSON object')
104
+ const keys = ['schema', 'state', 'changed', 'pending', 'checked_at', 'refresh_run', 'refresh_commit', 'feeds']
105
+ if (receipt.state === 'pending') keys.push('pending_pr')
106
+ assertExactKeys(receipt, keys, 'feed refresh receipt')
107
+ if (receipt.schema !== FEED_REFRESH_RECEIPT_SCHEMA) throw new Error(`unsupported feed refresh receipt schema ${receipt.schema}`)
108
+ if (receipt.state !== 'clean' && receipt.state !== 'pending') throw new Error('receipt state must be clean or pending')
109
+ if (typeof receipt.changed !== 'boolean' || typeof receipt.pending !== 'boolean') throw new Error('receipt changed and pending fields must be boolean')
110
+ if (receipt.changed !== (receipt.state === 'pending') || receipt.pending !== (receipt.state === 'pending')) {
111
+ throw new Error('receipt changed/pending fields do not agree with receipt state')
112
+ }
113
+ assertInstant(receipt.checked_at, 'checked_at')
114
+ assertHttpsUrl(receipt.refresh_run, 'refresh_run')
115
+ assertSha(receipt.refresh_commit, 'refresh_commit')
116
+ if (receipt.state === 'pending') assertHttpsUrl(receipt.pending_pr, 'pending_pr')
117
+ if (!Array.isArray(receipt.feeds) || !receipt.feeds.length) throw new Error('receipt feeds must be a non-empty array')
118
+ const priorPaths = new Set()
119
+ for (const [index, feed] of receipt.feeds.entries()) {
120
+ if (!isPlainObject(feed)) throw new Error(`receipt feeds[${index}] must be an object`)
121
+ assertExactKeys(feed, ['path', 'sha256'], `receipt feeds[${index}]`)
122
+ if (typeof feed.path !== 'string' || !feed.path.startsWith('feeds/') || !FEED_NAME_PATTERN.test(feed.path.slice('feeds/'.length))) {
123
+ throw new Error(`receipt feeds[${index}].path must identify one feeds/*.json file`)
124
+ }
125
+ if (priorPaths.has(feed.path)) throw new Error(`receipt contains duplicate feed path ${feed.path}`)
126
+ priorPaths.add(feed.path)
127
+ if (typeof feed.sha256 !== 'string' || !DIGEST_PATTERN.test(feed.sha256)) throw new Error(`receipt ${feed.path} has an invalid SHA-256 digest`)
128
+ }
129
+ const sorted = [...receipt.feeds].sort((left, right) => left.path.localeCompare(right.path))
130
+ if (JSON.stringify(sorted) !== JSON.stringify(receipt.feeds)) throw new Error('receipt feeds must be sorted by path')
131
+ return receipt
132
+ }
133
+
134
+ export function assertValidReleaseReceipt(receipt) {
135
+ if (!isPlainObject(receipt)) throw new Error('release receipt must be a JSON object')
136
+ if (typeof receipt.released !== 'boolean') throw new Error('release receipt released must be boolean')
137
+ assertExactKeys(receipt, receipt.released
138
+ ? ['schema', 'released', 'source_commit', 'version', 'tag', 'release_commit', 'registry_integrity']
139
+ : ['schema', 'released', 'source_commit'], 'release receipt')
140
+ if (receipt.schema !== RELEASE_RECEIPT_SCHEMA) throw new Error(`unsupported release receipt schema ${receipt.schema}`)
141
+ assertSha(receipt.source_commit, 'source_commit')
142
+ if (receipt.released) {
143
+ assertReleaseVersion(receipt.version)
144
+ if (receipt.tag !== `v${receipt.version}`) throw new Error(`release receipt tag ${receipt.tag} does not match version ${receipt.version}`)
145
+ assertSha(receipt.release_commit, 'release_commit')
146
+ assertSha512Integrity(receipt.registry_integrity, 'registry_integrity')
147
+ }
148
+ return receipt
149
+ }
150
+
151
+ export function assertValidEvalResults(artifact) {
152
+ if (!isPlainObject(artifact)) throw new Error('eval results must be a JSON object')
153
+ assertExactKeys(artifact, ['schema', 'generated', 'base_sha', 'plan_digest', 'eval_config_digest', 'configured', 'results'], 'eval results')
154
+ if (artifact.schema !== EVAL_RESULTS_SCHEMA) throw new Error(`unsupported eval results schema ${artifact.schema}`)
155
+ assertInstant(artifact.generated, 'generated')
156
+ assertSha(artifact.base_sha, 'base_sha')
157
+ assertDigest(artifact.plan_digest, 'plan_digest')
158
+ assertDigest(artifact.eval_config_digest, 'eval_config_digest')
159
+ if (typeof artifact.configured !== 'boolean') throw new Error('configured must be boolean')
160
+ if (!Array.isArray(artifact.results)) throw new Error('results must be an array')
161
+ if (!artifact.configured && artifact.results.length) throw new Error('results must be empty when configured is false')
162
+ for (const [index, result] of artifact.results.entries()) {
163
+ const label = `results[${index}]`
164
+ if (!isPlainObject(result)) throw new Error(`${label} must be an object`)
165
+ assertExactKeys(result, ['publisher', 'id', 'via', 'feed_digest', 'status', 'exit_code', 'report'], label)
166
+ if (typeof result.publisher !== 'string' || !result.publisher || typeof result.id !== 'string' || !result.id) throw new Error(`${label} identity is malformed`)
167
+ if (result.via !== null && typeof result.via !== 'string') throw new Error(`${label} clock is malformed`)
168
+ assertDigest(result.feed_digest, `${label}.feed_digest`)
169
+ if (!EVAL_STATUSES.has(result.status)) throw new Error(`${label} has an invalid status`)
170
+ if (result.exit_code !== null && !Number.isSafeInteger(result.exit_code)) throw new Error(`${label} has an invalid exit code`)
171
+ if (result.status === 'pass' && result.exit_code !== 0) throw new Error(`${label} passes with a non-zero exit code`)
172
+ if (result.report !== null && typeof result.report !== 'string') throw new Error(`${label} has an invalid report`)
173
+ }
174
+ return artifact
175
+ }
176
+
177
+ export const verificationResult = claims => {
178
+ const statuses = claims.map(claim => claim.status)
179
+ if (statuses.includes('mismatch')) return 'mismatch'
180
+ const applicable = statuses.filter(status => status !== 'not-applicable')
181
+ return applicable.length && applicable.every(status => status === 'verified') ? 'verified' : 'partial'
182
+ }
183
+
184
+ export function assertValidVerification(document) {
185
+ if (!isPlainObject(document)) throw new Error('verification must be a JSON object')
186
+ if (document.schema !== VERIFICATION_SCHEMA) throw new Error(`unsupported verification schema ${document.schema}`)
187
+ const info = document.receipt
188
+ if (!isPlainObject(info)) throw new Error('verification receipt must be an object')
189
+ const refresh = info.schema === FEED_REFRESH_RECEIPT_SCHEMA
190
+ const release = info.schema === RELEASE_RECEIPT_SCHEMA
191
+ for (const field of ['state', 'checked_at', 'pending_pr']) {
192
+ if (!refresh && Object.hasOwn(info, field)) throw new Error(`verification receipt.${field} applies only to ${FEED_REFRESH_RECEIPT_SCHEMA} receipts`)
193
+ }
194
+ if (!release && Object.hasOwn(info, 'released')) throw new Error(`verification receipt.released applies only to ${RELEASE_RECEIPT_SCHEMA} receipts`)
195
+ if (release && typeof info.released !== 'boolean') throw new Error('verification receipt.released must be boolean for a release receipt')
196
+ if (refresh) {
197
+ if (info.state !== 'clean' && info.state !== 'pending') throw new Error('verification receipt.state must be clean or pending')
198
+ assertInstant(info.checked_at, 'verification receipt.checked_at')
199
+ if ((info.state === 'pending') !== Object.hasOwn(info, 'pending_pr')) throw new Error('verification receipt.pending_pr must be present exactly when state is pending')
200
+ if (info.state === 'pending') assertHttpsUrl(info.pending_pr, 'verification receipt.pending_pr')
201
+ }
202
+ if (!Array.isArray(document.claims)) throw new Error('verification claims must be an array')
203
+ const names = new Set()
204
+ for (const [index, claim] of document.claims.entries()) {
205
+ if (!isPlainObject(claim)) throw new Error(`verification claims[${index}] must be an object`)
206
+ if (names.has(claim.claim)) throw new Error(`verification claim ${claim.claim} appears more than once`)
207
+ names.add(claim.claim)
208
+ const compared = Object.hasOwn(claim, 'expected') && Object.hasOwn(claim, 'actual')
209
+ if (claim.status === 'verified' && compared && claim.expected !== claim.actual) throw new Error(`verified claim ${claim.claim} has different expected and actual values`)
210
+ if (claim.status === 'mismatch' && compared && claim.expected === claim.actual) throw new Error(`mismatch claim ${claim.claim} has equal expected and actual values`)
211
+ if (claim.status === 'not-checked' && (typeof claim.input !== 'string' || !claim.input)) throw new Error(`not-checked claim ${claim.claim} must name the input that would check it`)
212
+ }
213
+ const expected = verificationResult(document.claims)
214
+ if (document.result !== expected) throw new Error(`verification result ${document.result} does not follow from its claims (${expected})`)
215
+ return document
216
+ }
@@ -4,9 +4,22 @@ import { TextDecoder } from 'node:util'
4
4
 
5
5
  import { normalizeConfig } from './config.mjs'
6
6
  import { JsonSchemaRegistry, validateJsonSchema } from './json-schema.mjs'
7
+ import {
8
+ EVAL_RESULTS_SCHEMA,
9
+ FEED_REFRESH_RECEIPT_SCHEMA,
10
+ RELEASE_RECEIPT_SCHEMA,
11
+ VERIFICATION_SCHEMA,
12
+ assertValidEvalResults,
13
+ assertValidFeedRefreshReceipt,
14
+ assertValidReleaseReceipt,
15
+ assertValidVerification,
16
+ } from './receipts.mjs'
7
17
  import { validateFeed } from './validate-feed.mjs'
8
18
 
9
- export const DOCUMENT_TYPES = Object.freeze(['feed', 'config', 'check', 'inventory', 'schedule', 'alert', 'plan'])
19
+ export const DOCUMENT_TYPES = Object.freeze([
20
+ 'feed', 'config', 'check', 'inventory', 'schedule', 'alert', 'plan',
21
+ 'eval', 'feed-refresh-receipt', 'npm-release-result', 'verification',
22
+ ])
10
23
 
11
24
  const SCHEMA_FILES = Object.freeze({
12
25
  feed: 'model-eol.schema.json',
@@ -16,6 +29,10 @@ const SCHEMA_FILES = Object.freeze({
16
29
  schedule: 'model-eol.schedule.schema.json',
17
30
  alert: 'model-eol.alert.schema.json',
18
31
  plan: 'model-eol.plan.schema.json',
32
+ eval: 'model-eol.eval.schema.json',
33
+ 'feed-refresh-receipt': 'model-eol.feed-refresh-receipt.schema.json',
34
+ 'npm-release-result': 'model-eol.npm-release-result.schema.json',
35
+ verification: 'model-eol.verification.schema.json',
19
36
  })
20
37
 
21
38
  const TYPE_ALIASES = new Map([
@@ -30,6 +47,17 @@ const SCHEMA_DISCRIMINATORS = new Map([
30
47
  ['model-eol/inventory@0.1', 'inventory'],
31
48
  ['model-eol/schedule@0.1', 'schedule'],
32
49
  ['model-eol/alert@0.1', 'alert'],
50
+ [EVAL_RESULTS_SCHEMA, 'eval'],
51
+ [FEED_REFRESH_RECEIPT_SCHEMA, 'feed-refresh-receipt'],
52
+ [RELEASE_RECEIPT_SCHEMA, 'npm-release-result'],
53
+ [VERIFICATION_SCHEMA, 'verification'],
54
+ ])
55
+
56
+ const RECEIPT_VALIDATORS = new Map([
57
+ ['eval', assertValidEvalResults],
58
+ ['feed-refresh-receipt', assertValidFeedRefreshReceipt],
59
+ ['npm-release-result', assertValidReleaseReceipt],
60
+ ['verification', assertValidVerification],
33
61
  ])
34
62
 
35
63
  let catalogCache
@@ -219,6 +247,14 @@ const semanticErrors = (document, type) => {
219
247
  return [{ path: '$.items', keyword: 'model-eol-plan', message: error.message }]
220
248
  }
221
249
  }
250
+ if (RECEIPT_VALIDATORS.has(type)) {
251
+ try {
252
+ RECEIPT_VALIDATORS.get(type)(document)
253
+ return []
254
+ } catch (error) {
255
+ return [{ path: '$', keyword: 'model-eol-receipt', message: error.message }]
256
+ }
257
+ }
222
258
  return []
223
259
  }
224
260