model-eol 0.2.4 → 0.3.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.
@@ -0,0 +1,112 @@
1
+ name: model-eol bot
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '17 4 * * 1'
6
+ workflow_dispatch:
7
+
8
+ concurrency:
9
+ group: model-eol-bot
10
+ cancel-in-progress: false
11
+
12
+ env:
13
+ # Major-line pin: receives compatible fixes and refreshed bundled feeds.
14
+ MODEL_EOL_PACKAGE: model-eol@0
15
+
16
+ jobs:
17
+ plan:
18
+ name: plan and evaluate
19
+ runs-on: ubuntu-latest
20
+ permissions:
21
+ contents: read
22
+ steps:
23
+ # The eval job must never hold write tokens.
24
+ - uses: actions/checkout@v4
25
+ with:
26
+ persist-credentials: false
27
+ - uses: actions/setup-node@v4
28
+ with:
29
+ node-version: 20
30
+ - name: Build the versioned plan
31
+ run: |
32
+ PLAN_ARGS=(plan .)
33
+ if [ ! -f .model-eol.json ]; then PLAN_ARGS+=(--days 90 --scope direct); fi
34
+ npx --yes --package="$MODEL_EOL_PACKAGE" model-eol "${PLAN_ARGS[@]}" > model-eol-plan.json
35
+ - name: Run the configured eval harness
36
+ env:
37
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
38
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
39
+ MODEL_EOL_EVAL_COMMAND: ${{ vars.MODEL_EOL_EVAL_COMMAND }}
40
+ MODEL_EOL_PLAN: ${{ github.workspace }}/model-eol-plan.json
41
+ MODEL_EOL_REPORT: ${{ github.workspace }}/model-eol-report.md
42
+ run: |
43
+ # The workflow variable is an explicit override. Otherwise, use the
44
+ # eval.command already validated from .model-eol.json by the plan step.
45
+ if [ -z "${MODEL_EOL_EVAL_COMMAND:-}" ] && [ -f .model-eol.json ]; then
46
+ MODEL_EOL_EVAL_COMMAND="$(node --input-type=module -e '
47
+ import fs from "node:fs"
48
+ const config = JSON.parse(fs.readFileSync(".model-eol.json", "utf8"))
49
+ if (typeof config.eval?.command === "string") process.stdout.write(config.eval.command)
50
+ ')"
51
+ fi
52
+ # Supply a provider-backed harness for this repository. It may read the
53
+ # plan and write Markdown to MODEL_EOL_REPORT, but this job has read-only
54
+ # contents permission and the publish job receives no provider keys.
55
+ if [ -n "${MODEL_EOL_EVAL_COMMAND:-}" ]; then
56
+ set +e
57
+ sh -c "$MODEL_EOL_EVAL_COMMAND"
58
+ eval_status=$?
59
+ set -e
60
+ printf '%s\n' "$eval_status" > model-eol-eval-exit-code
61
+ else
62
+ : > "$MODEL_EOL_REPORT"
63
+ printf '0\n' > model-eol-eval-exit-code
64
+ fi
65
+ - name: Upload plan and eval report
66
+ uses: actions/upload-artifact@v4
67
+ with:
68
+ name: model-eol-plan-and-report
69
+ path: |
70
+ model-eol-plan.json
71
+ model-eol-report.md
72
+ model-eol-eval-exit-code
73
+
74
+ publish:
75
+ name: publish migration work
76
+ needs: plan
77
+ runs-on: ubuntu-latest
78
+ permissions:
79
+ contents: write
80
+ pull-requests: write
81
+ issues: write
82
+ steps:
83
+ - uses: actions/checkout@v4
84
+ with:
85
+ # model-eol-bot authenticates its own temporary HTTPS clones and
86
+ # pushes with GITHUB_TOKEN; no checkout credential is persisted.
87
+ persist-credentials: false
88
+ - uses: actions/setup-node@v4
89
+ with:
90
+ node-version: 20
91
+ - name: Download the plan and eval report
92
+ uses: actions/download-artifact@v4
93
+ with:
94
+ name: model-eol-plan-and-report
95
+ - name: Publish model-eol PRs and issues
96
+ env:
97
+ GITHUB_TOKEN: ${{ secrets.MODEL_EOL_BOT_TOKEN || secrets.GITHUB_TOKEN }}
98
+ MODEL_EOL_PLAN_FILE: ${{ github.workspace }}/model-eol-plan.json
99
+ MODEL_EOL_EVAL_REPORT_FILE: ${{ github.workspace }}/model-eol-report.md
100
+ MODEL_EOL_EVAL_STATUS_FILE: ${{ github.workspace }}/model-eol-eval-exit-code
101
+ MODEL_EOL_TOKEN_KIND: ${{ secrets.MODEL_EOL_BOT_TOKEN && 'external-token' || 'github-token' }}
102
+ run: npx --yes --package="$MODEL_EOL_PACKAGE" model-eol-bot --repo "${{ github.repository }}" --target-dir .
103
+
104
+ # Security and checks note:
105
+ # A PR made with GITHUB_TOKEN does not trigger pull_request workflows, so checks
106
+ # may be skipped even though this job has opened the PR. For required checks, use
107
+ # a narrowly scoped fine-grained PAT or a GitHub App installation token in the
108
+ # optional MODEL_EOL_BOT_TOKEN secret instead.
109
+ # Keep provider API keys only in the read-only plan/eval job and never pass them to
110
+ # the publish job.
111
+ # Set feeds.allow_vendored_fallback: true in .model-eol.json only for degraded report-only runs; the default is false.
112
+ # Independent concurrent invocations need external mutual exclusion; this tool does not lock.
package/check.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // model-eol reference CLI - zero dependencies.
3
3
  //
4
4
  // Default mode preserves the original CI gate:
5
- // node check.mjs [paths...] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
5
+ // node check.mjs [paths...] [--config FILE] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
6
6
  //
7
7
  // New inventory modes:
8
8
  // node check.mjs inventory [paths...] [--json|--format json|cyclonedx|text]
@@ -14,10 +14,12 @@
14
14
  // exit codes: check/alert 0 = clear, 1 = finding, 2 = usage error;
15
15
  // apply 0 = all items applied, 1 = item refused, 2 = usage or plan error.
16
16
 
17
+ import fs from 'node:fs'
17
18
  import path from 'node:path'
18
19
 
19
20
  import { applyPlan } from './lib/apply.mjs'
20
21
  import { parseCliArgs } from './lib/cli.mjs'
22
+ import { CLI_DEFAULT_CONFIG, DEFAULT_CONFIG, loadConfig, normalizeConfig } from './lib/config.mjs'
21
23
  import { findingFromRef, isBad, loadFeeds } from './lib/feeds.mjs'
22
24
  import { buildPlan } from './lib/plan.mjs'
23
25
  import {
@@ -32,7 +34,44 @@ import {
32
34
  formatInventoryCycloneDX,
33
35
  formatSchedule,
34
36
  } from './lib/reports.mjs'
35
- import { addedLinesForTargets, filterFindingsToChanged, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
37
+ import { addedLinesForTargets, filterFindingsToChanged, gitRootFor, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
38
+
39
+ const rootForTarget = target => {
40
+ const absolute = path.resolve(target)
41
+ let stat
42
+ try {
43
+ stat = fs.lstatSync(absolute)
44
+ } catch {
45
+ return null
46
+ }
47
+ let resolved = absolute
48
+ try {
49
+ resolved = fs.realpathSync(absolute)
50
+ } catch {
51
+ }
52
+ try {
53
+ const gitRoot = gitRootFor(resolved)
54
+ if (gitRoot) return gitRoot
55
+ } catch {
56
+ }
57
+ return stat.isDirectory() ? resolved : path.dirname(resolved)
58
+ }
59
+
60
+ const configForTargets = ({ targets, explicit }) => {
61
+ const roots = [...new Set(targets.map(rootForTarget).filter(Boolean))]
62
+ if (explicit !== null) {
63
+ if (!explicit) throw new Error('--config must be a non-empty path')
64
+ return { file: path.resolve(explicit), roots }
65
+ }
66
+
67
+ const files = [...new Set(roots
68
+ .map(root => path.join(root, '.model-eol.json'))
69
+ .filter(file => fs.existsSync(file)))]
70
+ if (files.length > 1) {
71
+ throw new Error(`multiple .model-eol.json files found (${files.join(', ')}); use --config FILE to choose one`)
72
+ }
73
+ return { file: files[0] ?? null, roots }
74
+ }
36
75
 
37
76
  const main = () => {
38
77
  const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'help'])
@@ -46,6 +85,7 @@ try {
46
85
  args,
47
86
  options: {
48
87
  days: { type: 'string' },
88
+ config: { type: 'string' },
49
89
  feeds: { type: 'string' },
50
90
  via: { type: 'string' },
51
91
  scope: { type: 'string' },
@@ -69,10 +109,6 @@ try {
69
109
  const { values, positionals } = parsed
70
110
  if (values.help) command = 'help'
71
111
 
72
- const DAYS = Number(values.days ?? '90')
73
- const FEEDS_DIR = values.feeds ?? path.join(import.meta.dirname, 'feeds')
74
- const VIA = values.via ?? null // e.g. azure-ai-foundry, aws-bedrock
75
- const SCOPE = values.scope ?? 'all'
76
112
  const FORMAT = values.format ?? null
77
113
  const PLAN_FILE = values.plan ?? null
78
114
  const AS_JSON = values.json ?? false
@@ -86,12 +122,12 @@ if (command === 'help') {
86
122
  console.log(`model-eol
87
123
 
88
124
  Usage:
89
- node check.mjs [paths...] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
90
- node check.mjs check [paths...] [--days N] [--scope all|direct] [--json] [--changed BASE_REF] [--allow-incomplete]
91
- node check.mjs inventory [paths...] [--json] [--format json|cyclonedx|text]
92
- node check.mjs schedule [paths...] [--days N] [--json]
93
- node check.mjs alert [paths...] [--days N] [--scope all|direct] [--format github|markdown|badge] [--json]
94
- node check.mjs plan [paths...] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR] [--allow-incomplete]
125
+ node check.mjs [paths...] [--config FILE] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
126
+ node check.mjs check [paths...] [--config FILE] [--days N] [--scope all|direct] [--json] [--changed BASE_REF] [--allow-incomplete]
127
+ node check.mjs inventory [paths...] [--config FILE] [--json] [--format json|cyclonedx|text]
128
+ node check.mjs schedule [paths...] [--config FILE] [--days N] [--json]
129
+ node check.mjs alert [paths...] [--config FILE] [--days N] [--scope all|direct] [--format github|markdown|badge] [--json]
130
+ node check.mjs plan [paths...] [--config FILE] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR] [--allow-incomplete]
95
131
  node check.mjs apply --plan plan.json [--dry-run]
96
132
 
97
133
  Commands:
@@ -109,6 +145,10 @@ Scopes:
109
145
  all Check every tracked model ID found. This preserves the original behavior.
110
146
  direct Check direct API and generic model references; leave cloud/gateway refs in inventory.
111
147
 
148
+ Configuration:
149
+ .model-eol.json is loaded automatically from the target repository root.
150
+ --config FILE selects an explicit config. Explicit CLI flags override config values.
151
+
112
152
  Exit codes:
113
153
  0 Clean, or report generated successfully.
114
154
  1 Findings, alert errors, or refused apply items.
@@ -132,6 +172,23 @@ if (command === 'apply') {
132
172
  }
133
173
  }
134
174
 
175
+ let configLocation
176
+ let repositoryConfig
177
+ try {
178
+ configLocation = configForTargets({ targets, explicit: values.config ?? null })
179
+ repositoryConfig = configLocation.file
180
+ ? loadConfig(configLocation.file, { defaults: DEFAULT_CONFIG, allowMissing: false })
181
+ : normalizeConfig({}, { defaults: CLI_DEFAULT_CONFIG })
182
+ } catch (e) {
183
+ console.error(`failed to load repository config: ${e.message}`)
184
+ return 2
185
+ }
186
+
187
+ const DAYS = Number(values.days ?? repositoryConfig.days)
188
+ const FEEDS_DIR = values.feeds ?? path.join(import.meta.dirname, 'feeds')
189
+ const VIA = values.via ?? repositoryConfig.via // e.g. azure-ai-foundry, aws-bedrock
190
+ const SCOPE = values.scope ?? repositoryConfig.scope
191
+
135
192
  if (!Number.isFinite(DAYS) || !Number.isInteger(DAYS) || DAYS < 0) {
136
193
  console.error('--days must be a finite non-negative integer')
137
194
  return 2
@@ -172,6 +229,10 @@ try {
172
229
  entries: feedData.entries,
173
230
  keys: feedData.keys,
174
231
  includeDocs: INCLUDE_DOCS,
232
+ ignoreModels: repositoryConfig.ignore.models,
233
+ ignorePaths: repositoryConfig.ignore.paths,
234
+ ignoreRoots: configLocation.roots,
235
+ ignoredFiles: configLocation.file ? [configLocation.file] : [],
175
236
  })
176
237
  } catch (e) {
177
238
  console.error(`scan failed: ${e.message}`)
package/lib/config.mjs ADDED
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs'
2
+
3
+ import { MAX_REPORT_BYTES, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS } from './validate-feed.mjs'
4
+
5
+ const frozenArray = () => Object.freeze([])
6
+
7
+ export const DEFAULT_CONFIG = Object.freeze({
8
+ days: 90,
9
+ scope: 'direct',
10
+ via: null,
11
+ feeds: Object.freeze({ allow_vendored_fallback: false }),
12
+ ignore: Object.freeze({ models: frozenArray(), paths: frozenArray() }),
13
+ issues: Object.freeze({ enabled: true }),
14
+ eval: Object.freeze({ command: null, timeout_ms: 600000, max_report_bytes: 65536, pass_env: frozenArray() }),
15
+ })
16
+
17
+ export const CLI_DEFAULT_CONFIG = Object.freeze({
18
+ ...DEFAULT_CONFIG,
19
+ scope: 'all',
20
+ })
21
+
22
+ const copyDefaults = defaults => ({
23
+ days: defaults.days,
24
+ scope: defaults.scope,
25
+ via: defaults.via,
26
+ feeds: { allow_vendored_fallback: defaults.feeds.allow_vendored_fallback },
27
+ ignore: {
28
+ models: [...defaults.ignore.models],
29
+ paths: [...defaults.ignore.paths],
30
+ },
31
+ issues: { enabled: defaults.issues.enabled },
32
+ eval: {
33
+ command: defaults.eval.command,
34
+ timeout_ms: defaults.eval.timeout_ms,
35
+ max_report_bytes: defaults.eval.max_report_bytes,
36
+ pass_env: [...defaults.eval.pass_env],
37
+ },
38
+ })
39
+
40
+ const assert = (condition, message) => {
41
+ if (!condition) throw new Error(`invalid .model-eol.json: ${message}`)
42
+ }
43
+
44
+ const assertKnownKeys = (value, label, allowed) => {
45
+ for (const key of Object.keys(value)) {
46
+ const fullKey = label ? `${label}.${key}` : key
47
+ assert(allowed.has(key), `unknown key "${fullKey}"`)
48
+ }
49
+ }
50
+
51
+ const stringArray = (value, label) => {
52
+ assert(Array.isArray(value), `${label} must be an array`)
53
+ assert(value.every(item => typeof item === 'string' && item.length > 0), `${label} must contain non-empty strings`)
54
+ return value.slice()
55
+ }
56
+
57
+ const CREDENTIAL_ENV_PATTERN = /^(?:GITHUB_|GH_|ACTIONS_|SSH_|AWS_SECRET)|(?:TOKEN|SECRET|PASSWORD|CREDENTIAL)/i
58
+
59
+ export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
60
+ assert(raw && typeof raw === 'object' && !Array.isArray(raw), 'root must be an object')
61
+ assertKnownKeys(raw, '', new Set(['days', 'scope', 'via', 'feeds', 'ignore', 'issues', 'eval']))
62
+ const config = copyDefaults(defaults)
63
+
64
+ if (raw.days !== undefined) {
65
+ assert(Number.isInteger(raw.days) && raw.days >= 0, 'days must be a non-negative integer')
66
+ config.days = raw.days
67
+ }
68
+ if (raw.scope !== undefined) {
69
+ assert(raw.scope === 'all' || raw.scope === 'direct', 'scope must be all or direct')
70
+ config.scope = raw.scope
71
+ }
72
+ if (raw.via !== undefined) {
73
+ assert(raw.via === null || (typeof raw.via === 'string' && raw.via.length > 0), 'via must be null or a non-empty string')
74
+ config.via = raw.via
75
+ }
76
+ if (raw.feeds !== undefined) {
77
+ assert(raw.feeds && typeof raw.feeds === 'object' && !Array.isArray(raw.feeds), 'feeds must be an object')
78
+ assertKnownKeys(raw.feeds, 'feeds', new Set(['allow_vendored_fallback']))
79
+ if (raw.feeds.allow_vendored_fallback !== undefined) {
80
+ assert(typeof raw.feeds.allow_vendored_fallback === 'boolean', 'feeds.allow_vendored_fallback must be boolean')
81
+ config.feeds.allow_vendored_fallback = raw.feeds.allow_vendored_fallback
82
+ }
83
+ }
84
+ if (raw.ignore !== undefined) {
85
+ assert(raw.ignore && typeof raw.ignore === 'object' && !Array.isArray(raw.ignore), 'ignore must be an object')
86
+ assertKnownKeys(raw.ignore, 'ignore', new Set(['models', 'paths']))
87
+ if (raw.ignore.models !== undefined) config.ignore.models = stringArray(raw.ignore.models, 'ignore.models')
88
+ if (raw.ignore.paths !== undefined) config.ignore.paths = stringArray(raw.ignore.paths, 'ignore.paths')
89
+ }
90
+ if (raw.issues !== undefined) {
91
+ assert(raw.issues && typeof raw.issues === 'object' && !Array.isArray(raw.issues), 'issues must be an object')
92
+ assertKnownKeys(raw.issues, 'issues', new Set(['enabled']))
93
+ if (raw.issues.enabled !== undefined) assert(typeof raw.issues.enabled === 'boolean', 'issues.enabled must be boolean')
94
+ if (raw.issues.enabled !== undefined) config.issues.enabled = raw.issues.enabled
95
+ }
96
+ if (raw.eval !== undefined) {
97
+ assert(raw.eval && typeof raw.eval === 'object' && !Array.isArray(raw.eval), 'eval must be an object')
98
+ assertKnownKeys(raw.eval, 'eval', new Set(['command', 'timeout_ms', 'max_report_bytes', 'pass_env']))
99
+ if (raw.eval.command !== undefined) {
100
+ assert(raw.eval.command === null || (typeof raw.eval.command === 'string' && raw.eval.command.length > 0), 'eval.command must be null or a non-empty string')
101
+ config.eval.command = raw.eval.command
102
+ }
103
+ if (raw.eval.timeout_ms !== undefined) {
104
+ assert(Number.isSafeInteger(raw.eval.timeout_ms) && raw.eval.timeout_ms >= MIN_TIMEOUT_MS && raw.eval.timeout_ms <= MAX_TIMEOUT_MS, `eval.timeout_ms must be a safe integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}`)
105
+ config.eval.timeout_ms = raw.eval.timeout_ms
106
+ }
107
+ if (raw.eval.max_report_bytes !== undefined) {
108
+ assert(Number.isSafeInteger(raw.eval.max_report_bytes) && raw.eval.max_report_bytes > 0 && raw.eval.max_report_bytes <= MAX_REPORT_BYTES, `eval.max_report_bytes must be a safe positive integer <= ${MAX_REPORT_BYTES}`)
109
+ config.eval.max_report_bytes = raw.eval.max_report_bytes
110
+ }
111
+ if (raw.eval.pass_env !== undefined) {
112
+ const passEnv = stringArray(raw.eval.pass_env, 'eval.pass_env')
113
+ for (const name of passEnv) assert(!CREDENTIAL_ENV_PATTERN.test(name), `eval.pass_env variable "${name}" is not allowed`)
114
+ config.eval.pass_env = passEnv
115
+ }
116
+ }
117
+
118
+ return config
119
+ }
120
+
121
+ export const loadConfig = (file, { defaults = DEFAULT_CONFIG, allowMissing = true } = {}) => {
122
+ if (!fs.existsSync(file)) {
123
+ if (allowMissing) return normalizeConfig({}, { defaults })
124
+ throw new Error(`could not read config ${file}: file not found`)
125
+ }
126
+ let value
127
+ try {
128
+ value = JSON.parse(fs.readFileSync(file, 'utf8'))
129
+ } catch (error) {
130
+ throw new Error(`could not read config ${file}: ${error.message}`)
131
+ }
132
+ return normalizeConfig(value, { defaults })
133
+ }
package/lib/glob.mjs ADDED
@@ -0,0 +1,35 @@
1
+ const escapeRegExp = value => value.replace(/[.+^${}()|[\]\\]/g, '\\$&')
2
+
3
+ export const normalizeRepoPath = value => String(value ?? '')
4
+ .replaceAll('\\', '/')
5
+ .replace(/^\.\//, '')
6
+ .replace(/^\/+/, '')
7
+
8
+ export const globRegex = pattern => {
9
+ let source = ''
10
+ const value = normalizeRepoPath(pattern)
11
+ for (let i = 0; i < value.length; i++) {
12
+ const char = value[i]
13
+ if (char === '*' && value[i + 1] === '*') {
14
+ if (value[i + 2] === '/') {
15
+ source += '(?:.*/)?'
16
+ i += 2
17
+ } else {
18
+ source += '.*'
19
+ i++
20
+ }
21
+ } else if (char === '*') {
22
+ source += '[^/]*'
23
+ } else if (char === '?') {
24
+ source += '[^/]'
25
+ } else {
26
+ source += escapeRegExp(char)
27
+ }
28
+ }
29
+ return new RegExp(`^${source}$`)
30
+ }
31
+
32
+ export const matchesGlob = (file, pattern) => globRegex(pattern).test(normalizeRepoPath(file))
33
+
34
+ export const matchesAnyGlob = (file, patterns = []) =>
35
+ patterns.some(pattern => matchesGlob(file, pattern))
package/lib/scanner.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { spawnSync } from 'node:child_process'
4
4
 
5
5
  import { buildModelPattern } from './feeds.mjs'
6
+ import { matchesAnyGlob, matchesGlob, normalizeRepoPath } from './glob.mjs'
6
7
 
7
8
  export const CODE_EXT = new Set([
8
9
  '.c', '.cc', '.cjs', '.cpp', '.cs', '.cxx', '.go', '.h', '.hpp', '.java', '.js',
@@ -122,6 +123,7 @@ const classifyRef = ({ file, line, lines, publisher }) => {
122
123
 
123
124
  const isScannable = (file, includeDocs) => {
124
125
  const basename = path.basename(file)
126
+ if (basename === '.model-eol.json') return false
125
127
  if (basename === '.env' || basename.startsWith('.env.')) return true
126
128
  const ext = path.extname(file)
127
129
  return CODE_EXT.has(ext) || (includeDocs && DOC_EXT.has(ext))
@@ -241,17 +243,58 @@ export const addedLinesForTargets = (targets, baseRef) => {
241
243
  export const filterFindingsToChanged = (findings, addedLines) =>
242
244
  findings.filter(finding => addedLines.has(changedKey(finding.file, finding.line)))
243
245
 
244
- const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
246
+ const collectFilesDetailed = (targets, {
247
+ includeDocs = false,
248
+ ignorePaths = [],
249
+ ignoreRoots = [],
250
+ ignoredFiles = [],
251
+ } = {}) => {
245
252
  const files = []
246
253
  const notes = []
247
254
  const validTargets = []
248
255
  const seen = new Set()
256
+ const canonicalPath = value => {
257
+ const resolved = path.resolve(value)
258
+ try {
259
+ return fs.realpathSync(resolved)
260
+ } catch {
261
+ return resolved
262
+ }
263
+ }
264
+ const excludedFiles = new Set(ignoredFiles.map(canonicalPath))
265
+ const roots = ignoreRoots.map(canonicalPath)
249
266
  let fileLimitWarned = false
250
267
  let stopped = false
251
268
 
252
269
  const note = value => notes.push(value)
270
+ const repoPathsFor = absolute => {
271
+ const resolved = canonicalPath(absolute)
272
+ const repoPaths = []
273
+ for (const root of roots) {
274
+ const relative = path.relative(root, resolved)
275
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue
276
+ repoPaths.push(normalizeRepoPath(relative))
277
+ }
278
+ return repoPaths
279
+ }
280
+ const matchesIgnoredRepoPath = repoPath => matchesAnyGlob(repoPath, ignorePaths) ||
281
+ ignorePaths.some(pattern => {
282
+ const normalizedPattern = normalizeRepoPath(pattern).replace(/\/+$/, '')
283
+ return normalizedPattern && !/[*?]/.test(normalizedPattern) && repoPath.startsWith(`${normalizedPattern}/`)
284
+ })
285
+ const isIgnoredFile = absolute => {
286
+ const resolved = canonicalPath(absolute)
287
+ return excludedFiles.has(resolved) || repoPathsFor(resolved).some(matchesIgnoredRepoPath)
288
+ }
289
+ const isIgnoredDirectoryTree = absolute => repoPathsFor(absolute).some(repoPath =>
290
+ ignorePaths.some(pattern => {
291
+ const normalizedPattern = normalizeRepoPath(pattern)
292
+ const literalDirectory = !/[*?]/.test(normalizedPattern) && normalizedPattern.replace(/\/+$/, '') === repoPath
293
+ const subtreeGlob = normalizedPattern.endsWith('/**') && matchesGlob(repoPath, normalizedPattern.slice(0, -3))
294
+ return literalDirectory || subtreeGlob
295
+ }))
253
296
  const addFile = absolute => {
254
- if (stopped || seen.has(absolute)) return
297
+ if (stopped || seen.has(absolute) || isIgnoredFile(absolute)) return
255
298
  seen.add(absolute)
256
299
 
257
300
  let st
@@ -288,7 +331,7 @@ const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
288
331
  }
289
332
 
290
333
  const walk = absolute => {
291
- if (stopped) return
334
+ if (stopped || excludedFiles.has(canonicalPath(absolute))) return
292
335
  let st
293
336
  try {
294
337
  st = fs.lstatSync(absolute)
@@ -301,6 +344,7 @@ const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
301
344
  addFile(absolute)
302
345
  return
303
346
  }
347
+ if (isIgnoredDirectoryTree(absolute)) return
304
348
  if (SKIP_DIRS.has(path.basename(absolute))) return
305
349
  let names
306
350
  try {
@@ -373,11 +417,24 @@ const collectFilesDetailed = (targets, { includeDocs = false } = {}) => {
373
417
 
374
418
  export const collectFiles = (targets, options = {}) => collectFilesDetailed(targets, options).files
375
419
 
376
- export const scanTargets = ({ targets, entries, keys, includeDocs = false }) => {
377
- const collected = collectFilesDetailed(targets, { includeDocs })
420
+ export const scanTargets = ({
421
+ targets,
422
+ entries,
423
+ keys,
424
+ includeDocs = false,
425
+ ignoreModels = [],
426
+ ignorePaths = [],
427
+ ignoreRoots = [],
428
+ ignoredFiles = [],
429
+ }) => {
430
+ const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles })
378
431
  if (collected.validTargets.length === 0) throw new Error('no valid targets remain')
379
432
  const { files } = collected
380
433
  const pattern = buildModelPattern(keys)
434
+ const ignoredModelKeys = new Set(ignoreModels)
435
+ const ignoredCanonicalIds = new Set(ignoreModels.map(key => entries.get(key)?.entry.id ?? key))
436
+ const ignoresModel = (matched, id = null) =>
437
+ ignoredModelKeys.has(matched) || (id !== null && ignoredCanonicalIds.has(id))
381
438
  const modelRefs = []
382
439
  const candidateRefs = []
383
440
  const integrationHints = []
@@ -424,6 +481,7 @@ export const scanTargets = ({ targets, entries, keys, includeDocs = false }) =>
424
481
  let m
425
482
  while ((m = pattern.exec(lineText)) !== null) {
426
483
  const record = entries.get(m[1])
484
+ if (ignoresModel(m[1], record.entry.id)) continue
427
485
  const occurrence = [...lineText.matchAll(new RegExp(escapeRegExp(m[1]), 'g'))]
428
486
  .findIndex(match => match.index === m.index)
429
487
  const classification = classifyRef({
@@ -453,6 +511,7 @@ export const scanTargets = ({ targets, entries, keys, includeDocs = false }) =>
453
511
  }
454
512
 
455
513
  for (const candidate of findCandidates(lineText, entries)) {
514
+ if (ignoresModel(candidate)) continue
456
515
  const classification = classifyRef({
457
516
  file,
458
517
  line: i + 1,
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.2.4",
4
- "description": "A machine-readable deprecation feed format for AI models, plus a zero-dependency checker and inventory CLI.",
3
+ "version": "0.3.0",
4
+ "description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "model-eol": "check.mjs"
7
+ "model-eol": "check.mjs",
8
+ "model-eol-bot": "bot/bot.mjs"
8
9
  },
9
10
  "scripts": {
10
- "test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.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 bot/test/package.mjs && node scripts/test-feed-changelog.mjs"
11
12
  },
12
13
  "repository": {
13
14
  "type": "git",
@@ -31,7 +32,10 @@
31
32
  ],
32
33
  "files": [
33
34
  "check.mjs",
35
+ "bot.yml.example",
34
36
  "lib/",
37
+ "bot/bot.mjs",
38
+ "bot/lib/",
35
39
  "feeds/",
36
40
  "schema/",
37
41
  "SPEC.md",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://example.invalid/model-eol/0.1/bot-config.schema.json",
4
- "title": "model-eol bot configuration",
5
- "description": "Optional .model-eol.json configuration for the zero-dependency GitHub bot adapter.",
4
+ "title": "model-eol repository configuration",
5
+ "description": "Optional strict .model-eol.json configuration shared by the zero-dependency CLI, Action, and GitHub bot adapter.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
@@ -15,10 +15,11 @@
15
15
  "scope": {
16
16
  "enum": ["direct", "all"],
17
17
  "default": "direct",
18
- "description": "Use direct API references only, or include generic model references."
18
+ "description": "Use the direct-first scope (direct API plus generic model references, leaving cloud/gateway references as hints), or include all tracked references."
19
19
  },
20
20
  "via": {
21
21
  "type": ["string", "null"],
22
+ "minLength": 1,
22
23
  "default": null,
23
24
  "description": "Optional distributor lifecycle clock, such as azure-ai-foundry."
24
25
  },
@@ -42,13 +43,13 @@
42
43
  "type": "array",
43
44
  "items": { "type": "string", "minLength": 1 },
44
45
  "default": [],
45
- "description": "Canonical IDs or aliases to ignore. A canonical ID ignores all of its aliases."
46
+ "description": "Canonical IDs or aliases to ignore across CLI, Action, and bot. Either form ignores every alias of the canonical feed entry."
46
47
  },
47
48
  "paths": {
48
49
  "type": "array",
49
50
  "items": { "type": "string", "minLength": 1 },
50
51
  "default": [],
51
- "description": "Repo-relative simple globs supporting *, **, and ?."
52
+ "description": "Repo-relative simple globs supporting *, **, and ?. Matching paths are excluded before file coverage checks where possible."
52
53
  }
53
54
  },
54
55
  "default": { "models": [], "paths": [] }
@@ -71,17 +72,20 @@
71
72
  "properties": {
72
73
  "command": {
73
74
  "type": ["string", "null"],
75
+ "minLength": 1,
74
76
  "default": null,
75
- "description": "Opt-in command run in the temporary clone for changed patches."
77
+ "description": "Opt-in evaluation command. Direct bot --eval runs it per changed patch in a temporary clone; the split example workflow runs it once as a read-only plan preflight."
76
78
  },
77
79
  "timeout_ms": {
78
80
  "type": "integer",
79
- "minimum": 1,
81
+ "minimum": 1000,
82
+ "maximum": 3600000,
80
83
  "default": 600000
81
84
  },
82
85
  "max_report_bytes": {
83
86
  "type": "integer",
84
- "minimum": 0,
87
+ "minimum": 1,
88
+ "maximum": 8388608,
85
89
  "default": 65536
86
90
  },
87
91
  "pass_env": {