model-eol 0.3.0 → 0.4.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/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-03T21:43:59.800Z",
4
+ "generated": "2026-08-18T00:47:44.930Z",
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": [
@@ -308,35 +308,35 @@
308
308
  "source": "https://developers.openai.com/api/docs/deprecations",
309
309
  "announced": "2026-04-22",
310
310
  "shutdown": "2026-10-23",
311
- "replacement": "gpt-5.4-mini"
311
+ "replacement": "gpt-5.6-terra"
312
312
  },
313
313
  {
314
314
  "id": "ft-gpt-4",
315
315
  "source": "https://developers.openai.com/api/docs/deprecations",
316
316
  "announced": "2026-04-22",
317
317
  "shutdown": "2026-10-23",
318
- "replacement": "gpt-5.5"
318
+ "replacement": "gpt-5.6-sol"
319
319
  },
320
320
  {
321
321
  "id": "ft-gpt-4.1-nano-2025-04-14",
322
322
  "source": "https://developers.openai.com/api/docs/deprecations",
323
323
  "announced": "2026-04-22",
324
324
  "shutdown": "2026-10-23",
325
- "replacement": "gpt-5.4-nano"
325
+ "replacement": "gpt-5.6-luna"
326
326
  },
327
327
  {
328
328
  "id": "ft-babbage-002",
329
329
  "source": "https://developers.openai.com/api/docs/deprecations",
330
330
  "announced": "2026-04-22",
331
331
  "shutdown": "2026-10-23",
332
- "replacement": "gpt-5.4-mini"
332
+ "replacement": "gpt-5.6-terra"
333
333
  },
334
334
  {
335
335
  "id": "ft-davinci-002",
336
336
  "source": "https://developers.openai.com/api/docs/deprecations",
337
337
  "announced": "2026-04-22",
338
338
  "shutdown": "2026-10-23",
339
- "replacement": "gpt-5.4-mini"
339
+ "replacement": "gpt-5.6-terra"
340
340
  },
341
341
  {
342
342
  "id": "sora-2",
@@ -373,40 +373,28 @@
373
373
  "source": "https://developers.openai.com/api/docs/deprecations",
374
374
  "announced": "2025-09-26",
375
375
  "shutdown": "2026-09-28",
376
- "replacement_options": [
377
- "gpt-5.4-mini",
378
- "gpt-5-mini"
379
- ]
376
+ "replacement": "gpt-5.6-terra"
380
377
  },
381
378
  {
382
379
  "id": "babbage-002",
383
380
  "source": "https://developers.openai.com/api/docs/deprecations",
384
381
  "announced": "2025-09-26",
385
382
  "shutdown": "2026-09-28",
386
- "replacement_options": [
387
- "gpt-5.4-mini",
388
- "gpt-5-mini"
389
- ]
383
+ "replacement": "gpt-5.6-terra"
390
384
  },
391
385
  {
392
386
  "id": "davinci-002",
393
387
  "source": "https://developers.openai.com/api/docs/deprecations",
394
388
  "announced": "2025-09-26",
395
389
  "shutdown": "2026-09-28",
396
- "replacement_options": [
397
- "gpt-5.4-mini",
398
- "gpt-5-mini"
399
- ]
390
+ "replacement": "gpt-5.6-terra"
400
391
  },
401
392
  {
402
393
  "id": "gpt-3.5-turbo-1106",
403
394
  "source": "https://developers.openai.com/api/docs/deprecations",
404
395
  "announced": "2025-09-26",
405
396
  "shutdown": "2026-09-28",
406
- "replacement_options": [
407
- "gpt-5.4-mini",
408
- "gpt-5-mini"
409
- ]
397
+ "replacement": "gpt-5.6-terra"
410
398
  },
411
399
  {
412
400
  "id": "computer-use-preview-2025-03-11",
@@ -427,10 +415,7 @@
427
415
  },
428
416
  {
429
417
  "id": "gpt-4o-mini-tts-2025-03-20",
430
- "source": "https://developers.openai.com/api/docs/deprecations",
431
- "announced": "2026-04-22",
432
- "shutdown": "2026-07-23",
433
- "replacement": "gpt-4o-mini-tts-2025-12-15"
418
+ "source": "https://developers.openai.com/api/docs/deprecations"
434
419
  },
