model-eol 0.5.1 → 0.6.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/SPEC.md CHANGED
@@ -49,7 +49,9 @@ A feed is a JSON document: metadata + a list of model entries.
49
49
  | `replacement_note` | no | Free-text guidance such as parameter requirements or provider platform alternatives |
50
50
  | `notes` | no | Human context: structural differences, migration gotchas |
51
51
  | `distributions` | no | Per-distributor lifecycles - the same weights on different clocks (Azure, Bedrock, Vertex). Each entry: `via`, optional `announced`/`shutdown`/`status`/`date_precision`, `source` |
52
- | `date_precision` | no | Qualifies `shutdown`: absent or `"exact"` means the stated day; `"earliest"` means the provider commits only that the event happens no sooner (Google's "earliest possible" dates). Consumers treat an earliest date as the scheduled date - it is the soonest legal death - and display it as a lower bound |
52
+ | `date_precision` | no | Qualifies `shutdown`: absent or `"exact"` means the stated day; `"earliest"` is a scheduled date that cannot move earlier; `"tentative"` means the publisher lists a not-before retirement date without announcing deprecation |
53
+
54
+ Tentative entries carry no `announced`. Consumers must not treat their dates as scheduled retirements. The reference checker reports them as `scheduled` and never fails on them.
53
55
 
54
56
  Dates are ISO 8601, UTC. A model with no `announced` and no `shutdown` is an
55
57
  affirmative statement of "no retirement scheduled as of `generated`" - the absence
@@ -134,6 +136,15 @@ per lifecycle channel, with a deterministic channel-qualified `bom-ref`. This
134
136
  keeps direct publisher and distributor clocks distinct and binds each source
135
137
  occurrence to the lifecycle decision that evaluated it.
136
138
 
139
+ **Compatibility within 0.1.** The `model-eol/0.1` line evolves additively: new
140
+ optional fields and new enumeration values (such as `date_precision:
141
+ "tentative"`, added in 2026-09) may appear in feeds and reports without a
142
+ version change. A consumer that validates strictly against a snapshot of these
143
+ schemas should expect to refresh that snapshot when it upgrades, and a consumer
144
+ that reads defensively should ignore fields and values it does not recognise
145
+ rather than reject the document. Removing a field or changing the meaning of an
146
+ existing value is a new spec line.
147
+
137
148
  ## Non-goals (v0.1)
138
149
 
139
150
  Pricing, capabilities, context windows (models.dev and friends already do this);
package/bot/bot.mjs CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { isDeepStrictEqual } from 'node:util'
9
9
 
10
10
  import {
11
+ activeWaiver,
11
12
  branchFor,
12
13
  daysRemaining,
13
14
  hasMetadataMarker,
@@ -20,6 +21,7 @@ import {
20
21
  parseMetadata,
21
22
  repoPath,
22
23
  sha256,
24
+ staleWorkComment,
23
25
  stableJson,
24
26
  } from './lib/common.mjs'
25
27
  import { assertValidPlanDocument } from '../lib/validate-document.mjs'
@@ -342,6 +344,7 @@ const buildModelGroups = (plan, config, root, records) => {
342
344
  const ignoredIds = ignoredModelIds(plan, config)
343
345
  const groups = new Map()
344
346
  for (const item of plan.items) {
347
+ if (activeWaiver(item)) continue
345
348
  if (isIgnored(item, root, config, ignoredIds)) continue
346
349
  const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : plan.via
347
350
  const key = `${item.publisher}\0${item.id}\0${via ?? ''}`
@@ -369,6 +372,7 @@ const buildIssueGroups = (plan, config, root, records) => {
369
372
  const ignoredIds = ignoredModelIds(plan, config)
370
373
  const groups = new Map()
371
374
  for (const issue of plan.issues) {
375
+ if (activeWaiver(issue)) continue
372
376
  if (!ISSUE_REASONS.has(issue.reason)) continue
373
377
  if (issue.reason !== 'unresolved-channel' && !ACTIONABLE_STATUSES.has(issue.status)) continue
374
378
  if (isIgnored(issue, root, config, ignoredIds)) continue
@@ -640,8 +644,6 @@ const ownedIssueRecords = issues => issues
640
644
  .map(item => ({ item, metadata: parseMetadata(item.body) }))
641
645
  .filter(record => record.metadata)
642
646
 
643
- 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.`
644
-
645
647
  const staleClosedBody = (body, metadata) => {
646
648
  const lines = String(body ?? '').split(/\r?\n/)
647
649
  lines[0] = metadataLine({ ...metadata, stale_closed: true })
@@ -657,7 +659,7 @@ const groupFromMetadata = (kind, metadata) => ({
657
659
  branch: kind === 'model' ? branchFor(metadata.publisher, metadata.id, metadata.via ?? null) : undefined,
658
660
  })
659
661
 
660
- const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) => {
662
+ const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups, plan }) => {
661
663
  const activeModels = new Set(models.map(group => modelIdentity(group.publisher, group.id, group.via)))
662
664
  const activeIssues = new Set(issueGroups.flatMap(group => {
663
665
  const exact = issueIdentity(group.publisher, group.id || group.subject, group.via, group.channel)
@@ -665,12 +667,23 @@ const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) =
665
667
  ? [exact]
666
668
  : [exact, issueIdentity(group.publisher, group.id || group.subject, group.via, null)]
667
669
  }))
670
+ const waivedModels = new Map()
671
+ const waivedIssues = new Map()
672
+ for (const item of [...plan.items, ...plan.issues]) {
673
+ const waiver = activeWaiver(item)
674
+ if (!waiver || !item.id || !item.publisher) continue
675
+ const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : (item.via ?? plan.via)
676
+ const modelKey = modelIdentity(item.publisher, item.id, via)
677
+ const issueKey = issueIdentity(item.publisher, item.id, via, null)
678
+ if (!waivedModels.has(modelKey)) waivedModels.set(modelKey, waiver)
679
+ if (!waivedIssues.has(issueKey)) waivedIssues.set(issueKey, waiver)
680
+ }
668
681
  const decisions = []
669
682
  for (const record of ownedPullRecords(pulls, api.repo)) {
670
683
  if (!isOpen(record.item)) continue
671
684
  const key = modelIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via)
672
685
  if (activeModels.has(key)) continue
673
- await api.comment(record.item.number, staleWorkComment('pull request'))
686
+ await api.comment(record.item.number, staleWorkComment('pull request', waivedModels.get(key) ?? null))
674
687
  await api.updatePull(record.item.number, {
675
688
  state: 'closed',
676
689
  body: staleClosedBody(record.item.body, record.metadata),
@@ -683,7 +696,7 @@ const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) =
683
696
  const legacyKey = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, null)
684
697
  const evalFailure = record.metadata.channel === EVAL_FAILURE_CHANNEL
685
698
  if (activeIssues.has(key) || (!evalFailure && activeIssues.has(legacyKey))) continue
686
- await api.comment(record.item.number, staleWorkComment('issue'))
699
+ await api.comment(record.item.number, staleWorkComment('issue', waivedIssues.get(legacyKey) ?? waivedIssues.get(key) ?? null))
687
700
  await api.updateIssue(record.item.number, {
688
701
  state: 'closed',
689
702
  body: staleClosedBody(record.item.body, record.metadata),
@@ -1491,7 +1504,7 @@ export const runBot = async ({
1491
1504
  for (const group of issues) decisions.push(await processIssue({ api, issues: issueRecords, group, now }))
1492
1505
  }
1493
1506
  if (labelReady) {
1494
- decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: [...issues, ...evalIssueGroups] }))
1507
+ decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: [...issues, ...evalIssueGroups], plan }))
1495
1508
  }
1496
1509
  return { plan, config, decisions, feedsDir: feedSet.dir, degraded: false }
1497
1510
  } finally {
@@ -80,6 +80,12 @@ export const parseMetadata = body => {
80
80
 
81
81
  export const itemDigest = items => sortedJsonDigest(items)
82
82
 
83
+ export const activeWaiver = item => item?.waiver?.active === true ? item.waiver : null
84
+
85
+ export const staleWorkComment = (kind, waiver = null) => waiver
86
+ ? `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)}`
87
+ : `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.`
88
+
83
89
  const entityMap = new Map([
84
90
  ['&', '&'],
85
91
  ['<', '&lt;'],
package/feeds/google.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "spec": "model-eol/0.1",
3
3
  "publisher": "google",
4
- "generated": "2026-08-18T00:47:44.931Z",
4
+ "generated": "2026-08-31T05:30:20.090Z",
5
5
  "source": "https://ai.google.dev/gemini-api/docs/deprecations",
6
6
  "models": [
7
7
  {
@@ -574,7 +574,11 @@
574
574
  ]
575
575
  },
576
576
  {
577
- "id": "gemini-omni-flash-preview"
577
+ "id": "gemini-omni-flash-preview",
578
+ "source": "https://ai.google.dev/gemini-api/docs/deprecations",
579
+ "shutdown": "2026-09-30",
580
+ "date_precision": "earliest",
581
+ "replacement": "gemini-omni-1.1-flash"
578
582
  },
579
583
  {
580
584
  "id": "gemini-robotics-er-2-preview"
@@ -610,7 +614,20 @@
610
614
  "id": "gemini-robotics-er-2-streaming-preview"
611
615
  },
612
616
  {
613
- "id": "gemini-3.5-live-translate-preview"
617
+ "id": "gemini-3.5-live-translate-preview",
618
+ "source": "https://ai.google.dev/gemini-api/docs/deprecations"
619
+ },
620
+ {
621
+ "id": "gemini-3.5-transcribe-live",
622
+ "source": "https://ai.google.dev/gemini-api/docs/deprecations"
623
+ },
624
+ {
625
+ "id": "gemini-3.5-transcribe",
626
+ "source": "https://ai.google.dev/gemini-api/docs/deprecations"
627
+ },
628
+ {
629
+ "id": "gemini-omni-1.1-flash",
630
+ "source": "https://ai.google.dev/gemini-api/docs/deprecations"
614
631
  }
615
632
  ]
616
633
  }
package/feeds/openai.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "spec": "model-eol/0.1",
3
3
  "publisher": "openai",
4
- "generated": "2026-08-18T00:47:44.930Z",
4
+ "generated": "2026-08-31T05:30:19.216Z",
5
5
  "source": "https://developers.openai.com/api/docs/deprecations",
6
6
  "note": "Community-maintained seed dataset, compiled 2026-07-25 from the provider's deprecations page. Verify against the source before acting; this file demonstrates the format.",
7
7
  "models": [
@@ -937,7 +937,14 @@
937
937
  "id": "text-embedding-ada-002"
938
938
  },
939
939
  {
940
- "id": "whisper-1"
940
+ "id": "whisper-1",
941
+ "announced": "2026-08-26",
942
+ "shutdown": "2027-02-26",
943
+ "source": "https://developers.openai.com/api/docs/deprecations",
944
+ "replacement_options": [
945
+ "gpt-live-transcribe",
946
+ "gpt-transcribe"
947
+ ]
941
948
  },
942
949
  {
943
950
  "id": "tts-1"
@@ -988,10 +995,24 @@
988
995
  "id": "gpt-4o-mini-search-preview"
989
996
  },
990
997
  {
991
- "id": "gpt-4o-transcribe"
998
+ "id": "gpt-4o-transcribe",
999
+ "announced": "2026-08-26",
1000
+ "shutdown": "2027-02-26",
1001
+ "source": "https://developers.openai.com/api/docs/deprecations",
1002
+ "replacement_options": [
1003
+ "gpt-live-transcribe",
1004
+ "gpt-transcribe"
1005
+ ]
992
1006
  },
993
1007
  {
994
- "id": "gpt-4o-mini-transcribe"
1008
+ "id": "gpt-4o-mini-transcribe",
1009
+ "announced": "2026-08-26",
1010
+ "shutdown": "2027-02-26",
1011
+ "source": "https://developers.openai.com/api/docs/deprecations",
1012
+ "replacement_options": [
1013
+ "gpt-live-transcribe",
1014
+ "gpt-transcribe"
1015
+ ]
995
1016
  },
996
1017
  {
997
1018
  "id": "gpt-4o-mini-tts"
@@ -1009,7 +1030,14 @@
1009
1030
  "id": "gpt-4.1-mini"
1010
1031
  },
1011
1032
  {
1012
- "id": "gpt-4o-transcribe-diarize"
1033
+ "id": "gpt-4o-transcribe-diarize",
1034
+ "announced": "2026-08-26",
1035
+ "shutdown": "2027-02-26",
1036
+ "source": "https://developers.openai.com/api/docs/deprecations",
1037
+ "replacement_options": [
1038
+ "gpt-live-transcribe",
1039
+ "gpt-transcribe"
1040
+ ]
1013
1041
  },
1014
1042
  {
1015
1043
  "id": "gpt-5"
package/lib/config.mjs CHANGED
@@ -2,18 +2,23 @@ import fs from 'node:fs'
2
2
  import { TextDecoder } from 'node:util'
3
3
 
4
4
  import { matchesAnyGlob, normalizeRepoPath } from './glob.mjs'
5
- import { MAX_REPORT_BYTES, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS } from './validate-feed.mjs'
5
+ import { assertIsoDate, MAX_REPORT_BYTES, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS } from './validate-feed.mjs'
6
6
 
7
7
  const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
8
8
 
9
9
  const frozenArray = () => Object.freeze([])
10
10
 
11
+ export const MAX_WAIVERS = 500
12
+
13
+ const PUBLISHER_WAIVER_VIAS = new Set(['publisher', 'publisher-fallback'])
14
+
11
15
  export const DEFAULT_CONFIG = Object.freeze({
12
16
  days: 90,
13
17
  scope: 'direct',
14
18
  via: null,
15
19
  feeds: Object.freeze({ allow_vendored_fallback: false }),
16
20
  ignore: Object.freeze({ models: frozenArray(), paths: frozenArray() }),
21
+ waivers: frozenArray(),
17
22
  overrides: frozenArray(),
18
23
  routes: frozenArray(),
19
24
  issues: Object.freeze({ enabled: true }),
@@ -34,6 +39,10 @@ const copyDefaults = defaults => ({
34
39
  models: [...defaults.ignore.models],
35
40
  paths: [...defaults.ignore.paths],
36
41
  },
42
+ waivers: (defaults.waivers ?? []).map(waiver => ({
43
+ ...waiver,
44
+ ...(waiver.paths ? { paths: [...waiver.paths] } : {}),
45
+ })),
37
46
  overrides: (defaults.overrides ?? []).map(override => ({
38
47
  ...override,
39
48
  paths: [...override.paths],
@@ -41,6 +50,10 @@ const copyDefaults = defaults => ({
41
50
  models: [...(override.ignore.models ?? [])],
42
51
  paths: [...(override.ignore.paths ?? [])],
43
52
  } } : {}),
53
+ ...(override.waivers ? { waivers: override.waivers.map(waiver => ({
54
+ ...waiver,
55
+ ...(waiver.paths ? { paths: [...waiver.paths] } : {}),
56
+ })) } : {}),
44
57
  })),
45
58
  routes: (defaults.routes ?? []).map(route => ({ ...route, paths: [...route.paths] })),
46
59
  issues: { enabled: defaults.issues.enabled },
@@ -74,6 +87,12 @@ const nonEmptyString = (value, label) => {
74
87
  return value
75
88
  }
76
89
 
90
+ const boundedString = (value, label, limit) => {
91
+ nonEmptyString(value, label)
92
+ assert(value.length <= limit, `${label} must be at most ${limit} characters`)
93
+ return value
94
+ }
95
+
77
96
  const normalizeIgnore = (raw, label) => {
78
97
  assert(raw && typeof raw === 'object' && !Array.isArray(raw), `${label} must be an object`)
79
98
  assertKnownKeys(raw, label, new Set(['models', 'paths']))
@@ -83,10 +102,36 @@ const normalizeIgnore = (raw, label) => {
83
102
  }
84
103
  }
85
104
 
105
+ const normalizeWaiver = (raw, index, labelPrefix) => {
106
+ const label = `${labelPrefix}[${index}]`
107
+ assert(raw && typeof raw === 'object' && !Array.isArray(raw), `${label} must be an object`)
108
+ assertKnownKeys(raw, label, new Set(['model', 'paths', 'via', 'reason', 'owner', 'expires']))
109
+ const waiver = {
110
+ model: nonEmptyString(raw.model, `${label}.model`),
111
+ reason: boundedString(raw.reason, `${label}.reason`, 500),
112
+ owner: boundedString(raw.owner, `${label}.owner`, 200),
113
+ expires: nonEmptyString(raw.expires, `${label}.expires`),
114
+ }
115
+ try {
116
+ assertIsoDate(waiver.expires, `${label}.expires`)
117
+ } catch (error) {
118
+ assert(false, error.message)
119
+ }
120
+ if (raw.paths !== undefined) waiver.paths = stringArray(raw.paths, `${label}.paths`)
121
+ if (raw.via !== undefined) waiver.via = nonEmptyString(raw.via, `${label}.via`)
122
+ return waiver
123
+ }
124
+
125
+ const normalizeWaivers = (raw, label) => {
126
+ assert(Array.isArray(raw), `${label} must be an array`)
127
+ assert(raw.length <= MAX_WAIVERS, `${label} must contain at most ${MAX_WAIVERS} waivers`)
128
+ return raw.map((waiver, index) => normalizeWaiver(waiver, index, label))
129
+ }
130
+
86
131
  const normalizeOverride = (raw, index) => {
87
132
  const label = `overrides[${index}]`
88
133
  assert(raw && typeof raw === 'object' && !Array.isArray(raw), `${label} must be an object`)
89
- assertKnownKeys(raw, label, new Set(['paths', 'days', 'scope', 'via', 'ignore']))
134
+ assertKnownKeys(raw, label, new Set(['paths', 'days', 'scope', 'via', 'ignore', 'waivers']))
90
135
  const override = { paths: stringArray(raw.paths, `${label}.paths`) }
91
136
  assert(override.paths.length > 0, `${label}.paths must not be empty`)
92
137
  if (raw.days !== undefined) {
@@ -102,6 +147,7 @@ const normalizeOverride = (raw, index) => {
102
147
  override.via = raw.via
103
148
  }
104
149
  if (raw.ignore !== undefined) override.ignore = normalizeIgnore(raw.ignore, `${label}.ignore`)
150
+ if (raw.waivers !== undefined) override.waivers = normalizeWaivers(raw.waivers, `${label}.waivers`)
105
151
  return override
106
152
  }
107
153
 
@@ -126,7 +172,7 @@ const CREDENTIAL_ENV_PATTERN = /^(?:GITHUB_|GH_|ACTIONS_|SSH_|AWS_SECRET)|(?:TOK
126
172
 
127
173
  export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
128
174
  assert(raw && typeof raw === 'object' && !Array.isArray(raw), 'root must be an object')
129
- assertKnownKeys(raw, '', new Set(['days', 'scope', 'via', 'feeds', 'ignore', 'overrides', 'routes', 'issues', 'eval']))
175
+ assertKnownKeys(raw, '', new Set(['days', 'scope', 'via', 'feeds', 'ignore', 'waivers', 'overrides', 'routes', 'issues', 'eval']))
130
176
  const config = copyDefaults(defaults)
131
177
 
132
178
  if (raw.days !== undefined) {
@@ -154,10 +200,13 @@ export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
154
200
  if (raw.ignore.models !== undefined) config.ignore.models = ignore.models
155
201
  if (raw.ignore.paths !== undefined) config.ignore.paths = ignore.paths
156
202
  }
203
+ if (raw.waivers !== undefined) config.waivers = normalizeWaivers(raw.waivers, 'waivers')
157
204
  if (raw.overrides !== undefined) {
158
205
  assert(Array.isArray(raw.overrides), 'overrides must be an array')
159
206
  config.overrides = raw.overrides.map(normalizeOverride)
160
207
  }
208
+ const waiverCount = config.waivers.length + config.overrides.reduce((count, override) => count + (override.waivers?.length ?? 0), 0)
209
+ assert(waiverCount <= MAX_WAIVERS, `config must contain at most ${MAX_WAIVERS} waivers across the root and overrides`)
161
210
  if (raw.routes !== undefined) {
162
211
  assert(Array.isArray(raw.routes), 'routes must be an array')
163
212
  config.routes = raw.routes.map(normalizeRoute)
@@ -195,6 +244,11 @@ export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
195
244
 
196
245
  export const configForRepoPath = (config, repoPath) => {
197
246
  const normalizedPath = normalizeRepoPath(repoPath)
247
+ const matchesPath = patterns => matchesAnyGlob(normalizedPath, patterns) || patterns.some(pattern => {
248
+ const normalizedPattern = normalizeRepoPath(pattern).replace(/\/+$/, '')
249
+ return normalizedPattern && !/[*?]/.test(normalizedPattern) && normalizedPath.startsWith(`${normalizedPattern}/`)
250
+ })
251
+ const applicableWaivers = waivers => (waivers ?? []).filter(waiver => waiver.paths === undefined || matchesPath(waiver.paths))
198
252
  const policy = {
199
253
  days: config.days,
200
254
  scope: config.scope,
@@ -203,6 +257,7 @@ export const configForRepoPath = (config, repoPath) => {
203
257
  models: [...config.ignore.models],
204
258
  paths: [...config.ignore.paths],
205
259
  },
260
+ waivers: applicableWaivers(config.waivers),
206
261
  override_indexes: [],
207
262
  }
208
263
  for (const [index, override] of (config.overrides ?? []).entries()) {
@@ -215,6 +270,7 @@ export const configForRepoPath = (config, repoPath) => {
215
270
  policy.ignore.models.push(...override.ignore.models)
216
271
  policy.ignore.paths.push(...override.ignore.paths)
217
272
  }
273
+ policy.waivers.push(...applicableWaivers(override.waivers))
218
274
  }
219
275
  policy.ignore.models = [...new Set(policy.ignore.models)]
220
276
  policy.ignore.paths = [...new Set(policy.ignore.paths)]
@@ -244,6 +300,8 @@ export const configuredVias = config => [
244
300
  config.via,
245
301
  ...(config.overrides ?? []).map(override => override.via),
246
302
  ...(config.routes ?? []).map(route => route.via),
303
+ ...(config.waivers ?? []).map(waiver => waiver.via).filter(via => !PUBLISHER_WAIVER_VIAS.has(via)),
304
+ ...(config.overrides ?? []).flatMap(override => (override.waivers ?? []).map(waiver => waiver.via).filter(via => !PUBLISHER_WAIVER_VIAS.has(via))),
247
305
  ].filter(via => typeof via === 'string' && via.length > 0)
248
306
 
249
307
  export const loadConfig = (file, { defaults = DEFAULT_CONFIG, allowMissing = true } = {}) => {
package/lib/feeds.mjs CHANGED
@@ -127,6 +127,11 @@ export const lifecycleFor = (entry, options) => {
127
127
  } = options
128
128
  const sd = effectiveShutdown(entry, via)
129
129
  if (sd) {
130
+ const distribution = via
131
+ ? (entry.distributions ?? []).find(d => d.via === via)
132
+ : null
133
+ const publisherClock = sd.via === 'publisher' || sd.via === 'publisher-fallback'
134
+ const announced = publisherClock ? entry.announced : distribution?.announced
130
135
  if (sd.distribution_status === 'retired') {
131
136
  return {
132
137
  status: 'retired',
@@ -138,6 +143,19 @@ export const lifecycleFor = (entry, options) => {
138
143
  safe_until: sd.date,
139
144
  }
140
145
  }
146
+ if (sd.date && sd.date_precision === 'tentative' && !announced) {
147
+ const policyFloor = publisherClock ? policySafeUntil(policy, generated) : null
148
+ const safeUntil = policyFloor && policyFloor > sd.date ? policyFloor : sd.date
149
+ return {
150
+ status: 'scheduled',
151
+ shutdown: sd.date,
152
+ date_precision: sd.date_precision,
153
+ distribution_status: sd.distribution_status,
154
+ via: sd.via,
155
+ days: daysUntil(safeUntil, today),
156
+ safe_until: safeUntil,
157
+ }
158
+ }
141
159
  if (sd.date) {
142
160
  const remaining = daysUntil(sd.date, today)
143
161
  return {
@@ -150,10 +168,6 @@ export const lifecycleFor = (entry, options) => {
150
168
  safe_until: sd.date,
151
169
  }
152
170
  }
153
- const distribution = via
154
- ? (entry.distributions ?? []).find(d => d.via === via)
155
- : null
156
- const announced = distribution?.announced ?? (sd.via === 'publisher-fallback' ? entry.announced : null)
157
171
  const status = announced ? 'watch' : 'ok'
158
172
  return {
159
173
  status,
@@ -196,7 +210,20 @@ export const findingFromRef = (ref, options) => {
196
210
  policy,
197
211
  generated,
198
212
  })
199
- return {
213
+ const family = new Set([ref.entry.id, ...(ref.entry.aliases ?? [])])
214
+ const matchingWaivers = (ref.waivers ?? []).filter(waiver =>
215
+ family.has(waiver.model) && (waiver.via === undefined || waiver.via === lifecycle.via))
216
+ const todayDate = today.toISOString().slice(0, 10)
217
+ const selectedWaiver = matchingWaivers.find(waiver => todayDate < waiver.expires) ?? matchingWaivers[0] ?? null
218
+ const waiver = selectedWaiver
219
+ ? {
220
+ reason: selectedWaiver.reason,
221
+ owner: selectedWaiver.owner,
222
+ expires: selectedWaiver.expires,
223
+ active: todayDate < selectedWaiver.expires,
224
+ }
225
+ : null
226
+ const finding = {
200
227
  file: ref.file,
201
228
  line: ref.line,
202
229
  matched: ref.matched,
@@ -216,7 +243,10 @@ export const findingFromRef = (ref, options) => {
216
243
  replacement_options: ref.entry.replacement_options ?? null,
217
244
  replacement_note: ref.entry.replacement_note ?? null,
218
245
  threshold_days: days,
246
+ ...(waiver ? { waiver } : {}),
219
247
  }
248
+ return finding
220
249
  }
221
250
 
222
- export const isBad = finding => finding.status === 'retired' || finding.status === 'retiring'
251
+ export const isBad = finding =>
252
+ (finding.status === 'retired' || finding.status === 'retiring') && finding.waiver?.active !== true
package/lib/plan.mjs CHANGED
@@ -46,6 +46,7 @@ const issueFor = (ref, finding, reason, via) => ({
46
46
  replacement: finding.replacement,
47
47
  ...(finding.replacement_options?.length ? { replacement_options: finding.replacement_options } : {}),
48
48
  ...(finding.replacement_note ? { replacement_note: finding.replacement_note } : {}),
49
+ ...(finding.waiver ? { waiver: finding.waiver } : {}),
49
50
  reason,
50
51
  sources: sourceList(ref, finding, via),
51
52
  notes: notesFor(ref),
@@ -77,6 +78,11 @@ export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
77
78
  const effectiveVia = requestedVia(finding, via)
78
79
  const effectiveDays = finding.threshold_days ?? days
79
80
 
81
+ if (finding.waiver?.active) {
82
+ issues.push(issueFor(ref, finding, 'waived', effectiveVia))
83
+ return
84
+ }
85
+
80
86
  let reason
81
87
  if (finding.status === 'watch') reason = 'watch'
82
88
  else if (finding.status !== 'retired' && finding.status !== 'retiring') reason = 'outside-threshold'
@@ -133,6 +139,7 @@ export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
133
139
  effective_scope: finding.effective_scope ?? scope,
134
140
  ...(finding.policy_provenance ? { policy_provenance: finding.policy_provenance } : {}),
135
141
  ...(ref.mapped_from ? { mapped_from: ref.mapped_from } : {}),
142
+ ...(finding.waiver ? { waiver: finding.waiver } : {}),
136
143
  sources: sourceList(ref, finding, effectiveVia),
137
144
  notes: notesFor(ref),
138
145
  })