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/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\/)\b/i, 'OpenRouter gateway reference'],
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) || isKnownModel(value, entries)) continue
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 confidence = nearest.usage === 'direct-api' && !hasCompetingRoute ? 'high' : 'medium'
112
- return { usage: nearest.usage, provider: nearest.provider, confidence, evidence: nearest.evidence }
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, { includeDocs = false } = {}) => {
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 = ({ targets, entries, keys, includeDocs = false }) => {
377
- const collected = collectFilesDetailed(targets, { includeDocs })
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 isFeedDocument = (file, text) => {
475
+ const modelEolDocument = (file, text) => {
386
476
  if (!file.endsWith('.json')) return false
387
477
  try {
388
- const spec = JSON.parse(text)?.spec
389
- return typeof spec === 'string' && spec.startsWith('model-eol/')
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
- // A feed document is lifecycle data, not model usage.
404
- if (isFeedDocument(file, text)) {
405
- collected.notes.push({ reason: 'feed-document-skipped', file })
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
- for (const signal of findSignals(lineText)) {
411
- integrationHints.push({
412
- kind: 'integration-hint',
413
- usage: signal.usage,
414
- provider: signal.provider,
415
- file,
416
- line: i + 1,
417
- matched: signal.matched,
418
- evidence: signal.evidence,
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 (!pattern) continue
423
- pattern.lastIndex = 0
424
- let m
425
- while ((m = pattern.exec(lineText)) !== null) {
426
- const record = entries.get(m[1])
427
- const occurrence = [...lineText.matchAll(new RegExp(escapeRegExp(m[1]), 'g'))]
428
- .findIndex(match => match.index === m.index)
429
- const classification = classifyRef({
430
- file,
431
- line: i + 1,
432
- lines,
433
- publisher: record.publisher,
434
- })
435
- modelRefs.push({
436
- kind: 'model-reference',
437
- file,
438
- line: i + 1,
439
- matched: m[1],
440
- id: record.entry.id,
441
- entry: record.entry,
442
- publisher: record.publisher,
443
- source: record.source,
444
- feedNote: record.feedNote,
445
- policy: record.policy,
446
- generated: record.generated,
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 findCandidates(lineText, entries)) {
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.2.4",
4
- "description": "A machine-readable deprecation feed format for AI models, plus a zero-dependency checker and inventory CLI.",
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 bot configuration",
5
- "description": "Optional .model-eol.json configuration for the zero-dependency GitHub bot adapter.",
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 only, or include generic model 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. A canonical ID ignores all of its aliases."
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 run in the temporary clone for changed patches."
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": 1,
93
+ "minimum": 1000,
94
+ "maximum": 3600000,
80
95
  "default": 600000
81
96
  },
82
97
  "max_report_bytes": {
83
98
  "type": "integer",
84
- "minimum": 0,
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
  }