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.
- package/README.md +94 -2
- package/action.yml +1 -1
- package/bot/bot.mjs +22 -122
- package/bot/lib/common.mjs +2 -32
- package/check.mjs +83 -9
- package/feeds/anthropic.json +3 -3
- package/feeds/google.json +3 -3
- package/feeds/openai.json +5 -5
- package/lib/digests.mjs +146 -0
- package/lib/receipts.mjs +216 -0
- package/lib/validate-document.mjs +37 -1
- package/lib/verify.mjs +359 -0
- package/package.json +2 -2
- package/schema/model-eol.eval.schema.json +49 -0
- package/schema/model-eol.feed-refresh-receipt.schema.json +63 -0
- package/schema/model-eol.npm-release-result.schema.json +38 -0
- package/schema/model-eol.verification.schema.json +78 -0
package/lib/digests.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/receipts.mjs
ADDED
|
@@ -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([
|
|
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
|
|