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 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: flag only lines THIS change adds relative to the base ref
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 fails only when *this* change adds a model that is
148
- retired or retiring within the threshold. Add `via: aws-bedrock` (or
149
- `azure-ai-foundry`) to judge by a distributor's clock.
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 { addedLinesForTargets, filterFindingsToChanged, gitRootFor, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
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, answer the PR CI question: "this PR ADDS a
172
- dependency on a model that already has a death date".
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 allowedVias = new Set([...BUILTIN_CHANNELS, ...feedData.vias])
370
- const unknownVia = [values.via, ...configuredVias(repositoryConfig)]
371
- .find(via => via && !allowedVias.has(via))
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 policyCache = new Map()
383
- const policyForFile = file => {
384
- const absolute = path.resolve(file)
385
- if (!policyCache.has(absolute)) {
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 = scanTargets({
457
+ ({ scan, findings, checkFindings } = evaluateTargets({
403
458
  targets,
404
- entries: feedData.entries,
405
- keys: feedData.keys,
406
- includeDocs: INCLUDE_DOCS,
407
- ignoreRoots: configLocation.roots,
459
+ config: repositoryConfig,
460
+ roots: configLocation.roots,
408
461
  ignoredFiles: configLocation.file ? [configLocation.file] : [],
409
- policyForFile,
410
- routeForReference,
411
- mappedRoutesForFile,
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 findings = scan.modelRefs.map(ref => {
430
- const policy = policyForFile(ref.file)
431
- const route = routeForReference({ file: ref.file, matched: ref.matched, id: ref.id })
432
- const days = values.days === undefined ? policy.days : DAYS
433
- const scope = values.scope === undefined ? policy.scope : SCOPE
434
- const via = values.via === undefined ? (route?.via ?? policy.via) : VIA
435
- const finding = findingFromRef(ref, { days, via })
436
- finding.effective_scope = scope
437
- finding.requested_via = via
438
- if (policy.override_indexes.length || route) {
439
- finding.policy_provenance = {
440
- override_indexes: policy.override_indexes,
441
- route_index: route?.index ?? null,
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
- return finding
445
- })
446
- for (const candidate of scan.candidateRefs) {
447
- const policy = policyForFile(candidate.file)
448
- candidate.effective_scope = values.scope === undefined ? policy.scope : SCOPE
449
- if (policy.override_indexes.length) {
450
- candidate.policy_provenance = { override_indexes: policy.override_indexes, route_index: null }
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
- const checkFindings = findings.filter(finding =>
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
- changedFindings = filterFindingsToChanged(checkFindings, addedLinesForTargets(targets, CHANGED_BASE))
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 a model that is retired or
5
- # retiring within the threshold (existing references stay out of the way)
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
  #