model-eol 0.5.2 → 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/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
  })
package/lib/reports.mjs CHANGED
@@ -8,9 +8,16 @@ export const marker = status =>
8
8
  status === 'watch' ? '?' :
9
9
  '·'
10
10
 
11
- const shutdownText = finding => finding.date_precision === 'earliest'
12
- ? `no earlier than ${finding.shutdown}`
13
- : finding.shutdown
11
+ const shutdownText = finding =>
12
+ finding.date_precision === 'tentative' ? `not sooner than ${finding.shutdown}` :
13
+ finding.date_precision === 'earliest' ? `no earlier than ${finding.shutdown}` :
14
+ finding.shutdown
15
+
16
+ const tentativeFloor = finding => finding.date_precision === 'tentative'
17
+
18
+ const policyTail = finding => finding.safe_until && finding.shutdown && finding.safe_until > finding.shutdown
19
+ ? `, guaranteed until ${finding.safe_until} by ${finding.publisher} policy`
20
+ : ''
14
21
 
15
22
  const replacementTail = finding => finding.replacement
16
23
  ? ` -> ${finding.replacement}`
@@ -18,22 +25,32 @@ const replacementTail = finding => finding.replacement
18
25
  ? ` options: ${finding.replacement_options.join(' | ')}`
19
26
  : ''
20
27
 
21
- export const findingTail = (f, days) =>
28
+ const lifecycleTail = (f, days) =>
22
29
  f.status === 'retired' && !f.shutdown ? `RETIRED (shutdown date unavailable)${replacementTail(f)}` :
23
30
  f.status === 'retired' ? `RETIRED ${shutdownText(f)} (${-f.days} days ago)${replacementTail(f)}` :
24
31
  f.status === 'retiring' ? `RETIRES ${shutdownText(f)} (${f.days} days)${replacementTail(f)}` :
32
+ tentativeFloor(f) ? `scheduled - tentative, not announced: ${shutdownText(f)}${policyTail(f)}` :
25
33
  f.status === 'scheduled' ? `scheduled ${shutdownText(f)} (${f.days} days, outside --days ${f.threshold_days ?? days})` :
26
34
  f.status === 'watch' ? 'deprecation announced, no shutdown date yet' :
27
35
  'no retirement scheduled'
28
36
 
29
- const statusStyle = status =>
30
- status === 'retired' ? 'red' :
31
- status === 'retiring' ? 'yellow' :
32
- status === 'ok' ? 'dim' :
37
+ const waiverTail = finding => !finding.waiver
38
+ ? ''
39
+ : finding.waiver.active
40
+ ? ` [waived until ${finding.waiver.expires} by ${finding.waiver.owner}: ${finding.waiver.reason}]`
41
+ : ` [waiver expired on ${finding.waiver.expires}; owner ${finding.waiver.owner}: ${finding.waiver.reason}]`
42
+
43
+ export const findingTail = (finding, days) => `${lifecycleTail(finding, days)}${waiverTail(finding)}`
44
+
45
+ const statusStyle = finding =>
46
+ finding.waiver?.active === true ? 'yellow' :
47
+ finding.status === 'retired' ? 'red' :
48
+ finding.status === 'retiring' ? 'yellow' :
49
+ finding.status === 'ok' ? 'dim' :
33
50
  null
34
51
 
