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/README.md CHANGED
@@ -571,6 +571,98 @@ Flash-Lite GA models. The pipeline opened
571
571
  and downstream checks stopped warning about dates Google no longer listed. A
572
572
  lifecycle tracker has to retract stale alarms as well as add new ones.
573
573
 
574
+ ## Receipts
575
+
576
+ model-eol writes three receipt kinds. Each one is a public Draft-07 schema,
577
+ and `model-eol validate receipt.json` selects the schema from the receipt's
578
+ `schema` value.
579
+
580
+ | Kind | `schema` value | Binds |
581
+ |---|---|---|
582
+ | [Eval results manifest](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.eval.schema.json) | `model-eol.eval/0.1` | One pass, fail, or timeout result per migration group to the evaluated base commit, the plan digest, the eval settings digest, and each group's feed digest. Written by `model-eol-bot evaluate`. |
583
+ | [Feed-refresh receipt](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.feed-refresh-receipt.schema.json) | `model-eol/feed-refresh-receipt@0.1` | The refresh run URL, the checked commit, and the check time to the SHA-256 of every `feeds/*.json` file. A pending receipt also names the open feed PR. |
584
+ | [npm release result](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.npm-release-result.schema.json) | `model-eol/npm-release-result@0.2` | The release workflow's source commit to either no release, or one exact version, its tag, the release commit, and the registry tarball's sha512 integrity. |
585
+
586
+ Additive changes keep a receipt's version. Any change that makes an old
587
+ receipt invalid, or changes what a digest covers, is a new version. Validators
588
+ reject unknown versions. A few rules, such as feed sort order and `tag` equal
589
+ to `v` plus `version`, cannot be written in Draft-07. Each schema's
590
+ `description` lists them, and `model-eol validate` enforces them in code.
591
+
592
+ `model-eol validate` checks a receipt's structure only. `model-eol verify`
593
+ checks what a receipt claims against inputs you supply:
594
+
595
+ ```bash
596
+ # release result against the exact tarball you hold (no registry lookup)
597
+ npm pack model-eol@0.8.4
598
+ model-eol verify npm-release-result.json --package model-eol-0.8.4.tgz \
599
+ --commit RELEASE_SHA --source-commit SOURCE_SHA --version 0.8.4
600
+
601
+ # refresh receipt against a checkout's feeds directory
602
+ model-eol verify feed-refresh-receipt.json --feeds-dir ./feeds \
603
+ --commit REFRESH_SHA --run-url https://github.com/OWNER/REPO/actions/runs/ID
604
+
605
+ # eval manifest against the plan, config, and feeds the evaluator used
606
+ model-eol verify eval-results.json --commit BASE_SHA --plan plan.json \
607
+ --config .model-eol.json --feeds-dir ./feeds
608
+ ```
609
+
610
+ The receipt kind comes from its `schema` value. verify validates the receipt's
611
+ structure first, then reports each claim. It never reads the current
612
+ directory, git state, or the network; every input is an explicit flag.
613
+
614
+ | Flag | Checks |
615
+ |---|---|
616
+ | `--feeds-dir DIR` | Refresh: the SHA-256 of every `DIR/*.json`, compared with the receipt's `feeds` list as a set. Changed, missing, and extra files are each a mismatch. `DIR` is the feeds directory itself; a repository root (a directory holding `feeds/`) is refused. Eval: with `--plan` and `--config`, recomputes each result's `feed_digest` (`--plan` and `--config` alone already fix the expected result set). |
617
+ | `--commit SHA` | Refresh `refresh_commit`, release `release_commit`, or eval `base_sha`. |
618
+ | `--run-url URL` | Refresh `refresh_run`, after the same HTTPS URL normalization the receipt writer applies. |
619
+ | `--package FILE.tgz` | Release `registry_integrity`: the sha512 SRI of the tarball bytes. |
620
+ | `--source-commit SHA` | Release `source_commit`. |
621
+ | `--version X.Y.Z` | Release `version`; `tag` is bound to it as `vX.Y.Z`. |
622
+ | `--plan FILE` | Eval `plan_digest`, from a validated `model-eol.plan/0.1` document. |
623
+ | `--config FILE` | Eval `eval_config_digest` and `configured`, with the bot's settings resolution. `MODEL_EOL_EVAL_COMMAND` in the environment overrides `eval.command` as it does for the bot, and the output says so. For a repository without `.model-eol.json`, pass a file containing `{}`. |
624
+ | `--require-all` | Exit 1 when the result is `partial`. |
625
+ | `--json` | Emit a [`model-eol/verification@0.1`](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.verification.schema.json) document. |
626
+
627
+ SHA inputs are 40 to 64 hex characters; uppercase is lowercased. A flag that
628
+ does not apply to the receipt's kind is a usage error.
629
+
630
+ Each claim is `verified`, `mismatch` (with the receipt's value and the
631
+ computed one), `not-checked` (with the flag that would check it), or
632
+ `not-applicable` (a `released: false` result makes no release claims, so only
633
+ `source_commit` applies). Supplying `--commit`, `--version`, or `--package` for a
634
+ `released: false` result adds a `released` claim that mismatches, because those
635
+ inputs assert a release the receipt says did not happen. For a refresh receipt,
636
+ the report also shows its `state`, `checked_at`, and `pending_pr` as information;
637
+ a pending receipt verifies by the same rules as a clean one. For a release
638
+ result the report shows `released`; a release gate must pass `--package`,
639
+ `--version`, `--commit`, and `--require-all`, so that a no-release receipt
640
+ mismatches instead of verifying through `--source-commit` alone.
641
+
642
+ For a configured eval manifest, `--plan` and `--config` fix the set of result
643
+ identities the evaluator had to produce: a planned identity with no result, a
644
+ result for an unplanned identity, and a duplicated identity are each a
645
+ mismatch, and `--feeds-dir` additionally checks each result's `feed_digest`.
646
+ Without `--plan` and `--config`, a `results` claim is reported as not checked,
647
+ so stripping results from a manifest never verifies. Text output escapes
648
+ control characters in every printed value. The overall result is `verified` only when every
649
+ applicable claim is verified. It is `partial` when nothing mismatched but at
650
+ least one claim was not checked: the receipt is consistent with what you
651
+ supplied, not proven. It is `mismatch` when any claim mismatched.
652
+
653
+ Eval outcomes are never claims. `verify` does not check `status`, `exit_code`,
654
+ `report`, or `generated`, because an evaluation cannot be reproduced offline. A
655
+ `verified` eval manifest is bound to the supplied commit, plan, config, and
656
+ feeds. It does not mean the evals passed. A gate that needs passing evals must
657
+ read the results itself.
658
+
659
+ | Exit | Meaning |
660
+ |---|---|
661
+ | 0 | No mismatch: `verified`, or `partial` without `--require-all`. |
662
+ | 1 | At least one claim mismatched, or `partial` with `--require-all`. |
663
+ | 2 | Usage error, unreadable file, structurally invalid receipt, or unsupported receipt schema or version. No claim is reported. |
664
+ | 3 | Output write failure when the result would otherwise exit 0. |
665
+
574
666
  ## The actual ask
