model-eol 0.9.0 → 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 +8 -4
- package/bot/bot.mjs +23 -5
- package/bot/lib/eval.mjs +26 -12
- package/check.mjs +135 -62
- package/examples/workflows/model-eol.yml +3 -2
- package/lib/changed.mjs +493 -0
- package/lib/reports.mjs +24 -1
- package/lib/scanner.mjs +93 -19
- package/package.json +1 -1
- package/schema/model-eol.check.schema.json +53 -1
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:
|
|
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
|
|
148
|
-
retired or retiring within the threshold
|
|
149
|
-
|
|
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/bot/bot.mjs
CHANGED
|
@@ -67,6 +67,12 @@ const PLAN_SCHEMA = 'model-eol.plan/0.1'
|
|
|
67
67
|
const BOT_SCHEMA = 'model-eol.bot/0.1'
|
|
68
68
|
const EVAL_FAILURE_CHANNEL = 'configured-eval'
|
|
69
69
|
const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
|
|
70
|
+
const EVAL_RESULT_OVERHEAD_BYTES = 4096
|
|
71
|
+
|
|
72
|
+
export const evalArtifactBudget = (maxReportBytes, groupCount) => Math.min(
|
|
73
|
+
EVAL_ARTIFACT_MAX_BYTES,
|
|
74
|
+
Math.max(1024 * 1024, EVAL_RESULT_OVERHEAD_BYTES + groupCount * (6 * maxReportBytes + EVAL_RESULT_OVERHEAD_BYTES)),
|
|
75
|
+
)
|
|
70
76
|
const ISSUE_REASONS = new Set([
|
|
71
77
|
'not-direct-api',
|
|
72
78
|
'no-replacement',
|
|
@@ -223,7 +229,7 @@ const readJsonArtifact = (file, maxBytes, label) => {
|
|
|
223
229
|
const absolute = asAbsolute(file)
|
|
224
230
|
const artifact = readReportCapped(absolute, maxBytes + 1)
|
|
225
231
|
if (artifact.missing) throw new Error(`${label} is missing`)
|
|
226
|
-
if (Buffer.byteLength(artifact.report) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`)
|
|
232
|
+
if (artifact.truncated || Buffer.byteLength(artifact.report) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`)
|
|
227
233
|
try {
|
|
228
234
|
return JSON.parse(artifact.report)
|
|
229
235
|
} catch (error) {
|
|
@@ -233,7 +239,7 @@ const readJsonArtifact = (file, maxBytes, label) => {
|
|
|
233
239
|
|
|
234
240
|
const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead }) => {
|
|
235
241
|
const settings = effectiveEval(config, commandOverride)
|
|
236
|
-
const maxBytes =
|
|
242
|
+
const maxBytes = evalArtifactBudget(settings.max_report_bytes, groups.length)
|
|
237
243
|
const artifact = readJsonArtifact(file, maxBytes, 'eval results artifact')
|
|
238
244
|
if (artifact?.schema !== EVAL_SCHEMA) throw new Error(`refusing eval results schema ${artifact?.schema ?? 'missing'}; expected ${EVAL_SCHEMA}`)
|
|
239
245
|
try {
|
|
@@ -712,12 +718,12 @@ const evalWorkspaceDrift = (root, planFiles, expectedHashes, expectedHead) => {
|
|
|
712
718
|
return null
|
|
713
719
|
}
|
|
714
720
|
|
|
715
|
-
const
|
|
721
|
+
const writeTextAtomic = (file, text) => {
|
|
716
722
|
const absolute = asAbsolute(file)
|
|
717
723
|
fs.mkdirSync(path.dirname(absolute), { recursive: true })
|
|
718
724
|
const temporary = `${absolute}.${process.pid}.${sha256(`${Date.now()}-${absolute}`).slice(0, 8)}.tmp`
|
|
719
725
|
try {
|
|
720
|
-
fs.writeFileSync(temporary,
|
|
726
|
+
fs.writeFileSync(temporary, text, { mode: 0o600 })
|
|
721
727
|
fs.renameSync(temporary, absolute)
|
|
722
728
|
} finally {
|
|
723
729
|
fs.rmSync(temporary, { force: true })
|
|
@@ -843,7 +849,19 @@ export const evaluatePlan = async ({
|
|
|
843
849
|
configured: Boolean(settings.command),
|
|
844
850
|
results,
|
|
845
851
|
}
|
|
846
|
-
|
|
852
|
+
const budget = evalArtifactBudget(settings.max_report_bytes, groups.length)
|
|
853
|
+
let encoded
|
|
854
|
+
try {
|
|
855
|
+
encoded = `${JSON.stringify(artifact, null, 2)}\n`
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (!(error instanceof RangeError)) throw error
|
|
858
|
+
throw new Error(`refusing eval: eval results artifact is too large to encode, over its ${budget}-byte budget; lower eval.max_report_bytes`)
|
|
859
|
+
}
|
|
860
|
+
const encodedBytes = Buffer.byteLength(encoded)
|
|
861
|
+
if (encodedBytes > budget) {
|
|
862
|
+
throw new Error(`refusing eval: eval results artifact would be ${encodedBytes} bytes, over its ${budget}-byte budget; lower eval.max_report_bytes`)
|
|
863
|
+
}
|
|
864
|
+
writeTextAtomic(outputFile, encoded)
|
|
847
865
|
return { artifact, plan, config: { ...config, eval: settings }, degraded: Boolean(feedSet.degraded) }
|
|
848
866
|
} finally {
|
|
849
867
|
feedSet.cleanup()
|
package/bot/lib/eval.mjs
CHANGED
|
@@ -2,21 +2,36 @@ import fs from 'node:fs'
|
|
|
2
2
|
import { spawnSync } from 'node:child_process'
|
|
3
3
|
import { markdownFenceText } from './common.mjs'
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
const TRUNCATION_MARKER = '\n[model-eol: report truncated]'
|
|
6
|
+
|
|
7
|
+
const fitUtf8 = (text, limit) => {
|
|
8
|
+
const bytes = Buffer.from(text, 'utf8')
|
|
9
|
+
if (bytes.length <= limit) return text
|
|
10
|
+
let end = Math.max(0, limit)
|
|
11
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--
|
|
12
|
+
return bytes.subarray(0, end).toString('utf8')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const truncateReport = (text, limit) => {
|
|
16
|
+
const markerBytes = Buffer.byteLength(TRUNCATION_MARKER)
|
|
17
|
+
if (limit <= markerBytes) return fitUtf8(TRUNCATION_MARKER, limit)
|
|
18
|
+
return fitUtf8(text, limit - markerBytes) + TRUNCATION_MARKER
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const byteCap = limit => {
|
|
22
|
+
if (!Number.isFinite(limit)) throw new Error(`report byte limit must be finite, got ${limit}`)
|
|
23
|
+
return Math.max(0, Math.floor(limit))
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
export const capReport = (report, limit) => {
|
|
13
27
|
if (report === null || report === undefined) return null
|
|
14
|
-
const
|
|
15
|
-
|
|
28
|
+
const cap = byteCap(limit)
|
|
29
|
+
const text = Buffer.from(String(report), 'utf8').toString('utf8')
|
|
30
|
+
return Buffer.byteLength(text) > cap ? truncateReport(text, cap) : text
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
export const readReportCapped = (file, limit) => {
|
|
19
|
-
const cap =
|
|
34
|
+
const cap = byteCap(limit)
|
|
20
35
|
let lstat
|
|
21
36
|
try {
|
|
22
37
|
lstat = fs.lstatSync(file)
|
|
@@ -41,10 +56,9 @@ export const readReportCapped = (file, limit) => {
|
|
|
41
56
|
try {
|
|
42
57
|
const buffer = Buffer.alloc(cap + 1)
|
|
43
58
|
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
59
|
+
const text = buffer.subarray(0, Math.min(bytesRead, cap)).toString('utf8')
|
|
60
|
+
const truncated = bytesRead > cap || Buffer.byteLength(text) > cap
|
|
61
|
+
return { missing: false, truncated, report: truncated ? truncateReport(text, cap) : text }
|
|
48
62
|
} finally {
|
|
49
63
|
fs.closeSync(fd)
|
|
50
64
|
}
|
package/check.mjs
CHANGED
|
@@ -48,7 +48,8 @@ import {
|
|
|
48
48
|
formatInventoryCycloneDX,
|
|
49
49
|
formatSchedule,
|
|
50
50
|
} from './lib/reports.mjs'
|
|
51
|
-
import {
|
|
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,
|
|
172
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
.
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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 =
|
|
457
|
+
({ scan, findings, checkFindings } = evaluateTargets({
|
|
403
458
|
targets,
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
includeDocs: INCLUDE_DOCS,
|
|
407
|
-
ignoreRoots: configLocation.roots,
|
|
459
|
+
config: repositoryConfig,
|
|
460
|
+
roots: configLocation.roots,
|
|
408
461
|
ignoredFiles: configLocation.file ? [configLocation.file] : [],
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
})
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
5
|
-
# retiring within the threshold (
|
|
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
|
#
|
package/lib/changed.mjs
ADDED
|
@@ -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
|
-
|
|
231
|
-
const
|
|
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
|
-
|
|
234
|
-
return decoded.startsWith('b/') ? decoded.slice(2) : decoded
|
|
235
|
+
return decoded === '/dev/null' ? null : decoded
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
|
|
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(/^@@
|
|
362
|
+
const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
|
|
314
363
|
if (hunk) {
|
|
315
364
|
inHunk = true
|
|
316
|
-
newLine = Number(hunk[
|
|
317
|
-
newLinesRemaining = hunk[
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
}
|