model-eol 0.3.0 → 0.4.1

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/bot/bot.mjs CHANGED
@@ -20,12 +20,13 @@ import {
20
20
  parseMetadata,
21
21
  repoPath,
22
22
  sha256,
23
+ stableJson,
23
24
  } from './lib/common.mjs'
24
25
  import { validatePlanItems } from '../lib/apply.mjs'
25
26
  import { parseCliArgs } from '../lib/cli.mjs'
26
27
  import { loadConfig } from './lib/config.mjs'
27
28
  import { downloadFeeds } from './lib/feeds.mjs'
28
- import { readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
29
+ import { capReport, readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
29
30
  import { GitHubClient, hasModelEolLabel } from './lib/github.mjs'
30
31
  import {
31
32
  cloneRepository,
@@ -36,6 +37,7 @@ import {
36
37
  originFor,
37
38
  prepareBranch,
38
39
  pushBranch,
40
+ verifyBotBranch,
39
41
  } from './lib/git.mjs'
40
42
 
41
43
  const ROOT = path.resolve(import.meta.dirname, '..')
@@ -43,12 +45,15 @@ const CHECKER = path.join(ROOT, 'check.mjs')
43
45
  const VENDORED_FEEDS = path.join(ROOT, 'feeds')
44
46
  const PLAN_SCHEMA = 'model-eol.plan/0.1'
45
47
  const BOT_SCHEMA = 'model-eol.bot/0.1'
48
+ const EVAL_SCHEMA = 'model-eol.eval/0.1'
49
+ const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
46
50
  const ISSUE_REASONS = new Set([
47
51
  'not-direct-api',
48
52
  'no-replacement',
49
53
  'replacement-choice',
50
54
  'replacement-unresolved',
51
55
  'replacement-retiring',
56
+ 'shutdown-date-unavailable',
52
57
  'publisher-fallback',
53
58
  'unresolved-channel',
54
59
  ])
@@ -85,15 +90,44 @@ export const parseArgs = argv => {
85
90
  return options
86
91
  }
87
92
 
93
+ export const parseEvaluateArgs = argv => {
94
+ const { values } = parseCliArgs({
95
+ args: argv,
96
+ options: {
97
+ 'target-dir': { type: 'string' },
98
+ config: { type: 'string' },
99
+ 'plan-file': { type: 'string' },
100
+ 'output-file': { type: 'string' },
101
+ 'feeds-url': { type: 'string', multiple: true },
102
+ help: { type: 'boolean', short: 'h' },
103
+ },
104
+ help: 'model-eol-bot evaluate --help',
105
+ })
106
+ const feedsUrls = []
107
+ for (const value of values['feeds-url'] ?? []) {
108
+ feedsUrls.push(...value.split(',').map(item => item.trim()).filter(Boolean))
109
+ }
110
+ return {
111
+ targetDir: values['target-dir'] ?? process.cwd(),
112
+ configPath: values.config ?? null,
113
+ planFile: values['plan-file'] ?? null,
114
+ outputFile: values['output-file'] ?? null,
115
+ feedsUrls,
116
+ help: values.help ?? false,
117
+ }
118
+ }
119
+
88
120
  export const helpText = () => `model-eol bot
89
121
 
90
122
  Usage:
91
123
  node bot/bot.mjs [--repo owner/name] [--target-dir PATH] [--config PATH] [--dry-run] [--eval] [--feeds-url URL[,URL...]]
124
+ node bot/bot.mjs evaluate --target-dir PATH --plan-file PATH --output-file PATH [--config PATH] [--feeds-url URL[,URL...]]
92
125
 
93
126
  Environment:
94
127
  GITHUB_TOKEN Required unless --dry-run; authenticates the API and HTTPS Git remotes.
95
128
  GITHUB_API_URL GitHub API root, default https://api.github.com.
96
129
  MODEL_EOL_TOKEN_KIND Set to github-token to add the checks warning to PRs.
130
+ MODEL_EOL_EVAL_COMMAND Optional command override used by both evaluate and publish.
97
131
  `
98
132
 
99
133
  const validateRepo = repo => {
@@ -102,9 +136,12 @@ const validateRepo = repo => {
102
136
  }
103
137
 
104
138
  const spawnPlan = ({ workDir, config, configPath = null, feedsDir, checkerPath = CHECKER, warn = console.error }) => {
105
- const args = [checkerPath, 'plan', '.', '--days', String(config.days), '--scope', config.scope]
106
- if (config.via) args.push('--via', config.via)
139
+ const args = [checkerPath, 'plan', '.']
107
140
  if (configPath) args.push('--config', configPath)
141
+ else {
142
+ args.push('--days', String(config.days), '--scope', config.scope)
143
+ if (config.via) args.push('--via', config.via)
144
+ }
108
145
  if (feedsDir) args.push('--feeds', feedsDir)
109
146
  const result = spawnSync(process.execPath, args, {
110
147
  cwd: workDir,
@@ -213,6 +250,74 @@ const readEvalArtifact = (file, maxBytes, statusFile = null) => {
213
250
  return { status: exitCode === 0 ? 'pass' : 'fail', exit_code: exitCode, report: artifact.report || null }
214
251
  }
215
252
 
253
+ const withoutGenerated = plan => {
254
+ const { generated, ...stable } = plan
255
+ return stable
256
+ }
257
+
258
+ const planDigest = plan => sha256(stableJson(withoutGenerated(plan)))
259
+
260
+ const effectiveEval = (config, commandOverride = null) => ({
261
+ ...config.eval,
262
+ command: typeof commandOverride === 'string' && commandOverride.trim() ? commandOverride : config.eval.command,
263
+ })
264
+
265
+ const evalConfigDigest = settings => sha256(stableJson({
266
+ command: settings.command ?? null,
267
+ timeout_ms: settings.timeout_ms,
268
+ max_report_bytes: settings.max_report_bytes,
269
+ pass_env: settings.pass_env,
270
+ }))
271
+
272
+ const evalIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
273
+
274
+ const readJsonArtifact = (file, maxBytes, label) => {
275
+ const absolute = asAbsolute(file)
276
+ const artifact = readReportCapped(absolute, maxBytes + 1)
277
+ if (artifact.missing) throw new Error(`${label} is missing`)
278
+ if (Buffer.byteLength(artifact.report) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`)
279
+ try {
280
+ return JSON.parse(artifact.report)
281
+ } catch (error) {
282
+ throw new Error(`${label} is malformed JSON: ${error.message}`)
283
+ }
284
+ }
285
+
286
+ const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead }) => {
287
+ const settings = effectiveEval(config, commandOverride)
288
+ const maxBytes = Math.min(EVAL_ARTIFACT_MAX_BYTES, Math.max(1024 * 1024, groups.length * (settings.max_report_bytes + 2048)))
289
+ const artifact = readJsonArtifact(file, maxBytes, 'eval results artifact')
290
+ if (artifact?.schema !== EVAL_SCHEMA) throw new Error(`refusing eval results schema ${artifact?.schema ?? 'missing'}; expected ${EVAL_SCHEMA}`)
291
+ if (artifact.plan_digest !== planDigest(plan)) throw new Error('refusing eval results artifact: plan digest does not match the independently verified plan')
292
+ if (artifact.eval_config_digest !== evalConfigDigest(settings)) throw new Error('refusing eval results artifact: eval configuration digest does not match')
293
+ 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')
294
+ 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}`)
295
+ const configured = Boolean(settings.command)
296
+ if (artifact.configured !== configured) throw new Error('refusing eval results artifact: configured state does not match eval.command')
297
+ if (!Array.isArray(artifact.results)) throw new Error('refusing eval results artifact: results must be an array')
298
+ if (artifact.results.length !== (configured ? groups.length : 0)) {
299
+ throw new Error('refusing eval results artifact: expected exactly one result for every planned migration')
300
+ }
301
+ const expected = new Map(groups.map(group => [evalIdentity(group.publisher, group.id, group.via), group]))
302
+ const results = new Map()
303
+ for (const result of artifact.results) {
304
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('refusing eval results artifact: result must be an object')
305
+ if (typeof result.publisher !== 'string' || !result.publisher || typeof result.id !== 'string' || !result.id) throw new Error('refusing eval results artifact: result identity is malformed')
306
+ if (result.via !== null && typeof result.via !== 'string') throw new Error('refusing eval results artifact: result clock is malformed')
307
+ const key = evalIdentity(result.publisher, result.id, result.via)
308
+ const group = expected.get(key)
309
+ if (!group || results.has(key)) throw new Error('refusing eval results artifact: result identity is unexpected or duplicated')
310
+ if (result.feed_digest !== group.feedDigest) throw new Error(`refusing eval results artifact: feed digest mismatch for ${group.publisher}/${group.id}`)
311
+ if (!['pass', 'fail', 'timeout'].includes(result.status)) throw new Error(`refusing eval results artifact: invalid status for ${group.publisher}/${group.id}`)
312
+ 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}`)
313
+ 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}`)
314
+ if (result.report !== null && typeof result.report !== 'string') throw new Error(`refusing eval results artifact: invalid report for ${group.publisher}/${group.id}`)
315
+ 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}`)
316
+ results.set(key, { status: result.status, exit_code: result.exit_code, report: result.report })
317
+ }
318
+ return results
319
+ }
320
+
216
321
  const feedContext = feedsDir => {
217
322
  const records = new Map()
218
323
  let files = []
@@ -270,16 +375,17 @@ const buildModelGroups = (plan, config, root, records) => {
270
375
  const groups = new Map()
271
376
  for (const item of plan.items) {
272
377
  if (isIgnored(item, root, config, ignoredIds)) continue
273
- const key = `${item.publisher}\0${item.id}`
378
+ const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : plan.via
379
+ const key = `${item.publisher}\0${item.id}\0${via ?? ''}`
274
380
  if (!groups.has(key)) {
275
381
  groups.set(key, {
276
382
  kind: 'model',
277
383
  id: item.id,
278
384
  publisher: item.publisher,
279
385
  items: [],
280
- via: plan.via,
281
- branch: branchFor(item.publisher, item.id, plan.via),
282
- context: contextFor(records, item.publisher, item.id, plan.via),
386
+ via,
387
+ branch: branchFor(item.publisher, item.id, via),
388
+ context: contextFor(records, item.publisher, item.id, via),
283
389
  })
284
390
  }
285
391
  groups.get(key).items.push(item)
@@ -300,7 +406,8 @@ const buildIssueGroups = (plan, config, root, records) => {
300
406
  if (isIgnored(issue, root, config, ignoredIds)) continue
301
407
  const channel = issue.reason === 'unresolved-channel' ? (issue.provider || issue.matched || 'unresolved-channel') : null
302
408
  const subject = issue.id || channel || issue.matched || 'unresolved-model'
303
- const key = `${issue.publisher || ''}\0${subject}\0${issue.shutdown ?? ''}`
409
+ const via = Object.hasOwn(issue, 'requested_via') ? issue.requested_via : (issue.via ?? plan.via)
410
+ const key = `${issue.publisher || ''}\0${subject}\0${issue.shutdown ?? ''}\0${via ?? ''}\0${channel ?? ''}`
304
411
  if (!groups.has(key)) {
305
412
  groups.set(key, {
306
413
  kind: 'issue',
@@ -309,10 +416,10 @@ const buildIssueGroups = (plan, config, root, records) => {
309
416
  channel,
310
417
  publisher: issue.publisher || 'unknown',
311
418
  shutdown: issue.shutdown ?? null,
312
- via: issue.via ?? plan.via,
419
+ via,
313
420
  issues: [],
314
421
  root,
315
- context: issue.id ? contextFor(records, issue.publisher, issue.id, issue.via ?? plan.via) : { announced: null, notes: [], entry: null },
422
+ context: issue.id ? contextFor(records, issue.publisher, issue.id, via) : { announced: null, notes: [], entry: null },
316
423
  })
317
424
  }
318
425
  groups.get(key).issues.push(issue)
@@ -377,7 +484,7 @@ const replacementSection = (item, now) => [
377
484
  : []),
378
485
  ]
379
486
 
380
- export const buildPullBody = ({ group, headSha, now = new Date(), tokenKind = null, evalResult = null }) => {
487
+ export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null }) => {
381
488
  const item = group.items[0]
382
489
  const announced = markdownText(group.context?.announced ?? 'not specified')
383
490
  const days = daysRemaining(item.shutdown, now)
@@ -390,6 +497,7 @@ export const buildPullBody = ({ group, headSha, now = new Date(), tokenKind = nu
390
497
  replacement: item.replacement,
391
498
  replacement_options: item.replacement_options,
392
499
  replacement_note: item.replacement_note,
500
+ base_sha: baseSha,
393
501
  head_sha: headSha,
394
502
  feed_digest: group.feedDigest,
395
503
  }
@@ -479,11 +587,19 @@ const issueTitle = group => `model-eol: investigate ${group.subject}${group.shut
479
587
  const isOpen = item => item.state === 'open'
480
588
  const isMerged = item => Boolean(item.merged_at || item.pull_request?.merged_at || item.merged === true)
481
589
 
482
- const matchingPulls = (pulls, group) => pulls
483
- .filter(item => !item.labels || hasModelEolLabel(item) || item.head?.ref === group.branch || hasMetadataMarker(item.body))
590
+ const pullFromTargetRepo = (item, repo) => !item.head?.repo?.full_name || item.head.repo.full_name === repo
591
+
592
+ const pullOnExpectedBranch = (item, group, repo) => item.head?.ref === group.branch && pullFromTargetRepo(item, repo)
593
+
594
+ const matchingPulls = (pulls, group, repo) => pulls
595
+ .filter(item => hasModelEolLabel(item) && pullOnExpectedBranch(item, group, repo))
484
596
  .map(item => {
485
597
  const metadata = parseMetadata(item.body)
486
- return { item, metadata, conflict: hasMetadataMarker(item.body) && !metadata }
598
+ return {
599
+ item,
600
+ metadata,
601
+ conflict: hasMetadataMarker(item.body) && (!metadata || typeof metadata.head_sha !== 'string' || !metadata.head_sha),
602
+ }
487
603
  })
488
604
  .filter(record => record.conflict || (
489
605
  record.metadata?.id === group.id
@@ -492,13 +608,12 @@ const matchingPulls = (pulls, group) => pulls
492
608
  ))
493
609
 
494
610
  const matchingIssues = (issues, group) => issues
495
- .filter(item => !item.labels || hasModelEolLabel(item) || hasMetadataMarker(item.body))
611
+ .filter(item => hasModelEolLabel(item))
496
612
  .map(item => {
497
613
  const metadata = parseMetadata(item.body)
498
- return { item, metadata, conflict: hasMetadataMarker(item.body) && !metadata }
614
+ return { item, metadata, conflict: false }
499
615
  })
500
616
  .filter(record => {
501
- if (record.conflict) return true
502
617
  if (!record.metadata) return false
503
618
  if (record.metadata.id !== (group.id || group.subject)) return false
504
619
  if (record.metadata.publisher !== group.publisher) return false
@@ -506,17 +621,78 @@ const matchingIssues = (issues, group) => issues
506
621
  return !group.channel || record.metadata.channel === group.channel || (record.metadata.id === group.channel && !record.metadata.channel)
507
622
  })
508
623
 
509
- const currentHeadFor = async (api, pull, group) => {
510
- if (pull.head?.sha) return pull.head.sha
511
- const branch = pull.head?.ref || group.branch
512
- const encoded = branch.split('/').map(part => encodeURIComponent(part)).join('/')
513
- const { data } = await api.request('GET', `/repos/${api.repo}/git/ref/heads/${encoded}`)
514
- return data?.object?.sha ?? null
624
+ const modelIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
625
+ const issueIdentity = (publisher, id, via, channel = null) => `${publisher}\0${id}\0${via ?? ''}\0${channel ?? ''}`
626
+
627
+ const ownedPullRecords = (pulls, repo) => pulls
628
+ .filter(item => hasModelEolLabel(item) && pullFromTargetRepo(item, repo))
629
+ .map(item => ({ item, metadata: parseMetadata(item.body) }))
630
+ .filter(({ item, metadata }) => metadata
631
+ && typeof metadata.head_sha === 'string'
632
+ && metadata.head_sha
633
+ && item.head?.ref === branchFor(metadata.publisher, metadata.id, metadata.via ?? null))
634
+
635
+ const ownedIssueRecords = issues => issues
636
+ .filter(item => hasModelEolLabel(item))
637
+ .map(item => ({ item, metadata: parseMetadata(item.body) }))
638
+ .filter(record => record.metadata)
639
+
640
+ const staleWorkComment = kind => `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.`
641
+
642
+ const staleClosedBody = (body, metadata) => {
643
+ const lines = String(body ?? '').split(/\r?\n/)
644
+ lines[0] = metadataLine({ ...metadata, stale_closed: true })
645
+ return lines.join('\n')
646
+ }
647
+
648
+ const groupFromMetadata = (kind, metadata) => ({
649
+ kind,
650
+ id: metadata.id,
651
+ subject: metadata.id,
652
+ publisher: metadata.publisher,
653
+ via: metadata.via ?? null,
654
+ branch: kind === 'model' ? branchFor(metadata.publisher, metadata.id, metadata.via ?? null) : undefined,
655
+ })
656
+
657
+ const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) => {
658
+ const activeModels = new Set(models.map(group => modelIdentity(group.publisher, group.id, group.via)))
659
+ const activeIssues = new Set(issueGroups.flatMap(group => [
660
+ issueIdentity(group.publisher, group.id || group.subject, group.via, group.channel),
661
+ issueIdentity(group.publisher, group.id || group.subject, group.via, null),
662
+ ]))
663
+ const decisions = []
664
+ for (const record of ownedPullRecords(pulls, api.repo)) {
665
+ if (!isOpen(record.item)) continue
666
+ const key = modelIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via)
667
+ if (activeModels.has(key)) continue
668
+ await api.comment(record.item.number, staleWorkComment('pull request'))
669
+ await api.updatePull(record.item.number, {
670
+ state: 'closed',
671
+ body: staleClosedBody(record.item.body, record.metadata),
672
+ })
673
+ decisions.push(decision(groupFromMetadata('model', record.metadata), 'close-stale', { number: record.item.number }))
674
+ }
675
+ for (const record of ownedIssueRecords(issues)) {
676
+ if (!isOpen(record.item)) continue
677
+ const key = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, record.metadata.channel)
678
+ const legacyKey = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, null)
679
+ if (activeIssues.has(key) || activeIssues.has(legacyKey)) continue
680
+ await api.comment(record.item.number, staleWorkComment('issue'))
681
+ await api.updateIssue(record.item.number, {
682
+ state: 'closed',
683
+ body: staleClosedBody(record.item.body, record.metadata),
684
+ })
685
+ decisions.push(decision(groupFromMetadata('issue', record.metadata), 'close-stale', { number: record.item.number }))
686
+ }
687
+ return decisions
515
688
  }
516
689
 
517
690
  const standingDownComment = (group, metadata, currentHead) =>
518
691
  `model-eol is standing down for this branch. The branch head (${markdownCode(currentHead || 'unknown')}) no longer satisfies the bot lease checks for metadata head (${markdownCode(metadata?.head_sha || 'unknown')}), so a human change will not be overwritten. Please update or close this PR manually. An actor with push access can forge the configured identity.`
519
692
 
693
+ const staleBaseComment = (metadata, base) =>
694
+ `model-eol is standing down because this bot-owned PR no longer targets the expected default branch. Expected ${markdownCode(base)}, but the PR targets ${markdownCode(metadata || 'unknown')}. Please retarget or close this PR manually.`
695
+
520
696
  const writeJson = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2))
521
697
 
522
698
  const gitStatusPaths = cwd => {
@@ -563,7 +739,133 @@ const evalWorkspaceDrift = (root, planFiles, expectedHashes, expectedHead) => {
563
739
  return null
564
740
  }
565
741
 
566
- const makePatch = ({ source, base, branch, expectedHead, group, plan, config, checkerPath, evalEnabled, root, warn, gitAuth }) => {
742
+ const writeJsonAtomic = (file, value) => {
743
+ const absolute = asAbsolute(file)
744
+ fs.mkdirSync(path.dirname(absolute), { recursive: true })
745
+ const temporary = `${absolute}.${process.pid}.${sha256(`${Date.now()}-${absolute}`).slice(0, 8)}.tmp`
746
+ try {
747
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
748
+ fs.renameSync(temporary, absolute)
749
+ } finally {
750
+ fs.rmSync(temporary, { force: true })
751
+ }
752
+ }
753
+
754
+ export const evaluatePlan = async ({
755
+ targetDir = process.cwd(),
756
+ configPath = null,
757
+ planFile,
758
+ outputFile,
759
+ feedsUrls = [],
760
+ commandOverride = process.env.MODEL_EOL_EVAL_COMMAND || null,
761
+ fetchImpl = globalThis.fetch,
762
+ checkerPath = CHECKER,
763
+ vendoredFeeds = VENDORED_FEEDS,
764
+ warn = message => console.error(message),
765
+ now = new Date(),
766
+ } = {}) => {
767
+ if (!planFile) throw new Error('--plan-file is required for evaluate')
768
+ if (!outputFile) throw new Error('--output-file is required for evaluate')
769
+ const targetPath = path.resolve(targetDir)
770
+ const configFile = configFileFor({ targetDir: targetPath, configPath, scanPath: targetPath })
771
+ if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
772
+ const config = loadConfig(configFile)
773
+ const settings = effectiveEval(config, commandOverride)
774
+ const planConfigPath = fs.existsSync(configFile) ? configFile : null
775
+ const feedSet = await downloadFeeds({
776
+ urls: feedsUrls,
777
+ fetchImpl,
778
+ vendoredDir: vendoredFeeds,
779
+ allowVendoredFallback: config.feeds.allow_vendored_fallback,
780
+ warn,
781
+ })
782
+ try {
783
+ const generatedPlan = runPlan({ workDir: targetPath, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
784
+ const plan = verifiedPlanArtifact(planFile, generatedPlan)
785
+ const groups = buildModelGroups(plan, config, targetPath, feedContext(feedSet.dir))
786
+ const results = []
787
+ if (settings.command) {
788
+ for (const group of groups) {
789
+ const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-evaluate-'))
790
+ const clone = path.join(workRoot, 'repo')
791
+ const selectedPlanPath = path.join(workRoot, 'selected-plan.json')
792
+ const reportPath = path.join(workRoot, 'eval-report.md')
793
+ writeJson(selectedPlanPath, { ...plan, items: group.items, issues: [] })
794
+ const selectedPlanHash = sha256(fs.readFileSync(selectedPlanPath))
795
+ let result
796
+ try {
797
+ cloneRepository(targetPath, clone)
798
+ const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
799
+ cwd: clone,
800
+ encoding: 'utf8',
801
+ stdio: ['ignore', 'pipe', 'pipe'],
802
+ })
803
+ if (applied.status !== 0 || applied.error) {
804
+ throw new Error(`apply subprocess failed for ${group.publisher}/${group.id}: ${applied.error?.message || applied.stderr?.trim() || `exit ${applied.status}`}`)
805
+ }
806
+ const planFiles = [...new Set(group.items.map(item => repoPath(item.file, clone)))]
807
+ const postApplyHashes = hashFiles(clone, planFiles)
808
+ const postApplyHead = gitHead(clone)
809
+ try {
810
+ result = runEvalHook({
811
+ command: settings.command,
812
+ timeoutMs: settings.timeout_ms,
813
+ maxReportBytes: settings.max_report_bytes,
814
+ passEnv: settings.pass_env,
815
+ cwd: clone,
816
+ oldId: group.id,
817
+ newId: group.items[0].replacement,
818
+ planPath: selectedPlanPath,
819
+ reportPath,
820
+ })
821
+ } catch (error) {
822
+ result = { status: 'fail', exit_code: null, report: `eval runner error: ${error.message}` }
823
+ }
824
+ const drift = evalWorkspaceDrift(clone, planFiles, postApplyHashes, postApplyHead)
825
+ let planDrift = null
826
+ try {
827
+ if (sha256(fs.readFileSync(selectedPlanPath)) !== selectedPlanHash) planDrift = 'eval changed its selected plan artifact'
828
+ } catch (error) {
829
+ planDrift = `eval made its selected plan artifact unreadable (${error.message})`
830
+ }
831
+ if (drift || planDrift) {
832
+ result = {
833
+ ...result,
834
+ status: 'fail',
835
+ report: capReport([result.report, drift, planDrift].filter(Boolean).join('\n'), settings.max_report_bytes),
836
+ }
837
+ }
838
+ } finally {
839
+ fs.rmSync(workRoot, { recursive: true, force: true })
840
+ }
841
+ results.push({
842
+ publisher: group.publisher,
843
+ id: group.id,
844
+ via: group.via ?? null,
845
+ feed_digest: group.feedDigest,
846
+ status: result.status,
847
+ exit_code: result.exit_code,
848
+ report: capReport(result.report, settings.max_report_bytes),
849
+ })
850
+ }
851
+ }
852
+ const artifact = {
853
+ schema: EVAL_SCHEMA,
854
+ generated: now.toISOString(),
855
+ base_sha: gitHead(targetPath),
856
+ plan_digest: planDigest(plan),
857
+ eval_config_digest: evalConfigDigest(settings),
858
+ configured: Boolean(settings.command),
859
+ results,
860
+ }
861
+ writeJsonAtomic(outputFile, artifact)
862
+ return { artifact, plan, config: { ...config, eval: settings }, degraded: Boolean(feedSet.degraded) }
863
+ } finally {
864
+ feedSet.cleanup()
865
+ }
866
+ }
867
+
868
+ const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = false, group, plan, config, checkerPath, evalEnabled, root, warn, gitAuth }) => {
567
869
  const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-work-'))
568
870
  const planRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-plan-'))
569
871
  const clone = path.join(workRoot, 'repo')
@@ -612,7 +914,7 @@ const makePatch = ({ source, base, branch, expectedHead, group, plan, config, ch
612
914
  evalResult = {
613
915
  ...evalResult,
614
916
  status: 'fail',
615
- report: [evalResult.report, drift].filter(Boolean).join('\n'),
917
+ report: capReport([evalResult.report, drift].filter(Boolean).join('\n'), config.eval.max_report_bytes),
616
918
  }
617
919
  }
618
920
  }
@@ -627,7 +929,7 @@ const makePatch = ({ source, base, branch, expectedHead, group, plan, config, ch
627
929
  const files = planFiles
628
930
  const message = `model-eol: migrate ${group.id} to ${group.items[0].replacement} (${group.feedDigest.slice(0, 8)})`
629
931
  const headSha = commitAll(clone, files, message)
630
- pushBranch(clone, branch, expectedHead, gitAuth)
932
+ pushBranch(clone, branch, expectedHead, gitAuth, { allowMissing: allowMissingBranch })
631
933
  return { headSha, evalResult }
632
934
  } finally {
633
935
  fs.rmSync(workRoot, { recursive: true, force: true })
@@ -637,22 +939,28 @@ const makePatch = ({ source, base, branch, expectedHead, group, plan, config, ch
637
939
 
638
940
  const decision = (group, action, extra = {}) => ({ group, action, ...extra })
639
941
 
640
- const processModel = async ({ api, pulls, group, source, base, plan, config, checkerPath, evalEnabled, externalEval, root, now, tokenKind, warn, gitAuth }) => {
641
- if (externalEval && externalEval.status !== 'pass') {
642
- return decision(group, 'eval-failed', { evalResult: externalEval })
643
- }
644
- const matches = matchingPulls(pulls, group)
942
+ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, config, checkerPath, evalEnabled, externalEval, root, now, tokenKind, warn, gitAuth }) => {
943
+ const matches = matchingPulls(pulls, group, api.repo)
645
944
  const conflict = matches.find(record => record.conflict)
646
945
  if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
647
946
  const open = matches.find(record => isOpen(record.item))
648
947
  const currentDigest = group.feedDigest
649
948
  if (open) {
650
- if (open.metadata.feed_digest === currentDigest) return decision(group, 'skip-unchanged', { number: open.item.number })
651
- const currentHead = await currentHeadFor(api, open.item, group)
652
- if (!currentHead || currentHead !== open.metadata.head_sha) {
653
- await api.comment(open.item.number, standingDownComment(group, open.metadata, currentHead))
949
+ if (open.item.base?.ref && open.item.base.ref !== base) {
950
+ await api.comment(open.item.number, staleBaseComment(open.item.base.ref, base))
951
+ return decision(group, 'stand-down', { number: open.item.number })
952
+ }
953
+ const lease = verifyBotBranch(root, open.item.head?.ref || group.branch, open.metadata.head_sha, gitAuth)
954
+ if (!lease.safe) {
955
+ await api.comment(open.item.number, standingDownComment(group, open.metadata, lease.head))
654
956
  return decision(group, 'stand-down', { number: open.item.number })
655
957
  }
958
+ if (externalEval && externalEval.status !== 'pass') {
959
+ return decision(group, 'eval-failed', { number: open.item.number, evalResult: externalEval })
960
+ }
961
+ if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead) {
962
+ return decision(group, 'skip-unchanged', { number: open.item.number })
963
+ }
656
964
  let patch
657
965
  try {
658
966
  patch = makePatch({
@@ -681,19 +989,29 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
681
989
  return decision(group, 'eval-failed', { number: open.item.number, evalResult: patch.evalResult })
682
990
  }
683
991
  if (!patch.evalResult && externalEval) patch.evalResult = externalEval
684
- const body = buildPullBody({ group, headSha: patch.headSha, now, tokenKind, evalResult: patch.evalResult })
992
+ const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: patch.evalResult })
685
993
  await api.updatePull(open.item.number, { title: pullTitle(group), body })
686
994
  return decision(group, 'update', { number: open.item.number, body, headSha: patch.headSha })
687
995
  }
688
996
 
997
+ if (externalEval && externalEval.status !== 'pass') {
998
+ return decision(group, 'eval-failed', { evalResult: externalEval })
999
+ }
1000
+
689
1001
  const replacement = group.items[0].replacement
690
- // Legacy metadata without replacement keeps shutdown-only suppression.
691
- const merged = matches.find(record => !isOpen(record.item) && isMerged(record.item) && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
692
- if (merged) return decision(group, 'skip-merged', { number: merged.item.number })
693
- const dismissed = matches.find(record => !isOpen(record.item) && !isMerged(record.item) && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1002
+ const dismissed = matches.find(record => !isOpen(record.item) && !isMerged(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
694
1003
  if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
695
1004
 
696
- const previousBotHead = matches.find(record => record.metadata?.head_sha)?.metadata.head_sha ?? null
1005
+ const previous = matches.find(record => record.metadata?.head_sha) ?? null
1006
+ const previousBotHead = previous?.metadata.head_sha ?? null
1007
+ if (!previousBotHead) {
1008
+ const occupied = verifyBotBranch(root, group.branch, null, gitAuth)
1009
+ if (occupied.error) throw new Error(occupied.error)
1010
+ if (occupied.head) {
1011
+ const occupant = pulls.find(item => pullOnExpectedBranch(item, group, api.repo))
1012
+ return decision(group, 'conflict', { number: occupant?.number })
1013
+ }
1014
+ }
697
1015
  let patch
698
1016
  try {
699
1017
  patch = makePatch({
@@ -701,6 +1019,7 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
701
1019
  base,
702
1020
  branch: group.branch,
703
1021
  expectedHead: previousBotHead,
1022
+ allowMissingBranch: Boolean(previousBotHead && !isOpen(previous.item)),
704
1023
  group,
705
1024
  plan,
706
1025
  config,
@@ -723,8 +1042,9 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
723
1042
  return decision(group, 'eval-failed', { evalResult: patch.evalResult })
724
1043
  }
725
1044
  if (!patch.evalResult && externalEval) patch.evalResult = externalEval
726
- const body = buildPullBody({ group, headSha: patch.headSha, now, tokenKind, evalResult: patch.evalResult })
727
- const latestMatches = matchingPulls(await api.listPullsByHead(group.branch), group)
1045
+ const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: patch.evalResult })
1046
+ const latestPulls = await api.listPullsByHead(group.branch)
1047
+ const latestMatches = matchingPulls(latestPulls, group, api.repo)
728
1048
  const latestConflict = latestMatches.find(record => record.conflict)
729
1049
  if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
730
1050
  const latestOpen = latestMatches.find(record => isOpen(record.item))
@@ -732,8 +1052,10 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
732
1052
  await api.updatePull(latestOpen.item.number, { title: pullTitle(group), body })
733
1053
  return decision(group, 'update', { number: latestOpen.item.number, body, headSha: patch.headSha })
734
1054
  }
735
- const latestSuppressed = latestMatches.find(record => !isOpen(record.item) && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
736
- if (latestSuppressed) return decision(group, isMerged(latestSuppressed.item) ? 'skip-merged' : 'skip-dismissed', { number: latestSuppressed.item.number })
1055
+ const latestUntrusted = latestPulls.find(item => pullOnExpectedBranch(item, group, api.repo) && !latestMatches.some(record => record.item.number === item.number))
1056
+ if (latestUntrusted) return decision(group, 'conflict', { number: latestUntrusted.number })
1057
+ const latestSuppressed = latestMatches.find(record => !isOpen(record.item) && !isMerged(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1058
+ if (latestSuppressed) return decision(group, 'skip-dismissed', { number: latestSuppressed.item.number })
737
1059
  const created = await api.createPull({
738
1060
  title: pullTitle(group),
739
1061
  head: group.branch,
@@ -755,7 +1077,7 @@ const processIssue = async ({ api, issues, group, now }) => {
755
1077
  return decision(group, 'update', { number: open.item.number, body })
756
1078
  }
757
1079
  const replacement = group.issues[0].replacement ?? null
758
- const dismissed = matches.find(record => !isOpen(record.item) && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1080
+ const dismissed = matches.find(record => !isOpen(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
759
1081
  if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
760
1082
  const latestMatches = matchingIssues(await api.listIssues(), group)
761
1083
  const latestConflict = latestMatches.find(record => record.conflict)
@@ -765,7 +1087,7 @@ const processIssue = async ({ api, issues, group, now }) => {
765
1087
  await api.updateIssue(latestOpen.item.number, { title: issueTitle(group), body })
766
1088
  return decision(group, 'update', { number: latestOpen.item.number, body })
767
1089
  }
768
- const latestDismissed = latestMatches.find(record => !isOpen(record.item) && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1090
+ const latestDismissed = latestMatches.find(record => !isOpen(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
769
1091
  if (latestDismissed) return decision(group, 'skip-dismissed', { number: latestDismissed.item.number })
770
1092
  const created = await api.createIssue({ title: issueTitle(group), body, labels: ['model-eol'] })
771
1093
  return decision(group, 'create', { number: created.number, body })
@@ -814,6 +1136,43 @@ export const formatDecisions = (decisions, { degraded = false } = {}) => {
814
1136
  return lines.join('\n')
815
1137
  }
816
1138
 
1139
+ const BLOCKED_ACTIONS = new Set(['conflict', 'eval-failed', 'label-unavailable', 'report-only', 'stand-down'])
1140
+
1141
+ const appendDecisionSummary = (file, decisions, { degraded = false } = {}) => {
1142
+ if (!file) return
1143
+ const blocked = degraded || decisions.some(record => BLOCKED_ACTIONS.has(record.action))
1144
+ const lines = [
1145
+ '## model-eol bot',
1146
+ '',
1147
+ `Outcome: **${blocked ? 'blocked' : 'success'}**${degraded ? ' (vendored feed fallback; no writes)' : ''}`,
1148
+ '',
1149
+ '| Action | Finding |',
1150
+ '| --- | --- |',
1151
+ ]
1152
+ for (const record of decisions) {
1153
+ const group = record.group
1154
+ const label = group.kind === 'model' ? `${group.publisher}/${group.id}` : `${group.publisher}/${group.subject}`
1155
+ lines.push(`| ${markdownText(record.action)} | ${markdownText(label)} |`)
1156
+ }
1157
+ if (!decisions.length) lines.push('| none | No actionable findings |')
1158
+ fs.appendFileSync(file, `${lines.join('\n')}\n`)
1159
+ }
1160
+
1161
+ const appendEvalSummary = (file, artifact) => {
1162
+ if (!file) return
1163
+ const lines = [
1164
+ '## model-eol migration evals',
1165
+ '',
1166
+ artifact.configured ? 'Each migration was applied and evaluated in an isolated checkout.' : 'No eval command is configured.',
1167
+ '',
1168
+ '| Result | Migration |',
1169
+ '| --- | --- |',
1170
+ ]
1171
+ for (const result of artifact.results) lines.push(`| ${markdownText(result.status)} | ${markdownText(`${result.publisher}/${result.id}`)} |`)
1172
+ if (!artifact.results.length) lines.push('| not run | No configured migration evals |')
1173
+ fs.appendFileSync(file, `${lines.join('\n')}\n`)
1174
+ }
1175
+
817
1176
  export const runBot = async ({
818
1177
  repo,
819
1178
  targetDir = process.cwd(),
@@ -831,6 +1190,8 @@ export const runBot = async ({
831
1190
  planFile = null,
832
1191
  evalReportFile = null,
833
1192
  evalStatusFile = null,
1193
+ evalResultsFile = null,
1194
+ evalCommandOverride = null,
834
1195
  now = new Date(),
835
1196
  warn = message => console.error(message),
836
1197
  } = {}) => {
@@ -874,6 +1235,7 @@ export const runBot = async ({
874
1235
  baseClone = path.join(ownedBaseRoot, 'repo')
875
1236
  cloneRepository(source, baseClone, gitAuth)
876
1237
  const base = defaultBranch(baseClone, gitAuth)
1238
+ const baseHead = gitHead(baseClone)
877
1239
  const configFile = configFileFor({ targetDir, configPath, scanPath: baseClone })
878
1240
  if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
879
1241
  const config = loadConfig(configFile)
@@ -895,36 +1257,46 @@ export const runBot = async ({
895
1257
  const decisions = [...models.map(report), ...issues.map(report)]
896
1258
  return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
897
1259
  }
898
- const externalEval = readEvalArtifact(
1260
+ const externalEvalResults = evalResultsFile
1261
+ ? readEvalResults({ file: evalResultsFile, config, commandOverride: evalCommandOverride, plan, groups: models, baseHead })
1262
+ : null
1263
+ const legacyExternalEval = externalEvalResults ? null : readEvalArtifact(
899
1264
  evalReportFile ? asAbsolute(evalReportFile) : null,
900
1265
  config.eval.max_report_bytes,
901
1266
  evalStatusFile ? asAbsolute(evalStatusFile) : null,
902
1267
  )
903
1268
  const api = new GitHubClient({ repo, apiUrl, token, transport, warn })
904
- if (models.length || issues.length) await api.ensureModelEolLabel()
905
- const pulls = models.length ? await api.listPulls() : []
906
- const issueRecords = issues.length ? await api.listIssues() : []
1269
+ const labelReady = models.length || issues.length ? await api.ensureModelEolLabel() : true
1270
+ const pulls = await api.listPulls()
1271
+ const issueRecords = await api.listIssues()
907
1272
  const decisions = []
908
- for (const group of models) {
909
- decisions.push(await processModel({
910
- api,
911
- pulls,
912
- group,
913
- source,
914
- base,
915
- plan,
916
- config,
917
- checkerPath,
918
- evalEnabled,
919
- externalEval,
920
- root: baseClone,
921
- now,
922
- tokenKind,
923
- warn,
924
- gitAuth,
925
- }))
1273
+ if (!labelReady) {
1274
+ for (const group of [...models, ...issues]) decisions.push(decision(group, 'label-unavailable'))
1275
+ } else {
1276
+ for (const group of models) {
1277
+ const externalEval = externalEvalResults?.get(evalIdentity(group.publisher, group.id, group.via)) ?? legacyExternalEval
1278
+ decisions.push(await processModel({
1279
+ api,
1280
+ pulls,
1281
+ group,
1282
+ source,
1283
+ base,
1284
+ baseHead,
1285
+ plan,
1286
+ config,
1287
+ checkerPath,
1288
+ evalEnabled,
1289
+ externalEval,
1290
+ root: baseClone,
1291
+ now,
1292
+ tokenKind,
1293
+ warn,
1294
+ gitAuth,
1295
+ }))
1296
+ }
1297
+ for (const group of issues) decisions.push(await processIssue({ api, issues: issueRecords, group, now }))
926
1298
  }
927
- for (const group of issues) decisions.push(await processIssue({ api, issues: issueRecords, group, now }))
1299
+ decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: issues }))
928
1300
  return { plan, config, decisions, feedsDir: feedSet.dir, degraded: false }
929
1301
  } finally {
930
1302
  scan?.cleanup()
@@ -934,6 +1306,24 @@ export const runBot = async ({
934
1306
  }
935
1307
 
936
1308
  export const main = async (argv = process.argv.slice(2), env = process.env) => {
1309
+ if (argv[0] === 'evaluate') {
1310
+ const options = parseEvaluateArgs(argv.slice(1))
1311
+ if (options.help) {
1312
+ console.log(helpText())
1313
+ return 0
1314
+ }
1315
+ const result = await evaluatePlan({
1316
+ targetDir: options.targetDir,
1317
+ configPath: options.configPath,
1318
+ planFile: options.planFile,
1319
+ outputFile: options.outputFile,
1320
+ feedsUrls: options.feedsUrls,
1321
+ commandOverride: env.MODEL_EOL_EVAL_COMMAND || null,
1322
+ })
1323
+ appendEvalSummary(env.GITHUB_STEP_SUMMARY, result.artifact)
1324
+ for (const item of result.artifact.results) console.log(`${item.status} ${item.publisher}/${item.id}`)
1325
+ return 0
1326
+ }
937
1327
  const options = parseArgs(argv)
938
1328
  if (options.help) {
939
1329
  console.log(helpText())
@@ -949,6 +1339,8 @@ export const main = async (argv = process.argv.slice(2), env = process.env) => {
949
1339
  planFile: env.MODEL_EOL_PLAN_FILE || null,
950
1340
  evalReportFile: env.MODEL_EOL_EVAL_REPORT_FILE || null,
951
1341
  evalStatusFile: env.MODEL_EOL_EVAL_STATUS_FILE || null,
1342
+ evalResultsFile: env.MODEL_EOL_EVAL_RESULTS_FILE || null,
1343
+ evalCommandOverride: env.MODEL_EOL_EVAL_COMMAND || null,
952
1344
  token: env.GITHUB_TOKEN,
953
1345
  apiUrl: env.GITHUB_API_URL || 'https://api.github.com',
954
1346
  tokenKind: env.MODEL_EOL_TOKEN_KIND || null,
@@ -958,7 +1350,8 @@ export const main = async (argv = process.argv.slice(2), env = process.env) => {
958
1350
  if (result.degraded) console.log('model-eol status: degraded - vendored feed fallback enabled; no GitHub writes')
959
1351
  for (const item of result.decisions) console.log(`${item.action} ${item.group.publisher}/${item.group.kind === 'model' ? item.group.id : item.group.subject}`)
960
1352
  }
961
- return 0
1353
+ appendDecisionSummary(env.GITHUB_STEP_SUMMARY, result.decisions, { degraded: result.degraded })
1354
+ return result.degraded || result.decisions.some(item => BLOCKED_ACTIONS.has(item.action)) ? 1 : 0
962
1355
  }
963
1356
 
964
1357
  const invokedFile = (() => {