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/lib/glob.mjs CHANGED
@@ -29,7 +29,15 @@ export const globRegex = pattern => {
29
29
  return new RegExp(`^${source}$`)
30
30
  }
31
31
 
32
- export const matchesGlob = (file, pattern) => globRegex(pattern).test(normalizeRepoPath(file))
32
+ const compiledGlobs = new Map()
33
+ const compiledGlob = pattern => {
34
+ if (!compiledGlobs.has(pattern)) compiledGlobs.set(pattern, globRegex(pattern))
35
+ return compiledGlobs.get(pattern)
36
+ }
37
+
38
+ export const matchesGlob = (file, pattern) => compiledGlob(pattern).test(normalizeRepoPath(file))
33
39
 
34
- export const matchesAnyGlob = (file, patterns = []) =>
35
- patterns.some(pattern => matchesGlob(file, pattern))
40
+ export const matchesAnyGlob = (file, patterns = []) => {
41
+ const normalized = normalizeRepoPath(file)
42
+ return patterns.some(pattern => compiledGlob(pattern).test(normalized))
43
+ }
package/lib/reports.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isBad } from './feeds.mjs'
2
2
  import { color } from './color.mjs'
3
3
  import { incompleteScanNotes } from './scanner.mjs'
4
+ import { printable } from './verify.mjs'
4
5
 
5
6
  export const marker = status =>
6
7
  status === 'retired' ? '✗' :
@@ -196,6 +197,28 @@ export const buildAlert = schedule => {
196
197
  }
197
198
  }
198
199
 
