model-eol 0.8.4 → 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/lib/verify.mjs ADDED
@@ -0,0 +1,359 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { TextDecoder } from 'node:util'
4
+
5
+ import { loadConfig } from './config.mjs'
6
+ import { effectiveEval, evalConfigDigest, evalIdentity, feedContext, modelGroups, planDigest, sha256 } from './digests.mjs'
7
+ import {
8
+ EVAL_RESULTS_SCHEMA,
9
+ FEED_REFRESH_RECEIPT_SCHEMA,
10
+ RELEASE_RECEIPT_SCHEMA,
11
+ FEED_NAME_PATTERN,
12
+ SHA_PATTERN,
13
+ VERIFICATION_SCHEMA,
14
+ assertHttpsUrl,
15
+ hashFeedFiles,
16
+ sha512Integrity,
17
+ verificationResult,
18
+ } from './receipts.mjs'
19
+ import { assertValidPlanDocument, formatDocumentErrors, validateDocument } from './validate-document.mjs'
20
+
21
+ export class VerifyError extends Error {
22
+ constructor(message) {
23
+ super(message)
24
+ this.name = 'VerifyError'
25
+ this.exitCode = 2
26
+ }
27
+ }
28
+
29
+ const NAMED_CONTROLS = new Map([['\n', '\\n'], ['\r', '\\r'], ['\t', '\\t']])
30
+
31
+ export const printable = value => String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, character => {
32
+ const code = character.charCodeAt(0)
33
+ return NAMED_CONTROLS.get(character) ?? (code < 0x80 ? `\\x${code.toString(16).padStart(2, '0')}` : `\\u${code.toString(16).padStart(4, '0')}`)
34
+ })
35
+
36
+ const refuse = message => {
37
+ throw new VerifyError(message)
38
+ }
39
+
40
+ const KINDS = new Map([
41
+ [FEED_REFRESH_RECEIPT_SCHEMA, { type: 'feed-refresh-receipt', inputs: ['feeds-dir', 'commit', 'run-url'] }],
42
+ [RELEASE_RECEIPT_SCHEMA, { type: 'npm-release-result', inputs: ['package', 'commit', 'source-commit', 'version'] }],
43
+ [EVAL_RESULTS_SCHEMA, { type: 'eval', inputs: ['commit', 'plan', 'config', 'feeds-dir'] }],
44
+ ])
45
+
46
+ export const SUPPORTED_RECEIPT_SCHEMAS = Object.freeze([...KINDS.keys()])
47
+ export const VERIFY_INPUTS = Object.freeze(['feeds-dir', 'commit', 'run-url', 'package', 'source-commit', 'version', 'plan', 'config'])
48
+
49
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
50
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
51
+
52
+ const readReceipt = file => {
53
+ let bytes
54
+ try {
55
+ bytes = fs.readFileSync(file)
56
+ } catch (error) {
57
+ refuse(`could not read receipt ${file}: ${error.message}`)
58
+ }
59
+ let document
60
+ try {
61
+ document = JSON.parse(utf8Decoder.decode(bytes))
62
+ } catch (error) {
63
+ refuse(`receipt ${file} is not UTF-8 JSON: ${error.message}`)
64
+ }
65
+ return { bytes, document }
66
+ }
67
+
68
+ const commitInput = (value, flag) => {
69
+ const commit = String(value).toLowerCase()
70
+ if (!SHA_PATTERN.test(commit)) refuse(`${flag} must be a 40-64 character hexadecimal Git commit`)
71
+ return commit
72
+ }
73
+
74
+ const runUrlInput = value => {
75
+ try {
76
+ return assertHttpsUrl(value, '--run-url')
77
+ } catch (error) {
78
+ refuse(error.message)
79
+ }
80
+ }
81
+
82
+ const versionInput = value => {
83
+ if (!VERSION_PATTERN.test(value)) refuse('--version must be an exact X.Y.Z version without a leading v')
84
+ return value
85
+ }
86
+
87
+ const readBytes = (file, flag) => {
88
+ try {
89
+ return fs.readFileSync(file)
90
+ } catch (error) {
91
+ refuse(`could not read ${flag} ${file}: ${error.message}`)
92
+ }
93
+ }
94
+
95
+ const feedsDirInput = dir => {
96
+ let stat
97
+ try {
98
+ stat = fs.statSync(dir)
99
+ } catch (error) {
100
+ refuse(`could not read --feeds-dir ${dir}: ${error.message}`)
101
+ }
102
+ if (!stat.isDirectory()) refuse(`--feeds-dir ${dir} is not a directory`)
103
+ let nested = false
104
+ try {
105
+ nested = fs.statSync(path.join(dir, 'feeds')).isDirectory()
106
+ } catch {
107
+ }
108
+ if (nested) refuse(`--feeds-dir takes the feeds directory itself, not a repository root; pass ${path.join(dir, 'feeds')}`)
109
+ try {
110
+ const stray = fs.readdirSync(dir).find(name => name.endsWith('.json') && !FEED_NAME_PATTERN.test(name))
111
+ if (stray !== undefined) refuse(`--feeds-dir ${dir} holds ${JSON.stringify(stray)}, which is not a feeds/*.json name a receipt writer can emit`)
112
+ return hashFeedFiles(dir)
113
+ } catch (error) {
114
+ if (error instanceof VerifyError) throw error
115
+ refuse(`could not read --feeds-dir ${dir}: ${error.message}`)
116
+ }
117
+ }
118
+
119
+ const planInput = file => {
120
+ let plan
121
+ try {
122
+ plan = JSON.parse(utf8Decoder.decode(readBytes(file, '--plan')))
123
+ } catch (error) {
124
+ if (error instanceof VerifyError) throw error
125
+ refuse(`--plan ${file} is not UTF-8 JSON: ${error.message}`)
126
+ }
127
+ if (plan?.plan_schema !== 'model-eol.plan/0.1') refuse(`--plan ${file} has plan schema ${plan?.plan_schema ?? 'missing'}; expected model-eol.plan/0.1`)
128
+ try {
129
+ return assertValidPlanDocument(plan)
130
+ } catch (error) {
131
+ refuse(`--plan ${file} is not a valid plan: ${error.message}`)
132
+ }
133
+ }
134
+
135
+ const configInput = (file, env) => {
136
+ let config
137
+ try {
138
+ config = loadConfig(file, { allowMissing: false })
139
+ } catch (error) {
140
+ refuse(`--config ${file}: ${error.message}`)
141
+ }
142
+ const override = env.MODEL_EOL_EVAL_COMMAND || null
143
+ const settings = effectiveEval(config, override)
144
+ const note = override === null
145
+ ? ''
146
+ : settings.command === override
147
+ ? ' with the MODEL_EOL_EVAL_COMMAND override from the environment'
148
+ : ' (MODEL_EOL_EVAL_COMMAND is set but blank, so eval.command applies)'
149
+ return { config, settings, label: `--config ${file}${note}` }
150
+ }
151
+
152
+ const compare = (claim, expected, actual, input) => ({
153
+ claim,
154
+ status: expected === actual ? 'verified' : 'mismatch',
155
+ expected,
156
+ actual,
157
+ input,
158
+ })
159
+
160
+ const notChecked = (claim, input) => ({ claim, status: 'not-checked', input })
161
+
162
+ const notApplicable = (claim, input) => input === undefined
163
+ ? { claim, status: 'not-applicable' }
164
+ : { claim, status: 'not-applicable', input }
165
+
166
+ const flagLabel = (flag, value) => `--${flag} ${value}`
167
+
168
+ const feedClaims = (receiptFeeds, dir) => {
169
+ if (dir === undefined) return receiptFeeds.map(feed => notChecked(feed.path, '--feeds-dir DIR'))
170
+ const actual = new Map(feedsDirInput(dir).map(feed => [feed.path, feed.sha256]))
171
+ const expected = new Map(receiptFeeds.map(feed => [feed.path, feed.sha256]))
172
+ return [...new Set([...expected.keys(), ...actual.keys()])]
173
+ .sort((left, right) => left.localeCompare(right))
174
+ .map(file => compare(file, expected.get(file) ?? null, actual.get(file) ?? null, flagLabel('feeds-dir', dir)))
175
+ }
176
+
177
+ const refreshClaims = (receipt, inputs) => [
178
+ ...feedClaims(receipt.feeds, inputs['feeds-dir']),
179
+ inputs.commit === undefined
180
+ ? notChecked('refresh_commit', '--commit SHA')
181
+ : compare('refresh_commit', receipt.refresh_commit, commitInput(inputs.commit, '--commit'), flagLabel('commit', inputs.commit)),
182
+ inputs['run-url'] === undefined
183
+ ? notChecked('refresh_run', '--run-url URL')
184
+ : compare('refresh_run', assertHttpsUrl(receipt.refresh_run, 'refresh_run'), runUrlInput(inputs['run-url']), flagLabel('run-url', inputs['run-url'])),
185
+ ]
186
+
187
+ const releaseClaims = (receipt, inputs) => {
188
+ const sourceCommit = inputs['source-commit'] === undefined
189
+ ? notChecked('source_commit', '--source-commit SHA')
190
+ : compare('source_commit', receipt.source_commit, commitInput(inputs['source-commit'], '--source-commit'), flagLabel('source-commit', inputs['source-commit']))
191
+ const given = flag => inputs[flag] === undefined ? undefined : flagLabel(flag, inputs[flag])
192
+ if (!receipt.released) {
193
+ const releaseInputs = ['commit', 'version', 'package'].map(given).filter(Boolean)
194
+ return [
195
+ sourceCommit,
196
+ ...(releaseInputs.length ? [{ claim: 'released', status: 'mismatch', expected: false, actual: true, input: releaseInputs.join(', ') }] : []),
197
+ notApplicable('release_commit', given('commit')),
198
+ notApplicable('version', given('version')),
199
+ notApplicable('registry_integrity', given('package')),
200
+ ]
201
+ }
202
+ return [
203
+ sourceCommit,
204
+ inputs.commit === undefined
205
+ ? notChecked('release_commit', '--commit SHA')
206
+ : compare('release_commit', receipt.release_commit, commitInput(inputs.commit, '--commit'), given('commit')),
207
+ inputs.version === undefined
208
+ ? notChecked('version', '--version X.Y.Z')
209
+ : compare('version', receipt.version, versionInput(inputs.version), given('version')),
210
+ inputs.package === undefined
211
+ ? notChecked('registry_integrity', '--package FILE.tgz')
212
+ : compare('registry_integrity', receipt.registry_integrity, sha512Integrity(readBytes(inputs.package, '--package')), given('package')),
213
+ ]
214
+ }
215
+
216
+ // JSON-quoted parts keep labels injective; '' and null via share one identity, as in evalIdentity
217
+ const resultLabel = ({ publisher, id, via }) => `results[${[publisher, id, via || null].map(part => JSON.stringify(part)).join(', ')}]`
218
+
219
+ const plannedGroups = (plan, config, feedsDir) => {
220
+ // plan files are root-relative as `model-eol plan .` writes them, so any absolute root reproduces the bot's ignore matching
221
+ try {
222
+ return modelGroups(plan, config.config, path.resolve('/'), feedsDir === undefined ? new Map() : feedContext(feedsDir))
223
+ } catch (error) {
224
+ refuse(`--feeds-dir ${feedsDir} holds a malformed feed for a planned model: ${error.message}`)
225
+ }
226
+ }
227
+
228
+ const evalResultClaims = (receipt, { plan, config, inputs }) => {
229
+ const feedsDir = inputs['feeds-dir']
230
+ if (feedsDir !== undefined) feedsDirInput(feedsDir)
231
+ if (!receipt.configured) return []
232
+ const claims = []
233
+ const planned = plan && config
234
+ ? new Map(plannedGroups(plan, config, feedsDir).map(group => [evalIdentity(group.publisher, group.id, group.via), group]))
235
+ : null
236
+ const setInput = planned ? `${flagLabel('plan', inputs.plan)} and ${config.label}` : null
237
+ const digestInput = planned && feedsDir !== undefined ? `${setInput} and ${flagLabel('feeds-dir', feedsDir)}` : null
238
+ if (!planned) claims.push(notChecked('results', '--plan FILE and --config FILE'))
239
+ const copies = new Map()
240
+ for (const result of receipt.results) {
241
+ const key = evalIdentity(result.publisher, result.id, result.via)
242
+ const label = resultLabel(result)
243
+ const copy = (copies.get(key) ?? 0) + 1
244
+ copies.set(key, copy)
245
+ if (copy > 1) {
246
+ claims.push({ claim: `${label} (duplicate ${copy})`, status: 'mismatch', expected: true, actual: false, input: 'the receipt itself (one result per identity)' })
247
+ continue
248
+ }
249
+ if (!planned) {
250
+ claims.push(notChecked(`${label}.feed_digest`, '--plan FILE, --config FILE, and --feeds-dir DIR'))
251
+ continue
252
+ }
253
+ const group = planned.get(key)
254
+ if (!group) {
255
+ claims.push({ claim: label, status: 'mismatch', expected: true, actual: false, input: setInput })
256
+ continue
257
+ }
258
+ claims.push({ claim: label, status: 'verified', expected: true, actual: true, input: setInput })
259
+ claims.push(digestInput
260
+ ? compare(`${label}.feed_digest`, result.feed_digest, group.feedDigest, digestInput)
261
+ : notChecked(`${label}.feed_digest`, '--feeds-dir DIR'))
262
+ }
263
+ for (const [key, group] of planned ?? []) {
264
+ if (!copies.has(key)) claims.push({ claim: resultLabel(group), status: 'mismatch', expected: false, actual: true, input: setInput })
265
+ }
266
+ return claims
267
+ }
268
+
269
+ const evalClaims = (receipt, inputs, env) => {
270
+ const plan = inputs.plan === undefined ? null : planInput(inputs.plan)
271
+ const config = inputs.config === undefined ? null : configInput(inputs.config, env)
272
+ return [
273
+ inputs.commit === undefined
274
+ ? notChecked('base_sha', '--commit SHA')
275
+ : compare('base_sha', receipt.base_sha, commitInput(inputs.commit, '--commit'), flagLabel('commit', inputs.commit)),
276
+ plan
277
+ ? compare('plan_digest', receipt.plan_digest, planDigest(plan), flagLabel('plan', inputs.plan))
278
+ : notChecked('plan_digest', '--plan FILE'),
279
+ config
280
+ ? compare('eval_config_digest', receipt.eval_config_digest, evalConfigDigest(config.settings), config.label)
281
+ : notChecked('eval_config_digest', '--config FILE'),
282
+ config
283
+ ? compare('configured', receipt.configured, Boolean(config.settings.command), config.label)
284
+ : notChecked('configured', '--config FILE'),
285
+ ...evalResultClaims(receipt, { plan, config, inputs }),
286
+ ]
287
+ }
288
+
289
+ export const verifyReceipt = ({ file, inputs = {}, env = process.env }) => {
290
+ const { bytes, document } = readReceipt(file)
291
+ const schema = typeof document?.schema === 'string' ? document.schema : null
292
+ const kind = KINDS.get(schema)
293
+ if (!kind) {
294
+ refuse(`unsupported receipt schema ${schema === null ? '(missing)' : JSON.stringify(schema)}; supported schemas: ${SUPPORTED_RECEIPT_SCHEMAS.join(', ')}`)
295
+ }
296
+ const foreign = Object.keys(inputs).filter(flag => inputs[flag] !== undefined && !kind.inputs.includes(flag))
297
+ if (foreign.length) refuse(`${foreign.map(flag => `--${flag}`).join(', ')} does not apply to ${schema} receipts; accepted inputs: ${kind.inputs.map(flag => `--${flag}`).join(', ')}`)
298
+ const structural = validateDocument(document, { type: kind.type })
299
+ if (structural.errors.length) refuse(`structurally invalid ${schema} receipt ${file}: ${formatDocumentErrors(structural.errors).join('; ')}`)
300
+ const claims = kind.type === 'feed-refresh-receipt'
301
+ ? refreshClaims(document, inputs)
302
+ : kind.type === 'npm-release-result'
303
+ ? releaseClaims(document, inputs)
304
+ : evalClaims(document, inputs, env)
305
+ return {
306
+ schema: VERIFICATION_SCHEMA,
307
+ receipt: {
308
+ path: file,
309
+ schema,
310
+ sha256: sha256(bytes),
311
+ ...(kind.type === 'feed-refresh-receipt'
312
+ ? { state: document.state, checked_at: document.checked_at, ...(document.state === 'pending' ? { pending_pr: document.pending_pr } : {}) }
313
+ : {}),
314
+ ...(kind.type === 'npm-release-result' ? { released: document.released } : {}),
315
+ },
316
+ structural: 'valid',
317
+ claims,
318
+ result: verificationResult(claims),
319
+ }
320
+ }
321
+
322
+ const shown = value => value === null ? '(absent)' : printable(value)
323
+
324
+ export const formatVerification = report => {
325
+ const info = report.receipt
326
+ const names = report.claims.map(claim => printable(claim.claim))
327
+ const width = Math.max(0, ...names.map(name => name.length))
328
+ const lines = [
329
+ `receipt ${printable(info.path)}`,
330
+ ` schema ${printable(info.schema)}`,
331
+ ` sha256 ${printable(info.sha256)}`,
332
+ ...(info.state !== undefined
333
+ ? [` state ${printable(info.state)}, checked ${printable(info.checked_at)}${info.pending_pr !== undefined ? `, pending feed PR ${printable(info.pending_pr)}` : ''} (information only; not a claim)`]
334
+ : []),
335
+ ...(info.released !== undefined
336
+ ? [` released ${info.released}${info.released ? '' : ' (the receipt records no release; a release gate must also pass --package, --version, and --commit)'} (information only; not a claim)`]
337
+ : []),
338
+ 'structural: valid',
339
+ ]
340
+ for (const [index, claim] of report.claims.entries()) {
341
+ const input = claim.input === undefined ? undefined : printable(claim.input)
342
+ const detail = claim.status === 'verified'
343
+ ? `${shown(claim.actual)} (${input})`
344
+ : claim.status === 'mismatch'
345
+ ? `receipt ${shown(claim.expected)}, ${input} gives ${shown(claim.actual)}`
346
+ : claim.status === 'not-checked'
347
+ ? `needs ${input}`
348
+ : `the receipt records no release${input ? `; ${input} has nothing to check` : ''}`
349
+ lines.push(` ${claim.status.padEnd(14)} ${names[index].padEnd(width)} ${detail}`)
350
+ }
351
+ const unchecked = report.claims.filter(claim => claim.status === 'not-checked').length
352
+ const mismatched = report.claims.filter(claim => claim.status === 'mismatch').length
353
+ lines.push(report.result === 'mismatch'
354
+ ? `result: mismatch (${mismatched} claim${mismatched === 1 ? '' : 's'} contradicted by the supplied inputs)`
355
+ : report.result === 'partial'
356
+ ? `result: partial (no mismatch, but ${unchecked} claim${unchecked === 1 ? ' was' : 's were'} not checked; supply the inputs above, or use --require-all to fail)`
357
+ : 'result: verified (every applicable claim matched the supplied inputs)')
358
+ return lines.join('\n')
359
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "model-eol-bot": "bot/bot.mjs"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-action-contract.mjs && node scripts/test-document-validation.mjs && node scripts/test-eval-harness.mjs && node bot/test/package.mjs && node scripts/test-feed-changelog.mjs && node scripts/test-public-site.mjs"
11
+ "test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-action-contract.mjs && node scripts/test-document-validation.mjs && node scripts/test-receipts.mjs && node scripts/test-verify.mjs && node scripts/test-eval-harness.mjs && node bot/test/package.mjs && node scripts/test-feed-changelog.mjs && node scripts/test-public-site.mjs"
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.eval.schema.json",
4
+ "title": "model-eol eval results manifest",
5
+ "description": "Eval results manifest `model-eol.eval/0.1` written by `model-eol-bot evaluate`. It binds one pass, fail, or timeout result per planned migration group to the evaluated default-branch commit (base_sha), the SHA-256 of the plan without its generated time (plan_digest), the SHA-256 of the effective eval settings (eval_config_digest), and each group's feed digest. Publication refuses a manifest whose digests, base commit, configured state, or result identities do not match its own independently regenerated plan. Compatibility: additive changes keep this version; any change that makes an existing manifest invalid or changes what a digest covers is a new version, and validators reject unknown versions. Matching its digests, base commit, and result identities against a regenerated plan, and the per-report byte cap from eval.max_report_bytes, need the consumer's repository and stay in code. Calendar validity of generated relies on `format: date-time`, which many Draft-07 validators treat as an annotation unless format validation is enabled; model-eol's validator enforces it.",
6
+ "type": "object",
7
+ "required": ["schema", "generated", "base_sha", "plan_digest", "eval_config_digest", "configured", "results"],
8
+ "properties": {
9
+ "schema": { "const": "model-eol.eval/0.1" },
10
+ "generated": { "$ref": "#/definitions/utcInstant" },
11
+ "base_sha": { "$ref": "#/definitions/gitCommit" },
12
+ "plan_digest": { "$ref": "#/definitions/sha256" },
13
+ "eval_config_digest": { "$ref": "#/definitions/sha256" },
14
+ "configured": { "type": "boolean" },
15
+ "results": { "type": "array", "items": { "$ref": "#/definitions/result" } }
16
+ },
17
+ "additionalProperties": false,
18
+ "oneOf": [
19
+ { "required": ["configured"], "properties": { "configured": { "const": true } } },
20
+ { "required": ["configured"], "properties": { "configured": { "const": false }, "results": { "maxItems": 0 } } }
21
+ ],
22
+ "definitions": {
23
+ "utcInstant": {
24
+ "type": "string",
25
+ "format": "date-time",
26
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$"
27
+ },
28
+ "gitCommit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
29
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
30
+ "result": {
31
+ "type": "object",
32
+ "required": ["publisher", "id", "via", "feed_digest", "status", "exit_code", "report"],
33
+ "properties": {
34
+ "publisher": { "type": "string", "minLength": 1 },
35
+ "id": { "type": "string", "minLength": 1 },
36
+ "via": { "type": ["string", "null"] },
37
+ "feed_digest": { "$ref": "#/definitions/sha256" },
38
+ "status": { "enum": ["pass", "fail", "timeout"] },
39
+ "exit_code": { "type": ["integer", "null"], "minimum": -9007199254740991, "maximum": 9007199254740991 },
40
+ "report": { "type": ["string", "null"] }
41
+ },
42
+ "additionalProperties": false,
43
+ "oneOf": [
44
+ { "required": ["status"], "properties": { "status": { "const": "pass" }, "exit_code": { "type": "integer", "minimum": 0, "maximum": 0 } } },
45
+ { "required": ["status"], "properties": { "status": { "enum": ["fail", "timeout"] } } }
46
+ ]
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.feed-refresh-receipt.schema.json",
4
+ "title": "model-eol feed-refresh receipt",
5
+ "description": "Feed-refresh receipt `model-eol/feed-refresh-receipt@0.1` written by every successful feed-refresh run. It binds the refresh run URL (refresh_run), the commit the run checked (refresh_commit), and the check time (checked_at) to the SHA-256 of every feeds/*.json file the run produced. A clean receipt means the live sources matched those feeds; a pending receipt means the run opened or updated pending_pr with material changes. The public contract is published only when a receipt's digests match the exact feeds being published. Compatibility: additive changes keep this version; any change that makes an existing receipt invalid or changes what a digest covers is a new version, and validators reject unknown versions. Enforced in code, not by this schema: feeds must be sorted by path (String.prototype.localeCompare), and no two feeds may share a path. URLs: the writer always emits the WHATWG-normalized URL, and the code validator accepts any https URL that WHATWG URL parsing accepts without credentials. This schema's URL pattern is stricter than the code in some ways (it refuses an uppercase HTTPS scheme, empty userinfo such as https://@host, https:host without slashes, and surrounding whitespace) and looser than the normalized form in others (it allows an uppercase host or an explicit :443 port). Calendar validity of checked_at relies on `format: date-time`, which many Draft-07 validators treat as an annotation unless format validation is enabled; model-eol's validator enforces it.",
6
+ "type": "object",
7
+ "required": ["schema", "state", "changed", "pending", "checked_at", "refresh_run", "refresh_commit", "feeds"],
8
+ "properties": {
9
+ "schema": { "const": "model-eol/feed-refresh-receipt@0.1" },
10
+ "state": { "enum": ["clean", "pending"] },
11
+ "changed": { "type": "boolean" },
12
+ "pending": { "type": "boolean" },
13
+ "checked_at": {
14
+ "type": "string",
15
+ "format": "date-time",
16
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$"
17
+ },
18
+ "refresh_run": { "$ref": "#/definitions/httpsUrl" },
19
+ "refresh_commit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
20
+ "feeds": {
21
+ "type": "array",
22
+ "minItems": 1,
23
+ "uniqueItems": true,
24
+ "items": { "$ref": "#/definitions/feed" }
25
+ },
26
+ "pending_pr": { "$ref": "#/definitions/httpsUrl" }
27
+ },
28
+ "additionalProperties": false,
29
+ "oneOf": [
30
+ {
31
+ "properties": {
32
+ "state": { "const": "clean" },
33
+ "changed": { "const": false },
34
+ "pending": { "const": false },
35
+ "pending_pr": false
36
+ }
37
+ },
38
+ {
39
+ "required": ["pending_pr"],
40
+ "properties": {
41
+ "state": { "const": "pending" },
42
+ "changed": { "const": true },
43
+ "pending": { "const": true }
44
+ }
45
+ }
46
+ ],
47
+ "definitions": {
48
+ "httpsUrl": {
49
+ "type": "string",
50
+ "format": "uri",
51
+ "pattern": "^https://[^\\s/?#@]+([/?#][^\\s]*)?$"
52
+ },
53
+ "feed": {
54
+ "type": "object",
55
+ "required": ["path", "sha256"],
56
+ "properties": {
57
+ "path": { "type": "string", "pattern": "^feeds/[a-z0-9][a-z0-9-]*\\.json$" },
58
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
59
+ },
60
+ "additionalProperties": false
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.npm-release-result.schema.json",
4
+ "title": "model-eol npm release result",
5
+ "description": "npm release result `model-eol/npm-release-result@0.2` written by every npm-release run. It binds the workflow source commit (source_commit) to what the run published: either nothing (released false), or one exact stable 0.x version, its git tag, the release commit, and the npm registry tarball integrity (a canonical sha512 Subresource Integrity value). Downstream consumer checks install that exact version and compare the registry integrity instead of trusting npm's latest tag. Compatibility: additive changes keep this version; any change that makes an existing result invalid or changes what a digest covers is a new version, and validators reject unknown versions. Enforced in code, not by this schema: tag must equal \"v\" followed by version.",
6
+ "type": "object",
7
+ "required": ["schema", "released", "source_commit"],
8
+ "properties": {
9
+ "schema": { "const": "model-eol/npm-release-result@0.2" },
10
+ "released": { "type": "boolean" },
11
+ "source_commit": { "$ref": "#/definitions/gitCommit" },
12
+ "version": { "type": "string", "pattern": "^0\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" },
13
+ "tag": { "type": "string", "pattern": "^v0\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" },
14
+ "release_commit": { "$ref": "#/definitions/gitCommit" },
15
+ "registry_integrity": { "type": "string", "pattern": "^sha512-[A-Za-z0-9+/]{85}[AQgw]==$" }
16
+ },
17
+ "additionalProperties": false,
18
+ "oneOf": [
19
+ {
20
+ "properties": {
21
+ "released": { "const": false },
22
+ "version": false,
23
+ "tag": false,
24
+ "release_commit": false,
25
+ "registry_integrity": false
26
+ }
27
+ },
28
+ {
29
+ "required": ["version", "tag", "release_commit", "registry_integrity"],
30
+ "properties": {
31
+ "released": { "const": true }
32
+ }
33
+ }
34
+ ],
35
+ "definitions": {
36
+ "gitCommit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" }
37
+ }
38
+ }
@@ -0,0 +1,78 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.verification.schema.json",
4
+ "title": "model-eol receipt verification",
5
+ "description": "Verification report `model-eol/verification@0.1` emitted by `model-eol verify RECEIPT --json`. It names the receipt by path, schema, and the SHA-256 of its bytes, records that the receipt passed structural validation, and lists one entry per claim. A claim is verified (the supplied input reproduces the receipt's value), mismatch (it does not; expected is the receipt's value and actual is what the input produced, null when one side has no such entry), not-checked (input names the flag that would check it), or not-applicable (a no-release receipt makes no such claim). A no-release receipt checked with any release input (--commit, --version, --package) gets a released claim with status mismatch, expected false and actual true. For a feed-refresh receipt, receipt.state, receipt.checked_at, and receipt.pending_pr (exactly when state is pending, an https URL) are copied from the receipt as information; for an npm-release-result receipt, receipt.released is. They are not claims, and no other receipt kind carries them. A configured eval manifest checked with --plan and --config gets one claim per planned or recorded result identity, named results[publisher, id, via] with each part JSON-quoted; a duplicate identity is a mismatch on its own; without --plan and --config a not-checked results claim stands in for the set. Eval outcomes (status, exit_code, report, generated) are never claims: verified means the manifest is bound to the supplied inputs, not that the evals passed. Compatibility: additive changes keep this version; validators reject unknown versions. Enforced in code, not by this schema: claim names are unique; a verified claim with both expected and actual has them equal, a mismatch claim has them different, and a not-checked claim names its input; result is mismatch when any claim is mismatch, verified when every applicable claim is verified and at least one is, and partial otherwise.",
6
+ "type": "object",
7
+ "required": ["schema", "receipt", "structural", "claims", "result"],
8
+ "properties": {
9
+ "schema": { "const": "model-eol/verification@0.1" },
10
+ "receipt": {
11
+ "type": "object",
12
+ "required": ["path", "schema", "sha256"],
13
+ "properties": {
14
+ "path": { "type": "string", "minLength": 1 },
15
+ "schema": { "enum": ["model-eol.eval/0.1", "model-eol/feed-refresh-receipt@0.1", "model-eol/npm-release-result@0.2"] },
16
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
17
+ "state": { "enum": ["clean", "pending"] },
18
+ "checked_at": { "type": "string", "format": "date-time" },
19
+ "pending_pr": { "type": "string", "format": "uri", "pattern": "^https://[^/?#@]+([/?#]|$)" },
20
+ "released": { "type": "boolean" }
21
+ },
22
+ "additionalProperties": false,
23
+ "oneOf": [
24
+ {
25
+ "required": ["state", "checked_at"],
26
+ "properties": {
27
+ "schema": { "const": "model-eol/feed-refresh-receipt@0.1" },
28
+ "released": false
29
+ },
30
+ "oneOf": [
31
+ { "required": ["pending_pr"], "properties": { "state": { "const": "pending" } } },
32
+ { "properties": { "state": { "const": "clean" }, "pending_pr": false } }
33
+ ]
34
+ },
35
+ {
36
+ "required": ["released"],
37
+ "properties": {
38
+ "schema": { "const": "model-eol/npm-release-result@0.2" },
39
+ "state": false,
40
+ "checked_at": false,
41
+ "pending_pr": false
42
+ }
43
+ },
44
+ {
45
+ "properties": {
46
+ "schema": { "const": "model-eol.eval/0.1" },
47
+ "state": false,
48
+ "checked_at": false,
49
+ "pending_pr": false,
50
+ "released": false
51
+ }
52
+ }
53
+ ]
54
+ },
55
+ "structural": { "const": "valid" },
56
+ "claims": {
57
+ "type": "array",
58
+ "items": { "$ref": "#/definitions/claim" }
59
+ },
60
+ "result": { "enum": ["verified", "partial", "mismatch"] }
61
+ },
62
+ "additionalProperties": false,
63
+ "definitions": {
64
+ "value": { "type": ["string", "boolean", "null"] },
65
+ "claim": {
66
+ "type": "object",
67
+ "required": ["claim", "status"],
68
+ "properties": {
69
+ "claim": { "type": "string", "minLength": 1 },
70
+ "status": { "enum": ["verified", "mismatch", "not-checked", "not-applicable"] },
71
+ "expected": { "$ref": "#/definitions/value" },
72
+ "actual": { "$ref": "#/definitions/value" },
73
+ "input": { "type": "string", "minLength": 1 }
74
+ },
75
+ "additionalProperties": false
76
+ }
77
+ }
78
+ }