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/LICENSE +21 -0
- package/README.md +205 -0
- package/SPEC.md +134 -0
- package/action.yml +71 -0
- package/check.mjs +263 -0
- package/feeds/amazon.json +64 -0
- package/feeds/anthropic.json +252 -0
- package/feeds/google.json +542 -0
- package/feeds/openai.json +1158 -0
- package/lib/apply.mjs +409 -0
- package/lib/cli.mjs +25 -0
- package/lib/color.mjs +16 -0
- package/lib/feeds.mjs +169 -0
- package/lib/plan.mjs +159 -0
- package/lib/reports.mjs +322 -0
- package/lib/scanner.mjs +478 -0
- package/lib/validate-feed.mjs +246 -0
- package/package.json +45 -0
- package/schema/model-eol.alert.schema.json +28 -0
- package/schema/model-eol.bot-config.schema.json +106 -0
- package/schema/model-eol.inventory.schema.json +130 -0
- package/schema/model-eol.plan.schema.json +82 -0
- package/schema/model-eol.schedule.schema.json +31 -0
- package/schema/model-eol.schema.json +69 -0
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'
|