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.
@@ -0,0 +1,246 @@
1
+ export const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
2
+ export const MAX_IDENTIFIER_LENGTH = 256
3
+ export const MAX_TEXT_LENGTH = 4096
4
+ export const MAX_URL_LENGTH = 4096
5
+ export const MAX_REPORT_BYTES = 8 * 1024 * 1024
6
+ export const MIN_TIMEOUT_MS = 1000
7
+ export const MAX_TIMEOUT_MS = 3600000
8
+
9
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/
10
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
11
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
12
+ const DATE_PRECISIONS = new Set(['exact', 'earliest'])
13
+ const DISTRIBUTION_STATUSES = new Set(['active', 'legacy', 'extended-access', 'retired'])
14
+
15
+ export const assertIsoDate = (value, label = 'date') => {
16
+ if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
17
+ throw new Error(`${label} must be YYYY-MM-DD`)
18
+ }
19
+ const [year, month, day] = value.split('-').map(Number)
20
+ const date = new Date(Date.UTC(year, month - 1, day))
21
+ if (year >= 0 && year <= 99) date.setUTCFullYear(year)
22
+ if (
23
+ date.getUTCFullYear() !== year ||
24
+ date.getUTCMonth() + 1 !== month ||
25
+ date.getUTCDate() !== day
26
+ ) throw new Error(`${label} must be a real calendar date`)
27
+ return value
28
+ }
29
+
30
+ const isObject = value => value && typeof value === 'object' && !Array.isArray(value)
31
+
32
+ const isUrl = value => {
33
+ try {
34
+ const url = new URL(value)
35
+ return url.protocol === 'http:' || url.protocol === 'https:'
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ export const validateFeed = feed => {
42
+ const errors = []
43
+ const fail = (path, message) => errors.push({ path, message })
44
+ const checkString = (value, path, limit = MAX_TEXT_LENGTH) => {
45
+ if (typeof value !== 'string') return false
46
+ if (CONTROL_CHARACTER_PATTERN.test(value)) fail(path, 'must not contain control characters')
47
+ if (value.length > limit) fail(path, `must be at most ${limit} characters`)
48
+ return true
49
+ }
50
+ const checkIdentifier = (value, path, required = false) => {
51
+ if (value === undefined && !required) return false
52
+ if (!checkString(value, path, MAX_IDENTIFIER_LENGTH)) {
53
+ fail(path, required ? 'must be a string' : 'must be a string')
54
+ return false
55
+ }
56
+ if (!value || !MODEL_ID_PATTERN.test(value)) fail(path, 'must be a grammar-valid model ID')
57
+ return Boolean(value && MODEL_ID_PATTERN.test(value))
58
+ }
59
+ const checkUrl = (value, path) => {
60
+ if (value === undefined) return false
61
+ if (!checkString(value, path, MAX_URL_LENGTH)) {
62
+ fail(path, 'must be a string URL')
63
+ return false
64
+ }
65
+ if (!isUrl(value)) fail(path, 'must be an http or https URL')
66
+ return true
67
+ }
68
+ const checkDate = (value, path) => {
69
+ if (value === undefined) return false
70
+ if (!checkString(value, path, 32)) {
71
+ fail(path, 'must be YYYY-MM-DD')
72
+ return false
73
+ }
74
+ try {
75
+ assertIsoDate(value, path)
76
+ return true
77
+ } catch (error) {
78
+ fail(path, error.message)
79
+ return false
80
+ }
81
+ }
82
+ const checkDateTime = (value, path) => {
83
+ if (!checkString(value, path, 64)) {
84
+ fail(path, 'must be an ISO date-time')
85
+ return false
86
+ }
87
+ if (!DATE_TIME_PATTERN.test(value) || Number.isNaN(Date.parse(value))) {
88
+ fail(path, 'must be an ISO date-time')
89
+ return false
90
+ }
91
+ try {
92
+ assertIsoDate(value.slice(0, 10), path)
93
+ } catch (error) {
94
+ fail(path, error.message)
95
+ return false
96
+ }
97
+ return true
98
+ }
99
+ const checkObjectKeys = (value, path, allowed) => {
100
+ if (!isObject(value)) {
101
+ fail(path, 'must be an object')
102
+ return false
103
+ }
104
+ for (const key of Object.keys(value)) {
105
+ if (!allowed.has(key)) fail(`${path}.${key}`, 'unknown field')
106
+ }
107
+ return true
108
+ }
109
+ const walkStrings = (value, path, seen = new Set()) => {
110
+ if (typeof value === 'string') {
111
+ checkString(value, path)
112
+ return
113
+ }
114
+ if (!value || typeof value !== 'object' || seen.has(value)) return
115
+ seen.add(value)
116
+ if (Array.isArray(value)) {
117
+ value.forEach((item, index) => walkStrings(item, `${path}[${index}]`, seen))
118
+ } else {
119
+ for (const [key, item] of Object.entries(value)) walkStrings(item, `${path}.${key}`, seen)
120
+ }
121
+ }
122
+
123
+ walkStrings(feed, 'feed')
124
+ if (!isObject(feed)) {
125
+ fail('feed', 'must be a JSON object')
126
+ return errors
127
+ }
128
+ checkObjectKeys(feed, 'feed', new Set(['spec', 'publisher', 'generated', 'source', 'note', 'policy', 'models']))
129
+ if (feed.spec !== 'model-eol/0.1') fail('spec', `must be model-eol/0.1, got ${feed.spec}`)
130
+ if (!checkString(feed.publisher, 'publisher', MAX_IDENTIFIER_LENGTH) || !feed.publisher) fail('publisher', 'must be a non-empty string')
131
+ checkDateTime(feed.generated, 'generated')
132
+ checkUrl(feed.source, 'source')
133
+ if (feed.note !== undefined) checkString(feed.note, 'note', MAX_TEXT_LENGTH)
134
+ if (!Array.isArray(feed.models)) {
135
+ fail('models', 'must be an array')
136
+ return errors
137
+ }
138
+
139
+ if (feed.policy !== undefined && checkObjectKeys(feed.policy, 'policy', new Set(['min_notice_days', 'source']))) {
140
+ if (!Number.isSafeInteger(feed.policy.min_notice_days) || feed.policy.min_notice_days < 0) {
141
+ fail('policy.min_notice_days', 'must be a non-negative safe integer')
142
+ }
143
+ if (feed.policy.source === undefined) fail('policy.source', 'is required')
144
+ else checkUrl(feed.policy.source, 'policy.source')
145
+ }
146
+
147
+ const modelKeys = new Set()
148
+ const seenKeys = new Map()
149
+ const validDates = []
150
+ for (const [index, model] of feed.models.entries()) {
151
+ const at = `models[${index}]`
152
+ if (!checkObjectKeys(model, at, new Set([
153
+ 'id', 'aliases', 'announced', 'shutdown', 'date_precision', 'replacement',
154
+ 'replacement_options', 'replacement_note', 'notes', 'source', 'distributions',
155
+ ]))) continue
156
+
157
+ const idValid = checkIdentifier(model.id, `${at}.id`, true)
158
+ if (idValid) {
159
+ modelKeys.add(model.id)
160
+ const previous = seenKeys.get(model.id)
161
+ if (previous) fail(`${at}.id`, `duplicate id/alias "${model.id}" also used at ${previous}`)
162
+ else seenKeys.set(model.id, `${at}.id`)
163
+ }
164
+
165
+ if (model.aliases !== undefined) {
166
+ if (!Array.isArray(model.aliases) || model.aliases.length === 0) {
167
+ fail(`${at}.aliases`, 'must be a non-empty array')
168
+ } else {
169
+ for (const [aliasIndex, alias] of model.aliases.entries()) {
170
+ const aliasPath = `${at}.aliases[${aliasIndex}]`
171
+ if (!checkIdentifier(alias, aliasPath, true)) continue
172
+ modelKeys.add(alias)
173
+ const previous = seenKeys.get(alias)
174
+ if (previous) fail(aliasPath, `duplicate id/alias "${alias}" also used at ${previous}`)
175
+ else seenKeys.set(alias, aliasPath)
176
+ }
177
+ }
178
+ }
179
+
180
+ const announcedValid = checkDate(model.announced, `${at}.announced`)
181
+ const shutdownValid = checkDate(model.shutdown, `${at}.shutdown`)
182
+ if (announcedValid && shutdownValid) validDates.push({ at, announced: model.announced, shutdown: model.shutdown })
183
+ if (model.date_precision !== undefined) {
184
+ if (typeof model.date_precision !== 'string' || !DATE_PRECISIONS.has(model.date_precision)) fail(`${at}.date_precision`, 'must be exact or earliest')
185
+ }
186
+ if (model.replacement !== undefined) checkIdentifier(model.replacement, `${at}.replacement`)
187
+ if (model.replacement_options !== undefined) {
188
+ if (!Array.isArray(model.replacement_options) || model.replacement_options.length === 0) {
189
+ fail(`${at}.replacement_options`, 'must be a non-empty array')
190
+ } else {
191
+ for (const [optionIndex, option] of model.replacement_options.entries()) {
192
+ checkIdentifier(option, `${at}.replacement_options[${optionIndex}]`, true)
193
+ }
194
+ }
195
+ if (model.replacement !== undefined) fail(at, 'replacement and replacement_options are mutually exclusive')
196
+ }
197
+ if (model.replacement_note !== undefined) checkString(model.replacement_note, `${at}.replacement_note`, MAX_TEXT_LENGTH)
198
+ if (model.notes !== undefined) checkString(model.notes, `${at}.notes`, MAX_TEXT_LENGTH)
199
+ checkUrl(model.source, `${at}.source`)
200
+ if (model.announced || model.shutdown) {
201
+ if (!(model.source || feed.source)) fail(at, 'dated entry needs a source (entry- or feed-level)')
202
+ }
203
+ if (model.distributions !== undefined) {
204
+ if (!Array.isArray(model.distributions)) {
205
+ fail(`${at}.distributions`, 'must be an array')
206
+ } else {
207
+ for (const [distributionIndex, distribution] of model.distributions.entries()) {
208
+ const dAt = `${at}.distributions[${distributionIndex}]`
209
+ if (!checkObjectKeys(distribution, dAt, new Set(['via', 'announced', 'shutdown', 'date_precision', 'status', 'source']))) continue
210
+ if (!checkString(distribution.via, `${dAt}.via`, MAX_IDENTIFIER_LENGTH) || !distribution.via) fail(`${dAt}.via`, 'must be a non-empty string')
211
+ const distributionAnnounced = checkDate(distribution.announced, `${dAt}.announced`)
212
+ const distributionShutdown = checkDate(distribution.shutdown, `${dAt}.shutdown`)
213
+ if (distributionAnnounced && distributionShutdown && distribution.shutdown < distribution.announced) fail(dAt, 'shutdown precedes announced')
214
+ if (distribution.date_precision !== undefined && (typeof distribution.date_precision !== 'string' || !DATE_PRECISIONS.has(distribution.date_precision))) fail(`${dAt}.date_precision`, 'must be exact or earliest')
215
+ if (distribution.status !== undefined && (typeof distribution.status !== 'string' || !DISTRIBUTION_STATUSES.has(distribution.status))) fail(`${dAt}.status`, 'has an unsupported status')
216
+ checkUrl(distribution.source, `${dAt}.source`)
217
+ if (distribution.shutdown && !(distribution.source || model.source || feed.source)) fail(dAt, 'dated distribution needs a source')
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ for (const date of validDates) {
224
+ if (date.shutdown < date.announced) fail(date.at, 'shutdown precedes announced')
225
+ }
226
+ for (const [index, model] of feed.models.entries()) {
227
+ if (!isObject(model) || typeof model.replacement !== 'string' || !MODEL_ID_PATTERN.test(model.replacement)) continue
228
+ if (!modelKeys.has(model.replacement)) fail(`models[${index}].replacement`, `does not resolve to an id or alias in this feed: ${model.replacement}`)
229
+ }
230
+ return errors
231
+ }
232
+
233
+ export const assertValidFeed = (feed, label = 'feed') => {
234
+ const errors = validateFeed(feed)
235
+ if (errors.length) {
236
+ const models = Array.isArray(feed?.models) ? feed.models : []
237
+ const lines = errors.map(error => {
238
+ const modelIndex = error.path.match(/^models\[(\d+)\]/)?.[1]
239
+ const modelId = modelIndex !== undefined ? models[Number(modelIndex)]?.id : models.find(model => model?.id)?.id
240
+ const context = typeof modelId === 'string' ? ` model ${modelId}` : ''
241
+ return `${label}${context}: ${error.path}: ${error.message}`
242
+ })
243
+ throw new Error(lines.join('\n'))
244
+ }
245
+ return feed
246
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "model-eol",
3
+ "version": "0.2.1",
4
+ "description": "A machine-readable deprecation feed format for AI models, plus a zero-dependency checker and inventory CLI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "model-eol": "check.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-feed-changelog.mjs"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/thossullivan/model-eol.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/thossullivan/model-eol/issues"
18
+ },
19
+ "homepage": "https://github.com/thossullivan/model-eol",
20
+ "license": "MIT",
21
+ "author": "Tom Sullivan",
22
+ "keywords": [
23
+ "model deprecation",
24
+ "eol",
25
+ "llm",
26
+ "dependabot",
27
+ "sbom",
28
+ "model lifecycle",
29
+ "ai models",
30
+ "software bill of materials"
31
+ ],
32
+ "files": [
33
+ "check.mjs",
34
+ "lib/",
35
+ "feeds/",
36
+ "schema/",
37
+ "SPEC.md",
38
+ "README.md",
39
+ "LICENSE",
40
+ "action.yml"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20"
44
+ }
45
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://example.invalid/model-eol/0.1/alert.schema.json",
4
+ "title": "model-eol alert output",
5
+ "description": "Machine-readable alert payload emitted by `model-eol alert --json`. Draft 0.1 - the $id is a placeholder until the repo is public.",
6
+ "type": "object",
7
+ "required": ["schema", "generated", "threshold_days", "scope", "scan_notes", "errors", "warnings"],
8
+ "properties": {
9
+ "schema": { "const": "model-eol/alert@0.1" },
10
+ "generated": { "type": "string", "format": "date-time" },
11
+ "threshold_days": { "type": "number" },
12
+ "distributor": { "type": ["string", "null"] },
13
+ "scope": { "enum": ["all", "direct"] },
14
+ "scan_notes": { "type": "array", "items": { "$ref": "model-eol.inventory.schema.json#/definitions/scanNote" } },
15
+ "errors": { "type": "array", "items": { "$ref": "model-eol.inventory.schema.json#/definitions/modelReference" } },
16
+ "warnings": {
17
+ "type": "array",
18
+ "items": {
19
+ "oneOf": [
20
+ { "$ref": "model-eol.inventory.schema.json#/definitions/modelReference" },
21
+ { "$ref": "model-eol.inventory.schema.json#/definitions/candidateReference" },
22
+ { "$ref": "model-eol.inventory.schema.json#/definitions/integrationHint" }
23
+ ]
24
+ }
25
+ }
26
+ },
27
+ "additionalProperties": false
28
+ }
@@ -0,0 +1,106 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
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.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "days": {
10
+ "type": "integer",
11
+ "minimum": 0,
12
+ "default": 90,
13
+ "description": "Open migration work for models retiring within this many days."
14
+ },
15
+ "scope": {
16
+ "enum": ["direct", "all"],
17
+ "default": "direct",
18
+ "description": "Use direct API references only, or include generic model references."
19
+ },
20
+ "via": {
21
+ "type": ["string", "null"],
22
+ "default": null,
23
+ "description": "Optional distributor lifecycle clock, such as azure-ai-foundry."
24
+ },
25
+ "feeds": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "allow_vendored_fallback": {
30
+ "type": "boolean",
31
+ "default": false,
32
+ "description": "Continue with vendored feeds after a remote feed failure in degraded report-only mode."
33
+ }
34
+ },
35
+ "default": { "allow_vendored_fallback": false }
36
+ },
37
+ "ignore": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "properties": {
41
+ "models": {
42
+ "type": "array",
43
+ "items": { "type": "string", "minLength": 1 },
44
+ "default": [],
45
+ "description": "Canonical IDs or aliases to ignore. A canonical ID ignores all of its aliases."
46
+ },
47
+ "paths": {
48
+ "type": "array",
49
+ "items": { "type": "string", "minLength": 1 },
50
+ "default": [],
51
+ "description": "Repo-relative simple globs supporting *, **, and ?."
52
+ }
53
+ },
54
+ "default": { "models": [], "paths": [] }
55
+ },
56
+ "issues": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "properties": {
60
+ "enabled": {
61
+ "type": "boolean",
62
+ "default": true,
63
+ "description": "Maintain GitHub issues for non-patchable actionable findings."
64
+ }
65
+ },
66
+ "default": { "enabled": true }
67
+ },
68
+ "eval": {
69
+ "type": "object",
70
+ "additionalProperties": false,
71
+ "properties": {
72
+ "command": {
73
+ "type": ["string", "null"],
74
+ "default": null,
75
+ "description": "Opt-in command run in the temporary clone for changed patches."
76
+ },
77
+ "timeout_ms": {
78
+ "type": "integer",
79
+ "minimum": 1,
80
+ "default": 600000
81
+ },
82
+ "max_report_bytes": {
83
+ "type": "integer",
84
+ "minimum": 0,
85
+ "default": 65536
86
+ },
87
+ "pass_env": {
88
+ "type": "array",
89
+ "items": { "type": "string", "minLength": 1 },
90
+ "default": [],
91
+ "description": "Environment variable names explicitly passed to the eval command."
92
+ }
93
+ },
94
+ "default": { "command": null, "timeout_ms": 600000, "max_report_bytes": 65536, "pass_env": [] }
95
+ }
96
+ },
97
+ "default": {
98
+ "days": 90,
99
+ "scope": "direct",
100
+ "via": null,
101
+ "feeds": { "allow_vendored_fallback": false },
102
+ "ignore": { "models": [], "paths": [] },
103
+ "issues": { "enabled": true },
104
+ "eval": { "command": null, "timeout_ms": 600000, "max_report_bytes": 65536, "pass_env": [] }
105
+ }
106
+ }
@@ -0,0 +1,130 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://example.invalid/model-eol/0.1/inventory.schema.json",
4
+ "title": "model-eol inventory output",
5
+ "description": "Machine-readable repository inventory emitted by `model-eol inventory --json`. Draft 0.1 - the $id is a placeholder until the repo is public.",
6
+ "type": "object",
7
+ "required": ["schema", "generated", "threshold_days", "scope", "targets", "scanned_files", "scan_notes", "summary", "model_references", "candidate_model_references", "integration_hints"],
8
+ "properties": {
9
+ "schema": { "const": "model-eol/inventory@0.1" },
10
+ "generated": { "type": "string", "format": "date-time" },
11
+ "threshold_days": { "type": "number" },
12
+ "distributor": { "type": ["string", "null"] },
13
+ "scope": { "enum": ["all", "direct"] },
14
+ "targets": { "type": "array", "items": { "type": "string" } },
15
+ "scanned_files": { "type": "integer", "minimum": 0 },
16
+ "scan_notes": { "type": "array", "items": { "$ref": "#/definitions/scanNote" } },
17
+ "summary": {
18
+ "type": "object",
19
+ "required": ["model_references", "candidate_model_references", "integration_hints", "retired_or_retiring", "cloud_or_gateway_hints"],
20
+ "properties": {
21
+ "model_references": { "type": "integer", "minimum": 0 },
22
+ "candidate_model_references": { "type": "integer", "minimum": 0 },
23
+ "integration_hints": { "type": "integer", "minimum": 0 },
24
+ "retired_or_retiring": { "type": "integer", "minimum": 0 },
25
+ "cloud_or_gateway_hints": { "type": "integer", "minimum": 0 }
26
+ },
27
+ "additionalProperties": false
28
+ },
29
+ "model_references": { "type": "array", "items": { "$ref": "#/definitions/modelReference" } },
30
+ "candidate_model_references": { "type": "array", "items": { "$ref": "#/definitions/candidateReference" } },
31
+ "integration_hints": { "type": "array", "items": { "$ref": "#/definitions/integrationHint" } }
32
+ },
33
+ "additionalProperties": false,
34
+ "definitions": {
35
+ "usage": { "enum": ["direct-api", "cloud-provider", "gateway", "model-reference"] },
36
+ "confidence": { "enum": ["high", "medium", "low"] },
37
+ "status": { "enum": ["retired", "retiring", "scheduled", "watch", "ok"] },
38
+ "location": {
39
+ "type": "object",
40
+ "required": ["file", "line"],
41
+ "properties": {
42
+ "file": { "type": "string" },
43
+ "line": { "type": "integer", "minimum": 1 }
44
+ }
45
+ },
46
+ "modelReference": {
47
+ "allOf": [
48
+ { "$ref": "#/definitions/location" },
49
+ {
50
+ "type": "object",
51
+ "required": ["kind", "matched", "id", "publisher", "usage", "resolved_provider", "confidence", "evidence", "status", "shutdown", "via", "days", "safe_until", "replacement", "source"],
52
+ "properties": {
53
+ "kind": { "const": "model-reference" },
54
+ "matched": { "type": "string" },
55
+ "id": { "type": "string" },
56
+ "publisher": { "type": "string" },
57
+ "usage": { "$ref": "#/definitions/usage" },
58
+ "resolved_provider": { "type": "string" },
59
+ "confidence": { "$ref": "#/definitions/confidence" },
60
+ "evidence": { "type": "string" },
61
+ "status": { "$ref": "#/definitions/status" },
62
+ "shutdown": { "type": ["string", "null"] },
63
+ "via": { "type": ["string", "null"] },
64
+ "days": { "type": ["integer", "null"] },
65
+ "safe_until": { "type": ["string", "null"], "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
66
+ "replacement": { "type": ["string", "null"] },
67
+ "source": { "type": ["string", "null"] },
68
+ "policy": {
69
+ "type": ["object", "null"],
70
+ "required": ["min_notice_days", "source"],
71
+ "properties": {
72
+ "min_notice_days": { "type": "integer", "minimum": 0 },
73
+ "source": { "type": "string", "format": "uri" }
74
+ },
75
+ "additionalProperties": false
76
+ },
77
+ "feed_generated": { "type": ["string", "null"], "format": "date-time" }
78
+ }
79
+ }
80
+ ]
81
+ },
82
+ "candidateReference": {
83
+ "allOf": [
84
+ { "$ref": "#/definitions/location" },
85
+ {
86
+ "type": "object",
87
+ "required": ["kind", "matched", "usage", "resolved_provider", "confidence", "evidence", "reason"],
88
+ "properties": {
89
+ "kind": { "const": "candidate-model-reference" },
90
+ "matched": { "type": "string" },
91
+ "usage": { "$ref": "#/definitions/usage" },
92
+ "resolved_provider": { "type": "string" },
93
+ "confidence": { "$ref": "#/definitions/confidence" },
94
+ "evidence": { "type": "string" },
95
+ "reason": { "type": "string" }
96
+ }
97
+ }
98
+ ]
99
+ },
100
+ "integrationHint": {
101
+ "allOf": [
102
+ { "$ref": "#/definitions/location" },
103
+ {
104
+ "type": "object",
105
+ "required": ["kind", "usage", "provider", "matched", "evidence"],
106
+ "properties": {
107
+ "kind": { "const": "integration-hint" },
108
+ "usage": { "enum": ["direct-api", "cloud-provider", "gateway"] },
109
+ "provider": { "type": "string" },
110
+ "matched": { "type": "string" },
111
+ "evidence": { "type": "string" }
112
+ }
113
+ }
114
+ ]
115
+ },
116
+ "scanNote": {
117
+ "type": "object",
118
+ "required": ["reason"],
119
+ "properties": {
120
+ "reason": { "type": "string", "minLength": 1 },
121
+ "file": { "type": "string" },
122
+ "message": { "type": "string" },
123
+ "bytes": { "type": "integer", "minimum": 0 },
124
+ "limit_bytes": { "type": "integer", "minimum": 0 },
125
+ "limit_files": { "type": "integer", "minimum": 0 }
126
+ },
127
+ "additionalProperties": false
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://example.invalid/model-eol/0.1/plan.schema.json",
4
+ "title": "model-eol migration plan",
5
+ "description": "Deterministic, hash-guarded migration plan emitted by `model-eol plan`. Draft 0.1 - the $id is a placeholder until the repo is public.",
6
+ "type": "object",
7
+ "required": ["plan_schema", "generated", "threshold_days", "via", "scan_notes", "items", "issues"],
8
+ "properties": {
9
+ "plan_schema": { "const": "model-eol.plan/0.1" },
10
+ "generated": { "type": "string", "format": "date-time" },
11
+ "threshold_days": { "type": "number" },
12
+ "via": { "type": ["string", "null"] },
13
+ "scan_notes": { "type": "array", "items": { "$ref": "#/definitions/scanNote" } },
14
+ "items": { "type": "array", "items": { "$ref": "#/definitions/item" } },
15
+ "issues": { "type": "array", "items": { "$ref": "#/definitions/issue" } }
16
+ },
17
+ "additionalProperties": false,
18
+ "definitions": {
19
+ "item": {
20
+ "type": "object",
21
+ "required": ["file", "line", "occurrence", "matched", "expected_line_sha256", "id", "publisher", "replacement", "shutdown", "days", "status", "sources", "notes"],
22
+ "properties": {
23
+ "file": { "type": "string", "minLength": 1 },
24
+ "line": { "type": "integer", "minimum": 1 },
25
+ "occurrence": { "type": "integer", "minimum": 0 },
26
+ "matched": { "type": "string", "minLength": 1 },
27
+ "expected_line_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
28
+ "id": { "type": "string", "minLength": 1 },
29
+ "publisher": { "type": "string", "minLength": 1 },
30
+ "replacement": { "type": "string", "minLength": 1 },
31
+ "shutdown": { "type": "string" },
32
+ "days": { "type": "integer" },
33
+ "status": { "enum": ["retired", "retiring"] },
34
+ "sources": { "type": "array", "items": { "type": "string" } },
35
+ "notes": { "type": ["string", "null"] }
36
+ },
37
+ "additionalProperties": false
38
+ },
39
+ "issue": {
40
+ "type": "object",
41
+ "required": ["file", "line", "reason"],
42
+ "properties": {
43
+ "file": { "type": "string" },
44
+ "line": { "type": "integer", "minimum": 1 },
45
+ "matched": { "type": "string" },
46
+ "id": { "type": "string" },
47
+ "publisher": { "type": "string" },
48
+ "usage": { "enum": ["direct-api", "cloud-provider", "gateway", "model-reference"] },
49
+ "confidence": { "enum": ["high", "medium", "low"] },
50
+ "status": { "enum": ["retired", "retiring", "scheduled", "watch", "ok"] },
51
+ "shutdown": { "type": ["string", "null"] },
52
+ "via": { "type": ["string", "null"] },
53
+ "days": { "type": ["integer", "null"] },
54
+ "replacement": { "type": ["string", "null"] },
55
+ "replacement_options": {
56
+ "type": "array",
57
+ "minItems": 1,
58
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" }
59
+ },
60
+ "replacement_note": { "type": "string" },
61
+ "provider": { "type": "string" },
62
+ "reason": { "type": "string", "minLength": 1 },
63
+ "sources": { "type": "array", "items": { "type": "string" } },
64
+ "notes": { "type": ["string", "null"] }
65
+ },
66
+ "additionalProperties": false
67
+ },
68
+ "scanNote": {
69
+ "type": "object",
70
+ "required": ["reason"],
71
+ "properties": {
72
+ "reason": { "type": "string", "minLength": 1 },
73
+ "file": { "type": "string" },
74
+ "message": { "type": "string" },
75
+ "bytes": { "type": "integer", "minimum": 0 },
76
+ "limit_bytes": { "type": "integer", "minimum": 0 },
77
+ "limit_files": { "type": "integer", "minimum": 0 }
78
+ },
79
+ "additionalProperties": false
80
+ }
81
+ }
82
+ }