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/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
+ }
@@ -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
+ }