model-eol 0.3.0 → 0.4.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/scanner.mjs CHANGED
@@ -9,7 +9,7 @@ export const CODE_EXT = new Set([
9
9
  '.c', '.cc', '.cjs', '.cpp', '.cs', '.cxx', '.go', '.h', '.hpp', '.java', '.js',
10
10
  '.jsx', '.kt', '.kts', '.m', '.mjs', '.mm', '.php', '.py', '.rb', '.rs',
11
11
  '.scala', '.sh', '.swift', '.ts', '.tsx',
12
- '.cfg', '.env', '.ini', '.json', '.toml', '.yaml', '.yml',
12
+ '.baml', '.cfg', '.env', '.ini', '.json', '.toml', '.yaml', '.yml',
13
13
  '.sql', '.tf', '.tfvars',
14
14
  ])
15
15
  export const DOC_EXT = new Set(['.md', '.mdx', '.txt', '.rst'])
@@ -34,25 +34,25 @@ export const incompleteScanNotes = notes => notes.filter(note => INCOMPLETE_SCAN
34
34
  export const scanIsIncomplete = notes => incompleteScanNotes(notes).length > 0
35
35
 
36
36
  const directSignals = [
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*\()/i, 'OpenAI direct SDK/API'],
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*\()/i, 'Anthropic direct SDK/API'],
39
- ['google-gemini', /\b(GEMINI_API_KEY|GOOGLE_API_KEY|generativelanguage\.googleapis\.com|@google\/genai|google-generativeai|GoogleGenerativeAI)\b/i, 'Gemini direct SDK/API'],
40
- ['mistral', /\b(MISTRAL_API_KEY|api\.mistral\.ai|@mistralai\/mistralai|from\s+mistralai\s+import|MistralClient)\b/i, 'Mistral direct SDK/API'],
41
- ['cohere', /\b(COHERE_API_KEY|api\.cohere\.ai|from\s+cohere\s+import|new\s+CohereClient\s*\()/i, 'Cohere direct SDK/API'],
42
- ['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'],
43
43
  ]
44
44
 
45
45
  const cloudSignals = [
46
- ['azure-ai-foundry', /\b(AZURE_OPENAI|AzureOpenAI|@azure\/openai|azure\.ai\.openai|azure-ai-foundry|AZURE_AI)\b/i, 'Azure Foundry/OpenAI deployment reference'],
47
- ['aws-bedrock', /\b(BEDROCK_MODEL_ID|bedrock-runtime|BedrockRuntime|InvokeModel|invoke_model|amazon\.bedrock)\b/i, 'Amazon Bedrock reference'],
48
- ['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'],
49
49
  ]
50
50
 
51
51
  const gatewaySignals = [
52
- ['openrouter', /\b(OPENROUTER_API_KEY|openrouter\.ai|openrouter\/)\b/i, 'OpenRouter gateway reference'],
53
- ['litellm', /\b(LITELLM|litellm|LiteLLM)\b/, 'LiteLLM gateway reference'],
54
- ['portkey', /\b(PORTKEY_API_KEY|portkey|Portkey)\b/, 'Portkey gateway reference'],
55
- ['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'],
56
56
  ]
57
57
 
