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/plan.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { isBad, lifecycleFor } from './feeds.mjs'
|
|
5
|
+
|
|
6
|
+
const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
|
|
7
|
+
|
|
8
|
+
const sourceList = (ref, finding, via) => {
|
|
9
|
+
const sources = []
|
|
10
|
+
if (ref.source) sources.push(ref.source)
|
|
11
|
+
const distribution = via
|
|
12
|
+
? (ref.entry.distributions ?? []).find(d => d.via === via)
|
|
13
|
+
: null
|
|
14
|
+
if (finding.via === via && distribution?.source) sources.push(distribution.source)
|
|
15
|
+
return [...new Set(sources)]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const readLines = (file, cache) => {
|
|
19
|
+
if (!cache.has(file)) cache.set(file, fs.readFileSync(file, 'utf8').split('\n'))
|
|
20
|
+
return cache.get(file)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const notesFor = ref => [ref.feedNote, ref.entry.notes].filter(Boolean).join(' - ') || null
|
|
24
|
+
|
|
25
|
+
const issueFor = (ref, finding, reason, via) => ({
|
|
26
|
+
file: ref.file,
|
|
27
|
+
line: ref.line,
|
|
28
|
+
matched: ref.matched,
|
|
29
|
+
id: ref.id,
|
|
30
|
+
publisher: ref.publisher,
|
|
31
|
+
usage: ref.usage,
|
|
32
|
+
confidence: ref.confidence,
|
|
33
|
+
status: finding.status,
|
|
34
|
+
shutdown: finding.shutdown,
|
|
35
|
+
via: finding.via,
|
|
36
|
+
days: finding.days,
|
|
37
|
+
replacement: finding.replacement,
|
|
38
|
+
...(finding.replacement_options?.length ? { replacement_options: finding.replacement_options } : {}),
|
|
39
|
+
...(finding.replacement_note ? { replacement_note: finding.replacement_note } : {}),
|
|
40
|
+
reason,
|
|
41
|
+
sources: sourceList(ref, finding, via),
|
|
42
|
+
notes: notesFor(ref),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const scopeIncludes = (ref, scope) =>
|
|
46
|
+
scope !== 'direct' || ref.usage === 'direct-api' || ref.usage === 'model-reference'
|
|
47
|
+
|
|
48
|
+
const occurrenceInLine = (line, matched, index) => {
|
|
49
|
+
let occurrence = 0
|
|
50
|
+
let cursor = 0
|
|
51
|
+
while (true) {
|
|
52
|
+
const found = line.indexOf(matched, cursor)
|
|
53
|
+
if (found === -1 || found >= index) return occurrence
|
|
54
|
+
occurrence++
|
|
55
|
+
cursor = found + matched.length
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const buildPlan = ({ scan, findings, entries, days, via, scope }) => {
|
|
60
|
+
const items = []
|
|
61
|
+
const issues = []
|
|
62
|
+
const lineCache = new Map()
|
|
63
|
+
|
|
64
|
+
scan.modelRefs.forEach((ref, index) => {
|
|
65
|
+
if (!scopeIncludes(ref, scope)) return
|
|
66
|
+
const finding = findings[index]
|
|
67
|
+
if (!finding || finding.status === 'ok') return
|
|
68
|
+
|
|
69
|
+
let reason
|
|
70
|
+
if (finding.status === 'watch') reason = 'watch'
|
|
71
|
+
else if (finding.status !== 'retired' && finding.status !== 'retiring') reason = 'outside-threshold'
|
|
72
|
+
else if (finding.via === 'publisher-fallback') reason = 'publisher-fallback'
|
|
73
|
+
else if (ref.usage !== 'direct-api' || ref.confidence !== 'high') reason = 'not-direct-api'
|
|
74
|
+
else if (!finding.replacement && finding.replacement_options?.length) reason = 'replacement-choice'
|
|
75
|
+
else if (!finding.replacement) reason = 'no-replacement'
|
|
76
|
+
else {
|
|
77
|
+
const replacementRecord = entries.get(finding.replacement)
|
|
78
|
+
if (!replacementRecord || replacementRecord.publisher !== finding.publisher) reason = 'replacement-unresolved'
|
|
79
|
+
else {
|
|
80
|
+
const replacementLifecycle = lifecycleFor(replacementRecord.entry, { days, via })
|
|
81
|
+
if (isBad(replacementLifecycle)) reason = 'replacement-retiring'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let lines
|
|
86
|
+
try {
|
|
87
|
+
lines = readLines(ref.file, lineCache)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
reason = reason ?? 'unreadable-file'
|
|
90
|
+
}
|
|
91
|
+
const line = lines?.[ref.line - 1]
|
|
92
|
+
if (line === undefined) reason = reason ?? 'line-unavailable'
|
|
93
|
+
|
|
94
|
+
if (reason) {
|
|
95
|
+
issues.push(issueFor(ref, finding, reason, via))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const occurrence = Number.isInteger(ref.occurrence)
|
|
100
|
+
? ref.occurrence
|
|
101
|
+
: occurrenceInLine(line, ref.matched, line.indexOf(ref.matched))
|
|
102
|
+
if (line.indexOf(ref.matched) === -1) {
|
|
103
|
+
issues.push(issueFor(ref, finding, 'match-unavailable', via))
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
items.push({
|
|
108
|
+
file: ref.file,
|
|
109
|
+
line: ref.line,
|
|
110
|
+
occurrence,
|
|
111
|
+
matched: ref.matched,
|
|
112
|
+
expected_line_sha256: sha256(line),
|
|
113
|
+
id: ref.id,
|
|
114
|
+
publisher: ref.publisher,
|
|
115
|
+
replacement: finding.replacement,
|
|
116
|
+
shutdown: finding.shutdown,
|
|
117
|
+
days: finding.days,
|
|
118
|
+
status: finding.status,
|
|
119
|
+
sources: sourceList(ref, finding, via),
|
|
120
|
+
notes: notesFor(ref),
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
for (const candidate of scan.candidateRefs) {
|
|
125
|
+
if (scope === 'direct' && candidate.usage !== 'direct-api' && candidate.usage !== 'model-reference') continue
|
|
126
|
+
issues.push({
|
|
127
|
+
file: candidate.file,
|
|
128
|
+
line: candidate.line,
|
|
129
|
+
matched: candidate.matched,
|
|
130
|
+
usage: candidate.usage,
|
|
131
|
+
confidence: candidate.confidence,
|
|
132
|
+
reason: 'candidate',
|
|
133
|
+
notes: candidate.reason,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const hint of scan.integrationHints) {
|
|
138
|
+
if (hint.usage === 'direct-api') continue
|
|
139
|
+
issues.push({
|
|
140
|
+
file: hint.file,
|
|
141
|
+
line: hint.line,
|
|
142
|
+
matched: hint.matched,
|
|
143
|
+
usage: hint.usage,
|
|
144
|
+
provider: hint.provider,
|
|
145
|
+
reason: 'unresolved-channel',
|
|
146
|
+
notes: hint.evidence,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
plan_schema: 'model-eol.plan/0.1',
|
|
152
|
+
generated: new Date().toISOString(),
|
|
153
|
+
threshold_days: days,
|
|
154
|
+
via,
|
|
155
|
+
scan_notes: scan.notes,
|
|
156
|
+
items,
|
|
157
|
+
issues,
|
|
158
|
+
}
|
|
159
|
+
}
|
package/lib/reports.mjs
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { isBad } from './feeds.mjs'
|
|
2
|
+
import { color } from './color.mjs'
|
|
3
|
+
|
|
4
|
+
export const marker = status =>
|
|
5
|
+
status === 'retired' ? '✗' :
|
|
6
|
+
status === 'retiring' ? '!' :
|
|
7
|
+
status === 'watch' ? '?' :
|
|
8
|
+
'·'
|
|
9
|
+
|
|
10
|
+
const shutdownText = finding => finding.date_precision === 'earliest'
|
|
11
|
+
? `no earlier than ${finding.shutdown}`
|
|
12
|
+
: finding.shutdown
|
|
13
|
+
|
|
14
|
+
const replacementTail = finding => finding.replacement
|
|
15
|
+
? ` -> ${finding.replacement}`
|
|
16
|
+
: finding.replacement_options?.length
|
|
17
|
+
? ` options: ${finding.replacement_options.join(' | ')}`
|
|
18
|
+
: ''
|
|
19
|
+
|
|
20
|
+
export const findingTail = (f, days) =>
|
|
21
|
+
f.status === 'retired' ? `RETIRED ${shutdownText(f)} (${-f.days} days ago)${replacementTail(f)}` :
|
|
22
|
+
f.status === 'retiring' ? `RETIRES ${shutdownText(f)} (${f.days} days)${replacementTail(f)}` :
|
|
23
|
+
f.status === 'scheduled' ? `scheduled ${shutdownText(f)} (${f.days} days, outside --days ${days})` :
|
|
24
|
+
f.status === 'watch' ? 'deprecation announced, no shutdown date yet' :
|
|
25
|
+
'no retirement scheduled'
|
|
26
|
+
|
|
27
|
+
const statusStyle = status =>
|
|
28
|
+
status === 'retired' ? 'red' :
|
|
29
|
+
status === 'retiring' ? 'yellow' :
|
|
30
|
+
status === 'ok' ? 'dim' :
|
|
31
|
+
null
|
|
32
|
+
|
|
33
|
+
const humanFinding = (status, line) => {
|
|
34
|
+
const style = statusStyle(status)
|
|
35
|
+
return style ? color(style, line) : line
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const buildInventory = ({ scan, findings, days, via, scope, targets }) => {
|
|
39
|
+
const byKey = new Map(findings.map(f => [`${f.file}:${f.line}:${f.matched}`, f]))
|
|
40
|
+
const modelReferences = scan.modelRefs.map(ref => {
|
|
41
|
+
const lifecycle = byKey.get(`${ref.file}:${ref.line}:${ref.matched}`)
|
|
42
|
+
return {
|
|
43
|
+
kind: ref.kind,
|
|
44
|
+
file: ref.file,
|
|
45
|
+
line: ref.line,
|
|
46
|
+
matched: ref.matched,
|
|
47
|
+
id: ref.id,
|
|
48
|
+
publisher: ref.publisher,
|
|
49
|
+
usage: ref.usage,
|
|
50
|
+
resolved_provider: ref.resolved_provider,
|
|
51
|
+
confidence: ref.confidence,
|
|
52
|
+
evidence: ref.evidence,
|
|
53
|
+
status: lifecycle.status,
|
|
54
|
+
shutdown: lifecycle.shutdown,
|
|
55
|
+
date_precision: lifecycle.date_precision,
|
|
56
|
+
via: lifecycle.via,
|
|
57
|
+
days: lifecycle.days,
|
|
58
|
+
safe_until: lifecycle.safe_until,
|
|
59
|
+
replacement: lifecycle.replacement,
|
|
60
|
+
source: ref.source,
|
|
61
|
+
policy: ref.policy ?? null,
|
|
62
|
+
feed_generated: ref.generated ?? null,
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
return {
|
|
66
|
+
schema: 'model-eol/inventory@0.1',
|
|
67
|
+
generated: new Date().toISOString(),
|
|
68
|
+
threshold_days: days,
|
|
69
|
+
distributor: via,
|
|
70
|
+
scope,
|
|
71
|
+
targets,
|
|
72
|
+
scanned_files: scan.files.length,
|
|
73
|
+
scan_notes: scan.notes ?? [],
|
|
74
|
+
summary: {
|
|
75
|
+
model_references: modelReferences.length,
|
|
76
|
+
candidate_model_references: scan.candidateRefs.length,
|
|
77
|
+
integration_hints: scan.integrationHints.length,
|
|
78
|
+
retired_or_retiring: modelReferences.filter(isBad).length,
|
|
79
|
+
cloud_or_gateway_hints: scan.integrationHints.filter(h => h.usage !== 'direct-api').length,
|
|
80
|
+
},
|
|
81
|
+
model_references: modelReferences,
|
|
82
|
+
candidate_model_references: scan.candidateRefs,
|
|
83
|
+
integration_hints: scan.integrationHints,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const buildSchedule = inventory => {
|
|
88
|
+
const severity = { retired: 0, retiring: 1, scheduled: 2, watch: 3, ok: 4 }
|
|
89
|
+
const inScope = item =>
|
|
90
|
+
inventory.scope !== 'direct' || item.usage === 'direct-api' || item.usage === 'model-reference'
|
|
91
|
+
const items = inventory.model_references
|
|
92
|
+
.filter(item => item.status !== 'ok')
|
|
93
|
+
.filter(inScope)
|
|
94
|
+
.sort((a, b) =>
|
|
95
|
+
(severity[a.status] ?? 9) - (severity[b.status] ?? 9) ||
|
|
96
|
+
String(a.shutdown ?? '9999-99-99').localeCompare(String(b.shutdown ?? '9999-99-99')) ||
|
|
97
|
+
a.file.localeCompare(b.file) ||
|
|
98
|
+
a.line - b.line
|
|
99
|
+
)
|
|
100
|
+
const earliest = inventory.model_references
|
|
101
|
+
.filter(inScope)
|
|
102
|
+
.filter(item => item.safe_until)
|
|
103
|
+
.sort((a, b) =>
|
|
104
|
+
a.safe_until.localeCompare(b.safe_until) ||
|
|
105
|
+
a.id.localeCompare(b.id) ||
|
|
106
|
+
a.file.localeCompare(b.file) ||
|
|
107
|
+
a.line - b.line
|
|
108
|
+
)[0]
|
|
109
|
+
const policyGuarantees = [...new Map(inventory.model_references
|
|
110
|
+
.filter(inScope)
|
|
111
|
+
.filter(item => item.status === 'ok' && item.safe_until && item.policy && item.feed_generated)
|
|
112
|
+
.map(item => [`${item.publisher}:${item.id}`, item]))
|
|
113
|
+
.values()]
|
|
114
|
+
.sort((a, b) =>
|
|
115
|
+
a.safe_until.localeCompare(b.safe_until) ||
|
|
116
|
+
a.id.localeCompare(b.id) ||
|
|
117
|
+
a.file.localeCompare(b.file) ||
|
|
118
|
+
a.line - b.line
|
|
119
|
+
)
|
|
120
|
+
const result = {
|
|
121
|
+
schema: 'model-eol/schedule@0.1',
|
|
122
|
+
generated: inventory.generated,
|
|
123
|
+
threshold_days: inventory.threshold_days,
|
|
124
|
+
distributor: inventory.distributor,
|
|
125
|
+
scope: inventory.scope,
|
|
126
|
+
targets: inventory.targets,
|
|
127
|
+
scanned_files: inventory.scanned_files,
|
|
128
|
+
scan_notes: inventory.scan_notes ?? [],
|
|
129
|
+
earliest_risk: earliest ? { safe_until: earliest.safe_until, id: earliest.id } : null,
|
|
130
|
+
items,
|
|
131
|
+
candidate_model_references: inventory.candidate_model_references,
|
|
132
|
+
unresolved_integrations: inventory.integration_hints.filter(h => h.usage !== 'direct-api'),
|
|
133
|
+
}
|
|
134
|
+
Object.defineProperty(result, '_policy_guarantees', {
|
|
135
|
+
value: policyGuarantees,
|
|
136
|
+
enumerable: false,
|
|
137
|
+
})
|
|
138
|
+
return result
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const buildAlert = schedule => {
|
|
142
|
+
const errors = schedule.items.filter(item => item.status === 'retired' || item.status === 'retiring')
|
|
143
|
+
const warnings = [
|
|
144
|
+
...schedule.items.filter(item => item.status === 'scheduled' || item.status === 'watch'),
|
|
145
|
+
...schedule.candidate_model_references.map(item => ({ ...item, status: 'unknown' })),
|
|
146
|
+
...schedule.unresolved_integrations.map(item => ({ ...item, status: 'unresolved' })),
|
|
147
|
+
]
|
|
148
|
+
return {
|
|
149
|
+
schema: 'model-eol/alert@0.1',
|
|
150
|
+
generated: schedule.generated,
|
|
151
|
+
threshold_days: schedule.threshold_days,
|
|
152
|
+
distributor: schedule.distributor,
|
|
153
|
+
scope: schedule.scope,
|
|
154
|
+
scan_notes: schedule.scan_notes ?? [],
|
|
155
|
+
errors,
|
|
156
|
+
warnings,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const formatCheck = ({ findings, bad, scannedFiles, days, scope }) => {
|
|
161
|
+
const out = []
|
|
162
|
+
if (findings.length === 0) {
|
|
163
|
+
out.push(color('bold', `model-eol: no tracked model IDs found in ${scannedFiles} files for --scope ${scope}`))
|
|
164
|
+
}
|
|
165
|
+
for (const f of findings) {
|
|
166
|
+
const where = `${f.file}:${f.line}`
|
|
167
|
+
out.push(humanFinding(f.status, `${marker(f.status)} ${where} ${f.matched} ${findingTail(f, days)}${f.via && f.via !== 'publisher' ? ` [via ${f.via}]` : ''}`))
|
|
168
|
+
}
|
|
169
|
+
if (bad.length) out.push(`\n${color('bold', `model-eol: ${bad.length} finding(s) at or past the ${days}-day threshold`)}`)
|
|
170
|
+
return out.join('\n')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const formatInventory = (inventory, days) => {
|
|
174
|
+
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`)
|
|
176
|
+
if (inventory.model_references.length) {
|
|
177
|
+
out.push('\nModel references')
|
|
178
|
+
for (const item of inventory.model_references) {
|
|
179
|
+
out.push(humanFinding(item.status, `${marker(item.status)} ${item.file}:${item.line} ${item.matched} ${item.publisher}/${item.id} ${item.usage} ${findingTail(item, days)}`))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (inventory.candidate_model_references.length) {
|
|
183
|
+
out.push('\nCandidate model references')
|
|
184
|
+
for (const item of inventory.candidate_model_references) {
|
|
185
|
+
out.push(`? ${item.file}:${item.line} ${item.matched} ${item.usage} ${item.reason}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (inventory.integration_hints.length) {
|
|
189
|
+
out.push('\nIntegration hints')
|
|
190
|
+
for (const h of inventory.integration_hints) {
|
|
191
|
+
const note = h.usage === 'direct-api' ? 'direct API signal' : 'resolver needed for exact deployed model'
|
|
192
|
+
out.push(color('dim', `? ${h.file}:${h.line} ${h.provider} ${h.evidence} (${note})`))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out.join('\n')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const formatSchedule = (schedule, days) => {
|
|
199
|
+
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`)
|
|
201
|
+
for (const item of schedule.items) {
|
|
202
|
+
const safeUntil = item.safe_until ? ` [safe until ${item.safe_until}]` : ''
|
|
203
|
+
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}`))
|
|
204
|
+
}
|
|
205
|
+
if (schedule.unresolved_integrations.length) {
|
|
206
|
+
out.push('\nUnresolved cloud/gateway references')
|
|
207
|
+
for (const h of schedule.unresolved_integrations) {
|
|
208
|
+
out.push(color('dim', `? ${h.file}:${h.line} ${h.provider} ${h.evidence} (needs provider/gateway resolver)`))
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const item of schedule._policy_guarantees ?? []) {
|
|
212
|
+
const feedGenerated = String(item.feed_generated).slice(0, 10)
|
|
213
|
+
out.push(`guaranteed until ${item.safe_until} per ${item.publisher} stated policy (>=${item.policy.min_notice_days}d notice, feed generated ${feedGenerated})`)
|
|
214
|
+
}
|
|
215
|
+
out.push(color('bold', schedule.earliest_risk
|
|
216
|
+
? `earliest risk: ${schedule.earliest_risk.safe_until} (${schedule.earliest_risk.id})`
|
|
217
|
+
: 'earliest risk: none'))
|
|
218
|
+
return out.join('\n')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const formatInventoryCycloneDX = inventory => {
|
|
222
|
+
const components = new Map()
|
|
223
|
+
for (const item of inventory.model_references) {
|
|
224
|
+
let component = components.get(item.id)
|
|
225
|
+
if (!component) {
|
|
226
|
+
component = {
|
|
227
|
+
type: 'machine-learning-model',
|
|
228
|
+
name: item.id,
|
|
229
|
+
group: item.publisher,
|
|
230
|
+
properties: [
|
|
231
|
+
{ name: 'model-eol:status', value: String(item.status) },
|
|
232
|
+
{ name: 'model-eol:shutdown', value: item.shutdown ?? '' },
|
|
233
|
+
{ name: 'model-eol:replacement', value: item.replacement ?? '' },
|
|
234
|
+
{ name: 'model-eol:usage', value: item.usage },
|
|
235
|
+
],
|
|
236
|
+
evidence: { occurrences: [] },
|
|
237
|
+
}
|
|
238
|
+
components.set(item.id, component)
|
|
239
|
+
}
|
|
240
|
+
const usage = component.properties.find(property => property.name === 'model-eol:usage')
|
|
241
|
+
const usages = new Set(usage.value ? usage.value.split(',') : [])
|
|
242
|
+
usages.add(item.usage)
|
|
243
|
+
usage.value = [...usages].sort().join(',')
|
|
244
|
+
component.evidence.occurrences.push({ location: `${item.file}#${item.line}` })
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
bomFormat: 'CycloneDX',
|
|
248
|
+
specVersion: '1.6',
|
|
249
|
+
version: 1,
|
|
250
|
+
metadata: { timestamp: inventory.generated },
|
|
251
|
+
components: [...components.values()],
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const annotationEscape = value => String(value)
|
|
256
|
+
.replaceAll('%', '%25')
|
|
257
|
+
.replaceAll('\r', '%0D')
|
|
258
|
+
.replaceAll('\n', '%0A')
|
|
259
|
+
.replaceAll(':', '%3A')
|
|
260
|
+
.replaceAll(',', '%2C')
|
|
261
|
+
|
|
262
|
+
export const formatAlertGithub = (alert, days) => {
|
|
263
|
+
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`)}`)
|
|
265
|
+
for (const item of alert.errors) {
|
|
266
|
+
const title = `model-eol ${item.status}: ${item.matched}`
|
|
267
|
+
out.push(`::error file=${annotationEscape(item.file)},line=${item.line},title=${annotationEscape(title)}::${annotationEscape(findingTail(item, days))}`)
|
|
268
|
+
}
|
|
269
|
+
for (const item of alert.warnings) {
|
|
270
|
+
const title = item.kind === 'integration-hint'
|
|
271
|
+
? `model-eol unresolved ${item.provider}`
|
|
272
|
+
: `model-eol ${item.status}: ${item.matched ?? item.provider}`
|
|
273
|
+
const message = item.kind === 'integration-hint'
|
|
274
|
+
? `${item.evidence}; provider/gateway resolver needed`
|
|
275
|
+
: item.status === 'unknown'
|
|
276
|
+
? `${item.matched} is model-like but not present in loaded feeds`
|
|
277
|
+
: findingTail(item, days)
|
|
278
|
+
out.push(`::warning file=${annotationEscape(item.file)},line=${item.line},title=${annotationEscape(title)}::${annotationEscape(message)}`)
|
|
279
|
+
}
|
|
280
|
+
if (!out.length) out.push('model-eol alert: no findings')
|
|
281
|
+
return out.join('\n')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const formatAlertMarkdown = (alert, days) => {
|
|
285
|
+
const out = ['# model-eol alert', '']
|
|
286
|
+
if ((alert.scan_notes ?? []).length) out.push(`> Warning: scan incomplete; ${alert.scan_notes.length} scan note(s) recorded.`, '')
|
|
287
|
+
out.push(`- Errors: ${alert.errors.length}`)
|
|
288
|
+
out.push(`- Warnings: ${alert.warnings.length}`)
|
|
289
|
+
if (alert.errors.length) {
|
|
290
|
+
out.push('', '## Errors')
|
|
291
|
+
for (const item of alert.errors) {
|
|
292
|
+
out.push(`- ${item.file}:${item.line} \`${item.matched}\` - ${findingTail(item, days)}`)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (alert.warnings.length) {
|
|
296
|
+
out.push('', '## Warnings')
|
|
297
|
+
for (const item of alert.warnings) {
|
|
298
|
+
const label = item.matched ?? item.provider
|
|
299
|
+
const detail = item.kind === 'integration-hint'
|
|
300
|
+
? `${item.evidence}; resolver needed`
|
|
301
|
+
: item.status === 'unknown'
|
|
302
|
+
? item.reason
|
|
303
|
+
: findingTail(item, days)
|
|
304
|
+
out.push(`- ${item.file}:${item.line} \`${label}\` - ${detail}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out.join('\n')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export const formatAlertBadge = alert => {
|
|
311
|
+
const retired = alert.errors.filter(item => item.status === 'retired').length
|
|
312
|
+
const retiring = alert.errors.filter(item => item.status === 'retiring').length
|
|
313
|
+
const parts = []
|
|
314
|
+
if (retired) parts.push(`${retired} retired`)
|
|
315
|
+
if (retiring) parts.push(`${retiring} retiring`)
|
|
316
|
+
return {
|
|
317
|
+
schemaVersion: 1,
|
|
318
|
+
label: 'model-eol',
|
|
319
|
+
message: parts.length ? parts.join(' · ') : 'clear',
|
|
320
|
+
color: retired ? 'red' : retiring ? 'orange' : 'brightgreen',
|
|
321
|
+
}
|
|
322
|
+
}
|