model-eol 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -22
- package/action.yml +124 -9
- package/bot/bot.mjs +1371 -0
- package/bot/lib/common.mjs +122 -0
- package/bot/lib/config.mjs +1 -0
- package/bot/lib/eval.mjs +103 -0
- package/bot/lib/feeds.mjs +48 -0
- package/bot/lib/git.mjs +131 -0
- package/bot/lib/github.mjs +187 -0
- package/bot.yml.example +145 -0
- package/check.mjs +151 -17
- package/feeds/amazon.json +1 -1
- package/feeds/anthropic.json +1 -1
- package/feeds/google.json +88 -5
- package/feeds/openai.json +11 -26
- package/lib/config.mjs +258 -0
- package/lib/feeds.mjs +38 -4
- package/lib/glob.mjs +35 -0
- package/lib/plan.mjs +27 -8
- package/lib/reports.mjs +24 -7
- package/lib/scanner.mjs +247 -70
- package/package.json +8 -4
- package/schema/model-eol.bot-config.schema.json +73 -8
- package/schema/model-eol.inventory.schema.json +21 -0
- package/schema/model-eol.plan.schema.json +22 -0
package/lib/scanner.mjs
CHANGED
|
@@ -3,12 +3,13 @@ import path from 'node:path'
|
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
4
|
|
|
5
5
|
import { buildModelPattern } from './feeds.mjs'
|
|
6
|
+
import { matchesAnyGlob, matchesGlob, normalizeRepoPath } from './glob.mjs'
|
|
6
7
|
|
|
7
8
|
export const CODE_EXT = new Set([
|
|
8
9
|
'.c', '.cc', '.cjs', '.cpp', '.cs', '.cxx', '.go', '.h', '.hpp', '.java', '.js',
|
|
9
10
|
'.jsx', '.kt', '.kts', '.m', '.mjs', '.mm', '.php', '.py', '.rb', '.rs',
|
|
10
11
|
'.scala', '.sh', '.swift', '.ts', '.tsx',
|
|
11
|
-
'.cfg', '.env', '.ini', '.json', '.toml', '.yaml', '.yml',
|
|
12
|
+
'.baml', '.cfg', '.env', '.ini', '.json', '.toml', '.yaml', '.yml',
|
|
12
13
|
'.sql', '.tf', '.tfvars',
|
|
13
14
|
])
|
|
14
15
|
export const DOC_EXT = new Set(['.md', '.mdx', '.txt', '.rst'])
|
|
@@ -33,25 +34,25 @@ export const incompleteScanNotes = notes => notes.filter(note => INCOMPLETE_SCAN
|
|
|
33
34
|
export const scanIsIncomplete = notes => incompleteScanNotes(notes).length > 0
|
|
34
35
|
|
|
35
36
|
const directSignals = [
|
|
36
|
-
['openai', /\b(OPENAI_API_KEY|api\.openai\.com|from\s+openai\s+import|import\s+OpenAI\s+from\s+['"]openai['"]|require\(['"]openai['"]\)|new\s+OpenAI\s*\()/i, 'OpenAI direct SDK/API'],
|
|
37
|
-
['anthropic', /\b(ANTHROPIC_API_KEY|api\.anthropic\.com|@anthropic-ai\/sdk|from\s+anthropic\s+import|import\s+Anthropic\s+from\s+['"]@anthropic-ai\/sdk['"]|new\s+Anthropic\s*\()/i, 'Anthropic direct SDK/API'],
|
|
38
|
-
['google-gemini', /\b(GEMINI_API_KEY|GOOGLE_API_KEY|generativelanguage\.googleapis\.com|@google\/genai|google-generativeai|GoogleGenerativeAI)\b/i, 'Gemini direct SDK/API'],
|
|
39
|
-
['mistral', /\b(MISTRAL_API_KEY|api\.mistral\.ai|@mistralai\/mistralai|from\s+mistralai\s+import|MistralClient)\b/i, 'Mistral direct SDK/API'],
|
|
40
|
-
['cohere', /\b(COHERE_API_KEY|api\.cohere\.ai|from\s+cohere\s+import|new\s+CohereClient\s*\()/i, 'Cohere direct SDK/API'],
|
|
41
|
-
['xai', /\b(XAI_API_KEY|api\.x\.ai)\b/i, 'xAI direct SDK/API'],
|
|
37
|
+
['openai', /\b(OPENAI_API_KEY|api\.openai\.com|from\s+openai\s+import|import\s+OpenAI\s+from\s+['"]openai['"]|require\(['"]openai['"]\)|new\s+OpenAI\s*\(|provider\s+['"]?openai\b)/i, 'OpenAI direct SDK/API or BAML provider'],
|
|
38
|
+
['anthropic', /\b(ANTHROPIC_API_KEY|api\.anthropic\.com|@anthropic-ai\/sdk|from\s+anthropic\s+import|import\s+Anthropic\s+from\s+['"]@anthropic-ai\/sdk['"]|new\s+Anthropic\s*\(|provider\s+['"]?anthropic\b)/i, 'Anthropic direct SDK/API or BAML provider'],
|
|
39
|
+
['google-gemini', /\b(GEMINI_API_KEY|GOOGLE_API_KEY|generativelanguage\.googleapis\.com|@google\/genai|google-generativeai|GoogleGenerativeAI|provider\s+['"]?google-ai\b)\b/i, 'Gemini direct SDK/API or BAML provider'],
|
|
40
|
+
['mistral', /\b(MISTRAL_API_KEY|api\.mistral\.ai|@mistralai\/mistralai|from\s+mistralai\s+import|MistralClient|provider\s+['"]?mistral\b)\b/i, 'Mistral direct SDK/API or BAML provider'],
|
|
41
|
+
['cohere', /\b(COHERE_API_KEY|api\.cohere\.ai|from\s+cohere\s+import|new\s+CohereClient\s*\(|provider\s+['"]?cohere\b)/i, 'Cohere direct SDK/API or BAML provider'],
|
|
42
|
+
['xai', /\b(XAI_API_KEY|api\.x\.ai|provider\s+['"]?xai\b)\b/i, 'xAI direct SDK/API or BAML provider'],
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
const cloudSignals = [
|
|
45
|
-
['azure-ai-foundry', /\b(AZURE_OPENAI|AzureOpenAI|@azure\/openai|azure\.ai\.openai|azure-ai-foundry|AZURE_AI)\b/i, 'Azure Foundry/OpenAI deployment reference'],
|
|
46
|
-
['aws-bedrock', /\b(BEDROCK_MODEL_ID|bedrock-runtime|BedrockRuntime|InvokeModel|invoke_model|amazon\.bedrock)\b/i, 'Amazon Bedrock reference'],
|
|
47
|
-
['vertex-ai', /\b(VERTEX_AI|GOOGLE_CLOUD_PROJECT|aiplatform|vertexai|VertexAI|PredictionServiceClient|publishers\/google\/models)\b/i, 'Vertex AI reference'],
|
|
46
|
+
['azure-ai-foundry', /\b(AZURE_OPENAI|AzureOpenAI|@azure\/openai|azure\.ai\.openai|azure-ai-foundry|AZURE_AI|provider\s+['"]?azure-openai\b)\b/i, 'Azure Foundry/OpenAI deployment reference'],
|
|
47
|
+
['aws-bedrock', /\b(BEDROCK_MODEL_ID|bedrock-runtime|BedrockRuntime|InvokeModel|invoke_model|amazon\.bedrock|provider\s+['"]?aws-bedrock\b)\b/i, 'Amazon Bedrock reference'],
|
|
48
|
+
['vertex-ai', /\b(VERTEX_AI|GOOGLE_CLOUD_PROJECT|aiplatform|vertexai|VertexAI|PredictionServiceClient|publishers\/google\/models|provider\s+['"]?vertex-ai\b)\b/i, 'Vertex AI reference'],
|
|
48
49
|
]
|
|
49
50
|
|
|
50
51
|
const gatewaySignals = [
|
|
51
|
-
['openrouter', /\b(OPENROUTER_API_KEY|openrouter\.ai|openrouter
|
|
52
|
-
['litellm', /\b(LITELLM|litellm|LiteLLM)\b/, 'LiteLLM gateway reference'],
|
|
53
|
-
['portkey', /\b(PORTKEY_API_KEY|portkey|Portkey)\b/, 'Portkey gateway reference'],
|
|
54
|
-
['ai-gateway', /\b(AI_GATEWAY|Vercel AI Gateway|ai-gateway)\b/i, 'AI gateway reference'],
|
|
52
|
+
['openrouter', /\b(OPENROUTER_API_KEY|openrouter\.ai|openrouter\/|provider\s+['"]?openrouter\b)\b/i, 'OpenRouter gateway reference'],
|
|
53
|
+
['litellm', /\b(LITELLM|litellm|LiteLLM|provider\s+['"]?litellm\b)\b/, 'LiteLLM gateway reference'],
|
|
54
|
+
['portkey', /\b(PORTKEY_API_KEY|portkey|Portkey|provider\s+['"]?portkey\b)\b/, 'Portkey gateway reference'],
|
|
55
|
+
['ai-gateway', /\b(AI_GATEWAY|Vercel AI Gateway|ai-gateway|provider\s+['"]?ai-gateway\b)\b/i, 'AI gateway reference'],
|
|
55
56
|
]
|
|
56
57
|
|
|
57
58
|
const allSignals = [
|
|
@@ -71,6 +72,13 @@ const candidatePatterns = [
|
|
|
71
72
|
/["']((?:gpt|o[1345]|claude|gemini|mistral|command|grok|llama|deepseek|qwen|nova|embed)[A-Za-z0-9._:/-]{4,120})["']/gi,
|
|
72
73
|
]
|
|
73
74
|
|
|
75
|
+
const obviousCandidateNoise = value =>
|
|
76
|
+
/^[A-Z][A-Z0-9_]+$/.test(value) ||
|
|
77
|
+
/^commands?_[a-z0-9_]+$/i.test(value) ||
|
|
78
|
+
/^claude_code[._]/i.test(value) ||
|
|
79
|
+
/\.(?:md|mdx|json|ya?ml|toml|txt|rst)$/i.test(value) ||
|
|
80
|
+
/\.(?:token|cost)\.usage$/i.test(value)
|
|
81
|
+
|
|
74
82
|
const suffixes = value => {
|
|
75
83
|
const parts = value.split(/[\/.:]/).filter(Boolean)
|
|
76
84
|
return parts.flatMap((_, i) => [parts.slice(i).join('/'), parts.slice(i).join('.'), parts.slice(i).join(':')])
|
|
@@ -87,7 +95,7 @@ const findCandidates = (lineText, entries) => {
|
|
|
87
95
|
let m
|
|
88
96
|
while ((m = pattern.exec(lineText)) !== null) {
|
|
89
97
|
const value = m[1].trim()
|
|
90
|
-
if (seen.has(value)
|
|
98
|
+
if (seen.has(value)) continue
|
|
91
99
|
seen.add(value)
|
|
92
100
|
candidates.push(value)
|
|
93
101
|
}
|
|
@@ -95,6 +103,10 @@ const findCandidates = (lineText, entries) => {
|
|
|
95
103
|
return candidates
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
const publisherForProvider = provider => provider === 'google-gemini' ? 'google' : provider
|
|
107
|
+
|
|
108
|
+
const providerMatchesPublisher = (provider, publisher) => publisherForProvider(provider) === publisher
|
|
109
|
+
|
|
98
110
|
const classifyRef = ({ file, line, lines, publisher }) => {
|
|
99
111
|
const start = Math.max(0, line - 8)
|
|
100
112
|
const end = Math.min(lines.length, line + 4)
|
|
@@ -108,8 +120,12 @@ const classifyRef = ({ file, line, lines, publisher }) => {
|
|
|
108
120
|
const nearest = signals[0]
|
|
109
121
|
if (nearest) {
|
|
110
122
|
const hasCompetingRoute = signals.some(signal => signal.usage === 'cloud-provider' || signal.usage === 'gateway')
|
|
111
|
-
const
|
|
112
|
-
|
|
123
|
+
const providerMatches = nearest.usage !== 'direct-api' || providerMatchesPublisher(nearest.provider, publisher)
|
|
124
|
+
const confidence = nearest.usage === 'direct-api' && !hasCompetingRoute && providerMatches ? 'high' : 'medium'
|
|
125
|
+
const evidence = providerMatches
|
|
126
|
+
? nearest.evidence
|
|
127
|
+
: `${nearest.evidence}; signal provider ${nearest.provider} differs from model publisher ${publisher}`
|
|
128
|
+
return { usage: nearest.usage, provider: nearest.provider, confidence, evidence }
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
const lowerFile = file.toLowerCase()
|
|
@@ -122,6 +138,7 @@ const classifyRef = ({ file, line, lines, publisher }) => {
|
|
|
122
138
|
|
|
123
139
|
const isScannable = (file, includeDocs) => {
|
|
124
140
|
const basename = path.basename(file)
|
|
141
|
+
if (basename === '.model-eol.json') return false
|
|
125
142
|
if (basename === '.env' || basename.startsWith('.env.')) return true
|
|
126
143
|
const ext = path.extname(file)
|
|
127
144
|
return CODE_EXT.has(ext) || (includeDocs && DOC_EXT.has(ext))
|
|
@@ -241,17 +258,66 @@ export const addedLinesForTargets = (targets, baseRef) => {
|
|
|
241
258
|
export const filterFindingsToChanged = (findings, addedLines) =>
|
|
242
259
|
findings.filter(finding => addedLines.has(changedKey(finding.file, finding.line)))
|
|
243
260
|
|
|
244
|
-
const collectFilesDetailed = (targets, {
|
|
261
|
+
const collectFilesDetailed = (targets, {
|
|
262
|
+
includeDocs = false,
|
|
263
|
+
ignorePaths = [],
|
|
264
|
+
ignoreRoots = [],
|
|
265
|
+
ignoredFiles = [],
|
|
266
|
+
policyForFile = null,
|
|
267
|
+
} = {}) => {
|
|
245
268
|
const files = []
|
|
246
269
|
const notes = []
|
|
247
270
|
const validTargets = []
|
|
248
271
|
const seen = new Set()
|
|
272
|
+
const canonicalPath = value => {
|
|
273
|
+
const resolved = path.resolve(value)
|
|
274
|
+
try {
|
|
275
|
+
return fs.realpathSync(resolved)
|
|
276
|
+
} catch {
|
|
277
|
+
return resolved
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const excludedFiles = new Set(ignoredFiles.map(canonicalPath))
|
|
281
|
+
const roots = ignoreRoots.map(canonicalPath)
|
|
249
282
|
let fileLimitWarned = false
|
|
250
283
|
let stopped = false
|
|
251
284
|
|
|
252
285
|
const note = value => notes.push(value)
|
|
286
|
+
const repoPathsFor = absolute => {
|
|
287
|
+
const resolved = canonicalPath(absolute)
|
|
288
|
+
const repoPaths = []
|
|
289
|
+
for (const root of roots) {
|
|
290
|
+
const relative = path.relative(root, resolved)
|
|
291
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue
|
|
292
|
+
repoPaths.push(normalizeRepoPath(relative))
|
|
293
|
+
}
|
|
294
|
+
return repoPaths
|
|
295
|
+
}
|
|
296
|
+
const matchesIgnoredRepoPath = repoPath => matchesAnyGlob(repoPath, ignorePaths) ||
|
|
297
|
+
ignorePaths.some(pattern => {
|
|
298
|
+
const normalizedPattern = normalizeRepoPath(pattern).replace(/\/+$/, '')
|
|
299
|
+
return normalizedPattern && !/[*?]/.test(normalizedPattern) && repoPath.startsWith(`${normalizedPattern}/`)
|
|
300
|
+
})
|
|
301
|
+
const isIgnoredFile = absolute => {
|
|
302
|
+
const resolved = canonicalPath(absolute)
|
|
303
|
+
if (excludedFiles.has(resolved) || repoPathsFor(resolved).some(matchesIgnoredRepoPath)) return true
|
|
304
|
+
const policy = policyForFile?.(resolved)
|
|
305
|
+
return Boolean(policy?.repoPath && matchesIgnoredRepoPathWithPatterns(policy.repoPath, policy.ignore?.paths ?? []))
|
|
306
|
+
}
|
|
307
|
+
const matchesIgnoredRepoPathWithPatterns = (repoPath, patterns) => matchesAnyGlob(repoPath, patterns) ||
|
|
308
|
+
patterns.some(pattern => {
|
|
309
|
+
const normalizedPattern = normalizeRepoPath(pattern).replace(/\/+$/, '')
|
|
310
|
+
return normalizedPattern && !/[*?]/.test(normalizedPattern) && repoPath.startsWith(`${normalizedPattern}/`)
|
|
311
|
+
})
|
|
312
|
+
const isIgnoredDirectoryTree = absolute => repoPathsFor(absolute).some(repoPath =>
|
|
313
|
+
ignorePaths.some(pattern => {
|
|
314
|
+
const normalizedPattern = normalizeRepoPath(pattern)
|
|
315
|
+
const literalDirectory = !/[*?]/.test(normalizedPattern) && normalizedPattern.replace(/\/+$/, '') === repoPath
|
|
316
|
+
const subtreeGlob = normalizedPattern.endsWith('/**') && matchesGlob(repoPath, normalizedPattern.slice(0, -3))
|
|
317
|
+
return literalDirectory || subtreeGlob
|
|
318
|
+
}))
|
|
253
319
|
const addFile = absolute => {
|
|
254
|
-
if (stopped || seen.has(absolute)) return
|
|
320
|
+
if (stopped || seen.has(absolute) || isIgnoredFile(absolute)) return
|
|
255
321
|
seen.add(absolute)
|
|
256
322
|
|
|
257
323
|
let st
|
|
@@ -288,7 +354,7 @@ const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
|
|
|
288
354
|
}
|
|
289
355
|
|
|
290
356
|
const walk = absolute => {
|
|
291
|
-
if (stopped) return
|
|
357
|
+
if (stopped || excludedFiles.has(canonicalPath(absolute))) return
|
|
292
358
|
let st
|
|
293
359
|
try {
|
|
294
360
|
st = fs.lstatSync(absolute)
|
|
@@ -301,6 +367,7 @@ const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
|
|
|
301
367
|
addFile(absolute)
|
|
302
368
|
return
|
|
303
369
|
}
|
|
370
|
+
if (isIgnoredDirectoryTree(absolute)) return
|
|
304
371
|
if (SKIP_DIRS.has(path.basename(absolute))) return
|
|
305
372
|
let names
|
|
306
373
|
try {
|
|
@@ -373,25 +440,101 @@ const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
|
|
|
373
440
|
|
|
374
441
|
export const collectFiles = (targets, options = {}) => collectFilesDetailed(targets, options).files
|
|
375
442
|
|
|
376
|
-
export const scanTargets = ({
|
|
377
|
-
|
|
443
|
+
export const scanTargets = ({
|
|
444
|
+
targets,
|
|
445
|
+
entries,
|
|
446
|
+
keys,
|
|
447
|
+
includeDocs = false,
|
|
448
|
+
ignoreModels = [],
|
|
449
|
+
ignorePaths = [],
|
|
450
|
+
ignoreRoots = [],
|
|
451
|
+
ignoredFiles = [],
|
|
452
|
+
policyForFile = null,
|
|
453
|
+
routeForReference = null,
|
|
454
|
+
mappedRoutesForFile = null,
|
|
455
|
+
}) => {
|
|
456
|
+
const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles, policyForFile })
|
|
378
457
|
if (collected.validTargets.length === 0) throw new Error('no valid targets remain')
|
|
379
458
|
const { files } = collected
|
|
380
459
|
const pattern = buildModelPattern(keys)
|
|
460
|
+
const ignoredSets = models => ({
|
|
461
|
+
keys: new Set(models),
|
|
462
|
+
canonicalIds: new Set(models.map(key => entries.get(key)?.entry.id ?? key)),
|
|
463
|
+
})
|
|
464
|
+
const baseIgnored = ignoredSets(ignoreModels)
|
|
465
|
+
const ignoresModel = (file, matched, id = null) => {
|
|
466
|
+
const configured = policyForFile?.(path.resolve(file))?.ignore?.models ?? []
|
|
467
|
+
const dynamicIgnored = configured.length ? ignoredSets(configured) : null
|
|
468
|
+
return baseIgnored.keys.has(matched) || (id !== null && baseIgnored.canonicalIds.has(id)) ||
|
|
469
|
+
dynamicIgnored?.keys.has(matched) || (id !== null && dynamicIgnored?.canonicalIds.has(id))
|
|
470
|
+
}
|
|
381
471
|
const modelRefs = []
|
|
382
472
|
const candidateRefs = []
|
|
383
473
|
const integrationHints = []
|
|
384
474
|
|
|
385
|
-
const
|
|
475
|
+
const modelEolDocument = (file, text) => {
|
|
386
476
|
if (!file.endsWith('.json')) return false
|
|
387
477
|
try {
|
|
388
|
-
const
|
|
389
|
-
|
|
478
|
+
const value = JSON.parse(text)
|
|
479
|
+
const productSchema = [value?.spec, value?.schema, value?.plan_schema]
|
|
480
|
+
.some(identifier => typeof identifier === 'string' && (identifier.startsWith('model-eol/') || identifier.startsWith('model-eol.')))
|
|
481
|
+
const generatedCycloneDx = value?.bomFormat === 'CycloneDX' &&
|
|
482
|
+
value?.metadata?.properties?.some(property =>
|
|
483
|
+
property?.name === 'model-eol:generator' && property?.value === 'model-eol/inventory-cyclonedx@0.1')
|
|
484
|
+
return productSchema || generatedCycloneDx
|
|
390
485
|
} catch {
|
|
391
486
|
return false
|
|
392
487
|
}
|
|
393
488
|
}
|
|
394
489
|
|
|
490
|
+
const generatedSource = text => {
|
|
491
|
+
const header = text.slice(0, 8192)
|
|
492
|
+
return /This file was generated by BAML/i.test(header) ||
|
|
493
|
+
/Code generated .* DO NOT EDIT/i.test(header) ||
|
|
494
|
+
/^\s*(?:[/#*;-]+\s*)?@generated\b/im.test(header) ||
|
|
495
|
+
/AUTO[- ]GENERATED FILE[\s\S]{0,240}DO NOT EDIT/i.test(header)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const routeClassification = route => {
|
|
499
|
+
const usage = cloudSignals.some(([provider]) => provider === route.via)
|
|
500
|
+
? 'cloud-provider'
|
|
501
|
+
: gatewaySignals.some(([provider]) => provider === route.via)
|
|
502
|
+
? 'gateway'
|
|
503
|
+
: 'model-reference'
|
|
504
|
+
return {
|
|
505
|
+
usage,
|
|
506
|
+
provider: route.via,
|
|
507
|
+
confidence: 'high',
|
|
508
|
+
evidence: `configured route ${route.index} via ${route.via}`,
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const pushModelRef = ({ file, line, lineText, matchIndex = null, matched, record, classification, route = null, mappedFrom = null }) => {
|
|
513
|
+
if (ignoresModel(file, matched, record.entry.id) || (mappedFrom && ignoresModel(file, mappedFrom, record.entry.id))) return
|
|
514
|
+
const occurrence = [...lineText.matchAll(new RegExp(escapeRegExp(matched), 'g'))]
|
|
515
|
+
.findIndex(match => match.index === (matchIndex ?? lineText.indexOf(matched)))
|
|
516
|
+
modelRefs.push({
|
|
517
|
+
kind: 'model-reference',
|
|
518
|
+
file,
|
|
519
|
+
line,
|
|
520
|
+
matched,
|
|
521
|
+
id: record.entry.id,
|
|
522
|
+
entry: record.entry,
|
|
523
|
+
publisher: record.publisher,
|
|
524
|
+
source: record.source,
|
|
525
|
+
feedNote: record.feedNote,
|
|
526
|
+
policy: record.policy,
|
|
527
|
+
generated: record.generated,
|
|
528
|
+
usage: classification.usage,
|
|
529
|
+
resolved_provider: classification.provider,
|
|
530
|
+
confidence: classification.confidence,
|
|
531
|
+
evidence: classification.evidence,
|
|
532
|
+
occurrence: occurrence < 0 ? 0 : occurrence,
|
|
533
|
+
route_index: route?.index ?? null,
|
|
534
|
+
mapped_from: mappedFrom,
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
|
|
395
538
|
for (const file of files) {
|
|
396
539
|
let text
|
|
397
540
|
try {
|
|
@@ -400,59 +543,81 @@ export const scanTargets = ({ targets, entries, keys, includeDocs = false }) =>
|
|
|
400
543
|
collected.notes.push({ reason: 'unreadable-file', file, message: e.message })
|
|
401
544
|
continue
|
|
402
545
|
}
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
546
|
+
// Product artifacts and generated clients describe model IDs but are not
|
|
547
|
+
// authoritative usage or safe migration targets.
|
|
548
|
+
if (modelEolDocument(file, text)) {
|
|
549
|
+
collected.notes.push({ reason: 'model-eol-document-skipped', file })
|
|
550
|
+
continue
|
|
551
|
+
}
|
|
552
|
+
if (generatedSource(text)) {
|
|
553
|
+
collected.notes.push({ reason: 'generated-artifact-skipped', file })
|
|
406
554
|
continue
|
|
407
555
|
}
|
|
408
556
|
const lines = text.split('\n')
|
|
409
557
|
for (const [i, lineText] of lines.entries()) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
})
|
|
558
|
+
const signals = findSignals(lineText)
|
|
559
|
+
const candidates = findCandidates(lineText, entries)
|
|
560
|
+
const mappedSources = new Set()
|
|
561
|
+
const mappedSpans = []
|
|
562
|
+
const resolvedProviders = new Set()
|
|
563
|
+
const selectedMappings = new Map()
|
|
564
|
+
for (const configuredRoute of mappedRoutesForFile?.(file) ?? []) {
|
|
565
|
+
const route = routeForReference?.({ file, matched: configuredRoute.match, id: null }) ?? null
|
|
566
|
+
if (!route?.model) continue
|
|
567
|
+
selectedMappings.set(`${route.index}:${route.match}`, route)
|
|
568
|
+
}
|
|
569
|
+
for (const route of selectedMappings.values()) {
|
|
570
|
+
const record = entries.get(route.model)
|
|
571
|
+
if (!record) continue
|
|
572
|
+
const exactRoutePattern = new RegExp(`(?<![A-Za-z0-9._:/-])${escapeRegExp(route.match)}(?![A-Za-z0-9._:/-])`, 'g')
|
|
573
|
+
for (const match of lineText.matchAll(exactRoutePattern)) {
|
|
574
|
+
mappedSources.add(route.match)
|
|
575
|
+
mappedSpans.push({ start: match.index, end: match.index + route.match.length })
|
|
576
|
+
resolvedProviders.add(route.via)
|
|
577
|
+
pushModelRef({
|
|
578
|
+
file,
|
|
579
|
+
line: i + 1,
|
|
580
|
+
lineText,
|
|
581
|
+
matchIndex: match.index,
|
|
582
|
+
matched: route.match,
|
|
583
|
+
record,
|
|
584
|
+
classification: routeClassification(route),
|
|
585
|
+
route,
|
|
586
|
+
mappedFrom: route.match,
|
|
587
|
+
})
|
|
588
|
+
}
|
|
420
589
|
}
|
|
421
590
|
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
usage: classification.usage,
|
|
448
|
-
resolved_provider: classification.provider,
|
|
449
|
-
confidence: classification.confidence,
|
|
450
|
-
evidence: classification.evidence,
|
|
451
|
-
occurrence: occurrence < 0 ? 0 : occurrence,
|
|
452
|
-
})
|
|
591
|
+
if (pattern) {
|
|
592
|
+
pattern.lastIndex = 0
|
|
593
|
+
let m
|
|
594
|
+
while ((m = pattern.exec(lineText)) !== null) {
|
|
595
|
+
if (mappedSpans.some(span => m.index >= span.start && m.index + m[1].length <= span.end)) continue
|
|
596
|
+
const record = entries.get(m[1])
|
|
597
|
+
const route = routeForReference?.({ file, matched: m[1], id: record.entry.id }) ?? null
|
|
598
|
+
const classification = route ? routeClassification(route) : classifyRef({
|
|
599
|
+
file,
|
|
600
|
+
line: i + 1,
|
|
601
|
+
lines,
|
|
602
|
+
publisher: record.publisher,
|
|
603
|
+
})
|
|
604
|
+
if (route) resolvedProviders.add(route.via)
|
|
605
|
+
pushModelRef({
|
|
606
|
+
file,
|
|
607
|
+
line: i + 1,
|
|
608
|
+
lineText,
|
|
609
|
+
matchIndex: m.index,
|
|
610
|
+
matched: m[1],
|
|
611
|
+
record,
|
|
612
|
+
classification,
|
|
613
|
+
route,
|
|
614
|
+
})
|
|
615
|
+
}
|
|
453
616
|
}
|
|
454
617
|
|
|
455
|
-
for (const candidate of
|
|
618
|
+
for (const candidate of candidates) {
|
|
619
|
+
if (mappedSources.has(candidate) || isKnownModel(candidate, entries) || obviousCandidateNoise(candidate)) continue
|
|
620
|
+
if (ignoresModel(file, candidate)) continue
|
|
456
621
|
const classification = classifyRef({
|
|
457
622
|
file,
|
|
458
623
|
line: i + 1,
|
|
@@ -471,6 +636,18 @@ export const scanTargets = ({ targets, entries, keys, includeDocs = false }) =>
|
|
|
471
636
|
reason: 'model-like string not present in loaded feeds',
|
|
472
637
|
})
|
|
473
638
|
}
|
|
639
|
+
for (const signal of signals) {
|
|
640
|
+
if (resolvedProviders.has(signal.provider)) continue
|
|
641
|
+
integrationHints.push({
|
|
642
|
+
kind: 'integration-hint',
|
|
643
|
+
usage: signal.usage,
|
|
644
|
+
provider: signal.provider,
|
|
645
|
+
file,
|
|
646
|
+
line: i + 1,
|
|
647
|
+
matched: signal.matched,
|
|
648
|
+
evidence: signal.evidence,
|
|
649
|
+
})
|
|
650
|
+
}
|
|
474
651
|
}
|
|
475
652
|
}
|
|
476
653
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-eol",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"model-eol": "check.mjs"
|
|
7
|
+
"model-eol": "check.mjs",
|
|
8
|
+
"model-eol-bot": "bot/bot.mjs"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
|
-
"test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-feed-changelog.mjs"
|
|
11
|
+
"test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-action-contract.mjs && node bot/test/package.mjs && node scripts/test-feed-changelog.mjs"
|
|
11
12
|
},
|
|
12
13
|
"repository": {
|
|
13
14
|
"type": "git",
|
|
@@ -31,7 +32,10 @@
|
|
|
31
32
|
],
|
|
32
33
|
"files": [
|
|
33
34
|
"check.mjs",
|
|
35
|
+
"bot.yml.example",
|
|
34
36
|
"lib/",
|
|
37
|
+
"bot/bot.mjs",
|
|
38
|
+
"bot/lib/",
|
|
35
39
|
"feeds/",
|
|
36
40
|
"schema/",
|
|
37
41
|
"SPEC.md",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://example.invalid/model-eol/0.1/bot-config.schema.json",
|
|
4
|
-
"title": "model-eol
|
|
5
|
-
"description": "Optional .model-eol.json configuration
|
|
4
|
+
"title": "model-eol repository configuration",
|
|
5
|
+
"description": "Optional strict .model-eol.json configuration shared by the zero-dependency CLI, Action, and GitHub bot adapter.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
"scope": {
|
|
16
16
|
"enum": ["direct", "all"],
|
|
17
17
|
"default": "direct",
|
|
18
|
-
"description": "Use direct API references
|
|
18
|
+
"description": "Use the direct-first scope (direct API plus generic model references, leaving cloud/gateway references as hints), or include all tracked references."
|
|
19
19
|
},
|
|
20
20
|
"via": {
|
|
21
21
|
"type": ["string", "null"],
|
|
22
|
+
"minLength": 1,
|
|
22
23
|
"default": null,
|
|
23
24
|
"description": "Optional distributor lifecycle clock, such as azure-ai-foundry."
|
|
24
25
|
},
|
|
@@ -42,17 +43,29 @@
|
|
|
42
43
|
"type": "array",
|
|
43
44
|
"items": { "type": "string", "minLength": 1 },
|
|
44
45
|
"default": [],
|
|
45
|
-
"description": "Canonical IDs or aliases to ignore.
|
|
46
|
+
"description": "Canonical IDs or aliases to ignore across CLI, Action, and bot. Either form ignores every alias of the canonical feed entry."
|
|
46
47
|
},
|
|
47
48
|
"paths": {
|
|
48
49
|
"type": "array",
|
|
49
50
|
"items": { "type": "string", "minLength": 1 },
|
|
50
51
|
"default": [],
|
|
51
|
-
"description": "Repo-relative simple globs supporting *, **, and ?."
|
|
52
|
+
"description": "Repo-relative simple globs supporting *, **, and ?. Matching paths are excluded before file coverage checks where possible."
|
|
52
53
|
}
|
|
53
54
|
},
|
|
54
55
|
"default": { "models": [], "paths": [] }
|
|
55
56
|
},
|
|
57
|
+
"overrides": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": { "$ref": "#/definitions/pathOverride" },
|
|
60
|
+
"default": [],
|
|
61
|
+
"description": "Path-scoped policy overrides. Matching entries apply in array order; later scalar values win and ignore lists are additive."
|
|
62
|
+
},
|
|
63
|
+
"routes": {
|
|
64
|
+
"type": "array",
|
|
65
|
+
"items": { "$ref": "#/definitions/referenceRoute" },
|
|
66
|
+
"default": [],
|
|
67
|
+
"description": "Path- and optionally reference-scoped distributor routes. The last matching route wins. Exact match/model pairs can resolve repository deployment aliases to canonical feed IDs."
|
|
68
|
+
},
|
|
56
69
|
"issues": {
|
|
57
70
|
"type": "object",
|
|
58
71
|
"additionalProperties": false,
|
|
@@ -71,17 +84,20 @@
|
|
|
71
84
|
"properties": {
|
|
72
85
|
"command": {
|
|
73
86
|
"type": ["string", "null"],
|
|
87
|
+
"minLength": 1,
|
|
74
88
|
"default": null,
|
|
75
|
-
"description": "Opt-in command
|
|
89
|
+
"description": "Opt-in evaluation command. Direct bot --eval runs it per changed patch in a temporary clone; the split example workflow runs it once as a read-only plan preflight."
|
|
76
90
|
},
|
|
77
91
|
"timeout_ms": {
|
|
78
92
|
"type": "integer",
|
|
79
|
-
"minimum":
|
|
93
|
+
"minimum": 1000,
|
|
94
|
+
"maximum": 3600000,
|
|
80
95
|
"default": 600000
|
|
81
96
|
},
|
|
82
97
|
"max_report_bytes": {
|
|
83
98
|
"type": "integer",
|
|
84
|
-
"minimum":
|
|
99
|
+
"minimum": 1,
|
|
100
|
+
"maximum": 8388608,
|
|
85
101
|
"default": 65536
|
|
86
102
|
},
|
|
87
103
|
"pass_env": {
|
|
@@ -100,7 +116,56 @@
|
|
|
100
116
|
"via": null,
|
|
101
117
|
"feeds": { "allow_vendored_fallback": false },
|
|
102
118
|
"ignore": { "models": [], "paths": [] },
|
|
119
|
+
"overrides": [],
|
|
120
|
+
"routes": [],
|
|
103
121
|
"issues": { "enabled": true },
|
|
104
122
|
"eval": { "command": null, "timeout_ms": 600000, "max_report_bytes": 65536, "pass_env": [] }
|
|
123
|
+
},
|
|
124
|
+
"definitions": {
|
|
125
|
+
"stringArray": {
|
|
126
|
+
"type": "array",
|
|
127
|
+
"items": { "type": "string", "minLength": 1 }
|
|
128
|
+
},
|
|
129
|
+
"nonEmptyStringArray": {
|
|
130
|
+
"allOf": [
|
|
131
|
+
{ "$ref": "#/definitions/stringArray" },
|
|
132
|
+
{ "minItems": 1 }
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"ignore": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"additionalProperties": false,
|
|
138
|
+
"properties": {
|
|
139
|
+
"models": { "$ref": "#/definitions/stringArray" },
|
|
140
|
+
"paths": { "$ref": "#/definitions/stringArray" }
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"pathOverride": {
|
|
144
|
+
"type": "object",
|
|
145
|
+
"additionalProperties": false,
|
|
146
|
+
"required": ["paths"],
|
|
147
|
+
"properties": {
|
|
148
|
+
"paths": { "$ref": "#/definitions/nonEmptyStringArray" },
|
|
149
|
+
"days": { "type": "integer", "minimum": 0 },
|
|
150
|
+
"scope": { "enum": ["direct", "all"] },
|
|
151
|
+
"via": { "type": ["string", "null"], "minLength": 1 },
|
|
152
|
+
"ignore": { "$ref": "#/definitions/ignore" }
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"referenceRoute": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"additionalProperties": false,
|
|
158
|
+
"required": ["paths", "via"],
|
|
159
|
+
"properties": {
|
|
160
|
+
"paths": { "$ref": "#/definitions/nonEmptyStringArray" },
|
|
161
|
+
"via": { "type": "string", "minLength": 1 },
|
|
162
|
+
"match": { "type": "string", "minLength": 1 },
|
|
163
|
+
"model": { "type": "string", "minLength": 1 }
|
|
164
|
+
},
|
|
165
|
+
"dependencies": {
|
|
166
|
+
"match": ["model"],
|
|
167
|
+
"model": ["match"]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
105
170
|
}
|
|
106
171
|
}
|
|
@@ -60,8 +60,15 @@
|
|
|
60
60
|
"evidence": { "type": "string" },
|
|
61
61
|
"status": { "$ref": "#/definitions/status" },
|
|
62
62
|
"shutdown": { "type": ["string", "null"] },
|
|
63
|
+
"date_precision": { "enum": ["exact", "earliest", null] },
|
|
64
|
+
"distribution_status": { "enum": ["active", "legacy", "extended-access", "retired", null] },
|
|
63
65
|
"via": { "type": ["string", "null"] },
|
|
66
|
+
"requested_via": { "type": ["string", "null"] },
|
|
64
67
|
"days": { "type": ["integer", "null"] },
|
|
68
|
+
"threshold_days": { "type": "integer", "minimum": 0 },
|
|
69
|
+
"effective_scope": { "enum": ["all", "direct"] },
|
|
70
|
+
"policy_provenance": { "$ref": "#/definitions/policyProvenance" },
|
|
71
|
+
"mapped_from": { "type": "string", "minLength": 1 },
|
|
65
72
|
"safe_until": { "type": ["string", "null"], "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
|
|
66
73
|
"replacement": { "type": ["string", "null"] },
|
|
67
74
|
"source": { "type": ["string", "null"] },
|
|
@@ -92,6 +99,8 @@
|
|
|
92
99
|
"resolved_provider": { "type": "string" },
|
|
93
100
|
"confidence": { "$ref": "#/definitions/confidence" },
|
|
94
101
|
"evidence": { "type": "string" },
|
|
102
|
+
"effective_scope": { "enum": ["all", "direct"] },
|
|
103
|
+
"policy_provenance": { "$ref": "#/definitions/policyProvenance" },
|
|
95
104
|
"reason": { "type": "string" }
|
|
96
105
|
}
|
|
97
106
|
}
|
|
@@ -125,6 +134,18 @@
|
|
|
125
134
|
"limit_files": { "type": "integer", "minimum": 0 }
|
|
126
135
|
},
|
|
127
136
|
"additionalProperties": false
|
|
137
|
+
},
|
|
138
|
+
"policyProvenance": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"required": ["override_indexes", "route_index"],
|
|
141
|
+
"properties": {
|
|
142
|
+
"override_indexes": {
|
|
143
|
+
"type": "array",
|
|
144
|
+
"items": { "type": "integer", "minimum": 0 }
|
|
145
|
+
},
|
|
146
|
+
"route_index": { "type": ["integer", "null"], "minimum": 0 }
|
|
147
|
+
},
|
|
148
|
+
"additionalProperties": false
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
151
|
}
|