model-eol 0.2.3 → 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,8 +34,46 @@ 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'
36
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
+ }
75
+
76
+ const main = () => {
37
77
  const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'help'])
38
78
  const args = process.argv.slice(2)
39
79
 
@@ -45,6 +85,7 @@ try {
45
85
  args,
46
86
  options: {
47
87
  days: { type: 'string' },
88
+ config: { type: 'string' },
48
89
  feeds: { type: 'string' },
49
90
  via: { type: 'string' },
50
91
  scope: { type: 'string' },
@@ -62,16 +103,12 @@ try {
62
103
  })
63
104
  } catch (error) {
64
105
  console.error(error.message)
65
- process.exit(2)
106
+ return 2
66
107
  }
67
108
 
68
109
  const { values, positionals } = parsed
69
110
  if (values.help) command = 'help'
70
111
 
71
- const DAYS = Number(values.days ?? '90')
72
- const FEEDS_DIR = values.feeds ?? path.join(import.meta.dirname, 'feeds')
73
- const VIA = values.via ?? null // e.g. azure-ai-foundry, aws-bedrock
74
- const SCOPE = values.scope ?? 'all'
75
112
  const FORMAT = values.format ?? null
76
113
  const PLAN_FILE = values.plan ?? null
77
114
  const AS_JSON = values.json ?? false
@@ -85,12 +122,12 @@ if (command === 'help') {
85
122
  console.log(`model-eol
86
123
 
87
124
  Usage:
88
- node check.mjs [paths...] [--days N] [--feeds DIR] [--via DISTRIBUTOR] [--scope all|direct] [--json] [--include-docs] [--allow-incomplete]
89
- node check.mjs check [paths...] [--days N] [--scope all|direct] [--json] [--changed BASE_REF] [--allow-incomplete]
90
- node check.mjs inventory [paths...] [--json] [--format json|cyclonedx|text]
91
- node check.mjs schedule [paths...] [--days N] [--json]
92
- node check.mjs alert [paths...] [--days N] [--scope all|direct] [--format github|markdown|badge] [--json]
93
- 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]
94
131
  node check.mjs apply --plan plan.json [--dry-run]
95
132
 
96
133
  Commands:
@@ -107,43 +144,70 @@ Commands:
107
144
  Scopes:
108
145
  all Check every tracked model ID found. This preserves the original behavior.
109
146
  direct Check direct API and generic model references; leave cloud/gateway refs in inventory.
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
+
152
+ Exit codes:
153
+ 0 Clean, or report generated successfully.
154
+ 1 Findings, alert errors, or refused apply items.
155
+ 2 Usage, feed, scan, or plan errors.
156
+ 3 Output stream failure other than EPIPE.
110
157
  `)
111
- process.exit(0)
158
+ return 0
112
159
  }
113
160
 
114
161
  if (command === 'apply') {
115
162
  if (!PLAN_FILE || positionals.length) {
116
163
  console.error('apply requires --plan plan.json and accepts no target paths')
117
- process.exit(2)
164
+ return 2
118
165
  }
119
166
  try {
120
167
  const result = applyPlan({ planPath: PLAN_FILE, dryRun: DRY_RUN, rootDir: process.cwd() })
121
- process.exit(result.failed ? 1 : 0)
168
+ return result.failed ? 1 : 0
122
169
  } catch (e) {
123
170
  console.error(`failed to apply plan ${PLAN_FILE}: ${e.message}`)
124
- process.exit(2)
171
+ return 2
125
172
  }
126
173
  }
127
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
+
128
192
  if (!Number.isFinite(DAYS) || !Number.isInteger(DAYS) || DAYS < 0) {
129
193
  console.error('--days must be a finite non-negative integer')
130
- process.exit(2)
194
+ return 2
131
195
  }
132
196
  if (!['all', 'direct'].includes(SCOPE)) {
133
197
  console.error('--scope must be all or direct')
134
- process.exit(2)
198
+ return 2
135
199
  }
136
200
  if (CHANGED_BASE !== null && command !== 'check') {
137
201
  console.error('--changed is only supported by the check command')
138
- process.exit(2)
202
+ return 2
139
203
  }
140
204
  if (command === 'alert' && FORMAT !== null && !['github', 'markdown', 'badge', 'json'].includes(FORMAT)) {
141
205
  console.error('--format must be github, markdown, badge, or json for alert')
142
- process.exit(2)
206
+ return 2
143
207
  }
144
208
  if (command === 'inventory' && FORMAT !== null && !['json', 'cyclonedx', 'text'].includes(FORMAT)) {
145
209
  console.error('--format must be json, cyclonedx, or text for inventory')
146
- process.exit(2)
210
+ return 2
147
211
  }
148
212
 
149
213
  let feedData
@@ -151,11 +215,11 @@ try {
151
215
  feedData = loadFeeds(FEEDS_DIR)
152
216
  } catch (e) {
153
217
  console.error(`failed to load feeds from ${FEEDS_DIR}: ${e.message}`)
154
- process.exit(2)
218
+ return 2
155
219
  }
156
220
  if (feedData.entries.size === 0) {
157
221
  console.error(`no feed entries loaded from ${FEEDS_DIR}`)
158
- process.exit(2)
222
+ return 2
159
223
  }
160
224
 
161
225
  let scan
@@ -165,10 +229,14 @@ try {
165
229
  entries: feedData.entries,
166
230
  keys: feedData.keys,
167
231
  includeDocs: INCLUDE_DOCS,
232
+ ignoreModels: repositoryConfig.ignore.models,
233
+ ignorePaths: repositoryConfig.ignore.paths,
234
+ ignoreRoots: configLocation.roots,
235
+ ignoredFiles: configLocation.file ? [configLocation.file] : [],
168
236
  })