435
420
  {
436
421
  "id": "gpt-4o-search-preview-2025-03-11",
package/lib/config.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
 
3
+ import { matchesAnyGlob, normalizeRepoPath } from './glob.mjs'
3
4
  import { MAX_REPORT_BYTES, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS } from './validate-feed.mjs'
4
5
 
5
6
  const frozenArray = () => Object.freeze([])
@@ -10,6 +11,8 @@ export const DEFAULT_CONFIG = Object.freeze({
10
11
  via: null,
11
12
  feeds: Object.freeze({ allow_vendored_fallback: false }),
12
13
  ignore: Object.freeze({ models: frozenArray(), paths: frozenArray() }),
14
+ overrides: frozenArray(),
15
+ routes: frozenArray(),
13
16
  issues: Object.freeze({ enabled: true }),
14
17
  eval: Object.freeze({ command: null, timeout_ms: 600000, max_report_bytes: 65536, pass_env: frozenArray() }),
15
18
  })
@@ -28,6 +31,15 @@ const copyDefaults = defaults => ({
28
31
  models: [...defaults.ignore.models],
29
32
  paths: [...defaults.ignore.paths],
30
33
  },
34
+ overrides: (defaults.overrides ?? []).map(override => ({
35
+ ...override,
36
+ paths: [...override.paths],
37
+ ...(override.ignore ? { ignore: {
38
+ models: [...(override.ignore.models ?? [])],
39
+ paths: [...(override.ignore.paths ?? [])],
40
+ } } : {}),
41
+ })),
42
+ routes: (defaults.routes ?? []).map(route => ({ ...route, paths: [...route.paths] })),
31
43
  issues: { enabled: defaults.issues.enabled },
32
44
  eval: {
33
45
  command: defaults.eval.command,
@@ -54,11 +66,64 @@ const stringArray = (value, label) => {
54
66
  return value.slice()
55
67
  }
56
68
 
69
+ const nonEmptyString = (value, label) => {
70
+ assert(typeof value === 'string' && value.length > 0, `${label} must be a non-empty string`)
71
+ return value
72
+ }
73
+
74
+ const normalizeIgnore = (raw, label) => {
75
+ assert(raw && typeof raw === 'object' && !Array.isArray(raw), `${label} must be an object`)
76
+ assertKnownKeys(raw, label, new Set(['models', 'paths']))
77
+ return {
78
+ models: raw.models === undefined ? [] : stringArray(raw.models, `${label}.models`),
79
+ paths: raw.paths === undefined ? [] : stringArray(raw.paths, `${label}.paths`),
80
+ }
81
+ }
82
+
83
+ const normalizeOverride = (raw, index) => {
84
+ const label = `overrides[${index}]`
85
+ assert(raw && typeof raw === 'object' && !Array.isArray(raw), `${label} must be an object`)
86
+ assertKnownKeys(raw, label, new Set(['paths', 'days', 'scope', 'via', 'ignore']))
87
+ const override = { paths: stringArray(raw.paths, `${label}.paths`) }
88
+ assert(override.paths.length > 0, `${label}.paths must not be empty`)
89
+ if (raw.days !== undefined) {
90
+ assert(Number.isInteger(raw.days) && raw.days >= 0, `${label}.days must be a non-negative integer`)
91
+ override.days = raw.days
92
+ }
93
+ if (raw.scope !== undefined) {
94
+ assert(raw.scope === 'all' || raw.scope === 'direct', `${label}.scope must be all or direct`)
95
+ override.scope = raw.scope
96
+ }
97
+ if (raw.via !== undefined) {
98
+ assert(raw.via === null || (typeof raw.via === 'string' && raw.via.length > 0), `${label}.via must be null or a non-empty string`)
99
+ override.via = raw.via
100
+ }
101
+ if (raw.ignore !== undefined) override.ignore = normalizeIgnore(raw.ignore, `${label}.ignore`)
102
+ return override
103
+ }
104
+
105
+ const normalizeRoute = (raw, index) => {
106
+ const label = `routes[${index}]`
107
+ assert(raw && typeof raw === 'object' && !Array.isArray(raw), `${label} must be an object`)
108
+ assertKnownKeys(raw, label, new Set(['paths', 'via', 'match', 'model']))
109
+ const route = {
110
+ paths: stringArray(raw.paths, `${label}.paths`),
111
+ via: nonEmptyString(raw.via, `${label}.via`),
112
+ }
113
+ assert(route.paths.length > 0, `${label}.paths must not be empty`)
114
+ assert((raw.match === undefined) === (raw.model === undefined), `${label}.match and ${label}.model must be provided together`)
115
+ if (raw.match !== undefined) {
116
+ route.match = nonEmptyString(raw.match, `${label}.match`)
117
+ route.model = nonEmptyString(raw.model, `${label}.model`)
118
+ }
119
+ return route
120
+ }
121
+
57
122
  const CREDENTIAL_ENV_PATTERN = /^(?:GITHUB_|GH_|ACTIONS_|SSH_|AWS_SECRET)|(?:TOKEN|SECRET|PASSWORD|CREDENTIAL)/i
58
123
 
59
124
  export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
60
125
  assert(raw && typeof raw === 'object' && !Array.isArray(raw), 'root must be an object')
61
- assertKnownKeys(raw, '', new Set(['days', 'scope', 'via', 'feeds', 'ignore', 'issues', 'eval']))
126
+ assertKnownKeys(raw, '', new Set(['days', 'scope', 'via', 'feeds', 'ignore', 'overrides', 'routes', 'issues', 'eval']))
62
127
  const config = copyDefaults(defaults)
63
128
 
64
129
  if (raw.days !== undefined) {
@@ -82,10 +147,17 @@ export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
82
147
  }
83
148
  }
84
149
  if (raw.ignore !== undefined) {
85
- assert(raw.ignore && typeof raw.ignore === 'object' && !Array.isArray(raw.ignore), 'ignore must be an object')
86
- assertKnownKeys(raw.ignore, 'ignore', new Set(['models', 'paths']))
87
- if (raw.ignore.models !== undefined) config.ignore.models = stringArray(raw.ignore.models, 'ignore.models')
88
- if (raw.ignore.paths !== undefined) config.ignore.paths = stringArray(raw.ignore.paths, 'ignore.paths')
150
+ const ignore = normalizeIgnore(raw.ignore, 'ignore')
151
+ if (raw.ignore.models !== undefined) config.ignore.models = ignore.models
152
+ if (raw.ignore.paths !== undefined) config.ignore.paths = ignore.paths
153
+ }
154
+ if (raw.overrides !== undefined) {
155
+ assert(Array.isArray(raw.overrides), 'overrides must be an array')
156
+ config.overrides = raw.overrides.map(normalizeOverride)
157
+ }
158
+ if (raw.routes !== undefined) {
159
+ assert(Array.isArray(raw.routes), 'routes must be an array')
160
+ config.routes = raw.routes.map(normalizeRoute)
89
161
  }
90
162
  if (raw.issues !== undefined) {
91
163
  assert(raw.issues && typeof raw.issues === 'object' && !Array.isArray(raw.issues), 'issues must be an object')
@@ -118,6 +190,59 @@ export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
118
190
  return config
119
191
  }
120
192
 
193
+ export const configForRepoPath = (config, repoPath) => {
194
+ const normalizedPath = normalizeRepoPath(repoPath)
195
+ const policy = {
196
+ days: config.days,
197
+ scope: config.scope,
198
+ via: config.via,
199
+ ignore: {
200
+ models: [...config.ignore.models],
201
+ paths: [...config.ignore.paths],
202
+ },
203
+ override_indexes: [],
204
+ }
205
+ for (const [index, override] of (config.overrides ?? []).entries()) {
206
+ if (!matchesAnyGlob(normalizedPath, override.paths)) continue
207
+ policy.override_indexes.push(index)
208
+ if (override.days !== undefined) policy.days = override.days
209
+ if (override.scope !== undefined) policy.scope = override.scope
210
+ if (override.via !== undefined) policy.via = override.via
211
+ if (override.ignore) {
212
+ policy.ignore.models.push(...override.ignore.models)
213
+ policy.ignore.paths.push(...override.ignore.paths)
214
+ }
215
+ }
216
+ policy.ignore.models = [...new Set(policy.ignore.models)]
217
+ policy.ignore.paths = [...new Set(policy.ignore.paths)]
218
+ return policy
219
+ }
220
+
221
+ export const routeForRepoReference = (config, repoPath, { matched = null, id = null } = {}) => {
222
+ const normalizedPath = normalizeRepoPath(repoPath)
223
+ let selected = null
224
+ for (const [index, route] of (config.routes ?? []).entries()) {
225
+ if (!matchesAnyGlob(normalizedPath, route.paths)) continue
226
+ if (route.match !== undefined && route.match !== matched && route.match !== id) continue
227
+ selected = { ...route, index }
228
+ }
229
+ return selected
230
+ }
231
+
232
+ export const mappedRoutesForRepoPath = (config, repoPath) => {
233
+ const normalizedPath = normalizeRepoPath(repoPath)
234
+ return (config.routes ?? []).flatMap((route, index) =>
235
+ route.match !== undefined && route.model !== undefined && matchesAnyGlob(normalizedPath, route.paths)
236
+ ? [{ ...route, index }]
237
+ : [])
238
+ }
239
+
240
+ export const configuredVias = config => [
241
+ config.via,
242
+ ...(config.overrides ?? []).map(override => override.via),
243
+ ...(config.routes ?? []).map(route => route.via),
244
+ ].filter(via => typeof via === 'string' && via.length > 0)
245
+
121
246
  export const loadConfig = (file, { defaults = DEFAULT_CONFIG, allowMissing = true } = {}) => {
122
247
  if (!fs.existsSync(file)) {
123
248
  if (allowMissing) return normalizeConfig({}, { defaults })
package/lib/feeds.mjs CHANGED
@@ -5,12 +5,23 @@ import { assertIsoDate, assertValidFeed } from './validate-feed.mjs'
5
5
 
6
6
  export { assertIsoDate }
7
7
 
8
+ export const BUILTIN_CHANNELS = Object.freeze([
9
+ 'azure-ai-foundry',
10
+ 'aws-bedrock',
11
+ 'vertex-ai',
12
+ 'openrouter',
13
+ 'litellm',
14
+ 'portkey',
15
+ 'ai-gateway',
16
+ ])
17
+
8
18
  export const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
9
19
 
10
20
  export const loadFeeds = feedsDir => {
11
21
  const feeds = []
12
22
  const entries = new Map()
13
23
  const keyFiles = new Map()
24
+ const vias = new Set()
14
25
 
15
26
  for (const f of fs.readdirSync(feedsDir).filter(f => f.endsWith('.json'))) {
16
27
  const file = path.join(feedsDir, f)
@@ -22,6 +33,7 @@ export const loadFeeds = feedsDir => {
22
33
  assertValidFeed(feed, file)
23
34
  feeds.push({ ...feed, file })
24
35
  for (const m of feed.models ?? []) {
36
+ for (const distribution of m.distributions ?? []) vias.add(distribution.via)
25
37
  const record = {
26
38
  entry: m,
27
39
  publisher: feed.publisher,
@@ -45,6 +57,7 @@ export const loadFeeds = feedsDir => {
45
57
  feeds,
46
58
  entries,
47
59
  keys: [...entries.keys()].sort((a, b) => b.length - a.length),
60
+ vias: [...vias].sort(),
48
61
  }
49
62
  }
50
63
 
@@ -71,13 +84,18 @@ const policySafeUntil = (policy, generated) => {
71
84
  export const effectiveShutdown = (entry, via) => {
72
85
  if (via) {
73
86
  const d = (entry.distributions ?? []).find(d => d.via === via)
74
- if (d) return { date: d.shutdown ?? null, via, date_precision: d.date_precision ?? null }
87
+ if (d) return {
88
+ date: d.shutdown ?? null,
89
+ via,
90
+ date_precision: d.date_precision ?? null,
91
+ distribution_status: d.status ?? null,
92
+ }
75
93
  if (entry.shutdown || entry.announced) {
76
- return { date: entry.shutdown ?? null, via: 'publisher-fallback', date_precision: entry.date_precision ?? null }
94
+ return { date: entry.shutdown ?? null, via: 'publisher-fallback', date_precision: entry.date_precision ?? null, distribution_status: null }
77
95
  }
78
96
  return null
79
97
  }
80
- return entry.shutdown ? { date: entry.shutdown, via: 'publisher', date_precision: entry.date_precision ?? null } : null
98
+ return entry.shutdown ? { date: entry.shutdown, via: 'publisher', date_precision: entry.date_precision ?? null, distribution_status: null } : null
81
99
  }
82
100
 
83
101
  export const lifecycleFor = (entry, options) => {
@@ -90,12 +108,24 @@ export const lifecycleFor = (entry, options) => {
90
108
  } = options
91
109
  const sd = effectiveShutdown(entry, via)
92
110
  if (sd) {
111
+ if (sd.distribution_status === 'retired') {
112
+ return {
113
+ status: 'retired',
114
+ shutdown: sd.date,
115
+ date_precision: sd.date_precision,
116
+ distribution_status: sd.distribution_status,
117
+ via: sd.via,
118
+ days: sd.date ? daysUntil(sd.date, today) : null,
119
+ safe_until: sd.date,
120
+ }
121
+ }
93
122
  if (sd.date) {
94
123
  const remaining = daysUntil(sd.date, today)
95
124
  return {
96
125
  status: remaining < 0 ? 'retired' : remaining <= days ? 'retiring' : 'scheduled',
97
126
  shutdown: sd.date,
98
127
  date_precision: sd.date_precision,
128
+ distribution_status: sd.distribution_status,
99
129
  via: sd.via,
100
130
  days: remaining,
101
131
  safe_until: sd.date,
@@ -110,6 +140,7 @@ export const lifecycleFor = (entry, options) => {
110
140
  status,
111
141
  shutdown: null,
112
142
  date_precision: sd.date_precision,
143
+ distribution_status: sd.distribution_status,
113
144
  via: sd.via,
114
145
  days: null,
115
146
  safe_until: status === 'ok' && !entry.announced && !entry.shutdown
@@ -118,12 +149,13 @@ export const lifecycleFor = (entry, options) => {
118
149
  }
119
150
  }
120
151
  if (entry.announced) {
121
- return { status: 'watch', shutdown: null, date_precision: null, via: null, days: null, safe_until: null }
152
+ return { status: 'watch', shutdown: null, date_precision: null, distribution_status: null, via: null, days: null, safe_until: null }
122
153
  }
123
154
  return {
124
155
  status: 'ok',
125
156
  shutdown: null,
126
157
  date_precision: null,
158
+ distribution_status: null,
127
159
  via: null,
128
160
  days: null,
129
161
  safe_until: policySafeUntil(policy, generated),
@@ -157,12 +189,14 @@ export const findingFromRef = (ref, options) => {
157
189
  status: lifecycle.status,
158
190
  shutdown: lifecycle.shutdown,
159
191
  date_precision: lifecycle.date_precision ?? null,
192
+ distribution_status: lifecycle.distribution_status ?? null,
160
193
  via: lifecycle.via,
161
194
  days: lifecycle.days,
162
195
  safe_until: lifecycle.safe_until,
163
196
  replacement: ref.entry.replacement ?? null,
164
197
  replacement_options: ref.entry.replacement_options ?? null,
165
198
  replacement_note: ref.entry.replacement_note ?? null,
199
+ threshold_days: days,
166
200
  }
167
201
  }
168
202
 
package/lib/plan.mjs CHANGED
@@ -22,6 +22,10 @@ const readLines = (file, cache) => {
22
22
 
23
23
  const notesFor = ref => [ref.feedNote, ref.entry.notes].filter(Boolean).join(' - ') || null
24
24
 
25
+ const requestedVia = (finding, fallback) => Object.hasOwn(finding, 'requested_via')
26
+ ? finding.requested_via
27
+ : fallback
28
+
25
29
  const issueFor = (ref, finding, reason, via) => ({
26
30
  file: ref.file,
27
31
  line: ref.line,
@@ -33,6 +37,11 @@ const issueFor = (ref, finding, reason, via) => ({
33
37
  status: finding.status,
34
38
  shutdown: finding.shutdown,
35
39
  via: finding.via,
40
+ requested_via: requestedVia(finding, via),
41
+ threshold_days: finding.threshold_days,
42
+ effective_scope: finding.effective_scope,
43
+ ...(finding.policy_provenance ? { policy_provenance: finding.policy_provenance } : {}),
44
+ ...(ref.mapped_from ? { mapped_from: ref.mapped_from } : {}),
36
45
  days: finding.days,
37
46
  replacement: finding.replacement,
38
47
  ...(finding.replacement_options?.length ? { replacement_options: finding.replacement_options } : {}),
@@ -42,8 +51,8 @@ const issueFor = (ref, finding, reason, via) => ({
42
51
  notes: notesFor(ref),
43
52
  })
44
53
 
45
- const scopeIncludes = (ref, scope) =>
46
- scope !== 'direct' || ref.usage === 'direct-api' || ref.usage === 'model-reference'
54
+ const scopeIncludes = (ref, scope, finding = null) =>
55
+ (finding?.effective_scope ?? scope) !== 'direct' || ref.usage === 'direct-api' || ref.usage === 'model-reference'
47
56
 
48
57
  const occurrenceInLine = (line, matched, index) => {
49
58
  let occurrence = 0
@@ -62,13 +71,16 @@ export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
62
71
  const lineCache = new Map()
63
72
 
64
73
  scan.modelRefs.forEach((ref, index) => {
65
- if (!scopeIncludes(ref, scope)) return
66
74
  const finding = findings[index]
75
+ if (!scopeIncludes(ref, scope, finding)) return
67
76
  if (!finding || finding.status === 'ok') return
77
+ const effectiveVia = requestedVia(finding, via)
78
+ const effectiveDays = finding.threshold_days ?? days
68
79
 
69
80
  let reason
70
81
  if (finding.status === 'watch') reason = 'watch'
71
82
  else if (finding.status !== 'retired' && finding.status !== 'retiring') reason = 'outside-threshold'
83
+ else if (!finding.shutdown) reason = 'shutdown-date-unavailable'
72
84
  else if (finding.via === 'publisher-fallback') reason = 'publisher-fallback'
73
85
  else if (ref.usage !== 'direct-api' || ref.confidence !== 'high') reason = 'not-direct-api'
74
86
  else if (!finding.replacement && finding.replacement_options?.length) reason = 'replacement-choice'
@@ -77,7 +89,7 @@ export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
77
89
  const replacementRecord = entries.get(finding.replacement)
78
90
  if (!replacementRecord || replacementRecord.publisher !== finding.publisher) reason = 'replacement-unresolved'
79
91
  else {
80
- const replacementLifecycle = lifecycleFor(replacementRecord.entry, { days, via })
92
+ const replacementLifecycle = lifecycleFor(replacementRecord.entry, { days: effectiveDays, via: effectiveVia })
81
93
  if (isBad(replacementLifecycle)) reason = 'replacement-retiring'
82
94
  }
83
95
  }
@@ -92,7 +104,7 @@ export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
92
104
  if (line === undefined) reason = reason ?? 'line-unavailable'
93
105
 
94
106
  if (reason) {
95
- issues.push(issueFor(ref, finding, reason, via))
107
+ issues.push(issueFor(ref, finding, reason, effectiveVia))
96
108
  return
97
109
  }
98
110
 
@@ -100,7 +112,7 @@ export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
100
112
  ? ref.occurrence
101
113
  : occurrenceInLine(line, ref.matched, line.indexOf(ref.matched))
102
114
  if (line.indexOf(ref.matched) === -1) {
103
- issues.push(issueFor(ref, finding, 'match-unavailable', via))
115
+ issues.push(issueFor(ref, finding, 'match-unavailable', effectiveVia))
104
116
  return
105
117
  }
106
118
 
@@ -116,19 +128,26 @@ export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
116
128
  shutdown: finding.shutdown,
117
129
  days: finding.days,
118
130
  status: finding.status,
119
- sources: sourceList(ref, finding, via),
131
+ requested_via: effectiveVia,
132
+ threshold_days: effectiveDays,
133
+ effective_scope: finding.effective_scope ?? scope,
134
+ ...(finding.policy_provenance ? { policy_provenance: finding.policy_provenance } : {}),
135
+ ...(ref.mapped_from ? { mapped_from: ref.mapped_from } : {}),
136
+ sources: sourceList(ref, finding, effectiveVia),
120
137
  notes: notesFor(ref),
121
138
  })
122
139
  })
123
140
 
124
141
  for (const candidate of scan.candidateRefs) {
125
- if (scope === 'direct' && candidate.usage !== 'direct-api' && candidate.usage !== 'model-reference') continue
142
+ if ((candidate.effective_scope ?? scope) === 'direct' && candidate.usage !== 'direct-api' && candidate.usage !== 'model-reference') continue
126
143
  issues.push({
127
144
  file: candidate.file,
128
145
  line: candidate.line,
129
146
  matched: candidate.matched,
130
147
  usage: candidate.usage,
131
148
  confidence: candidate.confidence,
149
+ effective_scope: candidate.effective_scope ?? scope,
150
+ ...(candidate.policy_provenance ? { policy_provenance: candidate.policy_provenance } : {}),
132
151
  reason: 'candidate',
133
152
  notes: candidate.reason,
134
153
  })
package/lib/reports.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isBad } from './feeds.mjs'
2
2
  import { color } from './color.mjs'
3
+ import { incompleteScanNotes } from './scanner.mjs'
3
4
 
4
5
  export const marker = status =>
5
6
  status === 'retired' ? '✗' :
@@ -18,9 +19,10 @@ const replacementTail = finding => finding.replacement
18
19
  : ''
19
20
 
20
21
  export const findingTail = (f, days) =>
22
+ f.status === 'retired' && !f.shutdown ? `RETIRED (shutdown date unavailable)${replacementTail(f)}` :
21
23
  f.status === 'retired' ? `RETIRED ${shutdownText(f)} (${-f.days} days ago)${replacementTail(f)}` :
22
24
  f.status === 'retiring' ? `RETIRES ${shutdownText(f)} (${f.days} days)${replacementTail(f)}` :
23
- f.status === 'scheduled' ? `scheduled ${shutdownText(f)} (${f.days} days, outside --days ${days})` :
25
+ f.status === 'scheduled' ? `scheduled ${shutdownText(f)} (${f.days} days, outside --days ${f.threshold_days ?? days})` :
24
26
  f.status === 'watch' ? 'deprecation announced, no shutdown date yet' :
25
27
  'no retirement scheduled'
26
28
 
@@ -53,8 +55,14 @@ export const buildInventory = ({ scan, findings, days, via, scope, targets }) =>
53
55
  status: lifecycle.status,
54
56
  shutdown: lifecycle.shutdown,
55
57
  date_precision: lifecycle.date_precision,
58
+ distribution_status: lifecycle.distribution_status,
56
59
  via: lifecycle.via,
60
+ requested_via: lifecycle.requested_via,
57
61
  days: lifecycle.days,
62
+ threshold_days: lifecycle.threshold_days,
63
+ effective_scope: lifecycle.effective_scope,
64
+ ...(lifecycle.policy_provenance ? { policy_provenance: lifecycle.policy_provenance } : {}),
65
+ ...(ref.mapped_from ? { mapped_from: ref.mapped_from } : {}),
58
66
  safe_until: lifecycle.safe_until,
59
67
  replacement: lifecycle.replacement,
60
68
  source: ref.source,
@@ -87,7 +95,7 @@ export const buildInventory = ({ scan, findings, days, via, scope, targets }) =>
87
95
  export const buildSchedule = inventory => {
88
96
  const severity = { retired: 0, retiring: 1, scheduled: 2, watch: 3, ok: 4 }
89
97
  const inScope = item =>
90
- inventory.scope !== 'direct' || item.usage === 'direct-api' || item.usage === 'model-reference'
98
+ (item.effective_scope ?? inventory.scope) !== 'direct' || item.usage === 'direct-api' || item.usage === 'model-reference'
91
99
  const items = inventory.model_references
92
100
  .filter(item => item.status !== 'ok')
93
101
  .filter(inScope)
@@ -172,7 +180,8 @@ export const formatCheck = ({ findings, bad, scannedFiles, days, scope }) => {
172
180
 
173
181
  export const formatInventory = (inventory, days) => {
174
182
  const out = [color('bold', `model-eol inventory: ${inventory.summary.model_references} tracked model reference(s), ${inventory.summary.candidate_model_references} candidate(s), ${inventory.summary.integration_hints} integration hint(s) across ${inventory.scanned_files} files`)]
175
- if ((inventory.scan_notes ?? []).length) out.push(`WARNING: scan incomplete; ${inventory.scan_notes.length} scan note(s) recorded`)
183
+ const incompleteNotes = incompleteScanNotes(inventory.scan_notes ?? [])
184
+ if (incompleteNotes.length) out.push(`WARNING: scan incomplete; ${incompleteNotes.length} coverage-loss note(s) recorded`)
176
185
  if (inventory.model_references.length) {
177
186
  out.push('\nModel references')
178
187
  for (const item of inventory.model_references) {
@@ -197,7 +206,8 @@ export const formatInventory = (inventory, days) => {
197
206
 
198
207
  export const formatSchedule = (schedule, days) => {
199
208
  const out = [color('bold', `model-eol schedule: ${schedule.items.length} scheduled/watch finding(s), ${schedule.candidate_model_references.length} candidate(s), ${schedule.unresolved_integrations.length} unresolved cloud/gateway hint(s)`)]
200
- if ((schedule.scan_notes ?? []).length) out.push(`WARNING: scan incomplete; ${schedule.scan_notes.length} scan note(s) recorded`)
209
+ const incompleteNotes = incompleteScanNotes(schedule.scan_notes ?? [])
210
+ if (incompleteNotes.length) out.push(`WARNING: scan incomplete; ${incompleteNotes.length} coverage-loss note(s) recorded`)
201
211
  for (const item of schedule.items) {
202
212
  const safeUntil = item.safe_until ? ` [safe until ${item.safe_until}]` : ''
203
213
  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}`))
@@ -247,7 +257,12 @@ export const formatInventoryCycloneDX = inventory => {
247
257
  bomFormat: 'CycloneDX',
248
258
  specVersion: '1.6',
249
259
  version: 1,
250
- metadata: { timestamp: inventory.generated },
260
+ metadata: {
261
+ timestamp: inventory.generated,
262
+ properties: [
263
+ { name: 'model-eol:generator', value: 'model-eol/inventory-cyclonedx@0.1' },
264
+ ],
265
+ },
251
266
  components: [...components.values()],
252
267
  }
253
268
  }
@@ -261,7 +276,8 @@ const annotationEscape = value => String(value)
261
276
 
262
277
  export const formatAlertGithub = (alert, days) => {
263
278
  const out = []
264
- if ((alert.scan_notes ?? []).length) out.push(`::warning title=${annotationEscape('model-eol scan')}::${annotationEscape(`Scan incomplete; ${alert.scan_notes.length} scan note(s) recorded`)}`)
279
+ const incompleteNotes = incompleteScanNotes(alert.scan_notes ?? [])
280
+ if (incompleteNotes.length) out.push(`::warning title=${annotationEscape('model-eol scan')}::${annotationEscape(`Scan incomplete; ${incompleteNotes.length} coverage-loss note(s) recorded`)}`)
265
281
  for (const item of alert.errors) {
266
282
  const title = `model-eol ${item.status}: ${item.matched}`
267
283
  out.push(`::error file=${annotationEscape(item.file)},line=${item.line},title=${annotationEscape(title)}::${annotationEscape(findingTail(item, days))}`)
@@ -283,7 +299,8 @@ export const formatAlertGithub = (alert, days) => {
283
299
 
284
300
  export const formatAlertMarkdown = (alert, days) => {
285
301
  const out = ['# model-eol alert', '']
286
- if ((alert.scan_notes ?? []).length) out.push(`> Warning: scan incomplete; ${alert.scan_notes.length} scan note(s) recorded.`, '')
302
+ const incompleteNotes = incompleteScanNotes(alert.scan_notes ?? [])
303
+ if (incompleteNotes.length) out.push(`> Warning: scan incomplete; ${incompleteNotes.length} coverage-loss note(s) recorded.`, '')
287
304
  out.push(`- Errors: ${alert.errors.length}`)
288
305
  out.push(`- Warnings: ${alert.warnings.length}`)
289
306
  if (alert.errors.length) {