35
- const humanFinding = (status, line) => {
36
- const style = statusStyle(status)
52
+ const humanFinding = (finding, line) => {
53
+ const style = statusStyle(finding)
37
54
  return style ? color(style, line) : line
38
55
  }
39
56
 
@@ -67,6 +84,7 @@ export const buildInventory = ({ scan, findings, days, via, scope, targets }) =>
67
84
  replacement: lifecycle.replacement,
68
85
  ...(lifecycle.replacement_options?.length ? { replacement_options: lifecycle.replacement_options } : {}),
69
86
  ...(lifecycle.replacement_note ? { replacement_note: lifecycle.replacement_note } : {}),
87
+ ...(lifecycle.waiver ? { waiver: lifecycle.waiver } : {}),
70
88
  source: ref.source,
71
89
  policy: ref.policy ?? null,
72
90
  feed_generated: ref.generated ?? null,
@@ -149,9 +167,11 @@ export const buildSchedule = inventory => {
149
167
  }
150
168
 
151
169
  export const buildAlert = schedule => {
152
- const errors = schedule.items.filter(item => item.status === 'retired' || item.status === 'retiring')
170
+ const errors = schedule.items.filter(isBad)
153
171
  const warnings = [
154
- ...schedule.items.filter(item => item.status === 'scheduled' || item.status === 'watch'),
172
+ ...schedule.items.filter(item =>
173
+ item.status === 'scheduled' || item.status === 'watch' ||
174
+ ((item.status === 'retired' || item.status === 'retiring') && item.waiver?.active === true)),
155
175
  ...schedule.candidate_model_references.map(item => ({ ...item, status: 'unknown' })),
156
176
  ...schedule.unresolved_integrations.map(item => ({ ...item, status: 'unresolved' })),
157
177
  ]
@@ -174,7 +194,7 @@ export const formatCheck = ({ findings, bad, scannedFiles, days, scope }) => {
174
194
  }
175
195
  for (const f of findings) {
176
196
  const where = `${f.file}:${f.line}`
177
- out.push(humanFinding(f.status, `${marker(f.status)} ${where} ${f.matched} ${findingTail(f, days)}${f.via && f.via !== 'publisher' ? ` [via ${f.via}]` : ''}`))
197
+ out.push(humanFinding(f, `${marker(f.status)} ${where} ${f.matched} ${findingTail(f, days)}${f.via && f.via !== 'publisher' ? ` [via ${f.via}]` : ''}`))
178
198
  }
179
199
  if (bad.length) out.push(`\n${color('bold', `model-eol: ${bad.length} finding(s) at or past the ${days}-day threshold`)}`)
180
200
  return out.join('\n')
@@ -187,7 +207,7 @@ export const formatInventory = (inventory, days) => {
187
207
  if (inventory.model_references.length) {
188
208
  out.push('\nModel references')
189
209
  for (const item of inventory.model_references) {
190
- out.push(humanFinding(item.status, `${marker(item.status)} ${item.file}:${item.line} ${item.matched} ${item.publisher}/${item.id} ${item.usage} ${findingTail(item, days)}`))
210
+ out.push(humanFinding(item, `${marker(item.status)} ${item.file}:${item.line} ${item.matched} ${item.publisher}/${item.id} ${item.usage} ${findingTail(item, days)}`))
191
211
  }
192
212
  }
193
213
  if (inventory.candidate_model_references.length) {
@@ -212,7 +232,7 @@ export const formatSchedule = (schedule, days) => {
212
232
  if (incompleteNotes.length) out.push(`WARNING: scan incomplete; ${incompleteNotes.length} coverage-loss note(s) recorded`)
213
233
  for (const item of schedule.items) {
214
234
  const safeUntil = item.safe_until ? ` [safe until ${item.safe_until}]` : ''
215
- out.push(humanFinding(item.status, `${marker(item.status)} ${item.shutdown ? shutdownText(item) : 'TBD'} ${item.file}:${item.line} ${item.matched} ${findingTail(item, days)} [${item.usage}]${safeUntil}`))
235
+ out.push(humanFinding(item, `${marker(item.status)} ${item.shutdown ? shutdownText(item) : 'TBD'} ${item.file}:${item.line} ${item.matched} ${findingTail(item, days)} [${item.usage}]${safeUntil}`))
216
236
  }
217
237
  if (schedule.unresolved_integrations.length) {
218
238
  out.push('\nUnresolved cloud/gateway references')
@@ -279,6 +299,7 @@ export const formatInventoryCycloneDX = inventory => {
279
299
  identity,
280
300
  item,
281
301
  status: item.status,
302
+ occurrenceWaivers: new Map(),
282
303
  usages: new Set(),
283
304
  occurrences: new Set(),
284
305
  }
@@ -287,12 +308,54 @@ export const formatInventoryCycloneDX = inventory => {
287
308
  if ((statusSeverity.get(item.status) ?? 9) < (statusSeverity.get(group.status) ?? 9)) {
288
309
  group.status = item.status
289
310
  }
311
+ const location = `${item.file}#${item.line}`
312
+ const occurrenceWaiver = group.occurrenceWaivers.get(location) ?? { active: true, activeWaivers: [], expiredWaivers: [] }
313
+ if (item.waiver?.active === true) occurrenceWaiver.activeWaivers.push(item.waiver)
314
+ else {
315
+ occurrenceWaiver.active = false
316
+ if (item.waiver?.active === false) occurrenceWaiver.expiredWaivers.push(item.waiver)
317
+ }
318
+ group.occurrenceWaivers.set(location, occurrenceWaiver)
290
319
  group.usages.add(item.usage)
291
- group.occurrences.add(`${item.file}#${item.line}`)
320
+ group.occurrences.add(location)
292
321
  }
293
322
 
294
323
  const components = [...groups.values()].map(group => {
295
324
  const { item, identity } = group
325
+ const occurrenceWaivers = [...group.occurrenceWaivers.values()]
326
+ const activeWaivers = occurrenceWaivers.flatMap(occurrence => occurrence.activeWaivers)
327
+ const expiredWaivers = occurrenceWaivers.flatMap(occurrence => occurrence.expiredWaivers)
328
+ const everyOccurrenceWaived = occurrenceWaivers.every(occurrence => occurrence.active && occurrence.activeWaivers.length > 0)
329
+ let waiverProperties = []
330
+ if (activeWaivers.length > 0 && !everyOccurrenceWaived) {
331
+ waiverProperties = [{ name: 'model-eol:waiver_active', value: 'partial' }]
332
+ } else if (everyOccurrenceWaived) {
333
+ const earliestExpiry = activeWaivers.map(waiver => waiver.expires).sort()[0]
334
+ const distinctWaivers = new Set(activeWaivers.map(waiver => JSON.stringify([waiver.owner, waiver.expires, waiver.reason])))
335
+ waiverProperties = [
336
+ { name: 'model-eol:waiver_active', value: 'true' },
337
+ ...(distinctWaivers.size === 1
338
+ ? [
339
+ { name: 'model-eol:waiver_owner', value: activeWaivers[0].owner },
340
+ { name: 'model-eol:waiver_expires', value: earliestExpiry },
341
+ { name: 'model-eol:waiver_reason', value: activeWaivers[0].reason },
342
+ ]
343
+ : [{ name: 'model-eol:waiver_expires', value: earliestExpiry }]),
344
+ ]
345
+ } else if (expiredWaivers.length > 0) {
346
+ const latestExpiry = expiredWaivers.map(waiver => waiver.expires).sort().at(-1)
347
+ const distinctWaivers = new Set(expiredWaivers.map(waiver => JSON.stringify([waiver.owner, waiver.expires, waiver.reason])))
348
+ waiverProperties = [
349
+ { name: 'model-eol:waiver_active', value: 'false' },
350
+ ...(distinctWaivers.size === 1
351
+ ? [
352
+ { name: 'model-eol:waiver_owner', value: expiredWaivers[0].owner },
353
+ { name: 'model-eol:waiver_expires', value: latestExpiry },
354
+ { name: 'model-eol:waiver_reason', value: expiredWaivers[0].reason },
355
+ ]
356
+ : [{ name: 'model-eol:waiver_expires', value: latestExpiry }]),
357
+ ]
358
+ }
296
359
  return {
297
360
  type: 'machine-learning-model',
298
361
  'bom-ref': identity.bomRef,
@@ -319,6 +382,7 @@ export const formatInventoryCycloneDX = inventory => {
319
382
  ...(item.replacement_note
320
383
  ? [{ name: 'model-eol:replacement_note', value: item.replacement_note }]
321
384
  : []),
385
+ ...waiverProperties,
322
386
  { name: 'model-eol:usage', value: [...group.usages].sort().join(',') },
323
387
  ],
324
388
  evidence: {
package/lib/scanner.mjs CHANGED
@@ -638,6 +638,7 @@ export const scanTargets = ({
638
638
 
639
639
  const pushModelRef = ({ file, line, lineText, matchIndex = null, matched, record, classification, route = null, mappedFrom = null }) => {
640
640
  if (ignoresModel(file, matched, record.entry.id) || (mappedFrom && ignoresModel(file, mappedFrom, record.entry.id))) return
641
+ const waivers = policyForFile?.(path.resolve(file))?.waivers ?? []
641
642
  const occurrence = [...lineText.matchAll(new RegExp(escapeRegExp(matched), 'g'))]
642
643
  .findIndex(match => match.index === (matchIndex ?? lineText.indexOf(matched)))
643
644
  modelRefs.push({
@@ -659,6 +660,7 @@ export const scanTargets = ({
659
660
  occurrence: occurrence < 0 ? 0 : occurrence,
660
661
  route_index: route?.index ?? null,
661
662
  mapped_from: mappedFrom,
663
+ waivers,
662
664
  })
663
665
  }
664
666
 
@@ -64,7 +64,7 @@ export const normalizeDocumentType = value => {
64
64
  const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
65
65
 
66
66
  const configKeys = new Set([
67
- 'days', 'scope', 'via', 'feeds', 'ignore', 'overrides', 'routes', 'issues', 'eval',
67
+ 'days', 'scope', 'via', 'feeds', 'ignore', 'waivers', 'overrides', 'routes', 'issues', 'eval',
68
68
  ])
69
69
 
70
70
  export const detectDocumentType = (document, { file = null } = {}) => {