58
58
  const allSignals = [
@@ -72,6 +72,13 @@ const candidatePatterns = [
72
72
  /["']((?:gpt|o[1345]|claude|gemini|mistral|command|grok|llama|deepseek|qwen|nova|embed)[A-Za-z0-9._:/-]{4,120})["']/gi,
73
73
  ]
74
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
+
75
82
  const suffixes = value => {
76
83
  const parts = value.split(/[\/.:]/).filter(Boolean)
77
84
  return parts.flatMap((_, i) => [parts.slice(i).join('/'), parts.slice(i).join('.'), parts.slice(i).join(':')])
@@ -88,7 +95,7 @@ const findCandidates = (lineText, entries) => {
88
95
  let m
89
96
  while ((m = pattern.exec(lineText)) !== null) {
90
97
  const value = m[1].trim()
91
- if (seen.has(value) || isKnownModel(value, entries)) continue
98
+ if (seen.has(value)) continue
92
99
  seen.add(value)
93
100
  candidates.push(value)
94
101
  }
@@ -96,6 +103,10 @@ const findCandidates = (lineText, entries) => {
96
103
  return candidates
97
104
  }
98
105
 
106
+ const publisherForProvider = provider => provider === 'google-gemini' ? 'google' : provider
107
+
108
+ const providerMatchesPublisher = (provider, publisher) => publisherForProvider(provider) === publisher
109
+
99
110
  const classifyRef = ({ file, line, lines, publisher }) => {
100
111
  const start = Math.max(0, line - 8)
101
112
  const end = Math.min(lines.length, line + 4)
@@ -109,8 +120,12 @@ const classifyRef = ({ file, line, lines, publisher }) => {
109
120
  const nearest = signals[0]
110
121
  if (nearest) {
111
122
  const hasCompetingRoute = signals.some(signal => signal.usage === 'cloud-provider' || signal.usage === 'gateway')
112
- const confidence = nearest.usage === 'direct-api' && !hasCompetingRoute ? 'high' : 'medium'
113
- 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 }
114
129
  }
115
130
 
116
131
  const lowerFile = file.toLowerCase()
@@ -248,6 +263,7 @@ const collectFilesDetailed = (targets, {
248
263
  ignorePaths = [],
249
264
  ignoreRoots = [],
250
265
  ignoredFiles = [],
266
+ policyForFile = null,
251
267
  } = {}) => {
252
268
  const files = []
253
269
  const notes = []
@@ -284,8 +300,15 @@ const collectFilesDetailed = (targets, {
284
300
  })
285
301
  const isIgnoredFile = absolute => {
286
302
  const resolved = canonicalPath(absolute)
287
- return excludedFiles.has(resolved) || repoPathsFor(resolved).some(matchesIgnoredRepoPath)
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 ?? []))
288
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
+ })
289
312
  const isIgnoredDirectoryTree = absolute => repoPathsFor(absolute).some(repoPath =>
290
313
  ignorePaths.some(pattern => {
291
314
  const normalizedPattern = normalizeRepoPath(pattern)
@@ -426,29 +449,92 @@ export const scanTargets = ({
426
449
  ignorePaths = [],
427
450
  ignoreRoots = [],
428
451
  ignoredFiles = [],
452
+ policyForFile = null,
453
+ routeForReference = null,
454
+ mappedRoutesForFile = null,
429
455
  }) => {
430
- const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles })
456
+ const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles, policyForFile })
431
457
  if (collected.validTargets.length === 0) throw new Error('no valid targets remain')
432
458
  const { files } = collected
433
459
  const pattern = buildModelPattern(keys)
434
- const ignoredModelKeys = new Set(ignoreModels)
435
- const ignoredCanonicalIds = new Set(ignoreModels.map(key => entries.get(key)?.entry.id ?? key))
436
- const ignoresModel = (matched, id = null) =>
437
- ignoredModelKeys.has(matched) || (id !== null && ignoredCanonicalIds.has(id))
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
+ }
438
471
  const modelRefs = []
439
472
  const candidateRefs = []
440
473
  const integrationHints = []
441
474
 
442
- const isFeedDocument = (file, text) => {
475
+ const modelEolDocument = (file, text) => {
443
476
  if (!file.endsWith('.json')) return false
444
477
  try {
445
- const spec = JSON.parse(text)?.spec
446
- 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
447
485
  } catch {
448
486
  return false
449
487
  }
450
488
  }
451
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
+
452
538
  for (const file of files) {
453
539
  let text
454
540
  try {
@@ -457,61 +543,81 @@ export const scanTargets = ({
457
543
  collected.notes.push({ reason: 'unreadable-file', file, message: e.message })
458
544
  continue
459
545
  }
460
- // A feed document is lifecycle data, not model usage.
461
- if (isFeedDocument(file, text)) {
462
- 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 })
463
554
  continue
464
555
  }
465
556
  const lines = text.split('\n')
466
557
  for (const [i, lineText] of lines.entries()) {
467
- for (const signal of findSignals(lineText)) {
468
- integrationHints.push({
469
- kind: 'integration-hint',
470
- usage: signal.usage,
471
- provider: signal.provider,
472
- file,
473
- line: i + 1,
474
- matched: signal.matched,
475
- evidence: signal.evidence,
476
- })
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
+ }
477
589
  }
478
590
 
479
- if (!pattern) continue
480
- pattern.lastIndex = 0
481
- let m
482
- while ((m = pattern.exec(lineText)) !== null) {
483
- const record = entries.get(m[1])
484
- if (ignoresModel(m[1], record.entry.id)) continue
485
- const occurrence = [...lineText.matchAll(new RegExp(escapeRegExp(m[1]), 'g'))]
486
- .findIndex(match => match.index === m.index)
487
- const classification = classifyRef({
488
- file,
489
- line: i + 1,
490
- lines,
491
- publisher: record.publisher,
492
- })
493
- modelRefs.push({
494
- kind: 'model-reference',
495
- file,
496
- line: i + 1,
497
- matched: m[1],
498
- id: record.entry.id,
499
- entry: record.entry,
500
- publisher: record.publisher,
501
- source: record.source,
502
- feedNote: record.feedNote,
503
- policy: record.policy,
504
- generated: record.generated,
505
- usage: classification.usage,
506
- resolved_provider: classification.provider,
507
- confidence: classification.confidence,
508
- evidence: classification.evidence,
509
- occurrence: occurrence < 0 ? 0 : occurrence,
510
- })
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
+ }
511
616
  }
512
617
 
513
- for (const candidate of findCandidates(lineText, entries)) {
514
- if (ignoresModel(candidate)) continue
618
+ for (const candidate of candidates) {
619
+ if (mappedSources.has(candidate) || isKnownModel(candidate, entries) || obviousCandidateNoise(candidate)) continue
620
+ if (ignoresModel(file, candidate)) continue
515
621
  const classification = classifyRef({
516
622
  file,
517
623
  line: i + 1,
@@ -530,6 +636,18 @@ export const scanTargets = ({
530
636
  reason: 'model-like string not present in loaded feeds',
531
637
  })
532
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
+ }
533
651
  }
534
652
  }
535
653
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,6 +54,18 @@
54
54
  },
55
55
  "default": { "models": [], "paths": [] }
56
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
+ },
57
69
  "issues": {
58
70
  "type": "object",
59
71
  "additionalProperties": false,
@@ -104,7 +116,56 @@
104
116
  "via": null,
105
117
  "feeds": { "allow_vendored_fallback": false },
106
118
  "ignore": { "models": [], "paths": [] },
119
+ "overrides": [],
120
+ "routes": [],
107
121
  "issues": { "enabled": true },
108
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
+ }
109
170
  }
110
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
  }
