model-eol 0.10.0 → 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/check.mjs CHANGED
@@ -479,7 +479,7 @@ if (incompleteNotes.length && (command === 'check' || command === 'plan')) {
479
479
  if (!ALLOW_INCOMPLETE) return 2
480
480
  }
481
481
 
482
- const evaluateBase = ({ treeRoot, files, configFile, feedsDir, classificationPathFor }) => {
482
+ const loadBase = ({ configFile, feedsDir }) => {
483
483
  let config = repositoryConfig
484
484
  let baseFeeds = feedData
485
485
  try {
@@ -498,13 +498,18 @@ const evaluateBase = ({ treeRoot, files, configFile, feedsDir, classificationPat
498
498
  }
499
499
  const basePolicyError = policyError(config, baseFeeds, values.via)
500
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 })
501
506
  try {
502
507
  const base = evaluateTargets({
503
508
  targets: files,
504
509
  config,
505
510
  roots: [treeRoot],
506
511
  ignoredFiles: configFile ? [configFile] : [],
507
- feedData: baseFeeds,
512
+ feedData: feeds,
508
513
  overrides,
509
514
  includeDocs: INCLUDE_DOCS,
510
515
  noGit: true,
@@ -517,9 +522,10 @@ const evaluateBase = ({ treeRoot, files, configFile, feedsDir, classificationPat
517
522
  }
518
523
 
519
524
  let changedFindings = checkFindings
525
+ let changedStats
520
526
  if (CHANGED_BASE !== null) {
521
527
  try {
522
- changedFindings = selectChangedFindings({
528
+ const selection = selectChangedFindings({
523
529
  targets,
524
530
  baseRef: CHANGED_BASE,
525
531
  findings,
@@ -527,12 +533,24 @@ if (CHANGED_BASE !== null) {
527
533
  scannedFiles: scan.files,
528
534
  configFile: configLocation.file,
529
535
  feedsDir: FEEDS_DIR,
536
+ head: { config: repositoryConfig, feeds: feedData, overrides, repoPathFor: file => repoPathForFile(file, configLocation.roots) },
537
+ loadBase,
530
538
  evaluateBase,
531
539
  })
540
+ changedFindings = selection.findings
541
+ changedStats = selection.stats
532
542
  } catch (e) {
533
543
  console.error(`--changed failed: ${e.message}`)
534
544
  return 2
535
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
+ }
536
554
  }
537
555
  const bad = changedFindings.filter(isBad)
538
556
  const inventory = () => buildInventory({ scan, findings, days: DAYS, via: VIA, scope: SCOPE, targets })
package/lib/changed.mjs CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { spawnSync } from 'node:child_process'
5
5
 
6
+ import { configForRepoPath, mappedRoutesForRepoPath, repoPathMatches, routesForRepoPath } from './config.mjs'
6
7
  import { isBad } from './feeds.mjs'
7
8
  import { MAX_FILE_BYTES, SKIP_DIRS, baseLineFor, changedLinesForTargets, displayPath, incompleteScanNotes, isAddedLine } from './scanner.mjs'
8
9
 
@@ -130,9 +131,40 @@ const scannableTreePath = relative => relative.split('/').every(part =>
130
131
  const regularBlob = entry => entry.type === 'blob' && (entry.mode === '100644' || entry.mode === '100755')
131
132
 
132
133
  const untrackedFiles = (gitRoot, relativeTargets) => new Set(nulRecords(runGit(gitRoot, 'ls-files', [
133
- '--literal-pathspecs', 'ls-files', '-z', '--others', '--exclude-standard', '--', ...relativeTargets,
134
+ '-c', 'core.fsmonitor=false', '--literal-pathspecs', 'ls-files', '-z', '--others', '--exclude-standard', '--', ...relativeTargets,
134
135
  ])).filter(relative => !relative.endsWith('/')).map(relative => path.resolve(gitRoot, relative)))
135
136
 
137
+ const HASH_CHUNK_BYTES = 64 * 1024
138
+
139
+ // Hashes raw bytes with paths as literal arguments: --stdin-paths unquotes names, and filters would run PR-routed drivers.
140
+ const rawOids = (gitRoot, relatives) => {
141
+ const oids = []
142
+ for (let start = 0; start < relatives.length;) {
143
+ let end = start
144
+ let bytes = 0
145
+ while (end < relatives.length && (end === start || bytes + relatives[end].length < HASH_CHUNK_BYTES)) bytes += relatives[end++].length + 1
146
+ const chunk = relatives.slice(start, end)
147
+ let output = []
148
+ try {
149
+ output = runGit(gitRoot, 'hash-object', ['hash-object', '--no-filters', '--', ...chunk]).toString('utf8').split('\n')
150
+ } catch {
151
+ }
152
+ for (let index = 0; index < chunk.length; index++) oids.push(output[index] ?? null)
153
+ start = end
154
+ }
155
+ return oids
156
+ }
157
+
158
+ // git diff trusts index stat data and skips assume-unchanged and skip-worktree entries, so compare the bytes the scanner reads.
159
+ const editedWithoutHunks = (gitRoot, checks, baseListing) => {
160
+ if (!checks.length) return new Set()
161
+ const listing = baseListing()
162
+ const hashable = checks.filter(check => listing.get(check.baseRelative)?.type === 'blob')
163
+ const oids = rawOids(gitRoot, hashable.map(check => check.headRelative))
164
+ const same = new Set(hashable.filter((check, index) => oids[index] === listing.get(check.baseRelative).oid).map(check => check.absolute))
165
+ return new Set(checks.filter(check => !same.has(check.absolute)).map(check => check.absolute))
166
+ }
167
+
136
168
  const parseTree = output => {
137
169
  const entries = new Map()
138
170
  for (const record of nulRecords(output)) {
@@ -359,61 +391,176 @@ const baseSummary = finding => ({
359
391
  effective_scope: finding.effective_scope,
360
392
  })
361
393
 
362
- export const selectChangedFindings = ({ targets, baseRef, findings, checkFindings, scannedFiles, configFile, feedsDir, evaluateBase }) => {
394
+ // Waiver order, reason, and owner never change whether a finding is bad; route order does (the last match wins).
395
+ const policyKey = (config, repoPath, overrides) => {
396
+ const policy = configForRepoPath(config, repoPath)
397
+ return JSON.stringify({
398
+ days: overrides.days === undefined ? policy.days : null,
399
+ scope: overrides.scope === undefined ? policy.scope : null,
400
+ via: overrides.via === undefined ? policy.via : null,
401
+ ignore_models: [...policy.ignore.models].sort(),
402
+ excluded: repoPathMatches(repoPath, policy.ignore.paths),
403
+ waivers: policy.waivers.map(({ model, via = null, expires }) => JSON.stringify([model, via, expires])).sort(),
404
+ routes: routesForRepoPath(config, repoPath).map(({ via, match = null, model = null }) => [via, match, model]),
405
+ })
406
+ }
407
+
408
+ const feedRecords = feeds =>
409
+ new Map([...feeds.entries.values()].map(record => [record.entry.id, JSON.stringify([record.publisher, record.entry])]))
410
+
411
+ const changedFeedIds = (head, base) => {
412
+ const headRecords = feedRecords(head)
413
+ const baseRecords = feedRecords(base)
414
+ return new Set([...headRecords.keys(), ...baseRecords.keys()].filter(id => headRecords.get(id) !== baseRecords.get(id)))
415
+ }
416
+
417
+ const changedFeedKeys = (head, base) =>
418
+ new Set([...head.keys, ...base.keys].filter(key => head.entries.get(key)?.entry.id !== base.entries.get(key)?.entry.id))
419
+
420
+ const inputNarrowing = ({ head, base, findings }) => {
421
+ const ids = changedFeedIds(head.feeds, base.feeds)
422
+ const keys = changedFeedKeys(head.feeds, base.feeds)
423
+ const changedTarget = key => keys.has(key) || ids.has(head.feeds.entries.get(key)?.entry.id)
424
+ const byFile = new Map()
425
+ for (const finding of findings) {
426
+ const absolute = path.resolve(finding.file)
427
+ if (!byFile.has(absolute)) byFile.set(absolute, [])
428
+ byFile.get(absolute).push(finding)
429
+ }
430
+ return {
431
+ config: ({ absolute, baseRepoPath }) => baseRepoPath === null ||
432
+ policyKey(head.config, head.repoPathFor(absolute), head.overrides) !== policyKey(base.config, baseRepoPath, head.overrides),
433
+ feeds: ({ absolute }) => {
434
+ const fileFindings = byFile.get(absolute) ?? []
435
+ return fileFindings.some(finding => ids.has(finding.id) || keys.has(finding.matched)) ||
436
+ (fileFindings.length > 0 && mappedRoutesForRepoPath(head.config, head.repoPathFor(absolute)).some(route => changedTarget(route.model)))
437
+ },
438
+ }
439
+ }
440
+
441
+ const materializeInputs = ({ tempRoot, baseRef, config, feeds }) => {
442
+ const treeRoot = path.join(tempRoot, 'tree')
443
+ fs.mkdirSync(treeRoot)
444
+ const replacements = [[`${treeRoot}${path.sep}`, `${baseRef}:`], [treeRoot, `${baseRef}:`]]
445
+ let configFile
446
+ if (config.changed) {
447
+ configFile = null
448
+ if (config.bytes !== null) {
449
+ configFile = path.join(tempRoot, 'config', path.posix.basename(config.location))
450
+ fs.mkdirSync(path.dirname(configFile))
451
+ fs.writeFileSync(configFile, config.bytes, { flag: 'wx' })
452
+ replacements.unshift([configFile, `${baseRef}:${config.location}`])
453
+ }
454
+ }
455
+ let feedsDir
456
+ if (feeds.changed) {
457
+ feedsDir = path.join(tempRoot, 'feeds')
458
+ fs.mkdirSync(feedsDir)
459
+ for (const [name, bytes] of feeds.files) fs.writeFileSync(path.join(feedsDir, name), bytes, { flag: 'wx' })
460
+ replacements.unshift([`${feedsDir}${path.sep}`, `${baseRef}:${feeds.location ? `${feeds.location}/` : ''}`], [feedsDir, `${baseRef}:${feeds.location || '.'}`])
461
+ }
462
+ return { treeRoot, configFile, feedsDir, replacements }
463
+ }
464
+
465
+ const AFFECTED_INPUTS = ['config', 'feeds', 'rename', 'file', 'untracked']
466
+
467
+ export const selectChangedFindings = ({ targets, baseRef, findings, checkFindings, scannedFiles, configFile, feedsDir, head, loadBase, evaluateBase }) => {
363
468
  const diff = changedLinesForTargets(targets, baseRef)
364
469
  const { gitRoot, baseCommit } = diff
365
470
  const tree = baseTreeReader(gitRoot, baseCommit)
366
471
  const untracked = untrackedFiles(gitRoot, diff.relativeTargets)
367
472
  const config = configInput({ gitRoot, tree, configFile })
368
473
  const feeds = feedsInput({ gitRoot, tree, feedsDir })
369
- const sharedInputs = [...(config.changed ? ['config'] : []), ...(feeds.changed ? ['feeds'] : [])]
370
-
371
- const affected = new Map()
372
- for (const file of scannedFiles) {
373
- const absolute = path.resolve(file)
374
- const record = diff.files.get(absolute)
375
- const isUntracked = untracked.has(absolute)
376
- const inputs = [
377
- ...sharedInputs,
378
- ...(record?.renamed ? ['rename'] : []),
379
- ...(record?.hunks.length ? ['file'] : []),
380
- ...(isUntracked ? ['untracked'] : []),
381
- ]
382
- if (!inputs.length) continue
383
- affected.set(absolute, {
384
- inputs,
385
- baseRelative: isUntracked ? null : record ? record.basePath : repoRelative(gitRoot, absolute),
386
- hunks: record?.hunks ?? [],
387
- })
474
+ let unavailable = (config.changed && config.unavailable) || (feeds.changed && feeds.unavailable) || null
475
+ const stats = {
476
+ scanned_files: scannedFiles.length,
477
+ affected_files: 0,
478
+ affected_by: Object.fromEntries(AFFECTED_INPUTS.map(input => [input, 0])),
479
+ candidate_findings: 0,
480
+ base_materialized_files: 0,
481
+ base_scan_ms: 0,
388
482
  }
389
483
 
390
- const occurrences = new Map()
391
- const seen = new Map()
392
- for (const finding of findings) {
393
- const key = findingKey(path.resolve(finding.file), finding.line, finding.id, finding.matched)
394
- const index = seen.get(key) ?? 0
395
- seen.set(key, index + 1)
396
- occurrences.set(finding, index)
397
- }
484
+ let tempRoot = null
485
+ let materialized = null
486
+ try {
487
+ let baseInputs
488
+ let narrowing = null
489
+ if ((config.changed || feeds.changed) && unavailable === null) {
490
+ try {
491
+ tempRoot = temporaryRoot(gitRoot)
492
+ materialized = materializeInputs({ tempRoot, baseRef, config, feeds })
493
+ baseInputs = loadBase({ configFile: materialized.configFile, feedsDir: materialized.feedsDir })
494
+ narrowing = inputNarrowing({ head, base: baseInputs, findings })
495
+ } catch {
496
+ // No narrowing: every file stays affected and the base step below succeeds or fails as it did before.
497
+ if (tempRoot !== null) fs.rmSync(tempRoot, { recursive: true, force: true })
498
+ tempRoot = null
499
+ materialized = null
500
+ baseInputs = undefined
501
+ narrowing = null
502
+ }
503
+ }
398
504
 
399
- const candidates = checkFindings.filter(finding =>
400
- !isAddedLine(diff.added, finding.file, finding.line) && isBad(finding) && affected.has(path.resolve(finding.file)))
401
- const baseRelatives = [...new Set(candidates
402
- .map(finding => affected.get(path.resolve(finding.file)).baseRelative)
403
- .filter(relative => relative !== null))]
505
+ let listing = null
506
+ const baseListing = () => (listing ??= tree.lsTree(diff.relativeTargets, { recursive: true }))
507
+ const unaddedBad = checkFindings.filter(finding => !isAddedLine(diff.added, finding.file, finding.line) && isBad(finding))
508
+ const contentChecks = [...new Set(unaddedBad.map(finding => path.resolve(finding.file)))].flatMap(absolute => {
509
+ const record = diff.files.get(absolute)
510
+ const headRelative = repoRelative(gitRoot, absolute)
511
+ const baseRelative = record ? record.basePath : headRelative
512
+ return record?.hunks.length || untracked.has(absolute) || headRelative === null || baseRelative === null ? [] : [{ absolute, headRelative, baseRelative }]
513
+ })
514
+ const edited = editedWithoutHunks(gitRoot, contentChecks, baseListing)
515
+
516
+ const affected = new Map()
517
+ for (const file of scannedFiles) {
518
+ const absolute = path.resolve(file)
519
+ const record = diff.files.get(absolute)
520
+ const isUntracked = untracked.has(absolute)
521
+ const baseRelative = isUntracked ? null : record ? record.basePath : repoRelative(gitRoot, absolute)
522
+ const location = { absolute, baseRepoPath: baseRelative ?? repoRelative(gitRoot, absolute) }
523
+ const inputs = [
524
+ ...(config.changed && (narrowing === null || narrowing.config(location)) ? ['config'] : []),
525
+ ...(feeds.changed && (narrowing === null || narrowing.feeds(location)) ? ['feeds'] : []),
526
+ ...(record?.renamed ? ['rename'] : []),
527
+ ...(record?.hunks.length || edited.has(absolute) ? ['file'] : []),
528
+ ...(isUntracked ? ['untracked'] : []),
529
+ ]
530
+ if (!inputs.length) continue
531
+ for (const input of inputs) stats.affected_by[input]++
532
+ affected.set(absolute, { inputs, baseRelative, hunks: record?.hunks ?? [] })
533
+ }
534
+ stats.affected_files = affected.size
535
+
536
+ const occurrences = new Map()
537
+ const seen = new Map()
538
+ for (const finding of findings) {
539
+ const key = findingKey(path.resolve(finding.file), finding.line, finding.id, finding.matched)
540
+ const index = seen.get(key) ?? 0
541
+ seen.set(key, index + 1)
542
+ occurrences.set(finding, index)
543
+ }
404
544
 
405
- const baseFindings = new Map()
406
- const incomplete = new Set()
407
- let unavailable = (config.changed && config.unavailable) || (feeds.changed && feeds.unavailable) || null
408
- if (baseRelatives.length && unavailable === null) {
409
- const tempRoot = temporaryRoot(gitRoot)
410
- try {
411
- const treeRoot = path.join(tempRoot, 'tree')
412
- fs.mkdirSync(treeRoot)
413
- const listing = tree.lsTree(diff.relativeTargets, { recursive: true })
545
+ const candidates = checkFindings.filter(finding =>
546
+ !isAddedLine(diff.added, finding.file, finding.line) && isBad(finding) && affected.has(path.resolve(finding.file)))
547
+ stats.candidate_findings = candidates.length
548
+ const baseRelatives = [...new Set(candidates
549
+ .map(finding => affected.get(path.resolve(finding.file)).baseRelative)
550
+ .filter(relative => relative !== null))]
551
+
552
+ const baseFindings = new Map()
553
+ const incomplete = new Set()
554
+ if (baseRelatives.length && unavailable === null) {
555
+ if (tempRoot === null) {
556
+ tempRoot = temporaryRoot(gitRoot)
557
+ materialized = materializeInputs({ tempRoot, baseRef, config, feeds })
558
+ }
559
+ const { treeRoot, replacements } = materialized
560
+ const baseEntries = baseListing()
414
561
  const writes = []
415
562
  for (const relative of baseRelatives) {
416
- const entry = listing.get(relative)
563
+ const entry = baseEntries.get(relative)
417
564
  if (!entry || !scannableTreePath(relative)) continue
418
565
  if (!regularBlob(entry) || entry.size > MAX_FILE_BYTES) {
419
566
  incomplete.add(relative)
@@ -432,32 +579,17 @@ export const selectChangedFindings = ({ targets, baseRef, findings, checkFinding
432
579
  incomplete.add(write.relative)
433
580
  }
434
581
  }
435
- const replacements = [[`${treeRoot}${path.sep}`, `${baseRef}:`], [treeRoot, `${baseRef}:`]]
436
- let baseConfigFile
437
- if (config.changed) {
438
- baseConfigFile = null
439
- if (config.bytes !== null) {
440
- baseConfigFile = path.join(tempRoot, 'config', path.posix.basename(config.location))
441
- fs.mkdirSync(path.dirname(baseConfigFile))
442
- fs.writeFileSync(baseConfigFile, config.bytes, { flag: 'wx' })
443
- replacements.unshift([baseConfigFile, `${baseRef}:${config.location}`])
444
- }
445
- }
446
- let baseFeedsDir
447
- if (feeds.changed) {
448
- baseFeedsDir = path.join(tempRoot, 'feeds')
449
- fs.mkdirSync(baseFeedsDir)
450
- for (const [name, bytes] of feeds.files) fs.writeFileSync(path.join(baseFeedsDir, name), bytes, { flag: 'wx' })
451
- replacements.unshift([`${baseFeedsDir}${path.sep}`, `${baseRef}:${feeds.location ? `${feeds.location}/` : ''}`], [baseFeedsDir, `${baseRef}:${feeds.location || '.'}`])
452
- }
582
+ stats.base_materialized_files = files.length
453
583
  if (files.length) {
454
584
  const relativeToTree = file => path.relative(treeRoot, path.resolve(file)).split(path.sep).join('/')
585
+ const started = performance.now()
455
586
  try {
456
587
  const base = evaluateBase({
457
588
  treeRoot,
458
589
  files,
459
- configFile: baseConfigFile,
460
- feedsDir: baseFeedsDir,
590
+ configFile: materialized.configFile,
591
+ feedsDir: materialized.feedsDir,
592
+ inputs: baseInputs,
461
593
  classificationPathFor: file => displayPath(path.join(gitRoot, ...relativeToTree(file).split('/'))),
462
594
  })
463
595
  for (const note of incompleteScanNotes(base.notes)) if (note.file) incomplete.add(relativeToTree(note.file))
@@ -470,24 +602,26 @@ export const selectChangedFindings = ({ targets, baseRef, findings, checkFinding
470
602
  if (!(error instanceof BaseUnavailableError)) throw error
471
603
  unavailable = replacements.reduce((message, [from, to]) => message.split(from).join(to), error.message)
472
604
  }
605
+ stats.base_scan_ms = Math.round(performance.now() - started)
473
606
  }
474
- } finally {
475
- fs.rmSync(tempRoot, { recursive: true, force: true })
476
607
  }
477
- }
478
608
 
479
- return checkFindings.flatMap(finding => {
480
- if (isAddedLine(diff.added, finding.file, finding.line)) return [{ ...finding, changed: { reason: 'added-line' } }]
481
- const file = isBad(finding) ? affected.get(path.resolve(finding.file)) : null
482
- if (!file) return []
483
- const newlyActionable = base => [{ ...finding, changed: { reason: 'newly-actionable', inputs: [...file.inputs], base } }]
484
- if (file.baseRelative === null) return newlyActionable(null)
485
- if (unavailable !== null) return [{ ...finding, changed: { reason: 'base-unavailable', message: unavailable } }]
486
- const baseLine = baseLineFor(file.hunks, finding.line)
487
- if (baseLine === null || incomplete.has(file.baseRelative)) return newlyActionable(null)
488
- const match = baseFindings.get(findingKey(file.baseRelative, baseLine, finding.id, finding.matched))?.[occurrences.get(finding)]
489
- if (!match) return newlyActionable(null)
490
- if (isBad(match) && inCheckScope(match)) return []
491
- return newlyActionable(baseSummary(match))
492
- })
609
+ const selected = checkFindings.flatMap(finding => {
610
+ if (isAddedLine(diff.added, finding.file, finding.line)) return [{ ...finding, changed: { reason: 'added-line' } }]
611
+ const file = isBad(finding) ? affected.get(path.resolve(finding.file)) : null
612
+ if (!file) return []
613
+ const newlyActionable = base => [{ ...finding, changed: { reason: 'newly-actionable', inputs: [...file.inputs], base } }]
614
+ if (file.baseRelative === null) return newlyActionable(null)
615
+ if (unavailable !== null) return [{ ...finding, changed: { reason: 'base-unavailable', message: unavailable } }]
616
+ const baseLine = baseLineFor(file.hunks, finding.line)
617
+ if (baseLine === null || incomplete.has(file.baseRelative)) return newlyActionable(null)
618
+ const match = baseFindings.get(findingKey(file.baseRelative, baseLine, finding.id, finding.matched))?.[occurrences.get(finding)]
619
+ if (!match) return newlyActionable(null)
620
+ if (isBad(match) && inCheckScope(match)) return []
621
+ return newlyActionable(baseSummary(match))
622
+ })
623
+ return { findings: selected, stats }
624
+ } finally {
625
+ if (tempRoot !== null) fs.rmSync(tempRoot, { recursive: true, force: true })
626
+ }
493
627
  }
package/lib/config.mjs CHANGED
@@ -242,12 +242,17 @@ export const normalizeConfig = (raw, { defaults = DEFAULT_CONFIG } = {}) => {
242
242
  return config
243
243
  }
244
244
 
245
- export const configForRepoPath = (config, repoPath) => {
245
+ export const repoPathMatches = (repoPath, patterns) => {
246
246
  const normalizedPath = normalizeRepoPath(repoPath)
247
- const matchesPath = patterns => matchesAnyGlob(normalizedPath, patterns) || patterns.some(pattern => {
247
+ return matchesAnyGlob(normalizedPath, patterns) || patterns.some(pattern => {
248
248
  const normalizedPattern = normalizeRepoPath(pattern).replace(/\/+$/, '')
249
249
  return normalizedPattern && !/[*?]/.test(normalizedPattern) && normalizedPath.startsWith(`${normalizedPattern}/`)
250
250
  })
251
+ }
252
+
253
+ export const configForRepoPath = (config, repoPath) => {
254
+ const normalizedPath = normalizeRepoPath(repoPath)
255
+ const matchesPath = patterns => repoPathMatches(repoPath, patterns)
251
256
  const applicableWaivers = waivers => (waivers ?? []).filter(waiver => waiver.paths === undefined || matchesPath(waiver.paths))
252
257
  const policy = {
253
258
  days: config.days,
@@ -288,14 +293,14 @@ export const routeForRepoReference = (config, repoPath, { matched = null, id = n
288
293
  return selected
289
294
  }
290
295
 
291
- export const mappedRoutesForRepoPath = (config, repoPath) => {
296
+ export const routesForRepoPath = (config, repoPath) => {
292
297
  const normalizedPath = normalizeRepoPath(repoPath)
293
- return (config.routes ?? []).flatMap((route, index) =>
294
- route.match !== undefined && route.model !== undefined && matchesAnyGlob(normalizedPath, route.paths)
295
- ? [{ ...route, index }]
296
- : [])
298
+ return (config.routes ?? []).flatMap((route, index) => matchesAnyGlob(normalizedPath, route.paths) ? [{ ...route, index }] : [])
297
299
  }
298
300
 
301
+ export const mappedRoutesForRepoPath = (config, repoPath) =>
302
+ routesForRepoPath(config, repoPath).filter(route => route.match !== undefined && route.model !== undefined)
303
+
299
304
  export const configuredVias = config => [
300
305
  config.via,
301
306
  ...(config.overrides ?? []).map(override => override.via),
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/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',
@@ -291,7 +292,7 @@ export const changedLinesForTargets = (targets, baseRef) => {
291
292
  const { gitRoot, baseCommit, relativeTargets } = resolveChangedRepository(targets, baseRef)
292
293
  const { GIT_DIFF_OPTS, ...env } = process.env
293
294
  const result = spawnSync('git', [
294
- '-c', 'core.quotePath=true', '-c', 'diff.relative=false', '--literal-pathspecs',
295
+ '-c', 'core.quotePath=true', '-c', 'diff.relative=false', '-c', 'core.fsmonitor=false', '--literal-pathspecs',
295
296
  'diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '--find-renames',
296
297
  '--diff-algorithm=myers', '--indent-heuristic', '--inter-hunk-context=0',
297
298
  '--text', '--no-ext-diff', '--no-textconv', '--unified=0', baseCommit, '--', ...relativeTargets,
@@ -300,9 +301,10 @@ export const changedLinesForTargets = (targets, baseRef) => {
300
301
  encoding: 'utf8',
301
302
  env,
302
303
  stdio: ['ignore', 'pipe', 'pipe'],
304
+ maxBuffer: GIT_MAX_BUFFER,
303
305
  })
304
306
  if (result.error || result.status !== 0) {
305
- const detail = (result.stderr ?? '').trim()
307
+ const detail = (result.stderr ?? '').trim() || result.error?.message || ''
306
308
  throw new Error(`--changed git diff failed for base ref "${baseRef}"${detail ? `: ${detail}` : ''}`)
307
309
  }
308
310
 
@@ -548,6 +550,7 @@ const collectFilesDetailed = (targets, {
548
550
  cwd: gitRoot,
549
551
  encoding: 'utf8',
550
552
  stdio: ['ignore', 'pipe', 'pipe'],
553
+ maxBuffer: GIT_MAX_BUFFER,
551
554
  })
552
555
  if (result.error || result.status !== 0) {
553
556
  const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`
@@ -564,6 +567,7 @@ const collectFilesDetailed = (targets, {
564
567
  cwd: gitRoot,
565
568
  encoding: 'utf8',
566
569
  stdio: ['ignore', 'pipe', 'pipe'],
570
+ maxBuffer: GIT_MAX_BUFFER,
567
571
  })
568
572
  if (tracked.error || tracked.status !== 0) {
569
573
  const detail = tracked.error?.message || tracked.stderr?.trim() || `exit ${tracked.status}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.10.0",
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",