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.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/SPEC.md +134 -0
- package/action.yml +71 -0
- package/check.mjs +263 -0
- package/feeds/amazon.json +64 -0
- package/feeds/anthropic.json +252 -0
- package/feeds/google.json +542 -0
- package/feeds/openai.json +1158 -0
- package/lib/apply.mjs +409 -0
- package/lib/cli.mjs +25 -0
- package/lib/color.mjs +16 -0
- package/lib/feeds.mjs +169 -0
- package/lib/plan.mjs +159 -0
- package/lib/reports.mjs +322 -0
- package/lib/scanner.mjs +478 -0
- package/lib/validate-feed.mjs +246 -0
- package/package.json +45 -0
- package/schema/model-eol.alert.schema.json +28 -0
- package/schema/model-eol.bot-config.schema.json +106 -0
- package/schema/model-eol.inventory.schema.json +130 -0
- package/schema/model-eol.plan.schema.json +82 -0
- package/schema/model-eol.schedule.schema.json +31 -0
- package/schema/model-eol.schema.json +69 -0
package/lib/scanner.mjs
ADDED
|
@@ -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
|
+
}
|