169
237
  } catch (e) {
170
238
  console.error(`scan failed: ${e.message}`)
171
- process.exit(2)
239
+ return 2
172
240
  }
173
241
 
174
242
  const incompleteNotes = incompleteScanNotes(scan.notes)
@@ -179,23 +247,22 @@ if (incompleteNotes.length && (command === 'check' || command === 'plan')) {
179
247
  const location = note.file ? ` (${note.file})` : ''
180
248
  console.error(` ${note.reason}${location}${note.message ? `: ${note.message}` : ''}`)
181
249
  }
182
- if (!ALLOW_INCOMPLETE) process.exit(2)
250
+ if (!ALLOW_INCOMPLETE) return 2
183
251
  }
184
252
 
185
253
  const findings = scan.modelRefs.map(ref => findingFromRef(ref, { days: DAYS, via: VIA }))
186
254
  const checkFindings = SCOPE === 'direct'
187
255
  ? findings.filter(f => f.usage === 'direct-api' || f.usage === 'model-reference')
188
256
  : findings
189
- const changedFindings = CHANGED_BASE === null
190
- ? checkFindings
191
- : (() => {
192
- try {
193
- return filterFindingsToChanged(checkFindings, addedLinesForTargets(targets, CHANGED_BASE))
194
- } catch (e) {
195
- console.error(`--changed failed: ${e.message}`)
196
- process.exit(2)
197
- }
198
- })()
257
+ let changedFindings = checkFindings
258
+ if (CHANGED_BASE !== null) {
259
+ try {
260
+ changedFindings = filterFindingsToChanged(checkFindings, addedLinesForTargets(targets, CHANGED_BASE))
261
+ } catch (e) {
262
+ console.error(`--changed failed: ${e.message}`)
263
+ return 2
264
+ }
265
+ }
199
266
  const bad = changedFindings.filter(isBad)
200
267
  const inventory = () => buildInventory({ scan, findings, days: DAYS, via: VIA, scope: SCOPE, targets })
201
268
  const schedule = () => buildSchedule(inventory())
@@ -210,7 +277,7 @@ if (command === 'plan') {
210
277
  via: VIA,
211
278
  scope: SCOPE,
212
279
  }), null, 2))
213
- process.exit(0)
280
+ return 0
214
281
  }
215
282
 
216
283
  if (command === 'check') {
@@ -219,7 +286,7 @@ if (command === 'check') {
219
286
  } else {
220
287
  console.log(formatCheck({ findings: changedFindings, bad, scannedFiles: scan.files.length, days: DAYS, scope: SCOPE }))
221
288
  }
222
- process.exit(bad.length ? 1 : 0)
289
+ return bad.length ? 1 : 0
223
290
  }
224
291
 
225
292
  if (command === 'inventory') {
@@ -232,7 +299,7 @@ if (command === 'inventory') {
232
299
  } else {
233
300
  console.log(formatInventory(inv, DAYS))
234
301
  }
235
- process.exit(0)
302
+ return 0
236
303
  }
237
304
 
238
305
  if (command === 'schedule') {
@@ -242,7 +309,7 @@ if (command === 'schedule') {
242
309
  } else {
243
310
  console.log(formatSchedule(sched, DAYS))
244
311
  }
245
- process.exit(0)
312
+ return 0
246
313
  }
247
314
 
248
315
  if (command === 'alert') {
@@ -256,8 +323,46 @@ if (command === 'alert') {
256
323
  } else {
257
324
  console.log(formatAlertGithub(payload, DAYS))
258
325
  }
259
- process.exit(payload.errors.length ? 1 : 0)
326
+ return payload.errors.length ? 1 : 0
260
327
  }
261
328
 
262
329
  console.error(`unknown command: ${command}`)
263
- process.exit(2)
330
+ return 2
331
+ }
332
+
333
+ const writeAndCapture = (stream, chunk) => new Promise(resolve => {
334
+ let error = null
335
+ let settling = false
336
+ const settle = nextError => {
337
+ if (nextError && !error) error = nextError
338
+ if (settling) return
339
+ settling = true
340
+ setImmediate(() => {
341
+ stream.off('error', onError)
342
+ resolve(error)
343
+ })
344
+ }
345
+ const onError = nextError => settle(nextError)
346
+ stream.on('error', onError)
347
+ try {
348
+ stream.write(chunk, settle)
349
+ } catch (writeError) {
350
+ settle(writeError)
351
+ }
352
+ })
353
+ const drainStream = stream => writeAndCapture(stream, '')
354
+
355
+ const exitCode = main()
356
+ const [stdoutError, stderrError] = await Promise.all([
357
+ drainStream(process.stdout),
358
+ drainStream(process.stderr),
359
+ ])
360
+ const outputError = [stdoutError, stderrError].find(error => error && error.code !== 'EPIPE')
361
+ let finalExitCode = exitCode
362
+ if (exitCode === 0 && outputError) {
363
+ finalExitCode = 3
364
+ const diagnosticStream = stderrError ? (stdoutError ? null : process.stdout) : process.stderr
365
+ const detail = String(outputError.code ?? outputError.message ?? 'unknown error').replace(/[\r\n]+/g, ' ')
366
+ if (diagnosticStream) await writeAndCapture(diagnosticStream, `model-eol: output failed: ${detail}\n`)
367
+ }
368
+ process.exit(finalExitCode)
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))