@@ -31,6 +31,11 @@
31
31
  "shutdown": { "type": "string" },
32
32
  "days": { "type": "integer" },
33
33
  "status": { "enum": ["retired", "retiring"] },
34
+ "requested_via": { "type": ["string", "null"] },
35
+ "threshold_days": { "type": "integer", "minimum": 0 },
36
+ "effective_scope": { "enum": ["all", "direct"] },
37
+ "policy_provenance": { "$ref": "#/definitions/policyProvenance" },
38
+ "mapped_from": { "type": "string", "minLength": 1 },
34
39
  "sources": { "type": "array", "items": { "type": "string" } },
35
40
  "notes": { "type": ["string", "null"] }
36
41
  },
@@ -50,7 +55,12 @@
50
55
  "status": { "enum": ["retired", "retiring", "scheduled", "watch", "ok"] },
51
56
  "shutdown": { "type": ["string", "null"] },
52
57
  "via": { "type": ["string", "null"] },
58
+ "requested_via": { "type": ["string", "null"] },
53
59
  "days": { "type": ["integer", "null"] },
60
+ "threshold_days": { "type": "integer", "minimum": 0 },
61
+ "effective_scope": { "enum": ["all", "direct"] },
62
+ "policy_provenance": { "$ref": "#/definitions/policyProvenance" },
63
+ "mapped_from": { "type": "string", "minLength": 1 },
54
64
  "replacement": { "type": ["string", "null"] },
55
65
  "replacement_options": {
56
66
  "type": "array",
@@ -77,6 +87,18 @@
77
87
  "limit_files": { "type": "integer", "minimum": 0 }
78
88
  },
79
89
  "additionalProperties": false
90
+ },
91
+ "policyProvenance": {
92
+ "type": "object",
93
+ "required": ["override_indexes", "route_index"],
94
+ "properties": {
95
+ "override_indexes": {
96
+ "type": "array",
97
+ "items": { "type": "integer", "minimum": 0 }
98
+ },
99
+ "route_index": { "type": ["integer", "null"], "minimum": 0 }
100
+ },
101
+ "additionalProperties": false
80
102
  }
81
103
  }
82
104
  }