model-eol 0.9.1 → 0.10.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/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)
390
- }
391
- const routeForReference = ({ file, matched, id }) => {
392
- const policy = policyForFile(file)
393
- return routeForRepoReference(repositoryConfig, policy.repoPath, { matched, id })
448
+ const overrides = {
449
+ days: values.days === undefined ? undefined : DAYS,
450
+ scope: values.scope === undefined ? undefined : SCOPE,
451
+ via: values.via === undefined ? undefined : VIA,
394
452
  }
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,36 +479,56 @@ 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 evaluateBase = ({ treeRoot, files, configFile, feedsDir, classificationPathFor }) => {
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
+ try {
502
+ const base = evaluateTargets({
503
+ targets: files,
504
+ config,
505
+ roots: [treeRoot],
506
+ ignoredFiles: configFile ? [configFile] : [],
507
+ feedData: baseFeeds,
508
+ overrides,
509
+ includeDocs: INCLUDE_DOCS,
510
+ noGit: true,
511
+ classificationPathFor,
512
+ })
513
+ return { findings: base.findings, notes: base.scan.notes }
514
+ } catch (e) {
515
+ throw new BaseUnavailableError(`base scan failed: ${e.message}`)
451
516
  }
452
517
  }
453
- const checkFindings = findings.filter(finding =>
454
- finding.effective_scope !== 'direct' || finding.usage === 'direct-api' || finding.usage === 'model-reference')
518
+
455
519
  let changedFindings = checkFindings
456
520
  if (CHANGED_BASE !== null) {
457
521
  try {
458
- changedFindings = filterFindingsToChanged(checkFindings, addedLinesForTargets(targets, CHANGED_BASE))
522
+ changedFindings = selectChangedFindings({
523
+ targets,
524
+ baseRef: CHANGED_BASE,
525
+ findings,
526
+ checkFindings,
527
+ scannedFiles: scan.files,
528
+ configFile: configLocation.file,
529
+ feedsDir: FEEDS_DIR,
530
+ evaluateBase,
531
+ })
459
532
  } catch (e) {
460
533
  console.error(`--changed failed: ${e.message}`)
461
534
  return 2
@@ -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
  #
@@ -0,0 +1,493 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ import { isBad } from './feeds.mjs'
7
+ import { MAX_FILE_BYTES, SKIP_DIRS, baseLineFor, changedLinesForTargets, displayPath, incompleteScanNotes, isAddedLine } from './scanner.mjs'
8
+
9
+ export class BaseUnavailableError extends Error {
10
+ constructor(message) {
11
+ super(message)
12
+ this.name = 'BaseUnavailableError'
13
+ }
14
+ }
15
+
16
+ export const inCheckScope = finding =>
17
+ finding.effective_scope !== 'direct' || finding.usage === 'direct-api' || finding.usage === 'model-reference'
18
+
19
+ const MAX_SYMLINK_DEPTH = 40
20
+ const SUBMODULE_GIT_CONFIG = ['-c', 'protocol.allow=never', '-c', 'core.fsmonitor=false']
21
+
22
+ const runGit = (gitRoot, label, args, { input, hardened = false } = {}) => {
23
+ const result = spawnSync('git', hardened ? [...SUBMODULE_GIT_CONFIG, ...args] : args, {
24
+ cwd: gitRoot,
25
+ input,
26
+ env: hardened ? { ...process.env, GIT_NO_LAZY_FETCH: '1' } : process.env,
27
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
28
+ maxBuffer: 1024 * 1024 * 1024,
29
+ })
30
+ if (result.error || result.status !== 0) {
31
+ const detail = String(result.stderr ?? '').trim() || result.error?.message || `exit ${result.status}`
32
+ throw new Error(`git ${label} failed: ${detail}`)
33
+ }
34
+ return result.stdout
35
+ }
36
+
37
+ const nulRecords = buffer => buffer.toString('utf8').split('\0').filter(Boolean)
38
+
39
+ const repoRelative = (gitRoot, absolute) => {
40
+ const relative = path.relative(gitRoot, absolute)
41
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null
42
+ return relative.split(path.sep).join('/')
43
+ }
44
+
45
+ const onDiskName = (directory, name) => {
46
+ try {
47
+ const names = fs.readdirSync(directory)
48
+ if (names.includes(name)) return name
49
+ const wanted = fs.lstatSync(path.join(directory, name))
50
+ return names.find(other => {
51
+ const stat = fs.lstatSync(path.join(directory, other))
52
+ return stat.dev === wanted.dev && stat.ino === wanted.ino
53
+ }) ?? name
54
+ } catch {
55
+ return name
56
+ }
57
+ }
58
+
59
+ const lexicalRepoPath = (gitRoot, absolute) => {
60
+ const parts = path.resolve(absolute).split(path.sep)
61
+ for (let index = 1; index <= parts.length; index++) {
62
+ let real
63
+ try {
64
+ real = fs.realpathSync(parts.slice(0, index).join(path.sep) || path.sep)
65
+ } catch {
66
+ return null
67
+ }
68
+ if (real !== gitRoot) continue
69
+ const names = []
70
+ for (const part of parts.slice(index)) names.push(onDiskName(path.join(gitRoot, ...names), part))
71
+ return names.join('/')
72
+ }
73
+ return null
74
+ }
75
+
76
+ const nativeRealpath = target => {
77
+ try {
78
+ return fs.realpathSync.native(target)
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ const repoLocation = (gitRoot, target) => {
85
+ const physical = nativeRealpath(target)
86
+ const lexical = lexicalRepoPath(gitRoot, target)
87
+ // path.resolve folds "link/.." before the OS follows link, so trust the lexical path only when both agree.
88
+ if (lexical !== null && (physical === null || nativeRealpath(path.resolve(target)) === physical)) return lexical
89
+ return physical === null ? null : repoRelative(gitRoot, physical)
90
+ }
91
+
92
+ const isIgnored = (gitRoot, relative) => {
93
+ if (relative === '') return false
94
+ const result = spawnSync('git', ['check-ignore', '-q', '--', `./${relative}`], {
95
+ cwd: gitRoot,
96
+ encoding: 'utf8',
97
+ stdio: ['ignore', 'ignore', 'pipe'],
98
+ })
99
+ if (!result.error && (result.status === 0 || result.status === 1)) return result.status === 0
100
+ const detail = String(result.stderr ?? '').trim() || result.error?.message || `exit ${result.status}`
101
+ if (result.status === 128 && /in submodule|beyond a symbolic link/i.test(detail)) return false
102
+ throw new Error(`git check-ignore failed: ${detail}`)
103
+ }
104
+
105
+ const readRegular = absolute => {
106
+ let descriptor
107
+ try {
108
+ descriptor = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0))
109
+ } catch (error) {
110
+ return error.code === 'ENOENT' ? { bytes: null } : { message: error.message }
111
+ }
112
+ try {
113
+ return fs.fstatSync(descriptor).isFile() ? { bytes: fs.readFileSync(descriptor) } : { message: 'not a regular file' }
114
+ } catch (error) {
115
+ return { message: error.message }
116
+ } finally {
117
+ fs.closeSync(descriptor)
118
+ }
119
+ }
120
+
121
+ const readHead = absolute => {
122
+ const result = readRegular(absolute)
123
+ if (result.message) throw new Error(`could not read ${absolute}: ${result.message}`)
124
+ return result.bytes
125
+ }
126
+
127
+ const scannableTreePath = relative => relative.split('/').every(part =>
128
+ part && part !== '.' && part !== '..' && part.toLowerCase() !== '.git' && !SKIP_DIRS.has(part))
129
+
130
+ const regularBlob = entry => entry.type === 'blob' && (entry.mode === '100644' || entry.mode === '100755')
131
+
132
+ const untrackedFiles = (gitRoot, relativeTargets) => new Set(nulRecords(runGit(gitRoot, 'ls-files', [
133
+ '--literal-pathspecs', 'ls-files', '-z', '--others', '--exclude-standard', '--', ...relativeTargets,
134
+ ])).filter(relative => !relative.endsWith('/')).map(relative => path.resolve(gitRoot, relative)))
135
+
136
+ const parseTree = output => {
137
+ const entries = new Map()
138
+ for (const record of nulRecords(output)) {
139
+ const separator = record.indexOf('\t')
140
+ const [mode, type, oid, size] = record.slice(0, separator).split(/ +/)
141
+ if (separator === -1 || !/^[0-7]{6}$/.test(mode) || !/^[0-9a-f]{40,64}$/.test(oid ?? '')) {
142
+ throw new Error('git ls-tree returned a malformed record')
143
+ }
144
+ entries.set(record.slice(separator + 1), { mode, type, oid, size: size === '-' ? null : Number(size) })
145
+ }
146
+ return entries
147
+ }
148
+
149
+ const readBlobs = (gitRoot, entries, { hardened = false } = {}) => {
150
+ const output = runGit(gitRoot, 'cat-file', ['cat-file', '--batch'], { input: entries.map(entry => `${entry.oid}\n`).join(''), hardened })
151
+ const blobs = []
152
+ let offset = 0
153
+ for (const entry of entries) {
154
+ const newline = output.indexOf(0x0a, offset)
155
+ if (newline === -1) throw new Error('git cat-file returned a truncated record')
156
+ const [oid, type, size] = output.toString('utf8', offset, newline).split(' ')
157
+ if (oid !== entry.oid || type !== 'blob' || !/^\d+$/.test(size ?? '')) {
158
+ throw new Error(`git cat-file could not read blob ${entry.oid}`)
159
+ }
160
+ const start = newline + 1
161
+ const end = start + Number(size)
162
+ if (end + 1 > output.length) throw new Error('git cat-file returned a truncated record')
163
+ blobs.push(output.subarray(start, end))
164
+ offset = end + 1
165
+ }
166
+ return blobs
167
+ }
168
+
169
+ const baseTreeReader = (gitRoot, baseCommit, { hardened = false } = {}) => {
170
+ const lsTree = (pathspecs, { recursive = false } = {}) => parseTree(runGit(gitRoot, 'ls-tree', [
171
+ '--literal-pathspecs', 'ls-tree', ...(recursive ? ['-r'] : []), '-z', '-l', '--full-tree', baseCommit,
172
+ ...(pathspecs.length ? ['--', ...pathspecs] : []),
173
+ ], { hardened }))
174
+ const entries = new Map()
175
+ const lookup = relative => {
176
+ if (!entries.has(relative)) entries.set(relative, lsTree([relative]).get(relative) ?? null)
177
+ return entries.get(relative)
178
+ }
179
+ const blobs = new Map()
180
+ const read = entry => {
181
+ if (!blobs.has(entry.oid)) blobs.set(entry.oid, readBlobs(gitRoot, [entry], { hardened })[0])
182
+ return blobs.get(entry.oid)
183
+ }
184
+ const children = relative => [...lsTree(relative === '' ? [] : [`${relative}/`])].map(([child, entry]) => ({
185
+ name: relative === '' ? child : child.slice(relative.length + 1),
186
+ entry,
187
+ }))
188
+ const followTarget = (parent, target, depth) => {
189
+ const parts = target.split('/')
190
+ let current = parent
191
+ let unresolved = false
192
+ for (const [index, part] of parts.entries()) {
193
+ if (part === '' || part === '.') continue
194
+ if (part !== '..') {
195
+ current = current ? `${current}/${part}` : part
196
+ unresolved = true
197
+ continue
198
+ }
199
+ if (unresolved) {
200
+ const directory = resolve(current, depth + 1)
201
+ if (directory.state !== 'tree') {
202
+ return { result: directory.state === 'absent' ? { state: 'absent' } : { state: 'unavailable', message: `symbolic link target ${target} does not resolve to a directory` } }
203
+ }
204
+ current = directory.relative
205
+ unresolved = false
206
+ }
207
+ if (current === '') return { escaped: path.join(path.dirname(gitRoot), ...parts.slice(index + 1)) }
208
+ current = path.posix.dirname(current) === '.' ? '' : path.posix.dirname(current)
209
+ }
210
+ return { relative: current }
211
+ }
212
+ const resolve = (relative, depth = 0) => {
213
+ if (depth > MAX_SYMLINK_DEPTH) return { state: 'unavailable', message: 'too many levels of symbolic links' }
214
+ const parts = relative === '' ? [] : relative.split('/')
215
+ for (let index = 1; index <= parts.length; index++) {
216
+ const prefix = parts.slice(0, index).join('/')
217
+ const entry = lookup(prefix)
218
+ if (!entry) return { state: 'absent' }
219
+ const rest = parts.slice(index)
220
+ if (entry.mode === '160000') return { state: 'submodule', prefix, commit: entry.oid, rest: rest.join('/') }
221
+ if (entry.mode === '120000') {
222
+ const target = read(entry).toString('utf8')
223
+ const parent = path.posix.dirname(prefix) === '.' ? '' : path.posix.dirname(prefix)
224
+ if (path.posix.isAbsolute(target) && target.split('/').includes('..')) {
225
+ return { state: 'unavailable', message: `absolute symbolic link target ${target} contains ..` }
226
+ }
227
+ const followed = path.posix.isAbsolute(target) ? { escaped: path.resolve(target) } : followTarget(parent, target, depth)
228
+ if (followed.result) return followed.result
229
+ if (followed.relative !== undefined) return resolve([followed.relative, ...rest].filter(Boolean).join('/'), depth + 1)
230
+ const absolute = followed.escaped
231
+ const inside = repoRelative(gitRoot, absolute) ?? lexicalRepoPath(gitRoot, absolute)
232
+ if (inside === null) return { state: 'outside', absolute: path.join(absolute, ...rest) }
233
+ return resolve([inside, ...rest].filter(Boolean).join('/'), depth + 1)
234
+ }
235
+ if (rest.length && entry.type !== 'tree') return { state: 'absent' }
236
+ if (!rest.length && entry.type !== 'tree') {
237
+ return regularBlob(entry) ? { state: 'blob', entry } : { state: 'unavailable', message: 'not a regular file' }
238
+ }
239
+ }
240
+ return { state: 'tree', relative }
241
+ }
242
+ return { root: gitRoot, hardened, lsTree, read, children, resolve }
243
+ }
244
+
245
+ const submoduleReader = (reader, prefix, commit) => {
246
+ const unavailable = { state: 'unavailable', message: `in submodule ${prefix}, which is not checked out with base commit ${commit}` }
247
+ const candidate = path.join(reader.root, ...prefix.split('/'))
248
+ let root
249
+ try {
250
+ root = fs.realpathSync(candidate)
251
+ // A PR can commit a directory laid out as a git dir, but never a .git entry or a gitlink's checkout.
252
+ if (root !== candidate || !fs.lstatSync(root).isDirectory() || !fs.existsSync(path.join(root, '.git'))) return unavailable
253
+ const index = runGit(reader.root, 'ls-files', ['--literal-pathspecs', 'ls-files', '-s', '-z', '--', prefix], { hardened: reader.hardened })
254
+ if (!nulRecords(index).some(record => record.startsWith('160000 ') && record.endsWith(`\t${prefix}`))) return unavailable
255
+ const toplevel = runGit(root, 'rev-parse', ['rev-parse', '--show-toplevel'], { hardened: true }).toString('utf8').trim()
256
+ if (fs.realpathSync(toplevel) !== root) return unavailable
257
+ } catch {
258
+ return unavailable
259
+ }
260
+ const present = spawnSync('git', [...SUBMODULE_GIT_CONFIG, 'cat-file', '-e', `${commit}^{commit}`], {
261
+ cwd: root,
262
+ env: { ...process.env, GIT_NO_LAZY_FETCH: '1' },
263
+ stdio: 'ignore',
264
+ })
265
+ return present.status === 0 ? baseTreeReader(root, commit, { hardened: true }) : unavailable
266
+ }
267
+
268
+ const resolveBase = (reader, relative, depth = 0) => {
269
+ const resolved = reader.resolve(relative)
270
+ if (resolved.state !== 'submodule') return { ...resolved, reader }
271
+ if (depth > MAX_SYMLINK_DEPTH) return { state: 'unavailable', message: 'too many nested submodules' }
272
+ const submodule = submoduleReader(reader, resolved.prefix, resolved.commit)
273
+ return submodule.state === 'unavailable' ? submodule : resolveBase(submodule, resolved.rest, depth + 1)
274
+ }
275
+
276
+ const sameBytes = (left, right) => left === null || right === null ? left === right : left.equals(right)
277
+
278
+ const configInput = ({ gitRoot, tree, configFile }) => {
279
+ const location = repoLocation(gitRoot, configFile ?? path.join(gitRoot, '.model-eol.json'))
280
+ if (location === null || location === '') return { changed: false }
281
+ const resolved = resolveBase(tree, location)
282
+ if (resolved.state === 'absent' && isIgnored(gitRoot, location)) return { changed: false }
283
+ const head = configFile ? readHead(configFile) : null
284
+ const base = resolved.state === 'blob' ? { bytes: resolved.reader.read(resolved.entry) }
285
+ : resolved.state === 'absent' ? { bytes: null }
286
+ : resolved.state === 'outside' ? readRegular(resolved.absolute)
287
+ : { message: resolved.message ?? 'a directory' }
288
+ if (base.message) return { changed: true, location, unavailable: `base config ${location} is ${base.message}` }
289
+ return { changed: !sameBytes(head, base.bytes), location, bytes: base.bytes }
290
+ }
291
+
292
+ const readFeedDirectory = directory => {
293
+ const files = new Map()
294
+ for (const name of fs.readdirSync(directory).filter(name => name.endsWith('.json'))) {
295
+ const result = readRegular(path.join(directory, name))
296
+ if (result.message || result.bytes === null) return { message: `entry ${name} is ${result.message ?? 'missing'}` }
297
+ files.set(name, result.bytes)
298
+ }
299
+ return { files }
300
+ }
301
+
302
+ const feedsInput = ({ gitRoot, tree, feedsDir }) => {
303
+ const location = repoLocation(gitRoot, feedsDir)
304
+ if (location === null) return { changed: false }
305
+ const directory = resolveBase(tree, location)
306
+ if (directory.state === 'absent' && isIgnored(gitRoot, location)) return { changed: false }
307
+ const label = location || '.'
308
+ const headFiles = readFeedDirectory(feedsDir)
309
+ if (headFiles.message) throw new Error(`could not read feeds ${feedsDir}: ${headFiles.message}`)
310
+ const head = headFiles.files
311
+ const unavailable = message => ({ changed: true, location, unavailable: `base feeds ${message}` })
312
+ let base
313
+ if (directory.state === 'tree') {
314
+ base = new Map()
315
+ const { reader } = directory
316
+ for (const { name, entry } of reader.children(directory.relative)) {
317
+ if (!name.endsWith('.json')) continue
318
+ const resolved = regularBlob(entry) ? { state: 'blob', entry, reader }
319
+ : entry.mode === '120000' ? resolveBase(reader, directory.relative ? `${directory.relative}/${name}` : name)
320
+ : null
321
+ const outside = resolved?.state === 'outside' ? readRegular(resolved.absolute) : null
322
+ if (resolved?.state === 'blob') base.set(name, resolved.reader.read(resolved.entry))
323
+ else if (outside?.bytes) base.set(name, outside.bytes)
324
+ else return unavailable(`entry ${label}/${name} is not a readable regular file at base`)
325
+ }
326
+ } else if (directory.state === 'outside') {
327
+ let listed
328
+ try {
329
+ listed = readFeedDirectory(directory.absolute)
330
+ } catch (error) {
331
+ return unavailable(`directory ${label} could not be read at base: ${error.message}`)
332
+ }
333
+ if (listed.message) return unavailable(`directory ${label} at base: ${listed.message}`)
334
+ base = listed.files
335
+ } else if (directory.state === 'unavailable') {
336
+ return unavailable(`directory ${label} is ${directory.message}`)
337
+ } else {
338
+ return unavailable(`directory ${label} is ${directory.state === 'absent' ? 'missing' : 'not a directory'} at base`)
339
+ }
340
+ const changed = head.size !== base.size || [...head].some(([name, bytes]) => !base.has(name) || !bytes.equals(base.get(name)))
341
+ return { changed, location, files: base }
342
+ }
343
+
344
+ const temporaryRoot = gitRoot => {
345
+ const tmp = fs.realpathSync(os.tmpdir())
346
+ if (repoRelative(gitRoot, tmp) !== null) {
347
+ throw new Error(`the temporary directory ${tmp} is inside the git repository; set TMPDIR to a directory outside it`)
348
+ }
349
+ return fs.realpathSync(fs.mkdtempSync(path.join(tmp, 'model-eol-base-')))
350
+ }
351
+
352
+ const findingKey = (relative, line, id, matched) => JSON.stringify([relative, line, id, matched])
353
+
354
+ const baseSummary = finding => ({
355
+ status: finding.status,
356
+ via: finding.via,
357
+ usage: finding.usage,
358
+ threshold_days: finding.threshold_days,
359
+ effective_scope: finding.effective_scope,
360
+ })
361
+
362
+ export const selectChangedFindings = ({ targets, baseRef, findings, checkFindings, scannedFiles, configFile, feedsDir, evaluateBase }) => {
363
+ const diff = changedLinesForTargets(targets, baseRef)
364
+ const { gitRoot, baseCommit } = diff
365
+ const tree = baseTreeReader(gitRoot, baseCommit)
366
+ const untracked = untrackedFiles(gitRoot, diff.relativeTargets)
367
+ const config = configInput({ gitRoot, tree, configFile })
368
+ 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
+ })
388
+ }
389
+
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
+ }
398
+
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))]
404
+
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 })
414
+ const writes = []
415
+ for (const relative of baseRelatives) {
416
+ const entry = listing.get(relative)
417
+ if (!entry || !scannableTreePath(relative)) continue
418
+ if (!regularBlob(entry) || entry.size > MAX_FILE_BYTES) {
419
+ incomplete.add(relative)
420
+ continue
421
+ }
422
+ writes.push({ relative, entry, target: path.join(treeRoot, ...relative.split('/')) })
423
+ }
424
+ const blobs = writes.length ? readBlobs(gitRoot, writes.map(write => write.entry)) : []
425
+ const files = []
426
+ for (const [index, write] of writes.entries()) {
427
+ try {
428
+ fs.mkdirSync(path.dirname(write.target), { recursive: true })
429
+ fs.writeFileSync(write.target, blobs[index], { flag: 'wx' })
430
+ files.push(write.target)
431
+ } catch {
432
+ incomplete.add(write.relative)
433
+ }
434
+ }
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
+ }
453
+ if (files.length) {
454
+ const relativeToTree = file => path.relative(treeRoot, path.resolve(file)).split(path.sep).join('/')
455
+ try {
456
+ const base = evaluateBase({
457
+ treeRoot,
458
+ files,
459
+ configFile: baseConfigFile,
460
+ feedsDir: baseFeedsDir,
461
+ classificationPathFor: file => displayPath(path.join(gitRoot, ...relativeToTree(file).split('/'))),
462
+ })
463
+ for (const note of incompleteScanNotes(base.notes)) if (note.file) incomplete.add(relativeToTree(note.file))
464
+ for (const finding of base.findings) {
465
+ const key = findingKey(relativeToTree(finding.file), finding.line, finding.id, finding.matched)
466
+ if (!baseFindings.has(key)) baseFindings.set(key, [])
467
+ baseFindings.get(key).push(finding)
468
+ }
469
+ } catch (error) {
470
+ if (!(error instanceof BaseUnavailableError)) throw error
471
+ unavailable = replacements.reduce((message, [from, to]) => message.split(from).join(to), error.message)
472
+ }
473
+ }
474
+ } finally {
475
+ fs.rmSync(tempRoot, { recursive: true, force: true })
476
+ }
477
+ }
478
+
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
+ })
493
+ }
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
@@ -153,7 +153,7 @@ const isScannable = (file, includeDocs) => {
153
153
 
154
154
  const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
155
155
 
156
- const displayPath = absolute => {
156
+ export const displayPath = absolute => {
157
157
  const relative = path.relative(process.cwd(), absolute)
158
158
  return relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
159
159
  ? relative
@@ -227,14 +227,19 @@ const decodeGitQuotedPath = raw => {
227
227
  return decoded
228
228
  }
229
229
 
230
- export const parseDiffPath = value => {
231
- const raw = value.endsWith('\r') ? value.slice(0, -1) : value
230
+ const decodeDiffPath = value => {
231
+ const line = value.endsWith('\r') ? value.slice(0, -1) : value
232
+ // Git appends a tab to ---/+++ names that contain a space; a literal tab in a name is always quoted.
233
+ const raw = line.endsWith('\t') ? line.slice(0, -1) : line
232
234
  const decoded = raw.startsWith('"') ? decodeGitQuotedPath(raw) : raw
233
- if (decoded === '/dev/null') return null
234
- return decoded.startsWith('b/') ? decoded.slice(2) : decoded
235
+ return decoded === '/dev/null' ? null : decoded
235
236
  }
236
237
 
237
- export const addedLinesForTargets = (targets, baseRef) => {
238
+ const stripDiffPrefix = (decoded, prefix) => decoded !== null && decoded.startsWith(prefix) ? decoded.slice(prefix.length) : decoded
239
+
240
+ export const parseDiffPath = value => stripDiffPrefix(decodeDiffPath(value), 'b/')
241
+
242
+ export const resolveChangedRepository = (targets, baseRef) => {
238
243
  if (!baseRef) throw new Error('--changed requires a base ref')
239
244
  const roots = new Set()
240
245
  const resolvedTargets = []
@@ -279,13 +284,21 @@ export const addedLinesForTargets = (targets, baseRef) => {
279
284
  const detail = (resolvedBase.stderr ?? '').trim()
280
285
  throw new Error(`--changed could not resolve base ref "${baseRef}" to a commit${detail ? `: ${detail}` : ''}`)
281
286
  }
287
+ return { gitRoot, baseCommit, relativeTargets }
288
+ }
289
+
290
+ export const changedLinesForTargets = (targets, baseRef) => {
291
+ const { gitRoot, baseCommit, relativeTargets } = resolveChangedRepository(targets, baseRef)
292
+ const { GIT_DIFF_OPTS, ...env } = process.env
282
293
  const result = spawnSync('git', [
283
- '-c', 'core.quotePath=true',
284
- 'diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/',
294
+ '-c', 'core.quotePath=true', '-c', 'diff.relative=false', '--literal-pathspecs',
295
+ 'diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '--find-renames',
296
+ '--diff-algorithm=myers', '--indent-heuristic', '--inter-hunk-context=0',
285
297
  '--text', '--no-ext-diff', '--no-textconv', '--unified=0', baseCommit, '--', ...relativeTargets,
286
298
  ], {
287
299
  cwd: gitRoot,
288
300
  encoding: 'utf8',
301
+ env,
289
302
  stdio: ['ignore', 'pipe', 'pipe'],
290
303
  })
291
304
  if (result.error || result.status !== 0) {
@@ -294,27 +307,69 @@ export const addedLinesForTargets = (targets, baseRef) => {
294
307
  }
295
308
 
296
309
  const added = new Set()
310
+ const files = new Map()
311
+ let header = null
297
312
  let file = null
313
+ let record = null
298
314
  let inHunk = false
299
315
  let newLine = 0
300
316
  let newLinesRemaining = 0
317
+ const recordFor = headPath => {
318
+ const absolute = path.resolve(gitRoot, headPath)
319
+ if (!files.has(absolute)) {
320
+ files.set(absolute, {
321
+ headPath,
322
+ basePath: header.renameFrom ?? header.minusPath,
323
+ renamed: header.renameFrom !== null && header.renameFrom !== headPath,
324
+ hunks: [],
325
+ })
326
+ }
327
+ return files.get(absolute)
328
+ }
329
+ const finishHeader = () => {
330
+ if (header && !record && header.renameTo !== null) recordFor(header.renameTo)
331
+ }
301
332
  for (const line of result.stdout.split('\n')) {
302
333
  if (line.startsWith('diff --git ')) {
334
+ finishHeader()
335
+ header = { minusPath: null, renameFrom: null, renameTo: null }
303
336
  file = null
337
+ record = null
304
338
  inHunk = false
305
339
  newLinesRemaining = 0
306
340
  continue
307
341
  }
342
+ if (header && !inHunk) {
343
+ if (line.startsWith('rename from ')) {
344
+ header.renameFrom = decodeDiffPath(line.slice('rename from '.length))
345
+ continue
346
+ }
347
+ if (line.startsWith('rename to ')) {
348
+ header.renameTo = decodeDiffPath(line.slice('rename to '.length))
349
+ continue
350
+ }
351
+ if (line.startsWith('--- ')) {
352
+ header.minusPath = stripDiffPrefix(decodeDiffPath(line.slice(4)), 'a/')
353
+ continue
354
+ }
355
+ }
308
356
  if (!inHunk && line.startsWith('+++ ')) {
309
357
  const relative = parseDiffPath(line.slice(4))
310
358
  file = relative ? path.resolve(gitRoot, relative) : null
359
+ record = relative && header ? recordFor(relative) : null
311
360
  continue
312
361
  }
313
- const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
362
+ const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
314
363
  if (hunk) {
315
364
  inHunk = true
316
- newLine = Number(hunk[1])
317
- newLinesRemaining = hunk[2] === undefined ? 1 : Number(hunk[2])
365
+ newLine = Number(hunk[3])
366
+ newLinesRemaining = hunk[4] === undefined ? 1 : Number(hunk[4])
367
+ record?.hunks.push({
368
+ oldStart: Number(hunk[1]),
369
+ oldCount: hunk[2] === undefined ? 1 : Number(hunk[2]),
370
+ newStart: newLine,
371
+ newCount: newLinesRemaining,
372
+ })
318
373
  continue
319
374
  }
320
375
  // Unified-diff metadata such as "\" does not
@@ -327,11 +382,26 @@ export const addedLinesForTargets = (targets, baseRef) => {
327
382
  newLinesRemaining--
328
383
  }
329
384
  }
330
- return added
385
+ finishHeader()
386
+ return { gitRoot, baseCommit, relativeTargets, added, files }
331
387
  }
332
388
 
389
+ export const addedLinesForTargets = (targets, baseRef) => changedLinesForTargets(targets, baseRef).added
390
+
391
+ export const baseLineFor = (hunks, line) => {
392
+ let offset = 0
393
+ for (const hunk of hunks) {
394
+ const newEnd = hunk.newCount === 0 ? hunk.newStart : hunk.newStart + hunk.newCount - 1
395
+ if (hunk.newCount > 0 && line >= hunk.newStart && line <= newEnd) return null
396
+ if (newEnd < line) offset += hunk.newCount - hunk.oldCount
397
+ }
398
+ return line - offset
399
+ }
400
+
401
+ export const isAddedLine = (addedLines, file, line) => addedLines.has(changedKey(file, line))
402
+
333
403
  export const filterFindingsToChanged = (findings, addedLines) =>
334
- findings.filter(finding => addedLines.has(changedKey(finding.file, finding.line)))
404
+ findings.filter(finding => isAddedLine(addedLines, finding.file, finding.line))
335
405
 
336
406
  const collectFilesDetailed = (targets, {
337
407
  includeDocs = false,
@@ -339,6 +409,7 @@ const collectFilesDetailed = (targets, {
339
409
  ignoreRoots = [],
340
410
  ignoredFiles = [],
341
411
  policyForFile = null,
412
+ noGit = false,
342
413
  } = {}) => {
343
414
  const files = []
344
415
  const notes = []
@@ -472,7 +543,7 @@ const collectFilesDetailed = (targets, {
472
543
  const listGitFiles = (absoluteTarget, gitRoot) => {
473
544
  const relativeTarget = path.relative(gitRoot, absoluteTarget) || '.'
474
545
  const result = spawnSync('git', [
475
- 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', relativeTarget,
546
+ '--literal-pathspecs', 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', relativeTarget,
476
547
  ], {
477
548
  cwd: gitRoot,
478
549
  encoding: 'utf8',
@@ -488,7 +559,7 @@ const collectFilesDetailed = (targets, {
488
559
  return
489
560
  }
490
561
  const tracked = spawnSync('git', [
491
- 'ls-files', '-z', '--cached', '--stage', '--', relativeTarget,
562
+ '--literal-pathspecs', 'ls-files', '-z', '--cached', '--stage', '--', relativeTarget,
492
563
  ], {
493
564
  cwd: gitRoot,
494
565
  encoding: 'utf8',
@@ -557,7 +628,7 @@ const collectFilesDetailed = (targets, {
557
628
  } catch {
558
629
  resolvedTarget = absoluteTarget
559
630
  }
560
- const gitRoot = gitRootFor(resolvedTarget)
631
+ const gitRoot = noGit ? null : gitRootFor(resolvedTarget)
561
632
  if (gitRoot) listGitFiles(resolvedTarget, gitRoot)
562
633
  else walk(absoluteTarget)
563
634
  }
@@ -579,8 +650,10 @@ export const scanTargets = ({
579
650
  policyForFile = null,
580
651
  routeForReference = null,
581
652
  mappedRoutesForFile = null,
653
+ noGit = false,
654
+ classificationPathFor = null,
582
655
  }) => {
583
- const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles, policyForFile })
656
+ const collected = collectFilesDetailed(targets, { includeDocs, ignorePaths, ignoreRoots, ignoredFiles, policyForFile, noGit })
584
657
  if (collected.validTargets.length === 0) throw new Error('no valid targets remain')
585
658
  const { files } = collected
586
659
  const pattern = buildModelPattern(keys)
@@ -665,6 +738,7 @@ export const scanTargets = ({
665
738
  }
666
739
 
667
740
  for (const file of files) {
741
+ const classifiedFile = classificationPathFor ? classificationPathFor(file) : file
668
742
  let bytes
669
743
  try {
670
744
  bytes = fs.readFileSync(file)
@@ -732,7 +806,7 @@ export const scanTargets = ({
732
806
  const record = entries.get(m[1])
733
807
  const route = routeForReference?.({ file, matched: m[1], id: record.entry.id }) ?? null
734
808
  const classification = route ? routeClassification(route) : classifyRef({
735
- file,
809
+ file: classifiedFile,
736
810
  line: i + 1,
737
811
  lines,
738
812
  publisher: record.publisher,
@@ -755,7 +829,7 @@ export const scanTargets = ({
755
829
  if (mappedSources.has(candidate) || isKnownModel(candidate, entries) || obviousCandidateNoise(candidate)) continue
756
830
  if (ignoresModel(file, candidate)) continue
757
831
  const classification = classifyRef({
758
- file,
832
+ file: classifiedFile,
759
833
  line: i + 1,
760
834
  lines,
761
835
  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.0",
4
4
  "description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }