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.
- package/README.md +74 -20
- package/action.yml +124 -9
- package/bot/bot.mjs +978 -0
- package/bot/lib/common.mjs +120 -0
- package/bot/lib/config.mjs +1 -0
- package/bot/lib/eval.mjs +97 -0
- package/bot/lib/feeds.mjs +48 -0
- package/bot/lib/git.mjs +128 -0
- package/bot/lib/github.mjs +196 -0
- package/bot.yml.example +112 -0
- package/check.mjs +73 -12
- package/lib/config.mjs +133 -0
- package/lib/glob.mjs +35 -0
- package/lib/scanner.mjs +64 -5
- package/package.json +8 -4
- package/schema/model-eol.bot-config.schema.json +12 -8
package/bot.yml.example
ADDED
|
@@ -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, {
|
|
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 = ({
|
|
377
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
|
5
|
-
"description": "Optional .model-eol.json configuration
|
|
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
|
|
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.
|
|
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
|
|
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":
|
|
81
|
+
"minimum": 1000,
|
|
82
|
+
"maximum": 3600000,
|
|
80
83
|
"default": 600000
|
|
81
84
|
},
|
|
82
85
|
"max_report_bytes": {
|
|
83
86
|
"type": "integer",
|
|
84
|
-
"minimum":
|
|
87
|
+
"minimum": 1,
|
|
88
|
+
"maximum": 8388608,
|
|
85
89
|
"default": 65536
|
|
86
90
|
},
|
|
87
91
|
"pass_env": {
|