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 +21 -3
- package/lib/changed.mjs +215 -81
- package/lib/config.mjs +12 -7
- package/lib/glob.mjs +11 -3
- package/lib/scanner.mjs +6 -2
- package/package.json +2 -2
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
.map(finding =>
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
460
|
-
feedsDir:
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
245
|
+
export const repoPathMatches = (repoPath, patterns) => {
|
|
246
246
|
const normalizedPath = normalizeRepoPath(repoPath)
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|