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.
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, { includeDocs = false } = {}) => {
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 = ({ targets, entries, keys, includeDocs = false }) => {
377
- const collected = collectFilesDetailed(targets, { includeDocs })
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.2.3",
4
- "description": "A machine-readable deprecation feed format for AI models, plus a zero-dependency checker and inventory CLI.",
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 bot configuration",
5
- "description": "Optional .model-eol.json configuration for the zero-dependency GitHub bot adapter.",
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 only, or include generic model 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. A canonical ID ignores all of its aliases."
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 run in the temporary clone for changed patches."
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": 1,
81
+ "minimum": 1000,
82
+ "maximum": 3600000,
80
83
  "default": 600000
81
84
  },
82
85
  "max_report_bytes": {
83
86
  "type": "integer",
84
- "minimum": 0,
87
+ "minimum": 1,
88
+ "maximum": 8388608,
85
89
  "default": 65536
86
90
  },
87
91
  "pass_env": {