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,478 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ import { buildModelPattern } from './feeds.mjs'
6
+
7
+ export const CODE_EXT = new Set([
8
+ '.c', '.cc', '.cjs', '.cpp', '.cs', '.cxx', '.go', '.h', '.hpp', '.java', '.js',
9
+ '.jsx', '.kt', '.kts', '.m', '.mjs', '.mm', '.php', '.py', '.rb', '.rs',
10
+ '.scala', '.sh', '.swift', '.ts', '.tsx',
11
+ '.cfg', '.env', '.ini', '.json', '.toml', '.yaml', '.yml',
12
+ '.sql', '.tf', '.tfvars',
13
+ ])
14
+ export const DOC_EXT = new Set(['.md', '.mdx', '.txt', '.rst'])
15
+ export const SKIP_DIRS = new Set([
16
+ 'node_modules', '.git', 'dist', 'build', '.venv', 'venv', '__pycache__',
17
+ '.next', '.astro', '.worktrees', '.mypy_cache', '.ruff_cache', '.pytest_cache',
18
+ '.tox', '.terraform', 'target', 'vendor', 'coverage', 'logs', 'tmp',
19
+ ])
20
+ export const MAX_FILE_BYTES = 2 * 1024 * 1024
21
+ export const MAX_FILES = 100_000
22
+ export const INCOMPLETE_SCAN_REASONS = new Set([
23
+ 'file-too-large',
24
+ 'unreadable-file',
25
+ 'unreadable-path',
26
+ 'unreadable-directory',
27
+ 'file-count-cap',
28
+ 'git-listing-failure',
29
+ ])
30
+
31
+ export const incompleteScanNotes = notes => notes.filter(note => INCOMPLETE_SCAN_REASONS.has(note.reason))
32
+
33
+ export const scanIsIncomplete = notes => incompleteScanNotes(notes).length > 0
34
+
35
+ const directSignals = [
36
+ ['openai', /\b(OPENAI_API_KEY|api\.openai\.com|from\s+openai\s+import|import\s+OpenAI\s+from\s+['"]openai['"]|require\(['"]openai['"]\)|new\s+OpenAI\s*\()/i, 'OpenAI direct SDK/API'],
37
+ ['anthropic', /\b(ANTHROPIC_API_KEY|api\.anthropic\.com|@anthropic-ai\/sdk|from\s+anthropic\s+import|import\s+Anthropic\s+from\s+['"]@anthropic-ai\/sdk['"]|new\s+Anthropic\s*\()/i, 'Anthropic direct SDK/API'],
38
+ ['google-gemini', /\b(GEMINI_API_KEY|GOOGLE_API_KEY|generativelanguage\.googleapis\.com|@google\/genai|google-generativeai|GoogleGenerativeAI)\b/i, 'Gemini direct SDK/API'],
39
+ ['mistral', /\b(MISTRAL_API_KEY|api\.mistral\.ai|@mistralai\/mistralai|from\s+mistralai\s+import|MistralClient)\b/i, 'Mistral direct SDK/API'],
40
+ ['cohere', /\b(COHERE_API_KEY|api\.cohere\.ai|from\s+cohere\s+import|new\s+CohereClient\s*\()/i, 'Cohere direct SDK/API'],
41
+ ['xai', /\b(XAI_API_KEY|api\.x\.ai)\b/i, 'xAI direct SDK/API'],
42
+ ]
43
+
44
+ const cloudSignals = [
45
+ ['azure-ai-foundry', /\b(AZURE_OPENAI|AzureOpenAI|@azure\/openai|azure\.ai\.openai|azure-ai-foundry|AZURE_AI)\b/i, 'Azure Foundry/OpenAI deployment reference'],
46
+ ['aws-bedrock', /\b(BEDROCK_MODEL_ID|bedrock-runtime|BedrockRuntime|InvokeModel|invoke_model|amazon\.bedrock)\b/i, 'Amazon Bedrock reference'],
47
+ ['vertex-ai', /\b(VERTEX_AI|GOOGLE_CLOUD_PROJECT|aiplatform|vertexai|VertexAI|PredictionServiceClient|publishers\/google\/models)\b/i, 'Vertex AI reference'],
48
+ ]
49
+
50
+ const gatewaySignals = [
51
+ ['openrouter', /\b(OPENROUTER_API_KEY|openrouter\.ai|openrouter\/)\b/i, 'OpenRouter gateway reference'],
52
+ ['litellm', /\b(LITELLM|litellm|LiteLLM)\b/, 'LiteLLM gateway reference'],
53
+ ['portkey', /\b(PORTKEY_API_KEY|portkey|Portkey)\b/, 'Portkey gateway reference'],
54
+ ['ai-gateway', /\b(AI_GATEWAY|Vercel AI Gateway|ai-gateway)\b/i, 'AI gateway reference'],
55
+ ]
56
+
57
+ const allSignals = [
58
+ ...directSignals.map(([provider, pattern, evidence]) => ({ usage: 'direct-api', provider, pattern, evidence })),
59
+ ...cloudSignals.map(([provider, pattern, evidence]) => ({ usage: 'cloud-provider', provider, pattern, evidence })),
60
+ ...gatewaySignals.map(([provider, pattern, evidence]) => ({ usage: 'gateway', provider, pattern, evidence })),
61
+ ]
62
+
63
+ const findSignals = text => allSignals.flatMap(signal => {
64
+ const m = text.match(signal.pattern)
65
+ return m ? [{ ...signal, matched: m[0] }] : []
66
+ })
67
+
68
+ const candidatePatterns = [
69
+ /\b(?:model|model_id|modelId|modelName|engine|deployment|deployment_name)\b\s*[:=]\s*["']([^"']{4,120})["']/gi,
70
+ /["']((?:openai|anthropic|google|mistral|cohere|xai|amazon|meta-llama|deepseek|qwen)[\/.:][A-Za-z0-9._:/-]{4,120})["']/gi,
71
+ /["']((?:gpt|o[1345]|claude|gemini|mistral|command|grok|llama|deepseek|qwen|nova|embed)[A-Za-z0-9._:/-]{4,120})["']/gi,
72
+ ]
73
+
74
+ const suffixes = value => {
75
+ const parts = value.split(/[\/.:]/).filter(Boolean)
76
+ return parts.flatMap((_, i) => [parts.slice(i).join('/'), parts.slice(i).join('.'), parts.slice(i).join(':')])
77
+ }
78
+
79
+ const isKnownModel = (value, entries) =>
80
+ entries.has(value) || suffixes(value).some(v => entries.has(v))
81
+
82
+ const findCandidates = (lineText, entries) => {
83
+ const seen = new Set()
84
+ const candidates = []
85
+ for (const pattern of candidatePatterns) {
86
+ pattern.lastIndex = 0
87
+ let m
88
+ while ((m = pattern.exec(lineText)) !== null) {
89
+ const value = m[1].trim()
90
+ if (seen.has(value) || isKnownModel(value, entries)) continue
91
+ seen.add(value)
92
+ candidates.push(value)
93
+ }
94
+ }
95
+ return candidates
96
+ }
97
+
98
+ const classifyRef = ({ file, line, lines, publisher }) => {
99
+ const start = Math.max(0, line - 8)
100
+ const end = Math.min(lines.length, line + 4)
101
+ const signals = []
102
+ for (let i = start; i < end; i++) {
103
+ for (const signal of findSignals(lines[i])) {
104
+ signals.push({ ...signal, distance: Math.abs((i + 1) - line) })
105
+ }
106
+ }
107
+ signals.sort((a, b) => a.distance - b.distance)
108
+ const nearest = signals[0]
109
+ if (nearest) {
110
+ const hasCompetingRoute = signals.some(signal => signal.usage === 'cloud-provider' || signal.usage === 'gateway')
111
+ const confidence = nearest.usage === 'direct-api' && !hasCompetingRoute ? 'high' : 'medium'
112
+ return { usage: nearest.usage, provider: nearest.provider, confidence, evidence: nearest.evidence }
113
+ }
114
+
115
+ const lowerFile = file.toLowerCase()
116
+ if (lowerFile.includes('azure')) return { usage: 'cloud-provider', provider: 'azure-ai-foundry', confidence: 'low', evidence: 'azure-like file path' }
117
+ if (lowerFile.includes('bedrock')) return { usage: 'cloud-provider', provider: 'aws-bedrock', confidence: 'low', evidence: 'bedrock-like file path' }
118
+ if (lowerFile.includes('vertex')) return { usage: 'cloud-provider', provider: 'vertex-ai', confidence: 'low', evidence: 'vertex-like file path' }
119
+
120
+ return { usage: 'model-reference', provider: publisher, confidence: 'medium', evidence: 'tracked model ID or alias' }
121
+ }
122
+
123
+ const isScannable = (file, includeDocs) => {
124
+ const basename = path.basename(file)
125
+ if (basename === '.env' || basename.startsWith('.env.')) return true
126
+ const ext = path.extname(file)
127
+ return CODE_EXT.has(ext) || (includeDocs && DOC_EXT.has(ext))
128
+ }
129
+
130
+ const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
131
+
132
+ const displayPath = absolute => {
133
+ const relative = path.relative(process.cwd(), absolute)
134
+ return relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
135
+ ? relative
136
+ : absolute
137
+ }
138
+
139
+ export const gitRootFor = target => {
140
+ const cwd = fs.lstatSync(target).isDirectory() ? target : path.dirname(target)
141
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
142
+ cwd,
143
+ encoding: 'utf8',
144
+ stdio: ['ignore', 'pipe', 'ignore'],
145
+ })
146
+ if (result.error || result.status !== 0) return null
147
+ const root = result.stdout.trim()
148
+ return root ? fs.realpathSync(path.resolve(root)) : null
149
+ }
150
+
151
+ const changedKey = (file, line) => `${path.resolve(file)}:${line}`
152
+
153
+ const parseDiffPath = value => {
154
+ const raw = value.trim()
155
+ if (raw === '/dev/null') return null
156
+ let decoded = raw
157
+ if (raw.startsWith('"')) {
158
+ try {
159
+ decoded = JSON.parse(raw)
160
+ } catch {
161
+ decoded = raw.slice(1, raw.endsWith('"') ? -1 : undefined)
162
+ }
163
+ }
164
+ return decoded.startsWith('b/') ? decoded.slice(2) : decoded
165
+ }
166
+
167
+ export const addedLinesForTargets = (targets, baseRef) => {
168
+ if (!baseRef) throw new Error('--changed requires a base ref')
169
+ const roots = new Set()
170
+ const resolvedTargets = []
171
+ for (const target of targets) {
172
+ let resolvedTarget
173
+ try {
174
+ resolvedTarget = fs.realpathSync(path.resolve(target))
175
+ } catch {
176
+ resolvedTarget = null
177
+ }
178
+ let root
179
+ try {
180
+ root = resolvedTarget ? gitRootFor(resolvedTarget) : null
181
+ } catch {
182
+ root = null
183
+ }
184
+ if (!root) throw new Error(`--changed requires a git repository for target ${target}`)
185
+ roots.add(root)
186
+ resolvedTargets.push({ target, path: resolvedTarget })
187
+ }
188
+ if (roots.size !== 1) throw new Error('--changed targets must belong to the same git repository')
189
+ const gitRoot = [...roots][0]
190
+ const relativeTargets = resolvedTargets.map(({ target, path: resolvedTarget }) => {
191
+ const relative = path.relative(gitRoot, resolvedTarget)
192
+ if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
193
+ throw new Error(`--changed target is outside its git root: ${target}`)
194
+ }
195
+ return relative || '.'
196
+ })
197
+ const result = spawnSync('git', ['diff', '--unified=0', baseRef, '--', ...relativeTargets], {
198
+ cwd: gitRoot,
199
+ encoding: 'utf8',
200
+ stdio: ['ignore', 'pipe', 'pipe'],
201
+ })
202
+ if (result.error || result.status !== 0) {
203
+ const detail = (result.stderr ?? '').trim()
204
+ throw new Error(`--changed git diff failed for base ref "${baseRef}"${detail ? `: ${detail}` : ''}`)
205
+ }
206
+
207
+ const added = new Set()
208
+ let file = null
209
+ let inHunk = false
210
+ let newLine = 0
211
+ let newLinesRemaining = 0
212
+ for (const line of result.stdout.split('\n')) {
213
+ if (line.startsWith('diff --git ')) {
214
+ file = null
215
+ inHunk = false
216
+ newLinesRemaining = 0
217
+ continue
218
+ }
219
+ if (!inHunk && line.startsWith('+++ ')) {
220
+ const relative = parseDiffPath(line.slice(4))
221
+ file = relative ? path.resolve(gitRoot, relative) : null
222
+ continue
223
+ }
224
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
225
+ if (hunk) {
226
+ inHunk = true
227
+ newLine = Number(hunk[1])
228
+ newLinesRemaining = hunk[2] === undefined ? 1 : Number(hunk[2])
229
+ continue
230
+ }
231
+ if (!inHunk || !file || newLinesRemaining === 0) continue
232
+ if (line.startsWith('+')) added.add(changedKey(file, newLine))
233
+ if (!line.startsWith('-')) {
234
+ newLine++
235
+ newLinesRemaining--
236
+ }
237
+ }
238
+ return added
239
+ }
240
+
241
+ export const filterFindingsToChanged = (findings, addedLines) =>
242
+ findings.filter(finding => addedLines.has(changedKey(finding.file, finding.line)))
243
+
244
+ const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
245
+ const files = []
246
+ const notes = []
247
+ const validTargets = []
248
+ const seen = new Set()
249
+ let fileLimitWarned = false
250
+ let stopped = false
251
+
252
+ const note = value => notes.push(value)
253
+ const addFile = absolute => {
254
+ if (stopped || seen.has(absolute)) return
255
+ seen.add(absolute)
256
+
257
+ let st
258
+ try {
259
+ st = fs.lstatSync(absolute)
260
+ } catch (e) {
261
+ note({ reason: 'unreadable-file', file: displayPath(absolute), message: e.message })
262
+ return
263
+ }
264
+ if (!st.isFile() || st.isSymbolicLink() || !isScannable(absolute, includeDocs)) return
265
+ if (st.size > MAX_FILE_BYTES) {
266
+ note({
267
+ reason: 'file-too-large',
268
+ file: displayPath(absolute),
269
+ bytes: st.size,
270
+ limit_bytes: MAX_FILE_BYTES,
271
+ })
272
+ return
273
+ }
274
+ if (files.length >= MAX_FILES) {
275
+ stopped = true
276
+ if (!fileLimitWarned) {
277
+ note({
278
+ reason: 'file-count-cap',
279
+ file: displayPath(absolute),
280
+ limit_files: MAX_FILES,
281
+ message: `file-count cap (${MAX_FILES}) reached; remaining files skipped`,
282
+ })
283
+ fileLimitWarned = true
284
+ }
285
+ return
286
+ }
287
+ files.push(displayPath(absolute))
288
+ }
289
+
290
+ const walk = absolute => {
291
+ if (stopped) return
292
+ let st
293
+ try {
294
+ st = fs.lstatSync(absolute)
295
+ } catch (e) {
296
+ note({ reason: 'unreadable-path', file: displayPath(absolute), message: e.message })
297
+ return
298
+ }
299
+ if (st.isSymbolicLink()) return
300
+ if (!st.isDirectory()) {
301
+ addFile(absolute)
302
+ return
303
+ }
304
+ if (SKIP_DIRS.has(path.basename(absolute))) return
305
+ let names
306
+ try {
307
+ names = fs.readdirSync(absolute)
308
+ } catch (e) {
309
+ note({ reason: 'unreadable-directory', file: displayPath(absolute), message: e.message })
310
+ return
311
+ }
312
+ for (const name of names) {
313
+ walk(path.join(absolute, name))
314
+ if (stopped) return
315
+ }
316
+ }
317
+
318
+ const listGitFiles = (absoluteTarget, gitRoot) => {
319
+ const relativeTarget = path.relative(gitRoot, absoluteTarget) || '.'
320
+ const result = spawnSync('git', [
321
+ 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', relativeTarget,
322
+ ], {
323
+ cwd: gitRoot,
324
+ encoding: 'utf8',
325
+ stdio: ['ignore', 'pipe', 'pipe'],
326
+ })
327
+ if (result.error || result.status !== 0) {
328
+ const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`
329
+ note({
330
+ reason: 'git-listing-failure',
331
+ file: displayPath(absoluteTarget),
332
+ message: `git file listing failed: ${detail}; no recursive fallback used`,
333
+ })
334
+ return
335
+ }
336
+ for (const relativeFile of result.stdout.split('\0')) {
337
+ if (stopped) break
338
+ if (!relativeFile) continue
339
+ const absoluteFile = path.resolve(gitRoot, relativeFile)
340
+ if (path.relative(gitRoot, absoluteFile).split(path.sep).some(part => SKIP_DIRS.has(part))) continue
341
+ addFile(absoluteFile)
342
+ }
343
+ }
344
+
345
+ for (const target of targets) {
346
+ const absoluteTarget = path.resolve(target)
347
+ let st
348
+ try {
349
+ st = fs.lstatSync(absoluteTarget)
350
+ } catch (e) {
351
+ console.error(`model-eol: warning: target not found, skipping ${target}`)
352
+ continue
353
+ }
354
+ validTargets.push(absoluteTarget)
355
+ if (st.isSymbolicLink()) {
356
+ note({ reason: 'symlink-skipped', file: displayPath(absoluteTarget) })
357
+ continue
358
+ }
359
+
360
+ let resolvedTarget
361
+ try {
362
+ resolvedTarget = fs.realpathSync(absoluteTarget)
363
+ } catch {
364
+ resolvedTarget = absoluteTarget
365
+ }
366
+ const gitRoot = gitRootFor(resolvedTarget)
367
+ if (gitRoot) listGitFiles(resolvedTarget, gitRoot)
368
+ else walk(absoluteTarget)
369
+ }
370
+
371
+ return { files, notes, validTargets }
372
+ }
373
+
374
+ export const collectFiles = (targets, options = {}) => collectFilesDetailed(targets, options).files
375
+
376
+ export const scanTargets = ({ targets, entries, keys, includeDocs = false }) => {
377
+ const collected = collectFilesDetailed(targets, { includeDocs })
378
+ if (collected.validTargets.length === 0) throw new Error('no valid targets remain')
379
+ const { files } = collected
380
+ const pattern = buildModelPattern(keys)
381
+ const modelRefs = []
382
+ const candidateRefs = []
383
+ const integrationHints = []
384
+
385
+ const isFeedDocument = (file, text) => {
386
+ if (!file.endsWith('.json')) return false
387
+ try {
388
+ const spec = JSON.parse(text)?.spec
389
+ return typeof spec === 'string' && spec.startsWith('model-eol/')
390
+ } catch {
391
+ return false
392
+ }
393
+ }
394
+
395
+ for (const file of files) {
396
+ let text
397
+ try {
398
+ text = fs.readFileSync(file, 'utf8')
399
+ } catch (e) {
400
+ collected.notes.push({ reason: 'unreadable-file', file, message: e.message })
401
+ continue
402
+ }
403
+ // A feed document is lifecycle data, not model usage.
404
+ if (isFeedDocument(file, text)) {
405
+ collected.notes.push({ reason: 'feed-document-skipped', file })
406
+ continue
407
+ }
408
+ const lines = text.split('\n')
409
+ for (const [i, lineText] of lines.entries()) {
410
+ for (const signal of findSignals(lineText)) {
411
+ integrationHints.push({
412
+ kind: 'integration-hint',
413
+ usage: signal.usage,
414
+ provider: signal.provider,
415
+ file,
416
+ line: i + 1,
417
+ matched: signal.matched,
418
+ evidence: signal.evidence,
419
+ })
420
+ }
421
+
422
+ if (!pattern) continue
423
+ pattern.lastIndex = 0
424
+ let m
425
+ while ((m = pattern.exec(lineText)) !== null) {
426
+ const record = entries.get(m[1])
427
+ const occurrence = [...lineText.matchAll(new RegExp(escapeRegExp(m[1]), 'g'))]
428
+ .findIndex(match => match.index === m.index)
429
+ const classification = classifyRef({
430
+ file,
431
+ line: i + 1,
432
+ lines,
433
+ publisher: record.publisher,
434
+ })
435
+ modelRefs.push({
436
+ kind: 'model-reference',
437
+ file,
438
+ line: i + 1,
439
+ matched: m[1],
440
+ id: record.entry.id,
441
+ entry: record.entry,
442
+ publisher: record.publisher,
443
+ source: record.source,
444
+ feedNote: record.feedNote,
445
+ policy: record.policy,
446
+ generated: record.generated,
447
+ usage: classification.usage,
448
+ resolved_provider: classification.provider,
449
+ confidence: classification.confidence,
450
+ evidence: classification.evidence,
451
+ occurrence: occurrence < 0 ? 0 : occurrence,
452
+ })
453
+ }
454
+
455
+ for (const candidate of findCandidates(lineText, entries)) {
456
+ const classification = classifyRef({
457
+ file,
458
+ line: i + 1,
459
+ lines,
460
+ publisher: 'unknown',
461
+ })
462
+ candidateRefs.push({
463
+ kind: 'candidate-model-reference',
464
+ file,
465
+ line: i + 1,
466
+ matched: candidate,
467
+ usage: classification.usage,
468
+ resolved_provider: classification.provider,
469
+ confidence: classification.confidence === 'high' ? 'medium' : 'low',
470
+ evidence: classification.evidence,
471
+ reason: 'model-like string not present in loaded feeds',
472
+ })
473
+ }
474
+ }
475
+ }
476
+
477
+ return { files, modelRefs, candidateRefs, integrationHints, notes: collected.notes, validTargets: collected.validTargets }
478
+ }