575
667
 
576
668
  The goal is the format, not another checker. Providers already publish
@@ -592,7 +684,7 @@ is the most useful thing any of us can do here.
592
684
  canonical schemas, feeds, refresh health, a publication manifest, and Atom.
593
685
  Each deployment is bound to an exact feed-refresh receipt and verified against
594
686
  every live asset. `model-eol validate` checks feeds, repository policy, check
595
- reports, inventories, schedules, alerts, and plans against the same
687
+ reports, inventories, schedules, alerts, plans, and receipts against the same
596
688
  zero-dependency runtime contracts.
597
689
  - Current-model entries, and therefore policy-floor horizons, populate only when
598
690
  the refresh runs with provider API keys for the models endpoints. Anthropic's
package/action.yml CHANGED
@@ -41,7 +41,7 @@ inputs:
41
41
  required: false
42
42
  default: ''
43
43
  document-type:
44
- description: 'Optional validate document type: feed, config, check, inventory, schedule, alert, or plan'
44
+ description: 'Optional validate document type: feed, config, check, inventory, schedule, alert, plan, eval, feed-refresh-receipt, or npm-release-result'
45
45
  required: false
46
46
  default: ''
47
47
  include-docs:
package/bot/bot.mjs CHANGED
@@ -12,11 +12,9 @@ import {
12
12
  branchFor,
13
13
  daysRemaining,
14
14
  hasMetadataMarker,
15
- itemDigest,
16
15
  markdownCode,
17
16
  markdownLink,
18
17
  markdownText,
19
- matchesAnyGlob,
20
18
  metadataLine,
21
19
  parseMetadata,
22
20
  repoPath,
@@ -24,9 +22,22 @@ import {
24
22
  staleWorkComment,
25
23
  stableJson,
26
24
  } from './lib/common.mjs'
25
+ import {
26
+ contextFor,
27
+ effectiveEval,
28
+ evalConfigDigest,
29
+ evalIdentity,
30
+ feedContext,
31
+ groupDigest,
32
+ ignoredModelIds,
33
+ isIgnored,
34
+ modelGroups,
35
+ planDigest,
36
+ } from '../lib/digests.mjs'
27
37
  import { assertValidPlanDocument } from '../lib/validate-document.mjs'
28
38
  import { captureWindowFor, lifecycleFor } from '../lib/feeds.mjs'
29
39
  import { parseCliArgs } from '../lib/cli.mjs'
40
+ import { EVAL_RESULTS_SCHEMA as EVAL_SCHEMA, assertValidEvalResults } from '../lib/receipts.mjs'
30
41
  import { loadConfig } from './lib/config.mjs'
31
42
  import { downloadFeeds } from './lib/feeds.mjs'
32
43
  import { capReport, readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
@@ -47,12 +58,13 @@ import {
47
58
  verifyRemoteBranchHead,
48
59
  } from './lib/git.mjs'
49
60
 
61
+ export { contextFor }
62
+
50
63
  const ROOT = path.resolve(import.meta.dirname, '..')
51
64
  const CHECKER = path.join(ROOT, 'check.mjs')
52
65
  const VENDORED_FEEDS = path.join(ROOT, 'feeds')
53
66
  const PLAN_SCHEMA = 'model-eol.plan/0.1'
54
67
  const BOT_SCHEMA = 'model-eol.bot/0.1'
55
- const EVAL_SCHEMA = 'model-eol.eval/0.1'
56
68
  const EVAL_FAILURE_CHANNEL = 'configured-eval'
57
69
  const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
58
70
  const ISSUE_REASONS = new Set([
@@ -207,27 +219,6 @@ const verifiedPlanArtifact = (file, generatedPlan) => {
207
219
  return artifact
208
220
  }
209
221
 
210
- const withoutGenerated = plan => {
211
- const { generated, ...stable } = plan
212
- return stable
213
- }
214
-
215
- const planDigest = plan => sha256(stableJson(withoutGenerated(plan)))
216
-
217
- const effectiveEval = (config, commandOverride = null) => ({
218
- ...config.eval,
219
- command: typeof commandOverride === 'string' && commandOverride.trim() ? commandOverride : config.eval.command,
220
- })
221
-
222
- const evalConfigDigest = settings => sha256(stableJson({
223
- command: settings.command ?? null,
224
- timeout_ms: settings.timeout_ms,
225
- max_report_bytes: settings.max_report_bytes,
226
- pass_env: settings.pass_env,
227
- }))
228
-
229
- const evalIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
230
-
231
222
  const readJsonArtifact = (file, maxBytes, label) => {
232
223
  const absolute = asAbsolute(file)
233
224
  const artifact = readReportCapped(absolute, maxBytes + 1)
@@ -245,30 +236,26 @@ const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead
245
236
  const maxBytes = Math.min(EVAL_ARTIFACT_MAX_BYTES, Math.max(1024 * 1024, groups.length * (settings.max_report_bytes + 2048)))
246
237
  const artifact = readJsonArtifact(file, maxBytes, 'eval results artifact')
247
238
  if (artifact?.schema !== EVAL_SCHEMA) throw new Error(`refusing eval results schema ${artifact?.schema ?? 'missing'}; expected ${EVAL_SCHEMA}`)
239
+ try {
240
+ assertValidEvalResults(artifact)
241
+ } catch (error) {
242
+ throw new Error(`refusing eval results artifact: ${error.message}`)
243
+ }
248
244
  if (artifact.plan_digest !== planDigest(plan)) throw new Error('refusing eval results artifact: plan digest does not match the independently verified plan')
249
245
  if (artifact.eval_config_digest !== evalConfigDigest(settings)) throw new Error('refusing eval results artifact: eval configuration digest does not match')
250
- if (typeof artifact.base_sha !== 'string' || !/^[0-9a-f]{40,64}$/.test(artifact.base_sha)) throw new Error('refusing eval results artifact: evaluated base commit is malformed or missing')
251
246
  if (artifact.base_sha !== baseHead) throw new Error(`refusing eval results artifact: evaluated base commit ${artifact.base_sha} does not match current default-branch head ${baseHead}`)
252
247
  const configured = Boolean(settings.command)
253
248
  if (artifact.configured !== configured) throw new Error('refusing eval results artifact: configured state does not match eval.command')
254
- if (!Array.isArray(artifact.results)) throw new Error('refusing eval results artifact: results must be an array')
255
249
  if (artifact.results.length !== (configured ? groups.length : 0)) {
256
250
  throw new Error('refusing eval results artifact: expected exactly one result for every planned migration')
257
251
  }
258
252
  const expected = new Map(groups.map(group => [evalIdentity(group.publisher, group.id, group.via), group]))
259
253
  const results = new Map()
260
254
  for (const result of artifact.results) {
261
- if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('refusing eval results artifact: result must be an object')
262
- if (typeof result.publisher !== 'string' || !result.publisher || typeof result.id !== 'string' || !result.id) throw new Error('refusing eval results artifact: result identity is malformed')
263
- if (result.via !== null && typeof result.via !== 'string') throw new Error('refusing eval results artifact: result clock is malformed')
264
255
  const key = evalIdentity(result.publisher, result.id, result.via)
265
256
  const group = expected.get(key)
266
257
  if (!group || results.has(key)) throw new Error('refusing eval results artifact: result identity is unexpected or duplicated')
267
258
  if (result.feed_digest !== group.feedDigest) throw new Error(`refusing eval results artifact: feed digest mismatch for ${group.publisher}/${group.id}`)
268
- if (!['pass', 'fail', 'timeout'].includes(result.status)) throw new Error(`refusing eval results artifact: invalid status for ${group.publisher}/${group.id}`)
269
- if (result.exit_code !== null && !Number.isSafeInteger(result.exit_code)) throw new Error(`refusing eval results artifact: invalid exit code for ${group.publisher}/${group.id}`)
270
- if (result.status === 'pass' && result.exit_code !== 0) throw new Error(`refusing eval results artifact: passing result has a non-zero exit code for ${group.publisher}/${group.id}`)
271
- if (result.report !== null && typeof result.report !== 'string') throw new Error(`refusing eval results artifact: invalid report for ${group.publisher}/${group.id}`)
272
259
  if (result.report !== null && Buffer.byteLength(result.report) > settings.max_report_bytes) throw new Error(`refusing eval results artifact: report exceeds configured cap for ${group.publisher}/${group.id}`)
273
260
  results.set(key, { status: result.status, exit_code: result.exit_code, report: result.report })
274
261
  }
@@ -289,95 +276,8 @@ const boundEvalResults = ({ file, config, commandOverride, plan, groups, baseHea
289
276
  }
290
277
  }
291
278
 
292
- const feedContext = feedsDir => {
293
- const records = new Map()
294
- let files = []
295
- try {
296
- files = fs.readdirSync(feedsDir).filter(file => file.endsWith('.json')).sort()
297
- } catch {
298
- return records
299
- }
300
- for (const file of files) {
301
- let feed
302
- try {
303
- feed = JSON.parse(fs.readFileSync(path.join(feedsDir, file), 'utf8'))
304
- } catch {
305
- continue
306
- }
307
- if (feed?.spec !== 'model-eol/0.1' || !Array.isArray(feed.models)) continue
308
- for (const entry of feed.models) {
309
- for (const key of [entry.id, ...(entry.aliases ?? [])]) {
310
- if (typeof key === 'string') records.set(`${feed.publisher}\0${key}`, { feed, entry })
311
- }
312
- }
313
- }
314
- return records
315
- }
316
-
317
- export const contextFor = (records, publisher, id, via) => {
318
- const record = records.get(`${publisher}\0${id}`)
319
- if (!record) return { announced: null, notes: [], entry: null }
320
- const distribution = via ? (record.entry.distributions ?? []).find(item => item.via === via) : null
321
- return {
322
- announced: distribution ? distribution.announced ?? null : record.entry.announced ?? null,
323
- notes: [record.feed.note, record.entry.notes].filter(Boolean),
324
- entry: record.entry,
325
- }
326
- }
327
-
328
- const ignoredModelIds = (plan, config) => {
329
- const ignored = new Set(config.ignore.models)
330
- const ids = new Set()
331
- for (const item of [...plan.items, ...plan.issues]) {
332
- if (item.id && (ignored.has(item.id) || ignored.has(item.matched))) ids.add(item.id)
333
- }
334
- return ids
335
- }
336
-
337
- const isIgnored = (entry, root, config, ignoredIds) => {
338
- if (entry.id && ignoredIds.has(entry.id)) return true
339
- if (entry.id && config.ignore.models.includes(entry.id)) return true
340
- if (entry.matched && config.ignore.models.includes(entry.matched)) return true
341
- return matchesAnyGlob(repoPath(entry.file, root), config.ignore.paths)
342
- }
343
-
344
- const buildModelGroups = (plan, config, root, records) => {
345
- const ignoredIds = ignoredModelIds(plan, config)
346
- const groups = new Map()
347
- for (const item of plan.items) {
348
- if (activeWaiver(item)) continue
349
- if (isIgnored(item, root, config, ignoredIds)) continue
350
- const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : plan.via
351
- const key = `${item.publisher}\0${item.id}\0${via ?? ''}`
352
- if (!groups.has(key)) {
353
- groups.set(key, {
354
- kind: 'model',
355
- id: item.id,
356
- publisher: item.publisher,
357
- items: [],
358
- via,
359
- branch: branchFor(item.publisher, item.id, via),
360
- context: contextFor(records, item.publisher, item.id, via),
361
- })
362
- }
363
- groups.get(key).items.push(item)
364
- }
365
- for (const group of groups.values()) {
366
- group.items.sort((a, b) => `${a.file}:${a.line}:${a.occurrence}`.localeCompare(`${b.file}:${b.line}:${b.occurrence}`))
367
- group.feedDigest = groupDigest(group.items, group.context?.entry)
368
- }
369
- return [...groups.values()].sort((a, b) => `${a.publisher}/${a.id}`.localeCompare(`${b.publisher}/${b.id}`))
370
- }
371
-
372
- // the capture section reads clocks outside the plan items, so they join the digest that gates body updates
373
- const clockRows = entry => entry
374
- ? {
375
- shutdown: entry.shutdown ?? null,
376
- date_precision: entry.date_precision ?? null,
377
- distributions: itemDigest((entry.distributions ?? []).map(row => ({ via: row.via, shutdown: row.shutdown ?? null, status: row.status ?? null, date_precision: row.date_precision ?? null }))),
378
- }
379
- : null
380
- const groupDigest = (items, entry) => sha256(stableJson({ items: itemDigest(items), clocks: clockRows(entry) }))
279
+ const buildModelGroups = (plan, config, root, records) =>
280
+ modelGroups(plan, config, root, records, (publisher, id, via) => ({ branch: branchFor(publisher, id, via) }))
381
281
 
382
282
  const buildIssueGroups = (plan, config, root, records) => {
383
283
  const ignoredIds = ignoredModelIds(plan, config)
@@ -1,27 +1,7 @@
1
- import crypto from 'node:crypto'
2
- import path from 'node:path'
1
+ import { sha256 } from '../../lib/digests.mjs'
3
2
 
4
3
  export { matchesAnyGlob, matchesGlob } from '../../lib/glob.mjs'
5
-
6
- export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
7
-
8
- const stableValue = value => {
9
- if (Array.isArray(value)) return value.map(stableValue)
10
- if (value && typeof value === 'object') {
11
- return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]))
12
- }
13
- return value
14
- }
15
-
16
- export const stableJson = value => JSON.stringify(stableValue(value))
17
-
18
- export const sortedJsonDigest = values => {
19
- const sorted = values.slice().sort((a, b) => {
20
- const [left, right] = [stableJson(a), stableJson(b)]
21
- return left < right ? -1 : left > right ? 1 : 0
22
- })
23
- return sha256(stableJson(sorted))
24
- }
4
+ export { activeWaiver, itemDigest, repoPath, sha256, sortedJsonDigest, stableJson } from '../../lib/digests.mjs'
25
5
 
26
6
  const safeSegment = value => {
27
7
  const result = String(value ?? '')
@@ -45,12 +25,6 @@ export const daysRemaining = (shutdown, now = new Date()) => {
45
25
  return Math.ceil((new Date(`${shutdown}T00:00:00Z`) - now) / 86400000)
46
26
  }
47
27
 
48
- export const repoPath = (file, root) => {
49
- const absolute = path.isAbsolute(file) ? file : path.resolve(root, file)
50
- const relative = path.relative(root, absolute)
51
- return relative.split(path.sep).join('/').replace(/^\.\//, '')
52
- }
53
-
54
28
  export const metadataLine = metadata => `<!-- model-eol ${JSON.stringify(metadata).replaceAll('-->', '\\u002d\\u002d\\u003e')} -->`
55
29
 
56
30
  export const hasMetadataMarker = body => String(body ?? '').includes('<!-- model-eol')
@@ -82,10 +56,6 @@ export const parseMetadata = body => {
82
56
  }
83
57
  }
84
58
 
85
- export const itemDigest = items => sortedJsonDigest(items)
86
-
87
- export const activeWaiver = item => item?.waiver?.active === true ? item.waiver : null
88
-
89
59
  export const staleWorkComment = (kind, waiver = null) => waiver
90
60
  ? `model-eol is closing this bot-owned ${kind} because an active waiver makes its finding non-actionable. The waiver expires ${waiver.expires}, is owned by ${markdownText(waiver.owner)}, and records: ${markdownText(waiver.reason)}`
91
61
  : `model-eol is closing this bot-owned ${kind} because its finding is no longer actionable on the repository's current default branch. The reference may have been removed, ignored, retracted by the feed, moved to another clock, or disabled by repository configuration.`
package/check.mjs CHANGED
@@ -10,11 +10,13 @@
10
10
  // node check.mjs alert [paths...] [--format github|markdown|badge] [--json]
11
11
  // node check.mjs plan [paths...] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR]
12
12
  // node check.mjs apply --plan plan.json [--dry-run]
13
- // node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan]
13
+ // node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan|eval|feed-refresh-receipt|npm-release-result|verification]
14
+ // node check.mjs verify RECEIPT [inputs] [--require-all] [--json]
14
15
  //
15
16
  // exit codes: check/alert 0 = clear, 1 = finding, 2 = usage error;
16
17
  // apply 0 = all items applied, 1 = item refused, 2 = usage or plan error;
17
- // validate 0 = every document valid, 2 = usage, parse, schema, or semantic error.
18
+ // validate 0 = every document valid, 2 = usage, parse, schema, or semantic error;
19
+ // verify 0 = verified or partial, 1 = mismatch (or partial with --require-all), 2 = usage, read, structural, or schema error.
18
20
 
19
21
  import fs from 'node:fs'
20
22
  import path from 'node:path'
@@ -48,6 +50,7 @@ import {
48
50
  } from './lib/reports.mjs'
49
51
  import { addedLinesForTargets, filterFindingsToChanged, gitRootFor, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
50
52
  import { DOCUMENT_TYPES, formatDocumentErrors, validateDocumentFile } from './lib/validate-document.mjs'
53
+ import { SUPPORTED_RECEIPT_SCHEMAS, VERIFY_INPUTS, VerifyError, formatVerification, printable, verifyReceipt } from './lib/verify.mjs'
51
54
 
52
55
  const rootForTarget = target => {
53
56
  const absolute = path.resolve(target)
@@ -96,7 +99,7 @@ const repoPathForFile = (file, roots) => {
96
99
  }
97
100
 
98
101
  const main = () => {
99
- const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'validate', 'help'])
102
+ const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'validate', 'verify', 'help'])
100
103
  const args = process.argv.slice(2)
101
104
 
102
105
  if (args[0] === '--help' || args[0] === '-h') args[0] = 'help'
@@ -119,13 +122,20 @@ try {
119
122
  'dry-run': { type: 'boolean' },
120
123
  changed: { type: 'string' },
121
124
  type: { type: 'string' },
125
+ 'feeds-dir': { type: 'string' },
126
+ commit: { type: 'string' },
127
+ 'run-url': { type: 'string' },
128
+ package: { type: 'string' },
129
+ 'source-commit': { type: 'string' },
130
+ version: { type: 'string' },
131
+ 'require-all': { type: 'boolean' },
122
132
  help: { type: 'boolean', short: 'h' },
123
133
  },
124
134
  allowPositionals: true,
125
135
  help: 'node check.mjs --help',
126
136
  })
127
137
  } catch (error) {
128
- console.error(error.message)
138
+ console.error(printable(error.message))
129
139
  return 2
130
140
  }
