model-eol 0.4.0 → 0.5.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
@@ -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 parseDiffPath = value => {
171
- const raw = value.trim()
172
- if (raw === '/dev/null') return null
173
- let decoded = raw
174
- if (raw.startsWith('"')) {
175
- try {
176
- decoded = JSON.parse(raw)
177
- } catch {
178
- decoded = raw.slice(1, raw.endsWith('"') ? -1 : undefined)
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
- const result = spawnSync('git', ['diff', '--unified=0', baseRef, '--', ...relativeTargets], {
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.map(canonicalPath)
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 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))
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(resolved).some(matchesIgnoredRepoPath)) return true
304
- const policy = policyForFile?.(resolved)
305
- return Boolean(policy?.repoPath && matchesIgnoredRepoPathWithPatterns(policy.repoPath, policy.ignore?.paths ?? []))
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 (!st.isFile() || st.isSymbolicLink() || !isScannable(absolute, includeDocs)) return
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()) return
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 text
666
+ let bytes
540
667
  try {
541
- text = fs.readFileSync(file, 'utf8')
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
+ }
@@ -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.4.0",
3
+ "version": "0.5.0",
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": ">=20"
48
+ "node": ">=22"
48
49
  }
49
50
  }