model-eol 0.9.1 → 0.10.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/README.md +8 -4
- package/check.mjs +153 -62
- package/examples/workflows/model-eol.yml +3 -2
- package/lib/changed.mjs +627 -0
- package/lib/config.mjs +12 -7
- package/lib/glob.mjs +11 -3
- package/lib/reports.mjs +24 -1
- package/lib/scanner.mjs +98 -20
- package/package.json +2 -2
- package/schema/model-eol.check.schema.json +53 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ node check.mjs path/to/your/repo --days 90
|
|
|
73
73
|
<summary><b>Every command</b> - PR gate, distributor clocks, SBOM, badges, migration plans</summary>
|
|
74
74
|
|
|
75
75
|
```sh
|
|
76
|
-
# PR gate:
|
|
76
|
+
# PR gate: report only findings THIS change adds or makes newly actionable
|
|
77
77
|
node check.mjs check . --changed origin/main --days 90
|
|
78
78
|
# the same repo judged by a distributor's clock instead of the publisher's
|
|
79
79
|
node check.mjs path/to/your/repo --via aws-bedrock
|
|
@@ -144,9 +144,13 @@ the `v0` line:
|
|
|
144
144
|
scope: direct
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
This is the PR gate. It
|
|
148
|
-
retired or retiring within the threshold
|
|
149
|
-
|
|
147
|
+
This is the PR gate. It reports the findings *this* change adds or makes newly
|
|
148
|
+
actionable, and fails when one is retired or retiring within the threshold: a
|
|
149
|
+
model on an added line, or an unchanged reference the change makes bad (a
|
|
150
|
+
`.model-eol.json` edit, nearby SDK or cloud code, a rename, an in-repo feeds
|
|
151
|
+
change, an untracked file). References already bad at the base ref stay out of
|
|
152
|
+
the way. Add `via: aws-bedrock` (or `azure-ai-foundry`) to judge by a
|
|
153
|
+
distributor's clock.
|
|
150
154
|
|
|
151
155
|
A copy-paste workflow with both gates, the PR diff and a weekly full-repository
|
|
152
156
|
check, is in
|
package/check.mjs
CHANGED
|
@@ -48,7 +48,8 @@ import {
|
|
|
48
48
|
formatInventoryCycloneDX,
|
|
49
49
|
formatSchedule,
|
|
50
50
|
} from './lib/reports.mjs'
|
|
51
|
-
import {
|
|
51
|
+
import { BaseUnavailableError, inCheckScope, selectChangedFindings } from './lib/changed.mjs'
|
|
52
|
+
import { gitRootFor, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
|
|
52
53
|
import { DOCUMENT_TYPES, formatDocumentErrors, validateDocumentFile } from './lib/validate-document.mjs'
|
|
53
54
|
import { SUPPORTED_RECEIPT_SCHEMAS, VERIFY_INPUTS, VerifyError, formatVerification, printable, verifyReceipt } from './lib/verify.mjs'
|
|
54
55
|
|
|
@@ -98,6 +99,76 @@ const repoPathForFile = (file, roots) => {
|
|
|
98
99
|
return normalizeRepoPath(candidates[0]?.relative ?? absolute)
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
const policyError = (config, feedData, via) => {
|
|
103
|
+
const allowedVias = new Set([...BUILTIN_CHANNELS, ...feedData.vias])
|
|
104
|
+
const unknownVia = [via, ...configuredVias(config)]
|
|
105
|
+
.find(value => value && !allowedVias.has(value))
|
|
106
|
+
if (unknownVia) return `unknown lifecycle channel "${unknownVia}"; known values: ${[...allowedVias].sort().join(', ')}`
|
|
107
|
+
const unresolvedRoute = config.routes.find(route => route.model && !feedData.entries.has(route.model))
|
|
108
|
+
if (unresolvedRoute) return `configured route model "${unresolvedRoute.model}" is not present in loaded feeds`
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const evaluateTargets = ({ targets, config, roots, ignoredFiles, feedData, overrides, includeDocs, noGit = false, classificationPathFor = null }) => {
|
|
113
|
+
const policyCache = new Map()
|
|
114
|
+
const policyForFile = file => {
|
|
115
|
+
const absolute = path.resolve(file)
|
|
116
|
+
if (!policyCache.has(absolute)) {
|
|
117
|
+
const repoPath = repoPathForFile(absolute, roots)
|
|
118
|
+
policyCache.set(absolute, { ...configForRepoPath(config, repoPath), repoPath })
|
|
119
|
+
}
|
|
120
|
+
return policyCache.get(absolute)
|
|
121
|
+
}
|
|
122
|
+
const routeForReference = ({ file, matched, id }) => {
|
|
123
|
+
const policy = policyForFile(file)
|
|
124
|
+
return routeForRepoReference(config, policy.repoPath, { matched, id })
|
|
125
|
+
}
|
|
126
|
+
const mappedRoutesForFile = file => {
|
|
127
|
+
const policy = policyForFile(file)
|
|
128
|
+
return mappedRoutesForRepoPath(config, policy.repoPath)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const scan = scanTargets({
|
|
132
|
+
targets,
|
|
133
|
+
entries: feedData.entries,
|
|
134
|
+
keys: feedData.keys,
|
|
135
|
+
includeDocs,
|
|
136
|
+
ignoreRoots: roots,
|
|
137
|
+
ignoredFiles,
|
|
138
|
+
policyForFile,
|
|
139
|
+
routeForReference,
|
|
140
|
+
mappedRoutesForFile,
|
|
141
|
+
noGit,
|
|
142
|
+
classificationPathFor,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const findings = scan.modelRefs.map(ref => {
|
|
146
|
+
const policy = policyForFile(ref.file)
|
|
147
|
+
const route = routeForReference({ file: ref.file, matched: ref.matched, id: ref.id })
|
|
148
|
+
const days = overrides.days ?? policy.days
|
|
149
|
+
const scope = overrides.scope ?? policy.scope
|
|
150
|
+
const via = overrides.via ?? (route?.via ?? policy.via)
|
|
151
|
+
const finding = findingFromRef(ref, { days, via })
|
|
152
|
+
finding.effective_scope = scope
|
|
153
|
+
finding.requested_via = via
|
|
154
|
+
if (policy.override_indexes.length || route) {
|
|
155
|
+
finding.policy_provenance = {
|
|
156
|
+
override_indexes: policy.override_indexes,
|
|
157
|
+
route_index: route?.index ?? null,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return finding
|
|
161
|
+
})
|
|
162
|
+
for (const candidate of scan.candidateRefs) {
|
|
163
|
+
const policy = policyForFile(candidate.file)
|
|
164
|
+
candidate.effective_scope = overrides.scope ?? policy.scope
|
|
165
|
+
if (policy.override_indexes.length) {
|
|
166
|
+
candidate.policy_provenance = { override_indexes: policy.override_indexes, route_index: null }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { scan, findings, checkFindings: findings.filter(inCheckScope) }
|
|
170
|
+
}
|
|
171
|
+
|
|
101
172
|
const main = () => {
|
|
102
173
|
const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'validate', 'verify', 'help'])
|
|
103
174
|
const args = process.argv.slice(2)
|
|
@@ -168,8 +239,10 @@ Usage:
|
|
|
168
239
|
|
|
169
240
|
Commands:
|
|
170
241
|
check Fail when tracked model IDs are retired or retiring within --days.
|
|
171
|
-
With --changed BASE_REF,
|
|
172
|
-
|
|
242
|
+
With --changed BASE_REF, report findings this change adds or makes newly
|
|
243
|
+
actionable: every reference on an added line, and unchanged references that
|
|
244
|
+
a config, feeds, rename, nearby-code, or untracked-file change made bad.
|
|
245
|
+
References already bad at BASE_REF are not reported.
|
|
173
246
|
inventory List tracked model references plus direct/cloud/gateway integration hints.
|
|
174
247
|
--format json is the machine-readable default; --format cyclonedx emits a CycloneDX 1.6 ML-BOM.
|
|
175
248
|
schedule Show the retirement schedule for tracked references in the target.
|
|
@@ -366,50 +439,30 @@ if (feedData.entries.size === 0) {
|
|
|
366
439
|
return 2
|
|
367
440
|
}
|
|
368
441
|
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
.
|
|
372
|
-
if (unknownVia) {
|
|
373
|
-
console.error(`unknown lifecycle channel "${unknownVia}"; known values: ${[...allowedVias].sort().join(', ')}`)
|
|
374
|
-
return 2
|
|
375
|
-
}
|
|
376
|
-
const unresolvedRoute = repositoryConfig.routes.find(route => route.model && !feedData.entries.has(route.model))
|
|
377
|
-
if (unresolvedRoute) {
|
|
378
|
-
console.error(`configured route model "${unresolvedRoute.model}" is not present in loaded feeds`)
|
|
442
|
+
const headPolicyError = policyError(repositoryConfig, feedData, values.via)
|
|
443
|
+
if (headPolicyError) {
|
|
444
|
+
console.error(headPolicyError)
|
|
379
445
|
return 2
|
|
380
446
|
}
|
|
381
447
|
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const repoPath = repoPathForFile(absolute, configLocation.roots)
|
|
387
|
-
policyCache.set(absolute, { ...configForRepoPath(repositoryConfig, repoPath), repoPath })
|
|
388
|
-
}
|
|
389
|
-
return policyCache.get(absolute)
|
|
448
|
+
const overrides = {
|
|
449
|
+
days: values.days === undefined ? undefined : DAYS,
|
|
450
|
+
scope: values.scope === undefined ? undefined : SCOPE,
|
|
451
|
+
via: values.via === undefined ? undefined : VIA,
|
|
390
452
|
}
|
|
391
|
-
const routeForReference = ({ file, matched, id }) => {
|
|
392
|
-
const policy = policyForFile(file)
|
|
393
|
-
return routeForRepoReference(repositoryConfig, policy.repoPath, { matched, id })
|
|
394
|
-
}
|
|
395
|
-
const mappedRoutesForFile = file => {
|
|
396
|
-
const policy = policyForFile(file)
|
|
397
|
-
return mappedRoutesForRepoPath(repositoryConfig, policy.repoPath)
|
|
398
|
-
}
|
|
399
|
-
|
|
400
453
|
let scan
|
|
454
|
+
let findings
|
|
455
|
+
let checkFindings
|
|
401
456
|
try {
|
|
402
|
-
scan =
|
|
457
|
+
({ scan, findings, checkFindings } = evaluateTargets({
|
|
403
458
|
targets,
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
includeDocs: INCLUDE_DOCS,
|
|
407
|
-
ignoreRoots: configLocation.roots,
|
|
459
|
+
config: repositoryConfig,
|
|
460
|
+
roots: configLocation.roots,
|
|
408
461
|
ignoredFiles: configLocation.file ? [configLocation.file] : [],
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
})
|
|
462
|
+
feedData,
|
|
463
|
+
overrides,
|
|
464
|
+
includeDocs: INCLUDE_DOCS,
|
|
465
|
+
}))
|
|
413
466
|
} catch (e) {
|
|
414
467
|
console.error(`scan failed: ${e.message}`)
|
|
415
468
|
return 2
|
|
@@ -426,40 +479,78 @@ if (incompleteNotes.length && (command === 'check' || command === 'plan')) {
|
|
|
426
479
|
if (!ALLOW_INCOMPLETE) return 2
|
|
427
480
|
}
|
|
428
481
|
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
482
|
+
const loadBase = ({ configFile, feedsDir }) => {
|
|
483
|
+
let config = repositoryConfig
|
|
484
|
+
let baseFeeds = feedData
|
|
485
|
+
try {
|
|
486
|
+
if (configFile === null) config = normalizeConfig({}, { defaults: CLI_DEFAULT_CONFIG })
|
|
487
|
+
else if (configFile !== undefined) config = loadConfig(configFile, { defaults: DEFAULT_CONFIG, allowMissing: false })
|
|
488
|
+
} catch (e) {
|
|
489
|
+
throw new BaseUnavailableError(`failed to load base config: ${e.message}`)
|
|
490
|
+
}
|
|
491
|
+
if (feedsDir !== undefined) {
|
|
492
|
+
try {
|
|
493
|
+
baseFeeds = loadFeeds(feedsDir)
|
|
494
|
+
} catch (e) {
|
|
495
|
+
throw new BaseUnavailableError(`failed to load base feeds: ${e.message}`)
|
|
442
496
|
}
|
|
497
|
+
if (baseFeeds.entries.size === 0) throw new BaseUnavailableError('no feed entries loaded from base feeds')
|
|
443
498
|
}
|
|
444
|
-
|
|
445
|
-
})
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
499
|
+
const basePolicyError = policyError(config, baseFeeds, values.via)
|
|
500
|
+
if (basePolicyError) throw new BaseUnavailableError(`base policy: ${basePolicyError}`)
|
|
501
|
+
return { config, feeds: baseFeeds }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const evaluateBase = ({ treeRoot, files, configFile, feedsDir, inputs, classificationPathFor }) => {
|
|
505
|
+
const { config, feeds } = inputs ?? loadBase({ configFile, feedsDir })
|
|
506
|
+
try {
|
|
507
|
+
const base = evaluateTargets({
|
|
508
|
+
targets: files,
|
|
509
|
+
config,
|
|
510
|
+
roots: [treeRoot],
|
|
511
|
+
ignoredFiles: configFile ? [configFile] : [],
|
|
512
|
+
feedData: feeds,
|
|
513
|
+
overrides,
|
|
514
|
+
includeDocs: INCLUDE_DOCS,
|
|
515
|
+
noGit: true,
|
|
516
|
+
classificationPathFor,
|
|
517
|
+
})
|
|
518
|
+
return { findings: base.findings, notes: base.scan.notes }
|
|
519
|
+
} catch (e) {
|
|
520
|
+
throw new BaseUnavailableError(`base scan failed: ${e.message}`)
|
|
451
521
|
}
|
|
452
522
|
}
|
|
453
|
-
|
|
454
|
-
finding.effective_scope !== 'direct' || finding.usage === 'direct-api' || finding.usage === 'model-reference')
|
|
523
|
+
|
|
455
524
|
let changedFindings = checkFindings
|
|
525
|
+
let changedStats
|
|
456
526
|
if (CHANGED_BASE !== null) {
|
|
457
527
|
try {
|
|
458
|
-
|
|
528
|
+
const selection = selectChangedFindings({
|
|
529
|
+
targets,
|
|
530
|
+
baseRef: CHANGED_BASE,
|
|
531
|
+
findings,
|
|
532
|
+
checkFindings,
|
|
533
|
+
scannedFiles: scan.files,
|
|
534
|
+
configFile: configLocation.file,
|
|
535
|
+
feedsDir: FEEDS_DIR,
|
|
536
|
+
head: { config: repositoryConfig, feeds: feedData, overrides, repoPathFor: file => repoPathForFile(file, configLocation.roots) },
|
|
537
|
+
loadBase,
|
|
538
|
+
evaluateBase,
|
|
539
|
+
})
|
|
540
|
+
changedFindings = selection.findings
|
|
541
|
+
changedStats = selection.stats
|
|
459
542
|
} catch (e) {
|
|
460
543
|
console.error(`--changed failed: ${e.message}`)
|
|
461
544
|
return 2
|
|
462
545
|
}
|
|
546
|
+
const statsFile = process.env.MODEL_EOL_CHANGED_STATS
|
|
547
|
+
if (statsFile) {
|
|
548
|
+
try {
|
|
549
|
+
fs.writeFileSync(statsFile, `${JSON.stringify({ schema: 'model-eol/changed-stats@0', ...changedStats, total_ms: Math.round(performance.now()) })}\n`)
|
|
550
|
+
} catch (e) {
|
|
551
|
+
console.error(printable(`model-eol: warning: could not write --changed stats to ${statsFile}: ${e.message}`))
|
|
552
|
+
}
|
|
553
|
+
}
|
|
463
554
|
}
|
|
464
555
|
const bad = changedFindings.filter(isBad)
|
|
465
556
|
const inventory = () => buildInventory({ scan, findings, days: DAYS, via: VIA, scope: SCOPE, targets })
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# model-eol - copy this file to .github/workflows/model-eol.yml in YOUR repo.
|
|
2
2
|
#
|
|
3
3
|
# Two gates:
|
|
4
|
-
# - every PR: fail only if THIS change adds
|
|
5
|
-
# retiring within the threshold (
|
|
4
|
+
# - every PR: fail only if THIS change adds or makes newly actionable a model
|
|
5
|
+
# that is retired or retiring within the threshold (references that were
|
|
6
|
+
# already bad at the base stay out of the way)
|
|
6
7
|
# - weekly: full-repository check, so legacy references surface on a schedule
|
|
7
8
|
# instead of in an outage
|
|
8
9
|
#
|