131
141
 
@@ -152,7 +162,9 @@ Usage:
152
162
  node check.mjs alert [paths...] [--config FILE] [--days N] [--scope all|direct] [--format github|markdown|badge] [--json]
153
163
  node check.mjs plan [paths...] [--config FILE] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR] [--allow-incomplete]
154
164
  node check.mjs apply --plan plan.json [--dry-run]
155
- node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan]
165
+ node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan|eval|feed-refresh-receipt|npm-release-result|verification]
166
+ node check.mjs verify RECEIPT [--feeds-dir DIR] [--commit SHA] [--run-url URL] [--package FILE.tgz]
167
+ [--source-commit SHA] [--version X.Y.Z] [--plan FILE] [--config FILE] [--require-all] [--json]
156
168
 
157
169
  Commands:
158
170
  check Fail when tracked model IDs are retired or retiring within --days.
@@ -164,8 +176,38 @@ Commands:
164
176
  alert Emit GitHub Actions annotations, Markdown, or a Shields badge JSON and fail on errors.
165
177
  plan Emit a JSON migration plan with only safely patchable direct API references.
166
178
  apply Apply a plan with line-hash and occurrence checks; use --dry-run to preview.
167
- validate Validate a feed, config, check, inventory, schedule, alert, or plan document.
179
+ validate Validate a feed, config, check, inventory, schedule, alert, or plan document,
180
+ an eval, feed-refresh-receipt, or npm-release-result receipt, or a verify --json report.
168
181
  The type is inferred from its discriminator or .model-eol.json filename unless --type is set.
