model-eol 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/apply.mjs ADDED
@@ -0,0 +1,409 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
6
+
7
+ const occurrenceIndex = (line, value, occurrence) => {
8
+ if (!value || !Number.isInteger(occurrence) || occurrence < 0) return -1
9
+ let cursor = 0
10
+ for (let i = 0; i <= occurrence; i++) {
11
+ const index = line.indexOf(value, cursor)
12
+ if (index === -1) return -1
13
+ if (i === occurrence) return index
14
+ cursor = index + value.length
15
+ }
16
+ return -1
17
+ }
18
+
19
+ const itemLabel = item => `${item.file}:${item.line}`
20
+
21
+ const printError = (item, message) => {
22
+ console.error(`model-eol: apply refused ${itemLabel(item)}: ${message}`)
23
+ }
24
+
25
+ export const validatePlanItems = items => {
26
+ if (!Array.isArray(items)) throw new Error('plan must contain an items array')
27
+ for (const [index, item] of items.entries()) {
28
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
29
+ throw new Error(`plan item ${index} must be an object`)
30
+ }
31
+ if (typeof item.file !== 'string' || item.file.length === 0) {
32
+ throw new Error(`plan item ${index} file must be a non-empty string`)
33
+ }
34
+ if (!Number.isInteger(item.line) || item.line < 1) {
35
+ throw new Error(`plan item ${index} line must be an integer >= 1`)
36
+ }
37
+ if (!Number.isInteger(item.occurrence) || item.occurrence < 0) {
38
+ throw new Error(`plan item ${index} occurrence must be an integer >= 0`)
39
+ }
40
+ if (typeof item.matched !== 'string' || item.matched.length === 0) {
41
+ throw new Error(`plan item ${index} matched must be a non-empty string`)
42
+ }
43
+ if (typeof item.replacement !== 'string' || item.replacement.length === 0) {
44
+ throw new Error(`plan item ${index} replacement must be a non-empty string`)
45
+ }
46
+ if (typeof item.expected_line_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.expected_line_sha256)) {
47
+ throw new Error(`plan item ${index} expected_line_sha256 must be 64 lowercase hexadecimal characters`)
48
+ }
49
+ }
50
+ return items
51
+ }
52
+
53
+ const isInside = (root, candidate) => {
54
+ const relative = path.relative(root, candidate)
55
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
56
+ }
57
+
58
+ const realPathForContainment = value => {
59
+ let current = value
60
+ const suffix = []
61
+ while (true) {
62
+ try {
63
+ return path.join(fs.realpathSync(current), ...suffix)
64
+ } catch {
65
+ const parent = path.dirname(current)
66
+ if (parent === current) return value
67
+ suffix.unshift(path.basename(current))
68
+ current = parent
69
+ }
70
+ }
71
+ }
72
+
73
+ const resolvePlanFile = (rootDir, file) => {
74
+ const lexicalRoot = path.resolve(rootDir)
75
+ const root = fs.realpathSync(lexicalRoot)
76
+ if (file.split(/[\\/]/).includes('..')) throw new Error('file path contains .. traversal')
77
+ const resolved = path.resolve(lexicalRoot, file)
78
+ if (!path.isAbsolute(file) && !isInside(lexicalRoot, resolved)) throw new Error('file resolves outside rootDir')
79
+ const realFile = realPathForContainment(resolved)
80
+ if (!isInside(root, realFile)) throw new Error('file resolves outside rootDir')
81
+ return resolved
82
+ }
83
+
84
+ const groupItems = items => {
85
+ const groups = new Map()
86
+ for (const item of items) {
87
+ const file = item.resolvedFile ?? item.file
88
+ if (!groups.has(file)) groups.set(file, [])
89
+ groups.get(file).push(item)
90
+ }
91
+ return groups
92
+ }
93
+
94
+ const groupItemsByLine = items => {
95
+ const groups = new Map()
96
+ for (const item of items) {
97
+ if (!groups.has(item.line)) groups.set(item.line, [])
98
+ groups.get(item.line).push(item)
99
+ }
100
+ return groups
101
+ }
102
+
103
+ const MAX_RESTORE_ITEMS = 16
104
+ const MAX_RESTORE_STATES = 10000
105
+
106
+ const locateOccurrences = (line, items) => {
107
+ const locations = []
108
+ const failures = []
109
+ for (const item of items) {
110
+ const index = occurrenceIndex(line, item.matched, item.occurrence)
111
+ if (index === -1) {
112
+ failures.push({ item, message: 'expected occurrence of matched text is missing' })
113
+ continue
114
+ }
115
+ locations.push({ item, index })
116
+ }
117
+ return { locations, failures }
118
+ }
119
+
120
+ const locateReplacements = (line, items) => {
121
+ const located = locateOccurrences(line, items)
122
+ if (located.failures.length) return located
123
+ const seen = new Set()
124
+ const failures = []
125
+ const locations = []
126
+ for (const location of located.locations) {
127
+ const key = `${location.item.line}:${location.index}`
128
+ if (seen.has(key)) {
129
+ failures.push({ item: location.item, message: 'duplicate occurrence in plan' })
130
+ continue
131
+ }
132
+ seen.add(key)
133
+ locations.push(location)
134
+ }
135
+ return { locations, failures }
136
+ }
137
+
138
+ const applyLocatedReplacements = (line, locations) => {
139
+ let updated = line
140
+ for (const { item, index } of locations.toSorted((a, b) => b.index - a.index)) {
141
+ updated = updated.slice(0, index) + item.replacement + updated.slice(index + item.matched.length)
142
+ }
143
+ return updated
144
+ }
145
+
146
+ const hasExpectedPreImage = (line, items) => {
147
+ const expected = items[0].expected_line_sha256
148
+ return items.every(item => item.expected_line_sha256 === expected) && sha256(line) === expected
149
+ }
150
+
151
+ const restoreGroup = (postImage, items) => {
152
+ const expected = items[0].expected_line_sha256
153
+ if (!items.every(item => item.expected_line_sha256 === expected)) return null
154
+ if (items.length > MAX_RESTORE_ITEMS) return null
155
+
156
+ const descriptors = items.map((item, planIndex) => ({ item, planIndex }))
157
+ const visited = new Set()
158
+ const search = (line, remaining) => {
159
+ if (visited.size >= MAX_RESTORE_STATES) return null
160
+ const state = `${remaining.map(({ planIndex }) => planIndex).join(',')}\u0000${line}`
161
+ if (visited.has(state)) return null
162
+ visited.add(state)
163
+
164
+ if (remaining.length === 0) {
165
+ if (sha256(line) !== expected) return null
166
+ const located = locateReplacements(line, items)
167
+ if (located.failures.length) return null
168
+ return applyLocatedReplacements(line, located.locations) === postImage ? line : null
169
+ }
170
+
171
+ for (const [remainingIndex, { item }] of remaining.entries()) {
172
+ let cursor = 0
173
+ while (true) {
174
+ const index = line.indexOf(item.replacement, cursor)
175
+ if (index === -1) break
176
+ const restored = line.slice(0, index) + item.matched + line.slice(index + item.replacement.length)
177
+ const next = remaining.slice(0, remainingIndex).concat(remaining.slice(remainingIndex + 1))
178
+ const result = search(restored, next)
179
+ if (result !== null) return result
180
+ cursor = index + item.replacement.length
181
+ }
182
+ }
183
+ return null
184
+ }
185
+
186
+ return search(postImage, descriptors)
187
+ }
188
+
189
+ const temporaryFileFor = file => path.join(
190
+ path.dirname(file),
191
+ `.model-eol-${path.basename(file)}-${process.pid}-${crypto.randomBytes(12).toString('hex')}.tmp`,
192
+ )
193
+
194
+ const writeAtomically = (file, content) => {
195
+ const temporaryFile = temporaryFileFor(file)
196
+ try {
197
+ const mode = fs.statSync(file).mode & 0o7777
198
+ fs.writeFileSync(temporaryFile, content, { encoding: 'utf8', flag: 'wx', mode })
199
+ fs.chmodSync(temporaryFile, mode)
200
+ fs.renameSync(temporaryFile, file)
201
+ } catch (error) {
202
+ try {
203
+ fs.unlinkSync(temporaryFile)
204
+ } catch {
205
+ }
206
+ throw error
207
+ }
208
+ }
209
+
210
+ export const applyPlan = ({ planPath, dryRun = false, rootDir }) => {
211
+ if (typeof rootDir !== 'string' || rootDir.length === 0) throw new Error('applyPlan requires rootDir')
212
+ const plan = JSON.parse(fs.readFileSync(planPath, 'utf8'))
213
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) throw new Error('plan must be an object')
214
+ validatePlanItems(plan.items)
215
+
216
+ let failed = 0
217
+ let applied = 0
218
+ let alreadyApplied = 0
219
+
220
+ const prepared = []
221
+ for (const [planIndex, item] of plan.items.entries()) {
222
+ try {
223
+ prepared.push({ ...item, planIndex, resolvedFile: resolvePlanFile(rootDir, item.file) })
224
+ } catch (error) {
225
+ printError(item, error.message)
226
+ failed++
227
+ }
228
+ }
229
+
230
+ for (const [file, items] of groupItems(prepared)) {
231
+ let text
232
+ try {
233
+ text = fs.readFileSync(file, 'utf8')
234
+ } catch (e) {
235
+ for (const item of items) {
236
+ printError(item, `unreadable file: ${e.message}`)
237
+ failed++
238
+ }
239
+ continue
240
+ }
241
+
242
+ const lines = text.split('\n')
243
+ let ready = []
244
+ const already = []
245
+ const failures = []
246
+
247
+ const readyGroups = []
248
+ for (const [lineNumber, lineItems] of groupItemsByLine(items)) {
249
+ const line = lines[lineNumber - 1]
250
+ if (line === undefined) {
251
+ for (const item of lineItems) failures.push({ item, message: 'line is not present' })
252
+ continue
253
+ }
254
+
255
+ if (lineItems.length > MAX_RESTORE_ITEMS) {
256
+ for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
257
+ continue
258
+ }
259
+
260
+ if (hasExpectedPreImage(line, lineItems)) {
261
+ const located = locateOccurrences(line, lineItems)
262
+ failures.push(...located.failures)
263
+ const failedItems = new Set(located.failures.map(failure => failure.item))
264
+ const groupReady = lineItems.filter(item => !failedItems.has(item))
265
+ ready.push(...groupReady)
266
+ readyGroups.push({ lineNumber, items: groupReady, locations: located.locations })
267
+ continue
268
+ }
269
+
270
+ if (restoreGroup(line, lineItems) !== null) already.push(...lineItems)
271
+ else {
272
+ for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
273
+ }
274
+ }
275
+
276
+ if (failures.length) {
277
+ ready.sort((a, b) => a.planIndex - b.planIndex)
278
+ already.sort((a, b) => a.planIndex - b.planIndex)
279
+ failures.sort((a, b) => a.item.planIndex - b.item.planIndex)
280
+ for (const failure of failures) {
281
+ printError(failure.item, failure.message)
282
+ failed++
283
+ }
284
+ for (const item of ready) {
285
+ printError(item, 'another item for this file failed validation; file was not changed')
286
+ failed++
287
+ }
288
+ for (const item of already) {
289
+ console.log(`already-applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
290
+ alreadyApplied++
291
+ }
292
+ continue
293
+ }
294
+
295
+ const duplicateFailures = []
296
+ const duplicateItems = new Set()
297
+ for (const readyGroup of readyGroups) {
298
+ const located = locateReplacements(lines[readyGroup.lineNumber - 1], readyGroup.items)
299
+ duplicateFailures.push(...located.failures)
300
+ for (const failure of located.failures) duplicateItems.add(failure.item)
301
+ readyGroup.locations = located.locations
302
+ readyGroup.items = readyGroup.items.filter(item => !duplicateItems.has(item))
303
+ }
304
+ if (duplicateFailures.length) {
305
+ ready = ready.filter(item => !duplicateItems.has(item))
306
+ failures.push(...duplicateFailures)
307
+ ready.sort((a, b) => a.planIndex - b.planIndex)
308
+ already.sort((a, b) => a.planIndex - b.planIndex)
309
+ failures.sort((a, b) => a.item.planIndex - b.item.planIndex)
310
+ for (const failure of failures) {
311
+ printError(failure.item, failure.message)
312
+ failed++
313
+ }
314
+ for (const item of ready) {
315
+ printError(item, 'another item for this file failed validation; file was not changed')
316
+ failed++
317
+ }
318
+ continue
319
+ }
320
+
321
+ ready.sort((a, b) => a.planIndex - b.planIndex)
322
+ already.sort((a, b) => a.planIndex - b.planIndex)
323
+
324
+ const updatedLines = lines.slice()
325
+ for (const { lineNumber, locations } of readyGroups) {
326
+ updatedLines[lineNumber - 1] = applyLocatedReplacements(lines[lineNumber - 1], locations)
327
+ }
328
+ const updatedContent = updatedLines.join('\n')
329
+
330
+ for (const item of already) {
331
+ console.log(`already-applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
332
+ alreadyApplied++
333
+ }
334
+ if (!ready.length) continue
335
+
336
+ if (dryRun) {
337
+ for (const item of ready) {
338
+ console.log(`would change ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
339
+ }
340
+ continue
341
+ }
342
+
343
+ let latestText
344
+ try {
345
+ latestText = fs.readFileSync(file, 'utf8')
346
+ } catch (e) {
347
+ for (const item of ready) {
348
+ printError(item, `write failed: ${e.message}`)
349
+ failed++
350
+ }
351
+ continue
352
+ }
353
+ const latestLines = latestText.split('\n')
354
+ const latestReadyGroups = []
355
+ const verificationFailures = []
356
+ for (const { lineNumber, items: lineItems } of readyGroups) {
357
+ const line = latestLines[lineNumber - 1]
358
+ if (line === undefined) {
359
+ for (const item of lineItems) verificationFailures.push({ item, message: 'line is not present' })
360
+ continue
361
+ }
362
+ if (!hasExpectedPreImage(line, lineItems)) {
363
+ for (const item of lineItems) verificationFailures.push({ item, message: 'line hash does not match expected_line_sha256' })
364
+ continue
365
+ }
366
+ const located = locateReplacements(line, lineItems)
367
+ if (located.failures.length) {
368
+ verificationFailures.push(...located.failures)
369
+ continue
370
+ }
371
+ latestReadyGroups.push({ lineNumber, locations: located.locations })
372
+ }
373
+ verificationFailures.sort((a, b) => a.item.planIndex - b.item.planIndex)
374
+ if (verificationFailures.length) {
375
+ for (const failure of verificationFailures) {
376
+ printError(failure.item, failure.message)
377
+ failed++
378
+ }
379
+ const verificationFailedItems = new Set(verificationFailures.map(failure => failure.item))
380
+ for (const item of ready) {
381
+ if (!verificationFailedItems.has(item)) {
382
+ printError(item, 'another item for this file failed validation; file was not changed')
383
+ failed++
384
+ }
385
+ }
386
+ continue
387
+ }
388
+
389
+ const latestUpdatedLines = latestLines.slice()
390
+ for (const { lineNumber, locations } of latestReadyGroups) {
391
+ latestUpdatedLines[lineNumber - 1] = applyLocatedReplacements(latestLines[lineNumber - 1], locations)
392
+ }
393
+ try {
394
+ writeAtomically(file, latestText === text ? updatedContent : latestUpdatedLines.join('\n'))
395
+ } catch (e) {
396
+ for (const item of ready) {
397
+ printError(item, `write failed: ${e.message}`)
398
+ failed++
399
+ }
400
+ continue
401
+ }
402
+ for (const item of ready) {
403
+ console.log(`applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
404
+ applied++
405
+ }
406
+ }
407
+
408
+ return { failed, applied, alreadyApplied }
409
+ }
package/lib/cli.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { parseArgs as nodeParseArgs } from 'node:util'
2
+
3
+ export class CliUsageError extends Error {
4
+ constructor(message) {
5
+ super(message)
6
+ this.name = 'CliUsageError'
7
+ this.exitCode = 2
8
+ }
9
+ }
10
+
11
+ const unknownOption = error => String(error?.message ?? '').match(/Unknown option '([^']+)'/)?.[1]
12
+
13
+ export const parseCliArgs = ({ args, options, allowPositionals = false, help }) => {
14
+ try {
15
+ return nodeParseArgs({ args, options, strict: true, allowPositionals })
16
+ } catch (error) {
17
+ const option = unknownOption(error)
18
+ if (option) throw new CliUsageError(`unknown option ${option}; run ${help} for usage`)
19
+ if (String(error?.code ?? '').startsWith('ERR_PARSE_ARGS_')) {
20
+ const message = String(error.message).split('\n', 1)[0]
21
+ throw new CliUsageError(`${message}; run ${help} for usage`)
22
+ }
23
+ throw error
24
+ }
25
+ }
package/lib/color.mjs ADDED
@@ -0,0 +1,16 @@
1
+ const ANSI = Object.freeze({
2
+ red: '\x1b[31m',
3
+ yellow: '\x1b[33m',
4
+ dim: '\x1b[2m',
5
+ bold: '\x1b[1m',
6
+ reset: '\x1b[0m',
7
+ })
8
+
9
+ export const colorEnabled = (env = process.env, stdout = process.stdout) =>
10
+ env.NO_COLOR === undefined && (Boolean(stdout?.isTTY) || env.FORCE_COLOR !== undefined)
11
+
12
+ export const color = (style, value, { env = process.env, stdout = process.stdout } = {}) => {
13
+ const text = String(value)
14
+ const code = ANSI[style]
15
+ return code && colorEnabled(env, stdout) ? `${code}${text}${ANSI.reset}` : text
16
+ }
package/lib/feeds.mjs ADDED
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { assertIsoDate, assertValidFeed } from './validate-feed.mjs'
5
+
6
+ export { assertIsoDate }
7
+
8
+ export const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
9
+
10
+ export const loadFeeds = feedsDir => {
11
+ const feeds = []
12
+ const entries = new Map()
13
+ const keyFiles = new Map()
14
+
15
+ for (const f of fs.readdirSync(feedsDir).filter(f => f.endsWith('.json'))) {
16
+ const file = path.join(feedsDir, f)
17
+ const feed = JSON.parse(fs.readFileSync(file, 'utf8'))
18
+ if (feed.spec !== 'model-eol/0.1') {
19
+ console.error(`skipping ${f}: unknown spec ${feed.spec}`)
20
+ continue
21
+ }
22
+ assertValidFeed(feed, file)
23
+ feeds.push({ ...feed, file })
24
+ for (const m of feed.models ?? []) {
25
+ const record = {
26
+ entry: m,
27
+ publisher: feed.publisher,
28
+ source: m.source ?? feed.source ?? null,
29
+ feedNote: feed.note ?? null,
30
+ policy: feed.policy ?? null,
31
+ generated: feed.generated,
32
+ }
33
+ for (const key of [m.id, ...(m.aliases ?? [])]) {
34
+ const previousFile = keyFiles.get(key)
35
+ if (previousFile) {
36
+ throw new Error(`duplicate feed key "${key}" in ${previousFile} and ${file}`)
37
+ }
38
+ keyFiles.set(key, file)
39
+ entries.set(key, record)
40
+ }
41
+ }
42
+ }
43
+
44
+ return {
45
+ feeds,
46
+ entries,
47
+ keys: [...entries.keys()].sort((a, b) => b.length - a.length),
48
+ }
49
+ }
50
+
51
+ export const buildModelPattern = keys => {
52
+ if (!keys.length) return null
53
+ // Longest keys first so "o3-deep-research-2025-06-26" wins over alias
54
+ // "o3-deep-research".
55
+ return new RegExp(`(?<![A-Za-z0-9._-])(${keys.map(esc).join('|')})(?![A-Za-z0-9._-])`, 'g')
56
+ }
57
+
58
+ export const daysUntil = (iso, today = new Date()) =>
59
+ Math.ceil((new Date(`${iso}T00:00:00Z`) - today) / 86400000)
60
+
61
+ const policySafeUntil = (policy, generated) => {
62
+ if (!policy || !Number.isInteger(policy.min_notice_days) || policy.min_notice_days < 0) return null
63
+ const generatedDate = String(generated ?? '').slice(0, 10)
64
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(generatedDate)) return null
65
+ const date = new Date(`${generatedDate}T00:00:00Z`)
66
+ if (Number.isNaN(date.getTime())) return null
67
+ date.setUTCDate(date.getUTCDate() + policy.min_notice_days)
68
+ return date.toISOString().slice(0, 10)
69
+ }
70
+
71
+ export const effectiveShutdown = (entry, via) => {
72
+ if (via) {
73
+ const d = (entry.distributions ?? []).find(d => d.via === via)
74
+ if (d) return { date: d.shutdown ?? null, via, date_precision: d.date_precision ?? null }
75
+ if (entry.shutdown || entry.announced) {
76
+ return { date: entry.shutdown ?? null, via: 'publisher-fallback', date_precision: entry.date_precision ?? null }
77
+ }
78
+ return null
79
+ }
80
+ return entry.shutdown ? { date: entry.shutdown, via: 'publisher', date_precision: entry.date_precision ?? null } : null
81
+ }
82
+
83
+ export const lifecycleFor = (entry, options) => {
84
+ const {
85
+ days,
86
+ via,
87
+ today = new Date(),
88
+ policy = options.feed?.policy ?? options.feedPolicy ?? null,
89
+ generated = options.feed?.generated ?? options.feedGenerated ?? null,
90
+ } = options
91
+ const sd = effectiveShutdown(entry, via)
92
+ if (sd) {
93
+ if (sd.date) {
94
+ const remaining = daysUntil(sd.date, today)
95
+ return {
96
+ status: remaining < 0 ? 'retired' : remaining <= days ? 'retiring' : 'scheduled',
97
+ shutdown: sd.date,
98
+ date_precision: sd.date_precision,
99
+ via: sd.via,
100
+ days: remaining,
101
+ safe_until: sd.date,
102
+ }
103
+ }
104
+ const distribution = via
105
+ ? (entry.distributions ?? []).find(d => d.via === via)
106
+ : null
107
+ const announced = distribution?.announced ?? (sd.via === 'publisher-fallback' ? entry.announced : null)
108
+ const status = announced ? 'watch' : 'ok'
109
+ return {
110
+ status,
111
+ shutdown: null,
112
+ date_precision: sd.date_precision,
113
+ via: sd.via,
114
+ days: null,
115
+ safe_until: status === 'ok' && !entry.announced && !entry.shutdown
116
+ ? policySafeUntil(policy, generated)
117
+ : null,
118
+ }
119
+ }
120
+ if (entry.announced) {
121
+ return { status: 'watch', shutdown: null, date_precision: null, via: null, days: null, safe_until: null }
122
+ }
123
+ return {
124
+ status: 'ok',
125
+ shutdown: null,
126
+ date_precision: null,
127
+ via: null,
128
+ days: null,
129
+ safe_until: policySafeUntil(policy, generated),
130
+ }
131
+ }
132
+
133
+ export const findingFromRef = (ref, options) => {
134
+ const {
135
+ days,
136
+ via,
137
+ today = new Date(),
138
+ policy = ref.policy,
139
+ generated = ref.generated,
140
+ } = options
141
+ const lifecycle = lifecycleFor(ref.entry, {
142
+ days,
143
+ via,
144
+ today,
145
+ policy,
146
+ generated,
147
+ })
148
+ return {
149
+ file: ref.file,
150
+ line: ref.line,
151
+ matched: ref.matched,
152
+ id: ref.entry.id,
153
+ publisher: ref.publisher,
154
+ usage: ref.usage,
155
+ resolved_provider: ref.resolved_provider,
156
+ confidence: ref.confidence,
157
+ status: lifecycle.status,
158
+ shutdown: lifecycle.shutdown,
159
+ date_precision: lifecycle.date_precision ?? null,
160
+ via: lifecycle.via,
161
+ days: lifecycle.days,
162
+ safe_until: lifecycle.safe_until,
163
+ replacement: ref.entry.replacement ?? null,
164
+ replacement_options: ref.entry.replacement_options ?? null,
165
+ replacement_note: ref.entry.replacement_note ?? null,
166
+ }
167
+ }
168
+
169
+ export const isBad = finding => finding.status === 'retired' || finding.status === 'retiring'