200
+ const CHANGED_INPUT_LABELS = new Map([
201
+ ['config', 'config changed'],
202
+ ['feeds', 'feeds changed'],
203
+ ['rename', 'file renamed'],
204
+ ['file', 'nearby lines changed'],
205
+ ['untracked', 'untracked file'],
206
+ ])
207
+
208
+ const changedTail = finding => {
209
+ const changed = finding.changed
210
+ if (changed?.reason === 'base-unavailable') return ` [base unavailable: ${printable(changed.message)}]`
211
+ if (changed?.reason !== 'newly-actionable') return ''
212
+ const inputs = changed.inputs.map(input => CHANGED_INPUT_LABELS.get(input) ?? printable(input)).join(', ')
213
+ const base = changed.base
214
+ const outOfScope = base && (base.status === 'retired' || base.status === 'retiring') &&
215
+ base.effective_scope === 'direct' && base.usage !== 'direct-api' && base.usage !== 'model-reference'
216
+ const was = !base
217
+ ? 'no finding at base'
218
+ : `was ${base.status}${base.via && base.via !== 'publisher' ? ` via ${printable(base.via)}` : ''}${outOfScope ? ` but out of direct scope (${base.usage})` : ''} at base`
219
+ return ` [newly actionable: ${inputs}; ${was}]`
220
+ }
221
+
199
222
  export const formatCheck = ({ findings, bad, scannedFiles, days, scope }) => {
200
223
  const out = []
201
224
  if (findings.length === 0) {
@@ -203,7 +226,7 @@ export const formatCheck = ({ findings, bad, scannedFiles, days, scope }) => {
203
226
  }
204
227
  for (const f of findings) {
205
228
  const where = `${f.file}:${f.line}`
206
- out.push(humanFinding(f, `${marker(f.status)} ${where} ${f.matched} ${findingTail(f, days)}${f.via && f.via !== 'publisher' ? ` [via ${f.via}]` : ''}`))
229
+ out.push(humanFinding(f, `${marker(f.status)} ${where} ${f.matched} ${findingTail(f, days)}${f.via && f.via !== 'publisher' ? ` [via ${f.via}]` : ''}${changedTail(f)}`))
207
230
  }
208
231
  if (bad.length) out.push(`\n${color('bold', `model-eol: ${bad.length} finding(s) at or past the ${days}-day threshold`)}`)
209
232
  return out.join('\n')
package/lib/scanner.mjs CHANGED
@@ -20,6 +20,7 @@ export const SKIP_DIRS = new Set([
20
20
  '.tox', '.terraform', 'target', 'vendor', 'coverage', 'logs', 'tmp',
21
21
  ])
22
22
  export const MAX_FILE_BYTES = 2 * 1024 * 1024
23
+ export const GIT_MAX_BUFFER = 1024 * 1024 * 1024
23
24
  export const MAX_FILES = 100_000
24
25
  export const INCOMPLETE_SCAN_REASONS = new Set([
25
26
  'file-too-large',
@@ -153,7 +154,7 @@ const isScannable = (file, includeDocs) => {
153
154
 
154
155
  const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
155
156
 
156
- const displayPath = absolute => {
157
+ export const displayPath = absolute => {
157
158
  const relative = path.relative(process.cwd(), absolute)
158
159
  return relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
159
160
  ? relative
@@ -227,14 +228,19 @@ const decodeGitQuotedPath = raw => {
227
228
  return decoded
228
229
  }
229
230
 
230
- export const parseDiffPath = value => {
231
- const raw = value.endsWith('\r') ? value.slice(0, -1) : value
231
+ const decodeDiffPath = value => {
232
+ const line = value.endsWith('\r') ? value.slice(0, -1) : value
233
+ // Git appends a tab to ---/+++ names that contain a space; a literal tab in a name is always quoted.
234
+ const raw = line.endsWith('\t') ? line.slice(0, -1) : line
232
235
  const decoded = raw.startsWith('"') ? decodeGitQuotedPath(raw) : raw
233
- if (decoded === '/dev/null') return null
234
- return decoded.startsWith('b/') ? decoded.slice(2) : decoded
236
+ return decoded === '/dev/null' ? null : decoded
235
237
  }
236
238
 
237
- export const addedLinesForTargets = (targets, baseRef) => {
239
+ const stripDiffPrefix = (decoded, prefix) => decoded !== null && decoded.startsWith(prefix) ? decoded.slice(prefix.length) : decoded
240
+
241
+ export const parseDiffPath = value => stripDiffPrefix(decodeDiffPath(value), 'b/')
242
+
243
+ export const resolveChangedRepository = (targets, baseRef) => {
238
244
  if (!baseRef) throw new Error('--changed requires a base ref')
239
245
  const roots = new Set()
240
246
  const resolvedTargets = []
@@ -279,42 +285,93 @@ export const addedLinesForTargets = (targets, baseRef) => {
279
285
  const detail = (resolvedBase.stderr ?? '').trim()
280
286
  throw new Error(`--changed could not resolve base ref "${baseRef}" to a commit${detail ? `: ${detail}` : ''}`)
281
287
  }
288
+ return { gitRoot, baseCommit, relativeTargets }
289
+ }
290
+
291
+ export const changedLinesForTargets = (targets, baseRef) => {
292
+ const { gitRoot, baseCommit, relativeTargets } = resolveChangedRepository(targets, baseRef)
293
+ const { GIT_DIFF_OPTS, ...env } = process.env
282
294
  const result = spawnSync('git', [
283
- '-c', 'core.quotePath=true',
284
- 'diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/',
295
+ '-c', 'core.quotePath=true', '-c', 'diff.relative=false', '-c', 'core.fsmonitor=false', '--literal-pathspecs',
296
+ 'diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '--find-renames',
297
+ '--diff-algorithm=myers', '--indent-heuristic', '--inter-hunk-context=0',
285
298
  '--text', '--no-ext-diff', '--no-textconv', '--unified=0', baseCommit, '--', ...relativeTargets,
286
299
  ], {
287
300
  cwd: gitRoot,
288
301
  encoding: 'utf8',
302
+ env,
289
303
  stdio: ['ignore', 'pipe', 'pipe'],
304
+ maxBuffer: GIT_MAX_BUFFER,
290
305
  })
291
306
  if (result.error || result.status !== 0) {
292
- const detail = (result.stderr ?? '').trim()
307
+ const detail = (result.stderr ?? '').trim() || result.error?.message || ''
293
308
  throw new Error(`--changed git diff failed for base ref "${baseRef}"${detail ? `: ${detail}` : ''}`)
294
309
  }
295
310
 
296
311
  const added = new Set()
312
+ const files = new Map()
313
+ let header = null
297
314
  let file = null
315
+ let record = null
298
316
  let inHunk = false
299
317
  let newLine = 0
300
318
  let newLinesRemaining = 0
319
+ const recordFor = headPath => {
320
+ const absolute = path.resolve(gitRoot, headPath)
321
+ if (!files.has(absolute)) {
322
+ files.set(absolute, {
323
+ headPath,
324
+ basePath: header.renameFrom ?? header.minusPath,
325
+ renamed: header.renameFrom !== null && header.renameFrom !== headPath,
326
+ hunks: [],
327
+ })
328
+ }
329
+ return files.get(absolute)
330
+ }
331
+ const finishHeader = () => {
332
+ if (header && !record && header.renameTo !== null) recordFor(header.renameTo)
333
+ }
301
334
  for (const line of result.stdout.split('\n')) {
302
335
  if (line.startsWith('diff --git ')) {
336
+ finishHeader()
337
+ header = { minusPath: null, renameFrom: null, renameTo: null }
303
338
  file = null
339
+ record = null
304
340
  inHunk = false
305
341
  newLinesRemaining = 0
306
342
  continue
307
343
  }
344
+ if (header && !inHunk) {
345
+ if (line.startsWith('rename from ')) {
346
+ header.renameFrom = decodeDiffPath(line.slice('rename from '.length))
347
+ continue
348
+ }
349
+ if (line.startsWith('rename to ')) {
350
+ header.renameTo = decodeDiffPath(line.slice('rename to '.length))
351
+ continue
352
+ }
353
+ if (line.startsWith('--- ')) {
354
+ header.minusPath = stripDiffPrefix(decodeDiffPath(line.slice(4)), 'a/')
355
+ continue
356
+ }
357
+ }
308
358
  if (!inHunk && line.startsWith('+++ ')) {
309
359
  const relative = parseDiffPath(line.slice(4))
310
360
  file = relative ? path.resolve(gitRoot, relative) : null
361
+ record = relative && header ? recordFor(relative) : null
311
362
  continue
312
363
  }
313
- const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
364
+ const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
314
365
  if (hunk) {
315
366
  inHunk = true
316
- newLine = Number(hunk[1])
317
- newLinesRemaining = hunk[2] === undefined ? 1 : Number(hunk[2])
367
+ newLine = Number(hunk[3])
368
+ newLinesRemaining = hunk[4] === undefined ? 1 : Number(hunk[4])
369
+ record?.hunks.push({
370
+ oldStart: Number(hunk[1]),
371
+ oldCount: hunk[2] === undefined ? 1 : Number(hunk[2]),
372
+ newStart: newLine,
373
+ newCount: newLinesRemaining,
374
+ })
318
375
  continue
319
376
  }
320
377
  // Unified-diff metadata such as "\" does not
@@ -327,11 +384,26 @@ export const addedLinesForTargets = (targets, baseRef) => {
327
384
  newLinesRemaining--
328
385
  }
329
386
  }
330
- return added
387
+ finishHeader()
388
+ return { gitRoot, baseCommit, relativeTargets, added, files }
331
389
  }
332
390
 
391
+ export const addedLinesForTargets = (targets, baseRef) => changedLinesForTargets(targets, baseRef).added
392
+
393
+ export const baseLineFor = (hunks, line) => {
394
+ let offset = 0
395
+ for (const hunk of hunks) {
396
+ const newEnd = hunk.newCount === 0 ? hunk.newStart : hunk.newStart + hunk.newCount - 1
397
+ if (hunk.newCount > 0 && line >= hunk.newStart && line <= newEnd) return null
398
+ if (newEnd < line) offset += hunk.newCount - hunk.oldCount
399
+ }
400
+ return line - offset
401
+ }
402
+
403
+ export const isAddedLine = (addedLines, file, line) => addedLines.has(changedKey(file, line))
404
+
333
405
  export const filterFindingsToChanged = (findings, addedLines) =>
334
- findings.filter(finding => addedLines.has(changedKey(finding.file, finding.line)))
406
+ findings.filter(finding => isAddedLine(addedLines, finding.file, finding.line))
335
407
 
336
408
  const collectFilesDetailed = (targets, {
337
409
  includeDocs = false,
@@ -339,6 +411,7 @@ const collectFilesDetailed = (targets, {
339
411
  ignoreRoots = [],
340
412
  ignoredFiles = [],
341
413
  policyForFile = null,
414
+ noGit = false,
342
415
  } = {}) => {
343
416
  const files = []
344
417
  const notes = []
@@ -472,11 +545,12 @@ const collectFilesDetailed = (targets, {
472
545
  const listGitFiles = (absoluteTarget, gitRoot) => {
473
546
  const relativeTarget = path.relative(gitRoot, absoluteTarget) || '.'
474
547
  const result = spawnSync('git', [
475
- 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', relativeTarget,
548
+ '--literal-pathspecs', 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', relativeTarget,
476
549
  ], {
477
550
  cwd: gitRoot,
478
551
  encoding: 'utf8',
479
552
  stdio: ['ignore', 'pipe', 'pipe'],
553
+ maxBuffer: GIT_MAX_BUFFER,
480
554
  })
481
555
  if (result.error || result.status !== 0) {
482
556
  const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`
@@ -488,11 +562,12 @@ const collectFilesDetailed = (targets, {
488
562
  return
489
563
  }
490
564
  const tracked = spawnSync('git', [
491
- 'ls-files', '-z', '--cached', '--stage', '--', relativeTarget,
565
+ '--literal-pathspecs', 'ls-files', '-z', '--cached', '--stage', '--', relativeTarget,
492
566
  ], {
493
567
  cwd: gitRoot,
494
568
  encoding: 'utf8',
495
569
  stdio: ['ignore', 'pipe', 'pipe'],
570
+ maxBuffer: GIT_MAX_BUFFER,
496
571
  })
497
572
  if (tracked.error || tracked.status !== 0) {
498
573
  const detail = tracked.error?.message || tracked.stderr?.trim() || `exit ${tracked.status}`
@@ -557,7 +632,7 @@ const collectFilesDetailed = (targets, {
557
632
  } catch {
558
633
  resolvedTarget = absoluteTarget
559
634
  }
560
- const gitRoot = gitRootFor(resolvedTarget)
635
+ const gitRoot = noGit ? null : gitRootFor(resolvedTarget)
561
636
  if (gitRoot) listGitFiles(resolvedTarget, gitRoot)
562
637
  else walk(absoluteTarget)
563
638
  }
@@ -579,8 +654,10 @@ export const scanTargets = ({
579
654
  policyForFile = null,
580
655
  routeForReference = null,
581
656
  mappedRoutesForFile = null,
657
+ noGit = false,
658
+ classificationPathFor = null,
582
659
  }) => {
583
- const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles, policyForFile })
660
+ const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles, policyForFile, noGit })
584
661
  if (collected.validTargets.length === 0) throw new Error('no valid targets remain')
585
662
  const { files } = collected
586
663
  const pattern = buildModelPattern(keys)
@@ -665,6 +742,7 @@ export const scanTargets = ({
665
742
  }
666
743
 
667
744
  for (const file of files) {
745
+ const classifiedFile = classificationPathFor ? classificationPathFor(file) : file
668
746
  let bytes
669
747
  try {
670
748
  bytes = fs.readFileSync(file)
@@ -732,7 +810,7 @@ export const scanTargets = ({
732
810
  const record = entries.get(m[1])
733
811
  const route = routeForReference?.({ file, matched: m[1], id: record.entry.id }) ?? null
734
812
  const classification = route ? routeClassification(route) : classifyRef({
735
- file,
813
+ file: classifiedFile,
736
814
  line: i + 1,
737
815
  lines,
738
816
  publisher: record.publisher,
@@ -755,7 +833,7 @@ export const scanTargets = ({
755
833
  if (mappedSources.has(candidate) || isKnownModel(candidate, entries) || obviousCandidateNoise(candidate)) continue
756
834
  if (ignoresModel(file, candidate)) continue
757
835
  const classification = classifyRef({
758
- file,
836
+ file: classifiedFile,
759
837
  line: i + 1,
760
838
  lines,
761
839
  publisher: 'unknown',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "model-eol-bot": "bot/bot.mjs"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-action-contract.mjs && node scripts/test-document-validation.mjs && node scripts/test-receipts.mjs && node scripts/test-verify.mjs && node scripts/test-eval-harness.mjs && node bot/test/package.mjs && node scripts/test-feed-changelog.mjs && node scripts/test-public-site.mjs"
11
+ "test": "node test/run.mjs && node refresh/test/run.mjs && node bot/test/run.mjs && node scripts/test-action-contract.mjs && node scripts/test-document-validation.mjs && node scripts/test-receipts.mjs && node scripts/test-verify.mjs && node scripts/test-eval-harness.mjs && node bot/test/package.mjs && node scripts/test-feed-changelog.mjs && node scripts/test-public-site.mjs && node scripts/test-e2e-changed.mjs"
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
@@ -49,9 +49,61 @@
49
49
  "requested_via": { "type": ["string", "null"], "minLength": 1 },
50
50
  "waiver": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/waiver" },
51
51
  "policy_provenance": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/policyProvenance" },
52
- "mapped_from": { "type": "string", "minLength": 1 }
52
+ "mapped_from": { "type": "string", "minLength": 1 },
53
+ "changed": { "$ref": "#/definitions/changed" }
53
54
  },
54
55
  "additionalProperties": false
56
+ },
57
+ "changed": {
58
+ "description": "Present only with --changed: why the finding is reported for this change.",
59
+ "oneOf": [
60
+ {
61
+ "type": "object",
62
+ "required": ["reason"],
63
+ "properties": { "reason": { "const": "added-line" } },
64
+ "additionalProperties": false
65
+ },
66
+ {
67
+ "type": "object",
68
+ "required": ["reason", "inputs", "base"],
69
+ "properties": {
70
+ "reason": { "const": "newly-actionable" },
71
+ "inputs": {
72
+ "type": "array",
73
+ "minItems": 1,
74
+ "uniqueItems": true,
75
+ "items": { "enum": ["config", "feeds", "rename", "file", "untracked"] }
76
+ },
77
+ "base": {
78
+ "oneOf": [
79
+ { "type": "null" },
80
+ {
81
+ "type": "object",
82
+ "required": ["status", "via", "usage", "threshold_days", "effective_scope"],
83
+ "properties": {
84
+ "status": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/status" },
85
+ "via": { "type": ["string", "null"], "minLength": 1 },
86
+ "usage": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/usage" },
87
+ "threshold_days": { "type": "integer", "minimum": 0 },
88
+ "effective_scope": { "enum": ["all", "direct"] }
89
+ },
90
+ "additionalProperties": false
91
+ }
92
+ ]
93
+ }
94
+ },
95
+ "additionalProperties": false
96
+ },
97
+ {
98
+ "type": "object",
99
+ "required": ["reason", "message"],
100
+ "properties": {
101
+ "reason": { "const": "base-unavailable" },
102
+ "message": { "type": "string", "minLength": 1 }
103
+ },
104
+ "additionalProperties": false
105
+ }
106
+ ]
55
107
  }
56
108
  }
57
109
  }