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/README.md +343 -229
- package/SPEC.md +12 -1
- package/bot/bot.mjs +19 -6
- package/bot/lib/common.mjs +6 -0
- package/lib/config.mjs +61 -3
- package/lib/feeds.mjs +36 -6
- package/lib/plan.mjs +7 -0
- package/lib/reports.mjs +80 -16
- package/lib/scanner.mjs +2 -0
- package/lib/validate-document.mjs +1 -1
- package/lib/validate-feed.mjs +22 -3
- package/package.json +1 -1
- package/schema/model-eol.bot-config.schema.json +28 -1
- package/schema/model-eol.check.schema.json +2 -1
- package/schema/model-eol.inventory.schema.json +13 -1
- package/schema/model-eol.plan.schema.json +13 -0
- package/schema/model-eol.schema.json +2 -2
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"`
|
|
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 {
|
package/bot/lib/common.mjs
CHANGED
|
@@ -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
|
['<', '<'],
|
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
|
-
|
|
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 =>
|
|
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 =>
|
|
12
|
-
? `
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 = (
|
|
36
|
-
const style = statusStyle(
|
|
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(
|
|
170
|
+
const errors = schedule.items.filter(isBad)
|
|
153
171
|
const warnings = [
|
|
154
|
-
...schedule.items.filter(item =>
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 } = {}) => {
|