182
+ verify Check what a receipt claims against inputs you supply, offline. The receipt kind
183
+ comes from its schema value:
184
+ ${SUPPORTED_RECEIPT_SCHEMAS.join('\n ')}
185
+ Each claim is verified, mismatch, not-checked (its input was not supplied), or
186
+ not-applicable (a no-release receipt; a release input given for one adds a mismatched
187
+ released claim). Nothing is read from the current directory or git.
188
+ The result is verified only when every applicable claim is; partial means nothing
189
+ mismatched but some claim was not checked; mismatch means at least one claim failed.
190
+ --json emits a model-eol/verification@0.1 document.
191
+ Eval outcomes (status, exit_code, report) are never claims: verified means the
192
+ manifest is bound to the supplied commit, plan, config, and feeds, not that evals passed.
193
+
194
+ Verify inputs:
195
+ --feeds-dir DIR Feeds directory itself (DIR/*.json, e.g. ./feeds), not a repository root.
196
+ Feed-refresh: every feeds/*.json digest; missing, extra, and changed files mismatch.
197
+ Eval: with --plan and --config, recomputes each result's feed_digest
198
+ (--plan and --config alone already fix the expected result set).
199
+ --commit SHA Feed-refresh refresh_commit, npm-release release_commit, or eval base_sha.
200
+ --run-url URL Feed-refresh refresh_run, compared after HTTPS URL normalization.
201
+ --package FILE.tgz npm-release registry_integrity: sha512 SRI of the tarball bytes. No registry lookup.
202
+ --source-commit SHA npm-release source_commit.
203
+ --version X.Y.Z npm-release version (the tag is bound to it as vX.Y.Z).
204
+ --plan FILE Eval plan_digest from a model-eol.plan/0.1 document; also needed for feed digests.
205
+ --config FILE Eval eval_config_digest and configured, from .model-eol.json (use a file holding {}
206
+ for a repository without one). MODEL_EOL_EVAL_COMMAND overrides eval.command as it
207
+ does for the bot, and the output says when it applied.
208
+ --require-all Exit 1 when the result is partial. A release gate passes --package, --version,
209
+ --commit, and --require-all: a no-release receipt then mismatches.
210
+ SHA inputs are 40-64 hexadecimal characters; uppercase is lowercased.
169
211
 
170
212
  Scopes:
171
213
  all Check every tracked model ID found. This preserves the original behavior.
@@ -176,9 +218,10 @@ Configuration:
176
218
  --config FILE selects an explicit config. Explicit CLI flags override config values.
177
219
 
178
220
  Exit codes:
179
- 0 Clean, or report generated successfully.
180
- 1 Findings, alert errors, or refused apply items.
181
- 2 Usage, feed, scan, plan, or validation errors.
221
+ 0 Clean, or report generated successfully. verify: verified or partial (no claim mismatched).
222
+ 1 Findings, alert errors, or refused apply items. verify: a claim mismatched, or partial with --require-all.
223
+ 2 Usage, feed, scan, plan, or validation errors. verify: usage error, unreadable file, structurally
224
+ invalid receipt, or unsupported receipt schema.
182
225
  3 Output stream failure other than EPIPE.
183
226
  `)
184
227
  return 0
@@ -193,6 +236,37 @@ if (values.via === '') {
193
236
  return 2
194
237
  }
195
238
 
239
+ if (command === 'verify') {
240
+ const allowedOptions = new Set([...VERIFY_INPUTS, 'require-all', 'json'])
241
+ const unsupported = Object.keys(values).find(option => !allowedOptions.has(option))
242
+ if (positionals.length !== 1 || unsupported) {
243
+ const detail = unsupported ? `; --${unsupported} is not supported by verify` : ''
244
+ console.error(`verify requires exactly one RECEIPT${detail}; run node check.mjs --help for usage`)
245
+ return 2
246
+ }
247
+ let report
248
+ try {
249
+ report = verifyReceipt({
250
+ file: positionals[0],
251
+ inputs: Object.fromEntries(VERIFY_INPUTS.map(flag => [flag, values[flag]])),
252
+ env: process.env,
253
+ })
254
+ } catch (error) {
255
+ console.error(printable(error instanceof VerifyError
256
+ ? `model-eol verify: ${error.message}`
257
+ : `model-eol verify: unexpected error, nothing was checked: ${error?.message ?? error}`))
258
+ return 2
259
+ }
260
+ console.log(AS_JSON ? JSON.stringify(report, null, 2) : formatVerification(report))
261
+ if (report.result === 'mismatch') return 1
262
+ return report.result === 'partial' && values['require-all'] ? 1 : 0
263
+ }
264
+ const verifyOnly = ['feeds-dir', 'commit', 'run-url', 'package', 'source-commit', 'version', 'require-all'].find(option => values[option] !== undefined)
265
+ if (verifyOnly) {
266
+ console.error(`--${verifyOnly} is only supported by the verify command`)
267
+ return 2
268
+ }
269
+
196
270
  if (command === 'apply') {
197
271
  if (!PLAN_FILE || positionals.length) {
198
272
  console.error('apply requires --plan plan.json and accepts no target paths')