model-eol 0.4.1 → 0.5.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/README.md +147 -20
- package/SPEC.md +21 -6
- package/action.yml +30 -9
- package/bot/bot.mjs +361 -165
- package/bot/lib/git.mjs +49 -0
- package/bot/lib/github.mjs +4 -0
- package/bot.yml.example +20 -11
- package/check.mjs +55 -4
- package/examples/model-eol-eval.mjs +84 -0
- package/examples/workflows/model-eol.yml +52 -0
- package/feeds/amazon.json +5 -1
- package/feeds/anthropic.json +4 -1
- package/lib/apply.mjs +393 -231
- package/lib/config.mjs +16 -1
- package/lib/feeds.mjs +22 -3
- package/lib/json-schema.mjs +291 -0
- package/lib/reports.mjs +95 -22
- package/lib/scanner.mjs +159 -25
- package/lib/validate-document.mjs +320 -0
- package/lib/validate-feed.mjs +7 -1
- package/package.json +4 -3
- package/schema/model-eol.alert.schema.json +46 -10
- package/schema/model-eol.bot-config.schema.json +8 -4
- package/schema/model-eol.check.schema.json +56 -0
- package/schema/model-eol.inventory.schema.json +82 -81
- package/schema/model-eol.plan.schema.json +5 -5
- package/schema/model-eol.schedule.schema.json +10 -10
- package/schema/model-eol.schema.json +24 -8
package/lib/scanner.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { TextDecoder } from 'node:util'
|
|
4
5
|
|
|
5
6
|
import { buildModelPattern } from './feeds.mjs'
|
|
6
7
|
import { matchesAnyGlob, matchesGlob, normalizeRepoPath } from './glob.mjs'
|
|
@@ -25,10 +26,16 @@ export const INCOMPLETE_SCAN_REASONS = new Set([
|
|
|
25
26
|
'unreadable-file',
|
|
26
27
|
'unreadable-path',
|
|
27
28
|
'unreadable-directory',
|
|
29
|
+
'invalid-utf8',
|
|
30
|
+
'symlink-skipped',
|
|
31
|
+
'submodule-skipped',
|
|
32
|
+
'nested-repository-skipped',
|
|
28
33
|
'file-count-cap',
|
|
29
34
|
'git-listing-failure',
|
|
30
35
|
])
|
|
31
36
|
|
|
37
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
38
|
+
|
|
32
39
|
export const incompleteScanNotes = notes => notes.filter(note => INCOMPLETE_SCAN_REASONS.has(note.reason))
|
|
33
40
|
|
|
34
41
|
export const scanIsIncomplete = notes => incompleteScanNotes(notes).length > 0
|
|
@@ -167,17 +174,63 @@ export const gitRootFor = target => {
|
|
|
167
174
|
|
|
168
175
|
const changedKey = (file, line) => `${path.resolve(file)}:${line}`
|
|
169
176
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
const gitPathEscapes = new Map([
|
|
178
|
+
['a', 0x07],
|
|
179
|
+
['b', 0x08],
|
|
180
|
+
['t', 0x09],
|
|
181
|
+
['n', 0x0a],
|
|
182
|
+
['v', 0x0b],
|
|
183
|
+
['f', 0x0c],
|
|
184
|
+
['r', 0x0d],
|
|
185
|
+
['\\', 0x5c],
|
|
186
|
+
['"', 0x22],
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
const decodeGitQuotedPath = raw => {
|
|
190
|
+
if (!raw.endsWith('"')) throw new Error(`malformed quoted Git diff path: ${raw}`)
|
|
191
|
+
const bytes = []
|
|
192
|
+
let literalStart = 1
|
|
193
|
+
const appendLiteral = end => {
|
|
194
|
+
if (end > literalStart) bytes.push(...Buffer.from(raw.slice(literalStart, end), 'utf8'))
|
|
195
|
+
}
|
|
196
|
+
for (let index = 1; index < raw.length - 1; index++) {
|
|
197
|
+
const character = raw[index]
|
|
198
|
+
if (character === '"') throw new Error(`malformed quoted Git diff path: ${raw}`)
|
|
199
|
+
if (character !== '\\') continue
|
|
200
|
+
appendLiteral(index)
|
|
201
|
+
index++
|
|
202
|
+
if (index >= raw.length - 1) throw new Error(`malformed quoted Git diff path: ${raw}`)
|
|
203
|
+
const escaped = raw[index]
|
|
204
|
+
if (/[0-7]/.test(escaped)) {
|
|
205
|
+
let octal = escaped
|
|
206
|
+
while (octal.length < 3 && index + 1 < raw.length - 1 && /[0-7]/.test(raw[index + 1])) {
|
|
207
|
+
octal += raw[++index]
|
|
208
|
+
}
|
|
209
|
+
const value = Number.parseInt(octal, 8)
|
|
210
|
+
if (value > 0xff) throw new Error(`invalid octal escape in Git diff path: ${raw}`)
|
|
211
|
+
bytes.push(value)
|
|
212
|
+
} else if (gitPathEscapes.has(escaped)) {
|
|
213
|
+
bytes.push(gitPathEscapes.get(escaped))
|
|
214
|
+
} else {
|
|
215
|
+
throw new Error(`unsupported escape in Git diff path: ${raw}`)
|
|
179
216
|
}
|
|
217
|
+
literalStart = index + 1
|
|
218
|
+
}
|
|
219
|
+
appendLiteral(raw.length - 1)
|
|
220
|
+
let decoded
|
|
221
|
+
try {
|
|
222
|
+
decoded = utf8Decoder.decode(Buffer.from(bytes))
|
|
223
|
+
} catch {
|
|
224
|
+
throw new Error(`Git diff path is not valid UTF-8: ${raw}`)
|
|
180
225
|
}
|
|
226
|
+
if (decoded.includes('\0')) throw new Error(`Git diff path contains a NUL byte: ${raw}`)
|
|
227
|
+
return decoded
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const parseDiffPath = value => {
|
|
231
|
+
const raw = value.endsWith('\r') ? value.slice(0, -1) : value
|
|
232
|
+
const decoded = raw.startsWith('"') ? decodeGitQuotedPath(raw) : raw
|
|
233
|
+
if (decoded === '/dev/null') return null
|
|
181
234
|
return decoded.startsWith('b/') ? decoded.slice(2) : decoded
|
|
182
235
|
}
|
|
183
236
|
|
|
@@ -211,7 +264,26 @@ export const addedLinesForTargets = (targets, baseRef) => {
|
|
|
211
264
|
}
|
|
212
265
|
return relative || '.'
|
|
213
266
|
})
|
|
214
|
-
|
|
267
|
+
if (baseRef.startsWith('-')) {
|
|
268
|
+
throw new Error(`--changed base ref must not begin with "-": ${JSON.stringify(baseRef)}`)
|
|
269
|
+
}
|
|
270
|
+
const resolvedBase = spawnSync('git', [
|
|
271
|
+
'rev-parse', '--verify', '--quiet', '--end-of-options', `${baseRef}^{commit}`,
|
|
272
|
+
], {
|
|
273
|
+
cwd: gitRoot,
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
276
|
+
})
|
|
277
|
+
const baseCommit = (resolvedBase.stdout ?? '').trim()
|
|
278
|
+
if (resolvedBase.error || resolvedBase.status !== 0 || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(baseCommit)) {
|
|
279
|
+
const detail = (resolvedBase.stderr ?? '').trim()
|
|
280
|
+
throw new Error(`--changed could not resolve base ref "${baseRef}" to a commit${detail ? `: ${detail}` : ''}`)
|
|
281
|
+
}
|
|
282
|
+
const result = spawnSync('git', [
|
|
283
|
+
'-c', 'core.quotePath=true',
|
|
284
|
+
'diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/',
|
|
285
|
+
'--text', '--no-ext-diff', '--no-textconv', '--unified=0', baseCommit, '--', ...relativeTargets,
|
|
286
|
+
], {
|
|
215
287
|
cwd: gitRoot,
|
|
216
288
|
encoding: 'utf8',
|
|
217
289
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -245,6 +317,9 @@ export const addedLinesForTargets = (targets, baseRef) => {
|
|
|
245
317
|
newLinesRemaining = hunk[2] === undefined ? 1 : Number(hunk[2])
|
|
246
318
|
continue
|
|
247
319
|
}
|
|
320
|
+
// Unified-diff metadata such as "\" does not
|
|
321
|
+
// consume a line on either side of the hunk.
|
|
322
|
+
if (line.startsWith('\\ ')) continue
|
|
248
323
|
if (!inHunk || !file || newLinesRemaining === 0) continue
|
|
249
324
|
if (line.startsWith('+')) added.add(changedKey(file, newLine))
|
|
250
325
|
if (!line.startsWith('-')) {
|
|
@@ -278,20 +353,22 @@ const collectFilesDetailed = (targets, {
|
|
|
278
353
|
}
|
|
279
354
|
}
|
|
280
355
|
const excludedFiles = new Set(ignoredFiles.map(canonicalPath))
|
|
281
|
-
const roots = ignoreRoots.
|
|
356
|
+
const roots = [...new Set(ignoreRoots.flatMap(root => [path.resolve(root), canonicalPath(root)]))]
|
|
282
357
|
let fileLimitWarned = false
|
|
283
358
|
let stopped = false
|
|
284
359
|
|
|
285
360
|
const note = value => notes.push(value)
|
|
286
361
|
const repoPathsFor = absolute => {
|
|
287
|
-
const
|
|
288
|
-
const repoPaths =
|
|
289
|
-
for (const
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
362
|
+
const candidates = [...new Set([path.resolve(absolute), canonicalPath(absolute)])]
|
|
363
|
+
const repoPaths = new Set()
|
|
364
|
+
for (const candidate of candidates) {
|
|
365
|
+
for (const root of roots) {
|
|
366
|
+
const relative = path.relative(root, candidate)
|
|
367
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue
|
|
368
|
+
repoPaths.add(normalizeRepoPath(relative))
|
|
369
|
+
}
|
|
293
370
|
}
|
|
294
|
-
return repoPaths
|
|
371
|
+
return [...repoPaths]
|
|
295
372
|
}
|
|
296
373
|
const matchesIgnoredRepoPath = repoPath => matchesAnyGlob(repoPath, ignorePaths) ||
|
|
297
374
|
ignorePaths.some(pattern => {
|
|
@@ -300,9 +377,12 @@ const collectFilesDetailed = (targets, {
|
|
|
300
377
|
})
|
|
301
378
|
const isIgnoredFile = absolute => {
|
|
302
379
|
const resolved = canonicalPath(absolute)
|
|
303
|
-
if (excludedFiles.has(resolved) || repoPathsFor(
|
|
304
|
-
const
|
|
305
|
-
return
|
|
380
|
+
if (excludedFiles.has(resolved) || repoPathsFor(absolute).some(matchesIgnoredRepoPath)) return true
|
|
381
|
+
const policyPaths = [...new Set([path.resolve(absolute), resolved])]
|
|
382
|
+
return policyPaths.some(candidate => {
|
|
383
|
+
const policy = policyForFile?.(candidate)
|
|
384
|
+
return Boolean(policy?.repoPath && matchesIgnoredRepoPathWithPatterns(policy.repoPath, policy.ignore?.paths ?? []))
|
|
385
|
+
})
|
|
306
386
|
}
|
|
307
387
|
const matchesIgnoredRepoPathWithPatterns = (repoPath, patterns) => matchesAnyGlob(repoPath, patterns) ||
|
|
308
388
|
patterns.some(pattern => {
|
|
@@ -327,7 +407,11 @@ const collectFilesDetailed = (targets, {
|
|
|
327
407
|
note({ reason: 'unreadable-file', file: displayPath(absolute), message: e.message })
|
|
328
408
|
return
|
|
329
409
|
}
|
|
330
|
-
if (
|
|
410
|
+
if (st.isSymbolicLink()) {
|
|
411
|
+
if (!isIgnoredFile(absolute)) note({ reason: 'symlink-skipped', file: displayPath(absolute) })
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (!st.isFile() || !isScannable(absolute, includeDocs)) return
|
|
331
415
|
if (st.size > MAX_FILE_BYTES) {
|
|
332
416
|
note({
|
|
333
417
|
reason: 'file-too-large',
|
|
@@ -362,7 +446,10 @@ const collectFilesDetailed = (targets, {
|
|
|
362
446
|
note({ reason: 'unreadable-path', file: displayPath(absolute), message: e.message })
|
|
363
447
|
return
|
|
364
448
|
}
|
|
365
|
-
if (st.isSymbolicLink())
|
|
449
|
+
if (st.isSymbolicLink()) {
|
|
450
|
+
if (!isIgnoredFile(absolute)) note({ reason: 'symlink-skipped', file: displayPath(absolute) })
|
|
451
|
+
return
|
|
452
|
+
}
|
|
366
453
|
if (!st.isDirectory()) {
|
|
367
454
|
addFile(absolute)
|
|
368
455
|
return
|
|
@@ -400,10 +487,50 @@ const collectFilesDetailed = (targets, {
|
|
|
400
487
|
})
|
|
401
488
|
return
|
|
402
489
|
}
|
|
490
|
+
const tracked = spawnSync('git', [
|
|
491
|
+
'ls-files', '-z', '--cached', '--stage', '--', relativeTarget,
|
|
492
|
+
], {
|
|
493
|
+
cwd: gitRoot,
|
|
494
|
+
encoding: 'utf8',
|
|
495
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
496
|
+
})
|
|
497
|
+
if (tracked.error || tracked.status !== 0) {
|
|
498
|
+
const detail = tracked.error?.message || tracked.stderr?.trim() || `exit ${tracked.status}`
|
|
499
|
+
note({
|
|
500
|
+
reason: 'git-listing-failure',
|
|
501
|
+
file: displayPath(absoluteTarget),
|
|
502
|
+
message: `git index metadata listing failed: ${detail}; no recursive fallback used`,
|
|
503
|
+
})
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
const gitlinks = new Set()
|
|
507
|
+
for (const record of tracked.stdout.split('\0')) {
|
|
508
|
+
if (!record) continue
|
|
509
|
+
const separator = record.indexOf('\t')
|
|
510
|
+
const metadata = separator === -1 ? '' : record.slice(0, separator)
|
|
511
|
+
const relativeFile = separator === -1 ? '' : record.slice(separator + 1)
|
|
512
|
+
if (!/^[0-7]{6} [0-9a-f]+ [0-3]$/.test(metadata) || !relativeFile) {
|
|
513
|
+
note({
|
|
514
|
+
reason: 'git-listing-failure',
|
|
515
|
+
file: displayPath(absoluteTarget),
|
|
516
|
+
message: 'git index metadata listing returned a malformed record; no recursive fallback used',
|
|
517
|
+
})
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
if (metadata.startsWith('160000 ')) gitlinks.add(relativeFile)
|
|
521
|
+
}
|
|
403
522
|
for (const relativeFile of result.stdout.split('\0')) {
|
|
404
523
|
if (stopped) break
|
|
405
524
|
if (!relativeFile) continue
|
|
406
525
|
const absoluteFile = path.resolve(gitRoot, relativeFile)
|
|
526
|
+
if (gitlinks.has(relativeFile)) {
|
|
527
|
+
if (!isIgnoredFile(absoluteFile)) note({ reason: 'submodule-skipped', file: displayPath(absoluteFile) })
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
if (relativeFile.endsWith('/')) {
|
|
531
|
+
if (!isIgnoredFile(absoluteFile)) note({ reason: 'nested-repository-skipped', file: displayPath(absoluteFile) })
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
407
534
|
if (path.relative(gitRoot, absoluteFile).split(path.sep).some(part => SKIP_DIRS.has(part))) continue
|
|
408
535
|
addFile(absoluteFile)
|
|
409
536
|
}
|
|
@@ -536,13 +663,20 @@ export const scanTargets = ({
|
|
|
536
663
|
}
|
|
537
664
|
|
|
538
665
|
for (const file of files) {
|
|
539
|
-
let
|
|
666
|
+
let bytes
|
|
540
667
|
try {
|
|
541
|
-
|
|
668
|
+
bytes = fs.readFileSync(file)
|
|
542
669
|
} catch (e) {
|
|
543
670
|
collected.notes.push({ reason: 'unreadable-file', file, message: e.message })
|
|
544
671
|
continue
|
|
545
672
|
}
|
|
673
|
+
let text
|
|
674
|
+
try {
|
|
675
|
+
text = utf8Decoder.decode(bytes)
|
|
676
|
+
} catch (e) {
|
|
677
|
+
collected.notes.push({ reason: 'invalid-utf8', file, message: e.message })
|
|
678
|
+
continue
|
|
679
|
+
}
|
|
546
680
|
// Product artifacts and generated clients describe model IDs but are not
|
|
547
681
|
// authoritative usage or safe migration targets.
|
|
548
682
|
if (modelEolDocument(file, text)) {
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { TextDecoder } from 'node:util'
|
|
4
|
+
|
|
5
|
+
import { normalizeConfig } from './config.mjs'
|
|
6
|
+
import { JsonSchemaRegistry, validateJsonSchema } from './json-schema.mjs'
|
|
7
|
+
import { validateFeed } from './validate-feed.mjs'
|
|
8
|
+
|
|
9
|
+
export const DOCUMENT_TYPES = Object.freeze(['feed', 'config', 'check', 'inventory', 'schedule', 'alert', 'plan'])
|
|
10
|
+
|
|
11
|
+
const SCHEMA_FILES = Object.freeze({
|
|
12
|
+
feed: 'model-eol.schema.json',
|
|
13
|
+
config: 'model-eol.bot-config.schema.json',
|
|
14
|
+
check: 'model-eol.check.schema.json',
|
|
15
|
+
inventory: 'model-eol.inventory.schema.json',
|
|
16
|
+
schedule: 'model-eol.schedule.schema.json',
|
|
17
|
+
alert: 'model-eol.alert.schema.json',
|
|
18
|
+
plan: 'model-eol.plan.schema.json',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const TYPE_ALIASES = new Map([
|
|
22
|
+
['bot-config', 'config'],
|
|
23
|
+
['model-eol-config', 'config'],
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
27
|
+
|
|
28
|
+
const SCHEMA_DISCRIMINATORS = new Map([
|
|
29
|
+
['model-eol/check@0.1', 'check'],
|
|
30
|
+
['model-eol/inventory@0.1', 'inventory'],
|
|
31
|
+
['model-eol/schedule@0.1', 'schedule'],
|
|
32
|
+
['model-eol/alert@0.1', 'alert'],
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
let catalogCache
|
|
36
|
+
|
|
37
|
+
export const loadDocumentSchemaCatalog = () => {
|
|
38
|
+
if (catalogCache) return catalogCache
|
|
39
|
+
const directory = new URL('../schema/', import.meta.url)
|
|
40
|
+
const byType = new Map()
|
|
41
|
+
for (const type of DOCUMENT_TYPES) {
|
|
42
|
+
const file = new URL(SCHEMA_FILES[type], directory)
|
|
43
|
+
let schema
|
|
44
|
+
try {
|
|
45
|
+
schema = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(`failed to load ${type} schema: ${error.message}`)
|
|
48
|
+
}
|
|
49
|
+
byType.set(type, schema)
|
|
50
|
+
}
|
|
51
|
+
const registry = new JsonSchemaRegistry([...byType.values()])
|
|
52
|
+
registry.assertReferences()
|
|
53
|
+
const byId = new Map([...byType.entries()].map(([type, schema]) => [schema.$id, { type, schema }]))
|
|
54
|
+
catalogCache = { byType, byId, registry }
|
|
55
|
+
return catalogCache
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const normalizeDocumentType = value => {
|
|
59
|
+
if (typeof value !== 'string' || !value) return null
|
|
60
|
+
const normalized = TYPE_ALIASES.get(value) ?? value
|
|
61
|
+
return DOCUMENT_TYPES.includes(normalized) ? normalized : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
65
|
+
|
|
66
|
+
const configKeys = new Set([
|
|
67
|
+
'days', 'scope', 'via', 'feeds', 'ignore', 'overrides', 'routes', 'issues', 'eval',
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
export const detectDocumentType = (document, { file = null } = {}) => {
|
|
71
|
+
if (!isObject(document)) return null
|
|
72
|
+
if (typeof document.$schema === 'string' && typeof document.$id === 'string') return 'schema'
|
|
73
|
+
if (Object.hasOwn(document, 'spec')) return 'feed'
|
|
74
|
+
if (Object.hasOwn(document, 'plan_schema')) return 'plan'
|
|
75
|
+
if (typeof document.schema === 'string') return SCHEMA_DISCRIMINATORS.get(document.schema) ?? null
|
|
76
|
+
if (file && path.basename(file) === '.model-eol.json') return 'config'
|
|
77
|
+
const keys = Object.keys(document)
|
|
78
|
+
if (keys.length && keys.every(key => configKeys.has(key))) return 'config'
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const schemaDefinitionErrors = (document, catalog) => {
|
|
83
|
+
const errors = []
|
|
84
|
+
const fail = (path, keyword, message) => errors.push({ path, keyword, message })
|
|
85
|
+
if (!isObject(document)) return [{ path: '$', keyword: 'type', message: 'must be an object' }]
|
|
86
|
+
if (document.$schema !== 'http://json-schema.org/draft-07/schema#') {
|
|
87
|
+
fail('$.$schema', '$schema', 'must equal "http://json-schema.org/draft-07/schema#"')
|
|
88
|
+
}
|
|
89
|
+
const registered = catalog.byId.get(document.$id)
|
|
90
|
+
if (!registered) fail('$.$id', '$id', `must identify one of the ${catalog.byType.size} published model-eol schemas`)
|
|
91
|
+
|
|
92
|
+
const validTypes = new Set(['null', 'boolean', 'object', 'array', 'number', 'integer', 'string'])
|
|
93
|
+
const validFormats = new Set(['date', 'date-time', 'uri'])
|
|
94
|
+
const supportedKeywords = new Set([
|
|
95
|
+
'$schema', '$id', '$ref', '$comment',
|
|
96
|
+
'title', 'description', 'default', 'examples', 'readOnly', 'writeOnly',
|
|
97
|
+
'type', 'const', 'enum', 'format', 'pattern',
|
|
98
|
+
'minLength', 'maxLength', 'minimum', 'maximum',
|
|
99
|
+
'minItems', 'maxItems', 'uniqueItems', 'items',
|
|
100
|
+
'required', 'properties', 'additionalProperties', 'dependencies',
|
|
101
|
+
'definitions', 'allOf', 'oneOf',
|
|
102
|
+
])
|
|
103
|
+
const childPath = (base, key) => `${base}.${key}`
|
|
104
|
+
const lint = (rule, at) => {
|
|
105
|
+
if (typeof rule === 'boolean') return
|
|
106
|
+
if (!isObject(rule)) {
|
|
107
|
+
fail(at, 'schema', 'must be an object or boolean schema')
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
for (const keyword of Object.keys(rule)) {
|
|
111
|
+
if (!supportedKeywords.has(keyword)) {
|
|
112
|
+
fail(childPath(at, keyword), keyword, `unsupported JSON Schema keyword ${keyword}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (rule.$ref !== undefined && typeof rule.$ref !== 'string') fail(childPath(at, '$ref'), '$ref', 'must be a string')
|
|
116
|
+
if (rule.type !== undefined) {
|
|
117
|
+
const types = Array.isArray(rule.type) ? rule.type : [rule.type]
|
|
118
|
+
if (!types.length || types.some(type => typeof type !== 'string' || !validTypes.has(type))) {
|
|
119
|
+
fail(childPath(at, 'type'), 'type', 'must name one or more JSON types')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (rule.required !== undefined && (!Array.isArray(rule.required) || rule.required.some(value => typeof value !== 'string'))) {
|
|
123
|
+
fail(childPath(at, 'required'), 'required', 'must be an array of property names')
|
|
124
|
+
}
|
|
125
|
+
if (rule.enum !== undefined && (!Array.isArray(rule.enum) || rule.enum.length === 0)) {
|
|
126
|
+
fail(childPath(at, 'enum'), 'enum', 'must be a non-empty array')
|
|
127
|
+
}
|
|
128
|
+
if (rule.format !== undefined && (typeof rule.format !== 'string' || !validFormats.has(rule.format))) {
|
|
129
|
+
fail(childPath(at, 'format'), 'format', 'must be date, date-time, or uri')
|
|
130
|
+
}
|
|
131
|
+
if (rule.pattern !== undefined) {
|
|
132
|
+
if (typeof rule.pattern !== 'string') fail(childPath(at, 'pattern'), 'pattern', 'must be a string')
|
|
133
|
+
else {
|
|
134
|
+
try {
|
|
135
|
+
new RegExp(rule.pattern)
|
|
136
|
+
} catch {
|
|
137
|
+
fail(childPath(at, 'pattern'), 'pattern', 'must be a valid regular expression')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const keyword of ['minLength', 'maxLength', 'minItems', 'maxItems']) {
|
|
142
|
+
if (rule[keyword] !== undefined && (!Number.isInteger(rule[keyword]) || rule[keyword] < 0)) {
|
|
143
|
+
fail(childPath(at, keyword), keyword, 'must be a non-negative integer')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (rule.uniqueItems !== undefined && typeof rule.uniqueItems !== 'boolean') {
|
|
147
|
+
fail(childPath(at, 'uniqueItems'), 'uniqueItems', 'must be a boolean')
|
|
148
|
+
}
|
|
149
|
+
for (const keyword of ['minimum', 'maximum']) {
|
|
150
|
+
if (rule[keyword] !== undefined && (typeof rule[keyword] !== 'number' || !Number.isFinite(rule[keyword]))) {
|
|
151
|
+
fail(childPath(at, keyword), keyword, 'must be a finite number')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const keyword of ['properties', 'definitions']) {
|
|
155
|
+
if (rule[keyword] === undefined) continue
|
|
156
|
+
if (!isObject(rule[keyword])) {
|
|
157
|
+
fail(childPath(at, keyword), keyword, 'must be an object of schemas')
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
for (const [name, child] of Object.entries(rule[keyword])) lint(child, `${at}.${keyword}[${JSON.stringify(name)}]`)
|
|
161
|
+
}
|
|
162
|
+
if (rule.items !== undefined) lint(rule.items, childPath(at, 'items'))
|
|
163
|
+
if (rule.additionalProperties !== undefined) lint(rule.additionalProperties, childPath(at, 'additionalProperties'))
|
|
164
|
+
for (const keyword of ['allOf', 'oneOf']) {
|
|
165
|
+
if (rule[keyword] === undefined) continue
|
|
166
|
+
if (!Array.isArray(rule[keyword]) || rule[keyword].length === 0) {
|
|
167
|
+
fail(childPath(at, keyword), keyword, 'must be a non-empty array of schemas')
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
rule[keyword].forEach((child, index) => lint(child, `${at}.${keyword}[${index}]`))
|
|
171
|
+
}
|
|
172
|
+
if (rule.dependencies !== undefined) {
|
|
173
|
+
if (!isObject(rule.dependencies)) fail(childPath(at, 'dependencies'), 'dependencies', 'must be an object')
|
|
174
|
+
else {
|
|
175
|
+
for (const [name, dependency] of Object.entries(rule.dependencies)) {
|
|
176
|
+
const dependencyPath = `${at}.dependencies[${JSON.stringify(name)}]`
|
|
177
|
+
if (Array.isArray(dependency)) {
|
|
178
|
+
if (dependency.some(value => typeof value !== 'string')) fail(dependencyPath, 'dependencies', 'must contain property names')
|
|
179
|
+
} else lint(dependency, dependencyPath)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
lint(document, '$')
|
|
185
|
+
|
|
186
|
+
if (registered) {
|
|
187
|
+
try {
|
|
188
|
+
const companionSchemas = [...catalog.byType.values()].filter(schema => schema.$id !== document.$id)
|
|
189
|
+
const registry = new JsonSchemaRegistry([document, ...companionSchemas])
|
|
190
|
+
registry.assertReferences()
|
|
191
|
+
} catch (error) {
|
|
192
|
+
fail('$', '$ref', error.message)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return errors
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const semanticErrors = (document, type) => {
|
|
199
|
+
if (type === 'feed') {
|
|
200
|
+
return validateFeed(document).map(error => ({
|
|
201
|
+
path: error.path === 'feed' ? '$' : `$.${error.path}`,
|
|
202
|
+
keyword: 'model-eol-feed',
|
|
203
|
+
message: error.message,
|
|
204
|
+
}))
|
|
205
|
+
}
|
|
206
|
+
if (type === 'config') {
|
|
207
|
+
try {
|
|
208
|
+
normalizeConfig(document)
|
|
209
|
+
return []
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return [{ path: '$', keyword: 'model-eol-config', message: error.message }]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (type === 'plan') {
|
|
215
|
+
try {
|
|
216
|
+
validatePlanItems(document.items)
|
|
217
|
+
return []
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return [{ path: '$.items', keyword: 'model-eol-plan', message: error.message }]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return []
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const validateDocument = (document, { type, file = null } = {}) => {
|
|
226
|
+
const selectedType = type === undefined || type === null
|
|
227
|
+
? detectDocumentType(document, { file })
|
|
228
|
+
: normalizeDocumentType(type)
|
|
229
|
+
if (!selectedType) {
|
|
230
|
+
const detail = type
|
|
231
|
+
? `unknown document type ${JSON.stringify(type)}`
|
|
232
|
+
: typeof document?.schema === 'string'
|
|
233
|
+
? `unknown document schema ${JSON.stringify(document.schema)}`
|
|
234
|
+
: 'could not determine the document type'
|
|
235
|
+
return {
|
|
236
|
+
type: null,
|
|
237
|
+
errors: [{ path: '$', keyword: 'documentType', message: `${detail}; use --type ${DOCUMENT_TYPES.join('|')}` }],
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const catalog = loadDocumentSchemaCatalog()
|
|
242
|
+
if (selectedType === 'schema') return { type: selectedType, errors: schemaDefinitionErrors(document, catalog) }
|
|
243
|
+
const { byType, registry } = catalog
|
|
244
|
+
const schemaErrors = validateJsonSchema(document, byType.get(selectedType), { registry })
|
|
245
|
+
return {
|
|
246
|
+
type: selectedType,
|
|
247
|
+
errors: schemaErrors.length ? schemaErrors : semanticErrors(document, selectedType),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const formatDocumentErrors = errors => errors.map(error => `${error.path}: ${error.message}`)
|
|
252
|
+
|
|
253
|
+
export const assertValidDocument = (document, options = {}) => {
|
|
254
|
+
const result = validateDocument(document, options)
|
|
255
|
+
if (result.errors.length) throw new Error(formatDocumentErrors(result.errors).join('\n'))
|
|
256
|
+
return { document, type: result.type }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const validatePlanItems = items => {
|
|
260
|
+
if (!Array.isArray(items)) throw new Error('plan must contain an items array')
|
|
261
|
+
for (const [index, item] of items.entries()) {
|
|
262
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
263
|
+
throw new Error(`plan item ${index} must be an object`)
|
|
264
|
+
}
|
|
265
|
+
if (typeof item.file !== 'string' || item.file.length === 0) {
|
|
266
|
+
throw new Error(`plan item ${index} file must be a non-empty string`)
|
|
267
|
+
}
|
|
268
|
+
if (!Number.isInteger(item.line) || item.line < 1) {
|
|
269
|
+
throw new Error(`plan item ${index} line must be an integer >= 1`)
|
|
270
|
+
}
|
|
271
|
+
if (!Number.isInteger(item.occurrence) || item.occurrence < 0) {
|
|
272
|
+
throw new Error(`plan item ${index} occurrence must be an integer >= 0`)
|
|
273
|
+
}
|
|
274
|
+
if (typeof item.matched !== 'string' || item.matched.length === 0) {
|
|
275
|
+
throw new Error(`plan item ${index} matched must be a non-empty string`)
|
|
276
|
+
}
|
|
277
|
+
if (typeof item.replacement !== 'string' || item.replacement.length === 0) {
|
|
278
|
+
throw new Error(`plan item ${index} replacement must be a non-empty string`)
|
|
279
|
+
}
|
|
280
|
+
if (item.replacement === item.matched) {
|
|
281
|
+
throw new Error(`plan item ${index} replacement must differ from matched`)
|
|
282
|
+
}
|
|
283
|
+
if (typeof item.expected_line_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.expected_line_sha256)) {
|
|
284
|
+
throw new Error(`plan item ${index} expected_line_sha256 must be 64 lowercase hexadecimal characters`)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return items
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const assertValidPlanDocument = plan => {
|
|
291
|
+
validatePlanItems(plan?.items)
|
|
292
|
+
assertValidDocument(plan, { type: 'plan' })
|
|
293
|
+
return plan
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const readJsonDocument = file => {
|
|
297
|
+
let bytes
|
|
298
|
+
try {
|
|
299
|
+
bytes = fs.readFileSync(file)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
throw new Error(`could not read ${file}: ${error.message}`)
|
|
302
|
+
}
|
|
303
|
+
let source
|
|
304
|
+
try {
|
|
305
|
+
source = utf8Decoder.decode(bytes)
|
|
306
|
+
} catch {
|
|
307
|
+
throw new Error(`invalid UTF-8 in ${file}`)
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
return JSON.parse(source)
|
|
311
|
+
} catch (error) {
|
|
312
|
+
throw new Error(`invalid JSON in ${file}: ${error.message}`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const validateDocumentFile = (file, { type = null } = {}) => {
|
|
317
|
+
const document = readJsonDocument(file)
|
|
318
|
+
const result = validateDocument(document, { type, file })
|
|
319
|
+
return { ...result, document }
|
|
320
|
+
}
|
package/lib/validate-feed.mjs
CHANGED
|
@@ -204,17 +204,23 @@ export const validateFeed = feed => {
|
|
|
204
204
|
if (!Array.isArray(model.distributions)) {
|
|
205
205
|
fail(`${at}.distributions`, 'must be an array')
|
|
206
206
|
} else {
|
|
207
|
+
const seenDistributionVias = new Map()
|
|
207
208
|
for (const [distributionIndex, distribution] of model.distributions.entries()) {
|
|
208
209
|
const dAt = `${at}.distributions[${distributionIndex}]`
|
|
209
210
|
if (!checkObjectKeys(distribution, dAt, new Set(['via', 'announced', 'shutdown', 'date_precision', 'status', 'source']))) continue
|
|
210
211
|
if (!checkString(distribution.via, `${dAt}.via`, MAX_IDENTIFIER_LENGTH) || !distribution.via) fail(`${dAt}.via`, 'must be a non-empty string')
|
|
212
|
+
if (typeof distribution.via === 'string' && distribution.via.length > 0) {
|
|
213
|
+
const previous = seenDistributionVias.get(distribution.via)
|
|
214
|
+
if (previous) fail(`${dAt}.via`, `duplicate distributor via "${distribution.via}" also used at ${previous}`)
|
|
215
|
+
else seenDistributionVias.set(distribution.via, `${dAt}.via`)
|
|
216
|
+
}
|
|
211
217
|
const distributionAnnounced = checkDate(distribution.announced, `${dAt}.announced`)
|
|
212
218
|
const distributionShutdown = checkDate(distribution.shutdown, `${dAt}.shutdown`)
|
|
213
219
|
if (distributionAnnounced && distributionShutdown && distribution.shutdown < distribution.announced) fail(dAt, 'shutdown precedes announced')
|
|
214
220
|
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
221
|
if (distribution.status !== undefined && (typeof distribution.status !== 'string' || !DISTRIBUTION_STATUSES.has(distribution.status))) fail(`${dAt}.status`, 'has an unsupported status')
|
|
216
222
|
checkUrl(distribution.source, `${dAt}.source`)
|
|
217
|
-
if (distribution.shutdown && !(distribution.source || model.source || feed.source)) fail(dAt, 'dated distribution needs a source')
|
|
223
|
+
if ((distribution.announced || distribution.shutdown) && !(distribution.source || model.source || feed.source)) fail(dAt, 'dated distribution needs a source')
|
|
218
224
|
}
|
|
219
225
|
}
|
|
220
226
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-eol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"model-eol-bot": "bot/bot.mjs"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
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
|
+
"test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-action-contract.mjs && node scripts/test-document-validation.mjs && node scripts/test-eval-harness.mjs && node bot/test/package.mjs && node scripts/test-feed-changelog.mjs && node scripts/test-public-site.mjs"
|
|
12
12
|
},
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
@@ -38,12 +38,13 @@
|
|
|
38
38
|
"bot/lib/",
|
|
39
39
|
"feeds/",
|
|
40
40
|
"schema/",
|
|
41
|
+
"examples/",
|
|
41
42
|
"SPEC.md",
|
|
42
43
|
"README.md",
|
|
43
44
|
"LICENSE",
|
|
44
45
|
"action.yml"
|
|
45
46
|
],
|
|
46
47
|
"engines": {
|
|
47
|
-
"node": ">=
|
|
48
|
+
"node": ">=22"
|
|
48
49
|
}
|
|
49
50
|
}
|